UpworkAutomation / app /ui /motion_runtime.py
ananyajoshi's picture
Claude Opus 5 (1M context)
Turn the ambient panel float off by default
0f6c856
Raw History Blame Contribute Delete
27.6 kB
"""Motion β€” real animation on the live Streamlit DOM.
Why a runtime at all, when there is already a CSS motion layer: CSS can only
animate what it can select, from states it can express. It cannot start an
animation when a panel scrolls into view, it cannot run a spring, and it cannot
count a number up to the value the app just computed. Motion does all three.
How it reaches the page. ``st.markdown`` strips <script>, so nothing can be
executed from the markup the screens emit. ``components.html`` runs scripts,
but only inside its own sandboxed iframe. That iframe is same-origin with the
app (Streamlit sets it via srcdoc), so it can reach ``window.parent`` β€” the
same bridge :func:`app.ui.theme.apply_step_accent` already uses for the theme.
So the iframe is only a bootstrapper: it injects two <script> elements into the
PARENT document β€” the library, then the driver β€” and both then run in the
parent's own realm. That matters because Streamlit may discard the component
iframe on a rerun; anything still living in the iframe's realm would die with
it. Both injections are idempotent, and the driver installs a MutationObserver,
so it keeps animating the nodes Streamlit swaps in on every rerun without the
iframe having to run again.
Two rules the animations here must never break:
* **Nothing that is, or contains, a file uploader may be transformed.** A
transform makes an element the containing block for its ``position: fixed``
descendants, which is what broke dossier drag-and-drop once already. Those
surfaces are animated by opacity alone (see ``canMove``).
* **No element is ever left hidden.** Every "from" value lives in a keyframe
array, never in CSS and never written to the element up front, so a failure
to animate leaves content visible and correct rather than stranded at
``opacity: 0``.
* **Perspective and ``transform-style: preserve-3d`` follow the same rule as a
transform.** Both make an element the containing block for its
``position: fixed`` descendants. So the depth layer never declares them in
CSS on a shared ancestor β€” the driver sets them at runtime on an individual
surface's parent, and only when that parent passes ``canMove``. Putting a
perspective on ``.block-container`` would break the uploader app-wide.
The library is vendored (``app/assets/vendor/motion.umd.js``, MIT) rather than
loaded from a CDN, because this app also ships as an offline desktop launcher.
"""
from __future__ import annotations
import os
from functools import lru_cache
import streamlit as st
import streamlit.components.v1 as components
from app.paths import resource_path
@lru_cache(maxsize=1)
def _library() -> str:
"""The vendored Motion UMD bundle. Empty string if it is missing."""
try:
return resource_path("app", "assets", "vendor", "motion.umd.js").read_text(
encoding="utf-8"
)
except Exception:
return ""
# The driver. Runs in the parent realm; `Motion` is the UMD global.
_DRIVER = r"""
(function () {
if (window.__upsMotion) { window.__upsMotion.rescan(); return; }
var M = window.Motion;
if (!M || !M.animate) return;
// The ambient panel float β€” the perpetual translateZ/rotate loop added in
// "UI: 3D depth layer" β€” is OFF unless UPS_PANEL_FLOAT is set.
//
// It is the one effect that never settles, and a surface that never settles
// drags its buttons out from under the pointer: a click fires only when
// mousedown and mouseup land on the same element. Measured against that
// commit, Save Configuration failed to save on 4 of 4 attempts; on the
// commit before it, and with the float off, it saves every time.
//
// Freezing it near the pointer (below) fixes this in principle and in every
// automated test, but not reliably in the hand, so the working button wins
// over the effect. Set UPS_PANEL_FLOAT=1 to bring the drift back β€” the
// proximity-freeze machinery below stays in place for that case.
var FLOAT_ON = false;
try { FLOAT_ON = !!window.__upsPanelFloat; } catch (e) {}
var EASE = [0.22, 0.61, 0.36, 1];
var SPRING = { type: 'spring', stiffness: 320, damping: 30, mass: 0.9 };
var reduce = false;
try {
// UPS_REDUCE_MOTION=1 forces this on from the server side β€” an escape
// hatch that needs no OS setting and no code edit, for when the motion
// layer has to be taken out of the picture (e.g. to determine whether it
// is responsible for an interaction problem). It routes through the same
// already-proven bail-out as the accessibility preference below rather
// than adding a second, less-tested way to switch motion off.
reduce = !!window.__upsReduceMotion ||
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
} catch (e) {}
// Transforms are always written as Motion's discrete properties (y, scale,
// scaleX) and never as a transform STRING. Motion decomposes a string into
// its components, and a "none" end value resolves the scale component to 0,
// not 1 β€” which leaves the element occupying its space and painting nothing.
//
// And every entrance clears the inline transform once it has finished, so no
// surface is left permanently transformed: a transform makes an element the
// containing block for its fixed-position descendants.
function settle(anim, el) {
if (!anim || !anim.then) return;
anim.then(function () { el.style.transform = ''; }, function () {});
}
// A transform on one of these would become the containing block for the
// uploader's fixed-position drop overlay. Opacity only, always.
function canMove(el) {
return !el.querySelector('[data-testid="stFileUploader"]') &&
!el.closest('[data-testid="stFileUploader"]');
}
function once(el) {
if (el.dataset.upsM) return false;
el.dataset.upsM = '1';
return true;
}
// ── Depth ────────────────────────────────────────────────────────────
// A translateZ only reads as depth when an ancestor carries a
// perspective, and a perspective β€” like a transform β€” makes that
// ancestor the containing block for fixed-position descendants. So it
// goes on the surface's own parent, one at a time, and only through
// canMove(). A surface whose parent is disqualified still animates; it
// simply stays flat, which is the correct fallback.
function depthParent(el) {
var p = el.parentElement;
if (!p || p.dataset.upsP || !canMove(p)) return false;
p.dataset.upsP = '1';
p.style.perspective = '1200px';
p.style.perspectiveOrigin = '50% 42%';
return true;
}
// Five samples of a sine, phase-shifted. First and last are equal, so the
// loop closes seamlessly; the phase is what stops a grid of cards from
// breathing in unison, which is what makes ambient motion look mechanical.
function wave(amp, phase, offset) {
var k = [], t;
for (t = 0; t <= 4; t++) {
k.push(+((offset || 0) + amp * Math.sin(2 * Math.PI * (t / 4 + phase))).toFixed(3));
}
return k;
}
// The ambient loop. Never settles β€” it owns the element's transform for as
// long as the node lives, so nothing else may clear it. Anything that wants
// the transform (hover, press, tilt) pauses this first and resumes after.
function float3d(el, i) {
// OFF by default β€” see FLOAT_ON. This is the single choke point for the
// perpetual drift, so gating here leaves every other piece of the motion
// layer (entrances, press, hover lift, tilt on presentation cards, sheen,
// ring draw, counters) running exactly as designed.
if (!FLOAT_ON) return;
if (!canMove(el)) return;
depthParent(el);
el.style.transformStyle = 'preserve-3d';
var ph = (i % 5) / 5;
el.__upsFloat = M.animate(el, {
rotateY: wave(5.5, ph),
rotateX: wave(3.2, ph + 0.25),
z: wave(11, ph + 0.5, 11)
}, {
duration: 9 + (i % 4) * 1.5,
repeat: Infinity,
ease: 'easeInOut'
});
// Keeps the drift, but holds it still while the pointer is inside a
// surface the user can click into. See steadyForPointer below.
steadyForPointer(el);
// The float is created AFTER the entrance, which routinely finishes under
// a cursor that is already resting on the panel. Starting it running would
// drift the button out from under a press that is about to happen, so ask
// :hover directly rather than waiting for an event that will never come.
// It begins held; pointerleave releases it.
if (el.__upsInside || pointerIsOver(el)) {
el.__upsInside = 1;
holdFloat(el);
}
}
function holdFloat(el) {
if (el.__upsFloat) { try { el.__upsFloat.pause(); } catch (e) {} }
}
function resumeFloat(el) {
if (el.__upsFloat) { try { el.__upsFloat.play(); } catch (e) {} return true; }
return false;
}
// A surface that carries controls keeps its ambient float β€” it just holds
// still while you are aiming at something inside it.
//
// A `click` fires only when mousedown and mouseup land on the SAME element.
// A panel drifting a few pixels moves its buttons out from under the cursor
// between those two events, so clicks are silently swallowed and the button
// appears to need several stabs before one registers. Freezing on
// pointerenter makes the surface stationary for exactly as long as the
// pointer is inside it, and the drift resumes on the way out.
//
// This is the same handoff tilt3d performs, through the same pause/play
// pair, so nothing new ever owns the transform: the float stays its sole
// author and simply stops advancing. Pausing (rather than clearing) holds
// the current transform, so the surface never snaps.
var CONTROLS = 'button, input, select, textarea, a[href], [role="button"], ' +
'[role="tab"], [role="option"], [contenteditable="true"]';
// Armed the moment a surface is registered β€” BEFORE its entrance runs and
// long before the float starts. Attaching it later (say, when the float is
// created) leaves a hole: pointerenter does not re-fire for a pointer that
// is already inside, so a user whose cursor was over the panel while it
// arrived would never freeze it, and the button would drift out from under
// the press. That hole is what made the first attempt at this fix look like
// it worked under test and still fail in the hand.
//
// `__upsInside` carries the state across that ordering: it is the flag
// float3d consults so a float created while the pointer is already inside
// starts paused rather than immediately drifting.
//
// pointermove is a deliberate second trigger, not redundancy: it fires for
// a pointer that was already inside when the listeners went on, which is
// exactly the case pointerenter cannot see.
// Is the pointer over this surface RIGHT NOW? :hover is the only honest
// answer available without an event, and an event is exactly what we cannot
// rely on here: a cursor that came to rest on the panel before the float
// existed fires no pointerenter (it never re-enters) and no pointermove (it
// never moves). That resting cursor is the common case β€” the user is
// already looking at the form, reaching for the button β€” and it is why the
// earlier event-only attempts still failed in the hand while passing tests
// that moved the mouse.
function pointerIsOver(el) {
try { return el.matches(':hover'); } catch (e) { return false; }
}
// Freezing when the pointer ARRIVES is too late. The surface is still
// drifting throughout the approach, so the button moves while the hand is
// travelling to it and the press lands where the button used to be β€” which
// reads, correctly, as "the button did nothing". The surface therefore has
// to be still BEFORE the pointer gets there.
//
// So proximity, not containment: a control-bearing surface holds as soon as
// the pointer is within MARGIN of its box, and drifts again once the pointer
// moves away. One throttled document-level listener drives all of them,
// which also sidesteps every attach-ordering trap that sank the earlier
// attempts β€” it does not matter when a surface was registered, only where
// the pointer is on the next move.
// NOTE ON LIFETIME: inject() appends this whole driver to the parent
// document on EVERY Streamlit rerun, so this file's top level runs many
// times per page. Anything registered here must therefore be idempotent β€”
// both the surface list and the listener hang off `window`, guarded, or a
// busy session would accumulate one listener and one orphaned list per
// rerun, each holding nodes the others cannot see.
var STEADY = (window.__upsSteady = window.__upsSteady || []);
var STEADY_MARGIN = 140; // px of clearance β€” stops before you arrive
function steadyForPointer(el) {
if (el.dataset.upsSteady) return;
if (!el.querySelector(CONTROLS)) return; // presentation surfaces drift freely
el.dataset.upsSteady = '1';
STEADY.push(el);
}
if (!window.__upsSteadyBound) {
window.__upsSteadyBound = 1;
var steadyFrame = 0;
document.addEventListener('pointermove', function (e) {
if (steadyFrame) return;
var px = e.clientX, py = e.clientY;
steadyFrame = requestAnimationFrame(function () {
steadyFrame = 0;
var list = window.__upsSteady || [];
for (var i = list.length - 1; i >= 0; i--) {
var el = list[i];
// Streamlit replaces nodes on every rerun; drop the stale ones.
if (!el.isConnected) { list.splice(i, 1); continue; }
var r = el.getBoundingClientRect();
var near = px >= r.left - STEADY_MARGIN && px <= r.right + STEADY_MARGIN &&
py >= r.top - STEADY_MARGIN && py <= r.bottom + STEADY_MARGIN;
if (near && !el.__upsInside) { el.__upsInside = 1; holdFloat(el); }
else if (!near && el.__upsInside) { el.__upsInside = 0; resumeFloat(el); }
}
});
}, true);
}
// Pointer tilt. Hands over from the float and hands back on leave, because
// two animations writing the same transform component fight each other.
function tilt3d(el, max) {
var frame = 0;
el.addEventListener('pointerenter', function () { holdFloat(el); });
el.addEventListener('pointermove', function (e) {
if (frame) return;
frame = requestAnimationFrame(function () {
frame = 0;
var r = el.getBoundingClientRect();
if (!r.width || !r.height) return;
var px = (e.clientX - r.left) / r.width;
var py = (e.clientY - r.top) / r.height;
M.animate(el, {
rotateY: (px - 0.5) * 2 * max,
rotateX: (0.5 - py) * 2 * max,
z: 30
}, { type: 'spring', stiffness: 260, damping: 26 });
});
});
el.addEventListener('pointerleave', function () {
var back = M.animate(el, { rotateX: 0, rotateY: 0, z: 0 },
{ type: 'spring', stiffness: 180, damping: 22 });
if (back && back.then) {
back.then(function () {
if (!resumeFloat(el)) el.style.transform = '';
}, function () {});
}
});
}
// Panels, cards and tiles arrive as they scroll into view β€” not all at once
// on load, which is what made the page read as a still photograph.
var SURFACES = [
'[class*="st-key-ups_panel"]',
'.card', '.verdict', '.proposal-doc',
'.ups-panel', '.ups-kpi', '.ups-kv', '.ups-log',
'[data-testid="stAlert"]'
].join(',');
// Their contents cascade behind them.
var ROWS = '.ups-row, .ups-ev, .checkitem, .ups-kvc, .ups-thead';
function enter(el, i) {
var move = canMove(el);
M.inView(el, function () {
var anim = M.animate(
el,
move ? { opacity: [0, 1], y: [16, 0], z: [-140, 0], rotateX: [7, 0] }
: { opacity: [0, 1] },
{ duration: 0.56, ease: EASE }
);
// The ambient float takes over the transform the moment the entrance
// lets go of it, so the surface never sits perfectly still.
if (move && anim && anim.then) {
anim.then(function () {
el.style.transform = '';
float3d(el, i || 0);
}, function () {});
}
}, { amount: 0.12 });
}
function cascade(container) {
var items = container.querySelectorAll(ROWS);
if (!items.length) return;
M.inView(container, function () {
var anim = M.animate(
items,
{ opacity: [0, 1], y: [10, 0] },
{ duration: 0.42, ease: EASE, delay: M.stagger(0.045) }
);
if (anim && anim.then) {
anim.then(function () {
items.forEach(function (it) { it.style.transform = ''; });
}, function () {});
}
}, { amount: 0.1 });
}
// Numbers arrive at their value rather than appearing at it. Only the
// leading number is animated, so "78/100" and "$2,000" keep their shape.
function countUp(el) {
var raw = (el.textContent || '').trim();
var m = raw.match(/^(-?\d[\d,]*)(.*)$/s);
if (!m) return;
var target = parseFloat(m[1].replace(/,/g, ''));
if (!isFinite(target) || target === 0) return;
var tail = m[2], grouped = m[1].indexOf(',') !== -1;
M.inView(el, function () {
M.animate(0, target, {
duration: 0.9,
ease: EASE,
onUpdate: function (v) {
var n = Math.round(v);
el.textContent = (grouped ? n.toLocaleString() : String(n)) + tail;
}
});
}, { amount: 0.5 });
}
// The fit ring draws itself to the score, comet head and all.
function drawRing(svgRoot) {
var bar = svgRoot.querySelector('.bar');
var comet = svgRoot.querySelector('.comet');
if (!bar) return;
var total = parseFloat(bar.getAttribute('stroke-dasharray')) || 433.5;
var target = parseFloat(bar.getAttribute('stroke-dashoffset'));
if (!isFinite(target)) return;
var deg = 360 * (1 - target / total);
var host = svgRoot.closest('.ups-ring') || svgRoot;
M.inView(svgRoot, function () {
M.animate(bar, { strokeDashoffset: [total, target] },
{ duration: 1.1, ease: EASE });
if (comet) M.animate(comet, { rotate: [0, deg] }, { duration: 1.1, ease: EASE });
// The disc lies back as it draws, then rocks like a gauge that has not
// quite settled. Kept shallow β€” the score has to stay readable.
if (!canMove(host)) return;
depthParent(host);
host.style.transformStyle = 'preserve-3d';
var lie = M.animate(host, { rotateX: [34, 12] }, { duration: 1.1, ease: EASE });
if (lie && lie.then) {
lie.then(function () {
host.__upsFloat = M.animate(host, {
rotateX: wave(3.5, 0, 12),
rotateY: wave(5, 0.25)
}, { duration: 8, repeat: Infinity, ease: 'easeInOut' });
}, function () {});
}
}, { amount: 0.4 });
}
// Meters fill from empty to the width the app computed.
function fillMeter(fill) {
var target = fill.style.width;
if (!target) return;
M.inView(fill, function () {
M.animate(fill, { width: ['0%', target] }, { duration: 0.8, ease: EASE });
}, { amount: 0.6 });
}
// Hover and press are springs, so the whole surface feels physical. The
// inline transform is cleared on the way out, never left behind.
function springy(el, lift) {
M.hover(el, function () {
holdFloat(el);
M.animate(el, { y: lift }, SPRING);
return function () {
var back = M.animate(el, { y: 0 }, SPRING);
if (back && back.then) {
back.then(function () {
// Only clear the transform if nothing owns it. Clearing it out
// from under a running float leaves the surface snapping.
if (!resumeFloat(el)) el.style.transform = '';
}, function () {});
}
};
});
M.press(el, function () {
M.animate(el, { scale: 0.985, z: -18 }, { duration: 0.08, ease: 'easeOut' });
return function () {
var back = M.animate(el, { scale: 1, z: 0 }, SPRING);
if (back && back.then) {
back.then(function () {
if (!resumeFloat(el)) el.style.transform = '';
}, function () {});
}
};
});
}
function rescan() {
var root = document.querySelector('[data-testid="stAppViewContainer"]') || document.body;
if (!root) return;
root.querySelectorAll(SURFACES).forEach(function (el, i) {
if (!once(el)) return;
// Armed first, before the entrance and long before the float exists, so
// a pointer resting on the panel as it arrives is already accounted for.
steadyForPointer(el);
enter(el, i);
cascade(el);
});
// Evidence entries ride a travelling depth wave, so a findings list reads
// as a stack of physical cards rather than a table.
root.querySelectorAll('.ups-ev').forEach(function (el, i) {
if (el.dataset.upsD) return;
el.dataset.upsD = '1';
if (!canMove(el)) return;
depthParent(el);
el.style.transformStyle = 'preserve-3d';
el.__upsFloat = M.animate(el, {
z: wave(17, (i % 4) / 4, 17),
rotateX: wave(2.4, (i % 4) / 4 + 0.25)
}, { duration: 6.5, repeat: Infinity, ease: 'easeInOut' });
// Same rule as float3d: an entry that never settles holds still while
// the pointer is inside it, in case it ever carries a link or control.
steadyForPointer(el);
});
// The mark is a compass. Turning is the one thing it should do.
root.querySelectorAll('.ups-brand-mark').forEach(function (el) {
if (el.dataset.upsD) return;
el.dataset.upsD = '1';
M.animate(el, { rotateY: [0, 360] },
{ duration: 14, repeat: Infinity, ease: 'linear' });
});
root.querySelectorAll('.ups-kpi .val, .ups-ring .val b').forEach(function (el) {
if (once(el)) countUp(el);
});
root.querySelectorAll('.ups-ring svg').forEach(function (el) {
if (once(el)) drawRing(el);
});
root.querySelectorAll('.ups-meter i').forEach(function (el) {
if (once(el)) fillMeter(el);
});
// The tiles lift; buttons only press, because a button that moves under
// the cursor is a button that gets mis-clicked.
root.querySelectorAll('.ups-kpi').forEach(function (el) {
if (el.dataset.upsH) return;
el.dataset.upsH = '1';
if (canMove(el)) { springy(el, -4); tilt3d(el, 9); }
});
// The verdict and the analysis cards turn under the pointer. Panels are
// left alone: a whole panel tilting is seasick, not premium.
root.querySelectorAll('.verdict, .card').forEach(function (el) {
if (el.dataset.upsH) return;
el.dataset.upsH = '1';
if (canMove(el)) tilt3d(el, 7);
});
root.querySelectorAll('.stButton button, .stDownloadButton button').forEach(function (el) {
if (el.dataset.upsH) return;
el.dataset.upsH = '1';
M.press(el, function () {
M.animate(el, { scale: 0.97 }, { duration: 0.08, ease: 'easeOut' });
return function () { settle(M.animate(el, { scale: 1 }, SPRING), el); };
});
});
// The step you are on pulses into place when it changes.
root.querySelectorAll('.ups-seg-i.is-now').forEach(function (el) {
if (!once(el)) return;
settle(M.animate(el, { scaleX: [0.2, 1] },
{ type: 'spring', stiffness: 400, damping: 22 }), el);
});
}
if (reduce) {
// Nothing is hidden up front, so honouring the setting means simply not
// starting anything. The page is already in its final state.
window.__upsMotion = { rescan: function () {}, reduced: true };
return;
}
var pending = 0;
function schedule() {
if (pending) return;
pending = requestAnimationFrame(function () { pending = 0; rescan(); });
}
// Streamlit replaces DOM nodes on every rerun, so the driver watches for
// them rather than running once and assuming the page stays put.
var target = document.querySelector('[data-testid="stAppViewContainer"]') || document.body;
var observer = new MutationObserver(schedule);
observer.observe(target, { childList: true, subtree: true });
window.__upsMotion = { rescan: rescan, observer: observer, reduced: false };
rescan();
})();
"""
def inject() -> None:
"""Load Motion into the page and start the driver. Safe to call every run.
Fails silently and completely: if the bundle is missing or the parent
document is unreachable, the app simply keeps the CSS motion layer.
"""
lib = _library()
if not lib:
return
# Both payloads ride inside <script> elements, so nothing in them may
# contain a closing tag. Neither does today; this keeps that true after an
# upgrade. In JavaScript "<\\/script" and "</script" are the same string.
lib = lib.replace("</script", "<\\/script")
driver = _DRIVER.replace("</script", "<\\/script")
# Server-side motion kill switch. Set UPS_REDUCE_MOTION=1 to load the
# driver but start nothing, exactly as the OS accessibility preference
# does. Useful for isolating whether the motion layer is behind an
# interaction problem without touching code or system settings.
reduce_motion = (os.getenv("UPS_REDUCE_MOTION", "") or "").strip().lower() in (
"1", "true", "yes", "on"
)
# Opt-in for the perpetual panel drift. Off by default because it moves
# buttons out from under a click; see FLOAT_ON in the driver.
panel_float = (os.getenv("UPS_PANEL_FLOAT", "") or "").strip().lower() in (
"1", "true", "yes", "on"
)
components.html(
# The payloads come first: the bootstrap below reads them by id, and a
# <script> only sees the document that exists above it.
'<script type="text/plain" id="ups-motion-lib">' + lib + "</script>\n"
'<script type="text/plain" id="ups-motion-driver">' + driver + "</script>\n"
"<script>\n"
"(function () {\n"
" var libEl = document.getElementById('ups-motion-lib');\n"
" var drvEl = document.getElementById('ups-motion-driver');\n"
" if (!libEl || !drvEl) return;\n"
" var LIB = libEl.textContent, DRIVER = drvEl.textContent;\n"
" function boot() {\n"
" try {\n"
" var w = window.parent, d = w.document;\n"
" if (!d || !d.head) return false;\n"
" w.__upsReduceMotion = " + ("true" if reduce_motion else "false") + ";\n"
" w.__upsPanelFloat = " + ("true" if panel_float else "false") + ";\n"
" if (!w.Motion) {\n"
" var s = d.createElement('script');\n"
" s.id = 'ups-motion-lib';\n"
" s.textContent = LIB;\n"
" d.head.appendChild(s);\n"
" }\n"
" if (!w.Motion) return false;\n"
" var t = d.createElement('script');\n"
" t.textContent = DRIVER;\n"
" d.head.appendChild(t);\n"
" t.remove();\n"
" return true;\n"
" } catch (e) { return false; }\n"
" }\n"
" if (!boot()) {\n"
" var n = 0;\n"
" var iv = setInterval(function () {\n"
" if (boot() || ++n > 20) clearInterval(iv);\n"
" }, 50);\n"
" }\n"
"})();\n"
"</script>",
height=0,
)