Spaces:
Sleeping
Sleeping
Commit ·
feffb09
1
Parent(s): 0c8f330
feat(fe): always-on spin+power hero on shot detail
Browse filesA player reads two things: what spin and how hard. The widget carrying
both waited for a replay, so the page opened on debug numbers instead.
It now leads the page whenever the shot has a shotnet block, with a
plain-English gloss and a POWER_TIERS label beside the m/s figure.
Gate: pytest 160 passed + 7 skipped, 3 runs; demo 14 shots checked by
hand (hero, caveat, downgrade badge, suspect box, mobile quickbar).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- app/static/app.js +102 -13
- app/static/index.html +37 -21
- app/static/style.css +20 -1
app/static/app.js
CHANGED
|
@@ -289,6 +289,25 @@ const AND_RESIM_DEFAULT_ON = false;
|
|
| 289 |
// trusted range is [1, 8] m/s, hence a ceiling of 8.0.
|
| 290 |
const AND_V0_MIN = 0.5, AND_V0_MAX = 8.0;
|
| 291 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 292 |
const ANV = {
|
| 293 |
videoId: null,
|
| 294 |
timer: null,
|
|
@@ -944,6 +963,24 @@ const fmtTs = (s) => (s === null || s === undefined ? "—"
|
|
| 944 |
|
| 945 |
const CONF_LABEL = { low: "low", medium: "medium", high: "high" };
|
| 946 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 947 |
/** The shot table, filling in GRADUALLY. Numbers are shown exactly as the
|
| 948 |
server sent them; V0, phi and spin prefer the model column, falling back
|
| 949 |
to the analytic one labelled "bi" (ball) because they are different
|
|
@@ -1049,14 +1086,7 @@ function renderAnvShots(body) {
|
|
| 1049 |
} else if (i === 5) { // the confidence badge
|
| 1050 |
if (sn) {
|
| 1051 |
const b = document.createElement("span");
|
| 1052 |
-
b
|
| 1053 |
-
b.textContent = CONF_LABEL[sn.confidence] || sn.confidence;
|
| 1054 |
-
if (sn.confidence_raw && sn.confidence_raw !== sn.confidence) {
|
| 1055 |
-
b.title = `downgraded from ${CONF_LABEL[sn.confidence_raw]} -- `
|
| 1056 |
-
+ `detection density `
|
| 1057 |
-
+ `${(sn.det_density || 0).toFixed(2)}/frame`;
|
| 1058 |
-
b.textContent += " ↓";
|
| 1059 |
-
}
|
| 1060 |
td.appendChild(b);
|
| 1061 |
} else {
|
| 1062 |
td.textContent = s.spin_confidence
|
|
@@ -1213,8 +1243,8 @@ function renderAnvDetail() {
|
|
| 1213 |
const warn = $("#and-warnings");
|
| 1214 |
const line = $("#and-resim");
|
| 1215 |
const btn = $("#and-replay");
|
| 1216 |
-
$("#and-shotviz").classList.add("hidden"); // shown once replay starts
|
| 1217 |
if (!res.metrics) {
|
|
|
|
| 1218 |
tbl.innerHTML = "";
|
| 1219 |
warn.innerHTML = "";
|
| 1220 |
line.classList.add("hidden");
|
|
@@ -1230,7 +1260,10 @@ function renderAnvDetail() {
|
|
| 1230 |
syncQbAnalyzer();
|
| 1231 |
return;
|
| 1232 |
}
|
|
|
|
| 1233 |
const hasSn = fillAnMetrics(tbl, res, { flags: true });
|
|
|
|
|
|
|
| 1234 |
$("#and-caveat").classList.toggle("hidden", !hasSn);
|
| 1235 |
fillAnWarnings(warn, res);
|
| 1236 |
|
|
@@ -1310,8 +1343,11 @@ function fillAndShotviz(sn) {
|
|
| 1310 |
dot.setAttribute("cx", 50 - sn.a * 46);
|
| 1311 |
dot.setAttribute("cy", 50 - sn.b * 46);
|
| 1312 |
}
|
|
|
|
|
|
|
|
|
|
| 1313 |
$("#and-power-val").textContent =
|
| 1314 |
-
`${sn.v0_cue_mps.toFixed(2)} m/s (cue
|
| 1315 |
$("#and-spin-cap").textContent =
|
| 1316 |
`${sn.spin_vert} + ${sn.spin_side} (a=${sn.a.toFixed(2)}, `
|
| 1317 |
+ `b=${sn.b.toFixed(2)})`;
|
|
@@ -1320,6 +1356,60 @@ function fillAndShotviz(sn) {
|
|
| 1320 |
return true;
|
| 1321 |
}
|
| 1322 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1323 |
/** The Analyzer's quickbar row -- the mobile version of the replay widget:
|
| 1324 |
shown while a shot is open, hidden on the list screen.
|
| 1325 |
|
|
@@ -1366,9 +1456,8 @@ async function anvReplay() {
|
|
| 1366 |
closeSheet();
|
| 1367 |
const k = S.table.w / res.metrics.table_w_m;
|
| 1368 |
const pts = smoothTrack(res.track).map((p) => [p.x * k, p.y * k]);
|
| 1369 |
-
|
| 1370 |
-
|
| 1371 |
-
}
|
| 1372 |
// disable it BY HAND rather than through syncQbAnalyzer: `S.animating`
|
| 1373 |
// only becomes true INSIDE animateShot, so a sync call here would read
|
| 1374 |
// false and leave the button live
|
|
|
|
| 289 |
// trusted range is [1, 8] m/s, hence a ceiling of 8.0.
|
| 290 |
const AND_V0_MIN = 0.5, AND_V0_MAX = 8.0;
|
| 291 |
|
| 292 |
+
// How hard the shot was, in words: soft < 2.5 <= medium <= 4.5 < hard, on
|
| 293 |
+
// the model's CUE V0 in m/s. These are DISPLAY LABELS ONLY -- the m/s
|
| 294 |
+
// number is always printed beside the tier, so nothing is lost by rounding
|
| 295 |
+
// a shot into a word. The thresholds sit inside the trusted band
|
| 296 |
+
// AND_V0_MIN..AND_V0_MAX above and are meant to be re-tuned once there is a
|
| 297 |
+
// feel for real footage; this table is the single place to do it.
|
| 298 |
+
const POWER_TIERS = [
|
| 299 |
+
{ max: 2.5, inclusive: false, label: "soft" },
|
| 300 |
+
{ max: 4.5, inclusive: true, label: "medium" },
|
| 301 |
+
{ max: Infinity, inclusive: true, label: "hard" },
|
| 302 |
+
];
|
| 303 |
+
|
| 304 |
+
/** The power tier of a cue V0 in m/s. */
|
| 305 |
+
function powerTier(v0Cue) {
|
| 306 |
+
const t = POWER_TIERS.find(
|
| 307 |
+
(x) => (x.inclusive ? v0Cue <= x.max : v0Cue < x.max));
|
| 308 |
+
return (t || POWER_TIERS[POWER_TIERS.length - 1]).label;
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
const ANV = {
|
| 312 |
videoId: null,
|
| 313 |
timer: null,
|
|
|
|
| 963 |
|
| 964 |
const CONF_LABEL = { low: "low", medium: "medium", high: "high" };
|
| 965 |
|
| 966 |
+
/** Write the confidence badge into `el` -- the shot list cell and the
|
| 967 |
+
detail hero share this one builder, so the two places cannot drift.
|
| 968 |
+
|
| 969 |
+
The rank shown is the DISPLAYED one, which has already passed the
|
| 970 |
+
detection-density valve. When the valve downgraded it, the badge keeps
|
| 971 |
+
the ↓ marker and says what by: a rank that drops without explanation
|
| 972 |
+
reads as "the model is bad". */
|
| 973 |
+
function fillConfBadge(el, sn) {
|
| 974 |
+
el.className = `anv-badge anv-badge-${sn.confidence}`;
|
| 975 |
+
el.textContent = CONF_LABEL[sn.confidence] || sn.confidence;
|
| 976 |
+
el.removeAttribute("title");
|
| 977 |
+
if (sn.confidence_raw && sn.confidence_raw !== sn.confidence) {
|
| 978 |
+
el.title = `downgraded from ${CONF_LABEL[sn.confidence_raw]} -- `
|
| 979 |
+
+ `detection density ${(sn.det_density || 0).toFixed(2)}/frame`;
|
| 980 |
+
el.textContent += " ↓";
|
| 981 |
+
}
|
| 982 |
+
}
|
| 983 |
+
|
| 984 |
/** The shot table, filling in GRADUALLY. Numbers are shown exactly as the
|
| 985 |
server sent them; V0, phi and spin prefer the model column, falling back
|
| 986 |
to the analytic one labelled "bi" (ball) because they are different
|
|
|
|
| 1086 |
} else if (i === 5) { // the confidence badge
|
| 1087 |
if (sn) {
|
| 1088 |
const b = document.createElement("span");
|
| 1089 |
+
fillConfBadge(b, sn);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1090 |
td.appendChild(b);
|
| 1091 |
} else {
|
| 1092 |
td.textContent = s.spin_confidence
|
|
|
|
| 1243 |
const warn = $("#and-warnings");
|
| 1244 |
const line = $("#and-resim");
|
| 1245 |
const btn = $("#and-replay");
|
|
|
|
| 1246 |
if (!res.metrics) {
|
| 1247 |
+
hideAndHero();
|
| 1248 |
tbl.innerHTML = "";
|
| 1249 |
warn.innerHTML = "";
|
| 1250 |
line.classList.add("hidden");
|
|
|
|
| 1260 |
syncQbAnalyzer();
|
| 1261 |
return;
|
| 1262 |
}
|
| 1263 |
+
fillAndHero(res);
|
| 1264 |
const hasSn = fillAnMetrics(tbl, res, { flags: true });
|
| 1265 |
+
// ruled 1 of the schema docstring: model numbers on screen ALWAYS carry
|
| 1266 |
+
// the sim-only caveat, which now sits directly under the hero
|
| 1267 |
$("#and-caveat").classList.toggle("hidden", !hasSn);
|
| 1268 |
fillAnWarnings(warn, res);
|
| 1269 |
|
|
|
|
| 1343 |
dot.setAttribute("cx", 50 - sn.a * 46);
|
| 1344 |
dot.setAttribute("cy", 50 - sn.b * 46);
|
| 1345 |
}
|
| 1346 |
+
// the tier first, because it is the part a player reads; the m/s
|
| 1347 |
+
// number rides along so the word never hides a measurement, and "(cue)"
|
| 1348 |
+
// keeps it apart from the analytic BALL speed
|
| 1349 |
$("#and-power-val").textContent =
|
| 1350 |
+
`${powerTier(sn.v0_cue_mps)} -- ${sn.v0_cue_mps.toFixed(2)} m/s (cue)`;
|
| 1351 |
$("#and-spin-cap").textContent =
|
| 1352 |
`${sn.spin_vert} + ${sn.spin_side} (a=${sn.a.toFixed(2)}, `
|
| 1353 |
+ `b=${sn.b.toFixed(2)})`;
|
|
|
|
| 1356 |
return true;
|
| 1357 |
}
|
| 1358 |
|
| 1359 |
+
// The plain-English gloss under the big spin label. It is derived from the
|
| 1360 |
+
// SAME two class fields the label uses, never from raw a/b, so the sentence
|
| 1361 |
+
// can never contradict the label above it.
|
| 1362 |
+
const SPIN_VERT_WORDS = {
|
| 1363 |
+
follow: "hit above centre",
|
| 1364 |
+
stun: "hit at centre height",
|
| 1365 |
+
draw: "hit below centre",
|
| 1366 |
+
};
|
| 1367 |
+
const SPIN_SIDE_WORDS = { "side-L": "left side", "side-R": "right side" };
|
| 1368 |
+
|
| 1369 |
+
/** The big spin label, in the SYSTEM vocabulary (follow/stun/draw +
|
| 1370 |
+
side-L/side-R) so the headline and the numbers table say the same
|
| 1371 |
+
words. */
|
| 1372 |
+
const spinLabel = (sn) => sn.spin_vert.charAt(0).toUpperCase()
|
| 1373 |
+
+ `${sn.spin_vert.slice(1)} + ${sn.spin_side}`;
|
| 1374 |
+
|
| 1375 |
+
const spinExplain = (sn) =>
|
| 1376 |
+
`${SPIN_VERT_WORDS[sn.spin_vert] || sn.spin_vert}, `
|
| 1377 |
+
+ `${SPIN_SIDE_WORDS[sn.spin_side] || sn.spin_side}`;
|
| 1378 |
+
|
| 1379 |
+
/** The hero at the top of the detail page: WHAT SPIN and HOW HARD, the two
|
| 1380 |
+
things the player came for. It is on as soon as the page opens whenever
|
| 1381 |
+
the shot has a `shotnet` block -- the widget used to wait for a replay,
|
| 1382 |
+
which buried the headline.
|
| 1383 |
+
|
| 1384 |
+
Without a model block it says so in one line and falls back to the
|
| 1385 |
+
coarse analytic spin baseline, rather than inventing numbers. */
|
| 1386 |
+
function fillAndHero(res) {
|
| 1387 |
+
const sn = (res || {}).shotnet || null;
|
| 1388 |
+
const none = $("#and-noshotnet");
|
| 1389 |
+
$("#and-shotviz").classList.toggle("hidden", !sn);
|
| 1390 |
+
none.classList.toggle("hidden", Boolean(sn));
|
| 1391 |
+
if (sn) {
|
| 1392 |
+
fillAndShotviz(sn);
|
| 1393 |
+
$("#and-spin-label").textContent = spinLabel(sn);
|
| 1394 |
+
$("#and-spin-explain").textContent = spinExplain(sn);
|
| 1395 |
+
fillConfBadge($("#and-conf"), sn);
|
| 1396 |
+
return;
|
| 1397 |
+
}
|
| 1398 |
+
none.textContent = "No model prediction for this shot."
|
| 1399 |
+
+ (res.spin_class
|
| 1400 |
+
? ` Analytic spin baseline: ${res.spin_class}`
|
| 1401 |
+
+ (res.spin_confidence ? ` (confidence: ${res.spin_confidence})` : "")
|
| 1402 |
+
+ "."
|
| 1403 |
+
: "");
|
| 1404 |
+
}
|
| 1405 |
+
|
| 1406 |
+
/** A shot whose JSON carries no numbers at all (an error result): there is
|
| 1407 |
+
no headline to show, and the reason is printed with the warnings. */
|
| 1408 |
+
function hideAndHero() {
|
| 1409 |
+
$("#and-shotviz").classList.add("hidden");
|
| 1410 |
+
$("#and-noshotnet").classList.add("hidden");
|
| 1411 |
+
}
|
| 1412 |
+
|
| 1413 |
/** The Analyzer's quickbar row -- the mobile version of the replay widget:
|
| 1414 |
shown while a shot is open, hidden on the list screen.
|
| 1415 |
|
|
|
|
| 1456 |
closeSheet();
|
| 1457 |
const k = S.table.w / res.metrics.table_w_m;
|
| 1458 |
const pts = smoothTrack(res.track).map((p) => [p.x * k, p.y * k]);
|
| 1459 |
+
// the spin/power widget is the page hero now and is already on screen --
|
| 1460 |
+
// the replay no longer has to reveal it
|
|
|
|
| 1461 |
// disable it BY HAND rather than through syncQbAnalyzer: `S.animating`
|
| 1462 |
// only becomes true INSIDE animateShot, so a sync call here would read
|
| 1463 |
// false and leave the button live
|
app/static/index.html
CHANGED
|
@@ -89,24 +89,18 @@
|
|
| 89 |
<a id="and-download" class="btn-mini and-link hidden">⬇ Download mp4</a>
|
| 90 |
</div>
|
| 91 |
<h3 id="and-title"></h3>
|
| 92 |
-
<
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
its interactive ids and classes are its own
|
| 105 |
-
(.and-spin-dot / .and-power-bar) because .spin-dot/.power-bar
|
| 106 |
-
were swept globally by the earlier initialisers. The numbers
|
| 107 |
-
come from the SHOTNET set (the analytic path has no a/b) and
|
| 108 |
-
the set is named in the label. -->
|
| 109 |
-
<div id="and-shotviz" class="viz hidden">
|
| 110 |
<div class="viz-spin">
|
| 111 |
<svg id="and-spin-ball" viewBox="0 0 100 100" aria-label="cue-ball contact point (model)">
|
| 112 |
<defs>
|
|
@@ -123,12 +117,37 @@
|
|
| 123 |
<div class="viz-cap" id="and-spin-cap"></div>
|
| 124 |
</div>
|
| 125 |
<div class="viz-power">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
<div class="viz-lbl">Power <b id="and-power-val"></b></div>
|
| 127 |
<div id="and-power-bar" class="and-power-bar"></div>
|
| 128 |
<div class="viz-scale"><span>0.5</span><span>m/s</span><span>8.0</span></div>
|
| 129 |
<div class="viz-cap" id="and-viz-label"></div>
|
| 130 |
</div>
|
| 131 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
<div id="and-suspect" class="an-warn hidden"></div>
|
| 133 |
<!-- The re-simulation layer is a toggle, DEFAULTING OFF (the
|
| 134 |
observed path and the re-simulation currently disagree on the
|
|
@@ -141,9 +160,6 @@
|
|
| 141 |
<div id="and-resim" class="an-note hidden"></div>
|
| 142 |
<div id="and-warnings"></div>
|
| 143 |
<table id="and-metrics" class="an-metrics"></table>
|
| 144 |
-
<div id="and-caveat" class="an-caveat hidden">The model is
|
| 145 |
-
trained purely on simulation (P2) and has not been tuned on real
|
| 146 |
-
footage.</div>
|
| 147 |
<div class="an-note">The table on the left: a <b>solid line
|
| 148 |
coloured by time</b> is the observed track; balls carry the
|
| 149 |
number and colour from BallID when the result has them (older
|
|
|
|
| 89 |
<a id="and-download" class="btn-mini and-link hidden">⬇ Download mp4</a>
|
| 90 |
</div>
|
| 91 |
<h3 id="and-title"></h3>
|
| 92 |
+
<!-- The HERO of the page: what spin and how hard, the two numbers
|
| 93 |
+
a player actually reads. It is on from the moment the page
|
| 94 |
+
opens whenever the shot carries a `shotnet` block -- it used
|
| 95 |
+
to appear only during a replay, which buried the headline
|
| 96 |
+
under the debug numbers. It keeps the STYLE-ONLY markup and
|
| 97 |
+
CSS (.viz*) of the older #shotviz, but its interactive ids
|
| 98 |
+
and classes are its own (.and-spin-dot / .and-power-bar)
|
| 99 |
+
because .spin-dot/.power-bar were swept globally by the
|
| 100 |
+
earlier initialisers. The numbers come from the SHOTNET set
|
| 101 |
+
(the analytic path has no a/b) and the set is named in the
|
| 102 |
+
label. -->
|
| 103 |
+
<div id="and-shotviz" class="viz and-hero hidden">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
<div class="viz-spin">
|
| 105 |
<svg id="and-spin-ball" viewBox="0 0 100 100" aria-label="cue-ball contact point (model)">
|
| 106 |
<defs>
|
|
|
|
| 117 |
<div class="viz-cap" id="and-spin-cap"></div>
|
| 118 |
</div>
|
| 119 |
<div class="viz-power">
|
| 120 |
+
<!-- The confidence badge borrows the STYLE-ONLY .anv-badge
|
| 121 |
+
classes of the shot list (nothing binds events to them).
|
| 122 |
+
It shows the DISPLAYED rank, which has already been
|
| 123 |
+
through the detection-density valve, and keeps the same
|
| 124 |
+
downgrade marker the list row uses. -->
|
| 125 |
+
<div class="and-hero-head">
|
| 126 |
+
<div id="and-spin-label" class="and-hero-spin"></div>
|
| 127 |
+
<span id="and-conf" class="anv-badge"></span>
|
| 128 |
+
</div>
|
| 129 |
+
<div id="and-spin-explain" class="and-hero-sub"></div>
|
| 130 |
<div class="viz-lbl">Power <b id="and-power-val"></b></div>
|
| 131 |
<div id="and-power-bar" class="and-power-bar"></div>
|
| 132 |
<div class="viz-scale"><span>0.5</span><span>m/s</span><span>8.0</span></div>
|
| 133 |
<div class="viz-cap" id="and-viz-label"></div>
|
| 134 |
</div>
|
| 135 |
</div>
|
| 136 |
+
<!-- No model block: say so in one line and fall back to the coarse
|
| 137 |
+
analytic spin baseline, rather than inventing a number. -->
|
| 138 |
+
<div id="and-noshotnet" class="an-note hidden"></div>
|
| 139 |
+
<div id="and-caveat" class="an-caveat hidden">The model is
|
| 140 |
+
trained purely on simulation (P2) and has not been tuned on real
|
| 141 |
+
footage.</div>
|
| 142 |
+
<video id="and-video" class="and-video hidden" controls playsinline></video>
|
| 143 |
+
<div id="and-video-missing" class="an-note hidden">This shot has
|
| 144 |
+
no overlay.mp4 yet -- the result comes from a run before A2 (the
|
| 145 |
+
clip is deleted, so it cannot be rendered retroactively), or the
|
| 146 |
+
render failed (see the warnings).</div>
|
| 147 |
+
<!-- The replay animates the OBSERVED trajectory, so the real
|
| 148 |
+
shot comes back to life and is right regardless of the
|
| 149 |
+
model. -->
|
| 150 |
+
<button id="and-replay" class="an-cta" disabled>▶ Replay the shot (observed path)</button>
|
| 151 |
<div id="and-suspect" class="an-warn hidden"></div>
|
| 152 |
<!-- The re-simulation layer is a toggle, DEFAULTING OFF (the
|
| 153 |
observed path and the re-simulation currently disagree on the
|
|
|
|
| 160 |
<div id="and-resim" class="an-note hidden"></div>
|
| 161 |
<div id="and-warnings"></div>
|
| 162 |
<table id="and-metrics" class="an-metrics"></table>
|
|
|
|
|
|
|
|
|
|
| 163 |
<div class="an-note">The table on the left: a <b>solid line
|
| 164 |
coloured by time</b> is the observed track; balls carry the
|
| 165 |
number and colour from BallID when the result has them (older
|
app/static/style.css
CHANGED
|
@@ -154,7 +154,7 @@ main {
|
|
| 154 |
/* The replay widget on the detail page: it looks EXACTLY like the older
|
| 155 |
#shotviz but carries its OWN interactive classes, because .power-bar and
|
| 156 |
.spin-dot were swept globally by the suggestion tab's initialisers. */
|
| 157 |
-
#and-spin-ball { width:
|
| 158 |
.and-spin-dot { transition: cx .25s ease, cy .25s ease; }
|
| 159 |
.and-power-bar { display: flex; gap: 4px; }
|
| 160 |
.and-power-bar .seg {
|
|
@@ -165,6 +165,25 @@ main {
|
|
| 165 |
transition: background .2s;
|
| 166 |
}
|
| 167 |
#and-shotviz { margin-top: 10px; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
.and-toggle {
|
| 169 |
display: flex;
|
| 170 |
align-items: center;
|
|
|
|
| 154 |
/* The replay widget on the detail page: it looks EXACTLY like the older
|
| 155 |
#shotviz but carries its OWN interactive classes, because .power-bar and
|
| 156 |
.spin-dot were swept globally by the suggestion tab's initialisers. */
|
| 157 |
+
#and-spin-ball { width: 104px; height: 104px; display: block; margin: 0 auto; }
|
| 158 |
.and-spin-dot { transition: cx .25s ease, cy .25s ease; }
|
| 159 |
.and-power-bar { display: flex; gap: 4px; }
|
| 160 |
.and-power-bar .seg {
|
|
|
|
| 165 |
transition: background .2s;
|
| 166 |
}
|
| 167 |
#and-shotviz { margin-top: 10px; }
|
| 168 |
+
/* The hero at the top of the detail page: the spin label is the headline of
|
| 169 |
+
the whole page, so it reads at heading size with the plain sentence and
|
| 170 |
+
the power line stepping down from it. */
|
| 171 |
+
.and-hero-head {
|
| 172 |
+
display: flex;
|
| 173 |
+
align-items: center;
|
| 174 |
+
gap: 9px;
|
| 175 |
+
flex-wrap: wrap;
|
| 176 |
+
}
|
| 177 |
+
.and-hero-spin {
|
| 178 |
+
font-size: 19px;
|
| 179 |
+
font-weight: 700;
|
| 180 |
+
letter-spacing: .2px;
|
| 181 |
+
}
|
| 182 |
+
.and-hero-sub {
|
| 183 |
+
color: var(--muted);
|
| 184 |
+
font-size: 12.5px;
|
| 185 |
+
margin: 3px 0 10px;
|
| 186 |
+
}
|
| 187 |
.and-toggle {
|
| 188 |
display: flex;
|
| 189 |
align-items: center;
|