ananyajoshi commited on
Commit
48fd5c7
·
1 Parent(s): c9d9189

UI: 3D depth layer — ambient float, pointer tilt, ring lie-back

Browse files
app/assets/design_system.css CHANGED
@@ -939,3 +939,65 @@ h1, h2, h3 { font-family: var(--ff-display) !important; letter-spacing: -0.025em
939
 
940
  #MainMenu { visibility: hidden; }
941
  footer { visibility: hidden; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
939
 
940
  #MainMenu { visibility: hidden; }
941
  footer { visibility: hidden; }
942
+
943
+ /* ============================================================
944
+ DEPTH — Aurora in the round
945
+ ------------------------------------------------------------
946
+ These rules only lift a surface's contents onto their own
947
+ planes. They do nothing on their own: without a `perspective`
948
+ on an ancestor a translateZ collapses to flat, which is
949
+ exactly the safe default.
950
+
951
+ The perspective is NOT set here. app/ui/motion_runtime.py sets
952
+ it on a floated surface's parent at runtime, and only when that
953
+ parent passes canMove() — because `perspective` and
954
+ `transform-style: preserve-3d` each make an element the
955
+ containing block for its position:fixed descendants, the same
956
+ hazard as a transform. Putting either on .block-container would
957
+ break the file uploader's drop overlay app-wide.
958
+ ============================================================ */
959
+
960
+ /* KPI tiles — badge floats above the number, label sits behind. */
961
+ .ups-kpi .ic { transform: translateZ(26px); }
962
+ .ups-kpi .val { transform: translateZ(15px); }
963
+ .ups-kpi .lab { transform: translateZ(7px); }
964
+
965
+ /* The verdict reads as a stack: badge, headline, body. */
966
+ .verdict-badge { transform: translateZ(28px); }
967
+ .verdict h2 { transform: translateZ(15px); }
968
+ .verdict p { transform: translateZ(7px); }
969
+
970
+ /* Evidence: the claim in front of its own sentence. */
971
+ .ups-ev b { transform: translateZ(14px); }
972
+ .ups-ev .ups-cite { transform: translateZ(6px); }
973
+
974
+ /* The fit ring's number rides above the disc as it lies back. */
975
+ .ups-ring .val { transform: translateZ(30px); }
976
+
977
+ /* Proposal toolbar floats off the page it belongs to. */
978
+ .doc-toolbar { transform: translateZ(18px); }
979
+
980
+ /* The compass turns. Its own lockup carries the vanishing point —
981
+ the sidebar brand has no uploader beneath it, so this one
982
+ perspective is safe to declare statically. */
983
+ .ups-brand { perspective: 420px; }
984
+ .ups-brand-mark {
985
+ transform-style: preserve-3d;
986
+ backface-visibility: hidden;
987
+ will-change: transform;
988
+ }
989
+
990
+ /* Surfaces the driver floats. will-change only — the transform
991
+ itself is always written by Motion, never by CSS, so a failure
992
+ to animate leaves the surface flat and correct. */
993
+ .ups-panel, .ups-kpi, .ups-tile, .card, .verdict, .proposal-doc {
994
+ will-change: transform;
995
+ }
996
+
997
+ @media (prefers-reduced-motion: reduce) {
998
+ .ups-kpi .ic, .ups-kpi .val, .ups-kpi .lab,
999
+ .verdict-badge, .verdict h2, .verdict p,
1000
+ .ups-ev b, .ups-ev .ups-cite,
1001
+ .ups-ring .val, .doc-toolbar { transform: none; }
1002
+ .ups-brand { perspective: none; }
1003
+ }
app/ui/motion_runtime.py CHANGED
@@ -29,6 +29,12 @@ Two rules the animations here must never break:
29
  array, never in CSS and never written to the element up front, so a failure
30
  to animate leaves content visible and correct rather than stranded at
31
  ``opacity: 0``.
 
 
 
 
 
 
32
 
33
  The library is vendored (``app/assets/vendor/motion.umd.js``, MIT) rather than
34
  loaded from a CDN, because this app also ships as an offline desktop launcher.
@@ -95,6 +101,91 @@ _DRIVER = r"""
95
  return true;
96
  }
97
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
  // Panels, cards and tiles arrive as they scroll into view — not all at once
99
  // on load, which is what made the page read as a still photograph.
100
  var SURFACES = [
@@ -107,16 +198,23 @@ _DRIVER = r"""
107
  // Their contents cascade behind them.
108
  var ROWS = '.ups-row, .ups-ev, .checkitem, .ups-kvc, .ups-thead';
109
 
110
- function enter(el) {
111
  var move = canMove(el);
112
  M.inView(el, function () {
113
  var anim = M.animate(
114
  el,
115
- move ? { opacity: [0, 1], y: [16, 0], scale: [0.995, 1] }
116
  : { opacity: [0, 1] },
117
- { duration: 0.52, ease: EASE }
118
  );
119
- if (move) settle(anim, el);
 
 
 
 
 
 
 
120
  }, { amount: 0.12 });
121
  }
122
 
@@ -167,10 +265,26 @@ _DRIVER = r"""
167
  var target = parseFloat(bar.getAttribute('stroke-dashoffset'));
168
  if (!isFinite(target)) return;
169
  var deg = 360 * (1 - target / total);
 
170
  M.inView(svgRoot, function () {
171
  M.animate(bar, { strokeDashoffset: [total, target] },
172
  { duration: 1.1, ease: EASE });
173
  if (comet) M.animate(comet, { rotate: [0, deg] }, { duration: 1.1, ease: EASE });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
174
  }, { amount: 0.4 });
175
  }
176
 
@@ -187,12 +301,29 @@ _DRIVER = r"""
187
  // inline transform is cleared on the way out, never left behind.
188
  function springy(el, lift) {
189
  M.hover(el, function () {
 
190
  M.animate(el, { y: lift }, SPRING);
191
- return function () { settle(M.animate(el, { y: 0 }, SPRING), el); };
 
 
 
 
 
 
 
 
 
192
  });
193
  M.press(el, function () {
194
- M.animate(el, { scale: 0.985 }, { duration: 0.08, ease: 'easeOut' });
195
- return function () { settle(M.animate(el, { scale: 1 }, SPRING), el); };
 
 
 
 
 
 
 
196
  });
197
  }
198
 
@@ -200,12 +331,34 @@ _DRIVER = r"""
200
  var root = document.querySelector('[data-testid="stAppViewContainer"]') || document.body;
201
  if (!root) return;
202
 
203
- root.querySelectorAll(SURFACES).forEach(function (el) {
204
  if (!once(el)) return;
205
- enter(el);
206
  cascade(el);
207
  });
208
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
209
  root.querySelectorAll('.ups-kpi .val, .ups-ring .val b').forEach(function (el) {
210
  if (once(el)) countUp(el);
211
  });
@@ -223,7 +376,15 @@ _DRIVER = r"""
223
  root.querySelectorAll('.ups-kpi').forEach(function (el) {
224
  if (el.dataset.upsH) return;
225
  el.dataset.upsH = '1';
226
- if (canMove(el)) springy(el, -4);
 
 
 
 
 
 
 
 
227
  });
228
  root.querySelectorAll('.stButton button, .stDownloadButton button').forEach(function (el) {
229
  if (el.dataset.upsH) return;
 
29
  array, never in CSS and never written to the element up front, so a failure
30
  to animate leaves content visible and correct rather than stranded at
31
  ``opacity: 0``.
32
+ * **Perspective and ``transform-style: preserve-3d`` follow the same rule as a
33
+ transform.** Both make an element the containing block for its
34
+ ``position: fixed`` descendants. So the depth layer never declares them in
35
+ CSS on a shared ancestor — the driver sets them at runtime on an individual
36
+ surface's parent, and only when that parent passes ``canMove``. Putting a
37
+ perspective on ``.block-container`` would break the uploader app-wide.
38
 
39
  The library is vendored (``app/assets/vendor/motion.umd.js``, MIT) rather than
40
  loaded from a CDN, because this app also ships as an offline desktop launcher.
 
101
  return true;
102
  }
103
 
104
+ // ── Depth ────────────────────────────────────────────────────────────
105
+ // A translateZ only reads as depth when an ancestor carries a
106
+ // perspective, and a perspective — like a transform — makes that
107
+ // ancestor the containing block for fixed-position descendants. So it
108
+ // goes on the surface's own parent, one at a time, and only through
109
+ // canMove(). A surface whose parent is disqualified still animates; it
110
+ // simply stays flat, which is the correct fallback.
111
+ function depthParent(el) {
112
+ var p = el.parentElement;
113
+ if (!p || p.dataset.upsP || !canMove(p)) return false;
114
+ p.dataset.upsP = '1';
115
+ p.style.perspective = '1200px';
116
+ p.style.perspectiveOrigin = '50% 42%';
117
+ return true;
118
+ }
119
+
120
+ // Five samples of a sine, phase-shifted. First and last are equal, so the
121
+ // loop closes seamlessly; the phase is what stops a grid of cards from
122
+ // breathing in unison, which is what makes ambient motion look mechanical.
123
+ function wave(amp, phase, offset) {
124
+ var k = [], t;
125
+ for (t = 0; t <= 4; t++) {
126
+ k.push(+((offset || 0) + amp * Math.sin(2 * Math.PI * (t / 4 + phase))).toFixed(3));
127
+ }
128
+ return k;
129
+ }
130
+
131
+ // The ambient loop. Never settles — it owns the element's transform for as
132
+ // long as the node lives, so nothing else may clear it. Anything that wants
133
+ // the transform (hover, press, tilt) pauses this first and resumes after.
134
+ function float3d(el, i) {
135
+ if (!canMove(el)) return;
136
+ depthParent(el);
137
+ el.style.transformStyle = 'preserve-3d';
138
+ var ph = (i % 5) / 5;
139
+ el.__upsFloat = M.animate(el, {
140
+ rotateY: wave(5.5, ph),
141
+ rotateX: wave(3.2, ph + 0.25),
142
+ z: wave(11, ph + 0.5, 11)
143
+ }, {
144
+ duration: 9 + (i % 4) * 1.5,
145
+ repeat: Infinity,
146
+ ease: 'easeInOut'
147
+ });
148
+ }
149
+
150
+ function holdFloat(el) {
151
+ if (el.__upsFloat) { try { el.__upsFloat.pause(); } catch (e) {} }
152
+ }
153
+ function resumeFloat(el) {
154
+ if (el.__upsFloat) { try { el.__upsFloat.play(); } catch (e) {} return true; }
155
+ return false;
156
+ }
157
+
158
+ // Pointer tilt. Hands over from the float and hands back on leave, because
159
+ // two animations writing the same transform component fight each other.
160
+ function tilt3d(el, max) {
161
+ var frame = 0;
162
+ el.addEventListener('pointerenter', function () { holdFloat(el); });
163
+ el.addEventListener('pointermove', function (e) {
164
+ if (frame) return;
165
+ frame = requestAnimationFrame(function () {
166
+ frame = 0;
167
+ var r = el.getBoundingClientRect();
168
+ if (!r.width || !r.height) return;
169
+ var px = (e.clientX - r.left) / r.width;
170
+ var py = (e.clientY - r.top) / r.height;
171
+ M.animate(el, {
172
+ rotateY: (px - 0.5) * 2 * max,
173
+ rotateX: (0.5 - py) * 2 * max,
174
+ z: 30
175
+ }, { type: 'spring', stiffness: 260, damping: 26 });
176
+ });
177
+ });
178
+ el.addEventListener('pointerleave', function () {
179
+ var back = M.animate(el, { rotateX: 0, rotateY: 0, z: 0 },
180
+ { type: 'spring', stiffness: 180, damping: 22 });
181
+ if (back && back.then) {
182
+ back.then(function () {
183
+ if (!resumeFloat(el)) el.style.transform = '';
184
+ }, function () {});
185
+ }
186
+ });
187
+ }
188
+
189
  // Panels, cards and tiles arrive as they scroll into view — not all at once
190
  // on load, which is what made the page read as a still photograph.
191
  var SURFACES = [
 
198
  // Their contents cascade behind them.
199
  var ROWS = '.ups-row, .ups-ev, .checkitem, .ups-kvc, .ups-thead';
200
 
201
+ function enter(el, i) {
202
  var move = canMove(el);
203
  M.inView(el, function () {
204
  var anim = M.animate(
205
  el,
206
+ move ? { opacity: [0, 1], y: [16, 0], z: [-140, 0], rotateX: [7, 0] }
207
  : { opacity: [0, 1] },
208
+ { duration: 0.56, ease: EASE }
209
  );
210
+ // The ambient float takes over the transform the moment the entrance
211
+ // lets go of it, so the surface never sits perfectly still.
212
+ if (move && anim && anim.then) {
213
+ anim.then(function () {
214
+ el.style.transform = '';
215
+ float3d(el, i || 0);
216
+ }, function () {});
217
+ }
218
  }, { amount: 0.12 });
219
  }
220
 
 
265
  var target = parseFloat(bar.getAttribute('stroke-dashoffset'));
266
  if (!isFinite(target)) return;
267
  var deg = 360 * (1 - target / total);
268
+ var host = svgRoot.closest('.ups-ring') || svgRoot;
269
  M.inView(svgRoot, function () {
270
  M.animate(bar, { strokeDashoffset: [total, target] },
271
  { duration: 1.1, ease: EASE });
272
  if (comet) M.animate(comet, { rotate: [0, deg] }, { duration: 1.1, ease: EASE });
273
+
274
+ // The disc lies back as it draws, then rocks like a gauge that has not
275
+ // quite settled. Kept shallow — the score has to stay readable.
276
+ if (!canMove(host)) return;
277
+ depthParent(host);
278
+ host.style.transformStyle = 'preserve-3d';
279
+ var lie = M.animate(host, { rotateX: [34, 12] }, { duration: 1.1, ease: EASE });
280
+ if (lie && lie.then) {
281
+ lie.then(function () {
282
+ host.__upsFloat = M.animate(host, {
283
+ rotateX: wave(3.5, 0, 12),
284
+ rotateY: wave(5, 0.25)
285
+ }, { duration: 8, repeat: Infinity, ease: 'easeInOut' });
286
+ }, function () {});
287
+ }
288
  }, { amount: 0.4 });
289
  }
290
 
 
301
  // inline transform is cleared on the way out, never left behind.
302
  function springy(el, lift) {
303
  M.hover(el, function () {
304
+ holdFloat(el);
305
  M.animate(el, { y: lift }, SPRING);
306
+ return function () {
307
+ var back = M.animate(el, { y: 0 }, SPRING);
308
+ if (back && back.then) {
309
+ back.then(function () {
310
+ // Only clear the transform if nothing owns it. Clearing it out
311
+ // from under a running float leaves the surface snapping.
312
+ if (!resumeFloat(el)) el.style.transform = '';
313
+ }, function () {});
314
+ }
315
+ };
316
  });
317
  M.press(el, function () {
318
+ M.animate(el, { scale: 0.985, z: -18 }, { duration: 0.08, ease: 'easeOut' });
319
+ return function () {
320
+ var back = M.animate(el, { scale: 1, z: 0 }, SPRING);
321
+ if (back && back.then) {
322
+ back.then(function () {
323
+ if (!resumeFloat(el)) el.style.transform = '';
324
+ }, function () {});
325
+ }
326
+ };
327
  });
328
  }
329
 
 
331
  var root = document.querySelector('[data-testid="stAppViewContainer"]') || document.body;
332
  if (!root) return;
333
 
334
+ root.querySelectorAll(SURFACES).forEach(function (el, i) {
335
  if (!once(el)) return;
336
+ enter(el, i);
337
  cascade(el);
338
  });
339
 
340
+ // Evidence entries ride a travelling depth wave, so a findings list reads
341
+ // as a stack of physical cards rather than a table.
342
+ root.querySelectorAll('.ups-ev').forEach(function (el, i) {
343
+ if (el.dataset.upsD) return;
344
+ el.dataset.upsD = '1';
345
+ if (!canMove(el)) return;
346
+ depthParent(el);
347
+ el.style.transformStyle = 'preserve-3d';
348
+ el.__upsFloat = M.animate(el, {
349
+ z: wave(17, (i % 4) / 4, 17),
350
+ rotateX: wave(2.4, (i % 4) / 4 + 0.25)
351
+ }, { duration: 6.5, repeat: Infinity, ease: 'easeInOut' });
352
+ });
353
+
354
+ // The mark is a compass. Turning is the one thing it should do.
355
+ root.querySelectorAll('.ups-brand-mark').forEach(function (el) {
356
+ if (el.dataset.upsD) return;
357
+ el.dataset.upsD = '1';
358
+ M.animate(el, { rotateY: [0, 360] },
359
+ { duration: 14, repeat: Infinity, ease: 'linear' });
360
+ });
361
+
362
  root.querySelectorAll('.ups-kpi .val, .ups-ring .val b').forEach(function (el) {
363
  if (once(el)) countUp(el);
364
  });
 
376
  root.querySelectorAll('.ups-kpi').forEach(function (el) {
377
  if (el.dataset.upsH) return;
378
  el.dataset.upsH = '1';
379
+ if (canMove(el)) { springy(el, -4); tilt3d(el, 9); }
380
+ });
381
+
382
+ // The verdict and the analysis cards turn under the pointer. Panels are
383
+ // left alone: a whole panel tilting is seasick, not premium.
384
+ root.querySelectorAll('.verdict, .card').forEach(function (el) {
385
+ if (el.dataset.upsH) return;
386
+ el.dataset.upsH = '1';
387
+ if (canMove(el)) tilt3d(el, 7);
388
  });
389
  root.querySelectorAll('.stButton button, .stDownloadButton button').forEach(function (el) {
390
  if (el.dataset.upsH) return;