/* Slant 0.2.

   Drawn at 2162 x 1389 deliberately, a large monitor. Every size is expressed
   in rem and the whole scale hangs off the root font size, so nothing needs
   hand-tuning per breakpoint. 0.74vw resolves to 16px at 2162, which makes
   1rem = 16px at design size; the clamp floor stops it collapsing on narrow
   screens and the ceiling stops it growing past the design on ultrawide, where
   the margins should absorb the extra width instead. rem inside the clamp
   rather than px keeps the reader's browser font-size preference working. */

html { font-size: clamp(0.6rem, 0.74vw, 1rem); }

/* DM Sans, vendored. Variable, so two files cover 400/500/600.
   Latin only: the family ships no Cyrillic at all (verified against the font
   binary, 0 glyphs in U+0400-04FF), so Russian and Ukrainian output falls
   through to the stack below. Known and accepted. The same gap is why the
   design itself set the keyboard hint in Inter: DM Sans has no glyph for
   either the command or the return symbol. */
@font-face {
  font-family: 'DM Sans';
  font-style: normal;
  font-weight: 100 1000;
  font-display: swap;
  src: url(../fonts/dmsans-latin.woff2) format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
    U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193,
    U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: 'DM Sans';
  font-style: normal;
  font-weight: 100 1000;
  font-display: swap;
  src: url(../fonts/dmsans-latin-ext.woff2) format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF,
    U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020,
    U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}

:root {
  /* Open Color, by name. No token file: these are the palette's own names and
     the per-shuffle hue rotation is generated from the same source in JS. */
  --oc-gray-0: #f8f9fa;
  --oc-gray-1: #f1f3f5;
  --oc-gray-3: #dee2e6;
  --oc-gray-4: #ced4da;
  --oc-gray-5: #adb5bd;
  --oc-gray-6: #868e96;
  --oc-gray-7: #495057;
  --oc-gray-8: #343a40;
  --oc-gray-9: #212529;
  --oc-orange-6: #fd7e14;
  /* Fixed reds for button states. Deliberately not --hue: that is re-rolled
     every shuffle, and a control should not change colour with the card. */
  --oc-red-5: #ff6b6b;
  --oc-red-7: #f03e3e;

  --ink: var(--oc-gray-8);
  --alert: var(--oc-orange-6);

  --page-x: 3.7%;
  --page-y: 5rem;
  --radius: 2rem;
  --card-pad: 3rem;
  --card-gap: 5rem;

  --font: 'DM Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
  /* DM Sans has no command or return glyph; the design used Inter here too. */
  --font-sym: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;

  --fade: 320ms;
  --ease-out: cubic-bezier(0.23, 1, 0.32, 1);

  /* Set per shuffle from JS. Red-5 / red-4 matches the Result frame. */
  --hue: #ff6b6b;
  --hue-light: #ff8787;
  --rot-a: -4.5deg;
  --rot-b: 6.7deg;
}

*, *::before, *::after { box-sizing: border-box; }

html, body { height: 100%; margin: 0; }

body {
  background: #fff;
  color: var(--ink);
  font-family: var(--font);
  font-size: 2.25rem;
  /* DM Sans' own default: the frame's 36px text sits on a 47px line, and the
     reading column only totals the design's 880px with this exact ratio. */
  line-height: 1.3056;
  -webkit-font-smoothing: antialiased;
  overflow: hidden;
}

.sprite { position: absolute; width: 0; height: 0; }

button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
textarea, input { font: inherit; color: inherit; background: none; border: 0; margin: 0; padding: 0; }
textarea { resize: none; outline: none; overflow: auto; }
p, h2 { margin: 0; }

::selection { background: var(--hue-light); color: #fff; }


/* ── Screens ─────────────────────────────────────────────────────────── */

.screen {
  position: absolute;
  inset: 0;
  padding: var(--page-y) var(--page-x);
  animation: fade-in var(--fade) var(--ease-out) both;
}
.screen[hidden] { display: none; }
.screen--leaving { animation: fade-out var(--fade) var(--ease-out) both; pointer-events: none; }

@keyframes fade-in  { from { opacity: 0 } to { opacity: 1 } }
@keyframes fade-out { from { opacity: 1 } to { opacity: 0 } }


/* ── Pills ───────────────────────────────────────────────────────────── */

.pill {
  position: relative;
  display: inline-flex;
  align-items: flex-start;
  gap: 0.75rem;
  padding: 1.5rem 2.5rem 1.5rem 3rem;   /* asymmetric, from the layer data */
  border: 1px solid var(--ink);
  border-radius: 3.5rem;
  font-size: 3rem;
  font-weight: 500;
  line-height: 3.875rem;
  color: var(--ink);
  transition: border-color 140ms ease, color 140ms ease;
}

/* Hover and press, in colour rather than motion.

   The first pass scaled the pill on hover. Scaling resamples the type inside
   it and the label goes soft at exactly the moment the eye is on it, which is
   a bad trade for a button whose whole job is one word. Border and label
   colour carry the same signal and leave the text alone.

   No pointer gate needed now: none of this is motion, so there is nothing for
   a touch device to get stuck in.

   Dimmed and normal pills hover identically. The dimming tracks "nothing
   entered yet", never "you cannot press this" -- the changelog calls the pill
   always active regardless of dimming -- so the hover makes the same promise
   either way. .pill:hover outranks .pill.is-dim on specificity, so one rule
   covers both.

   Nothing responds while the sweep is running: the pill really is inert then
   (reframe returns early on state.busy), so reacting would be a lie. */
.pill:hover:not(.is-sweeping) {
  color: var(--oc-red-5);
  border-color: var(--oc-red-5);
}

/* Press deepens the same red. Every one of these starts a 15-20 second wait,
   and a button that does not visibly take the click reads as broken for the
   second before the sweep appears. */
.pill:active:not(.is-sweeping) {
  color: var(--oc-red-7);
  border-color: var(--oc-red-7);
}

.pill--sm { padding: 0.5rem 1.5rem; }

/* A class selector outranks the UA stylesheet's [hidden] rule, so display has
   to be re-hidden explicitly or the Retry button shows on a good result. */
.pill[hidden] { display: none; }

.pill__arrow { width: 4rem; height: 4rem; flex: none; }

/* Dimmed while a request is in flight. Still rendered, never removed: the
   sweep needs a border to travel along. */
.pill.is-dim { color: var(--oc-gray-6); border-color: var(--oc-gray-4); }

.pill:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }

.hint {
  margin-top: 1rem;
  font-family: var(--font-sym);
  font-size: 2rem;
  line-height: 2.4375rem;   /* the frame's 39px box, not the inherited 1.4 */
  letter-spacing: -0.0147em;
  text-align: center;
  color: var(--oc-gray-5);
  transition: opacity 160ms var(--ease-out);
}
.hint.is-dim { opacity: 0.45; }
.hint[hidden] { display: none; }


/* Border sweep. One arc of a quarter perimeter travels while the rest of the
   border stays at its normal weight. SVG rather than a masked conic gradient
   because the fully-rounded ends make the path exact, and pathLength="100"
   normalises the dash maths so the arc stays a quarter at any rendered size. */
.sweep {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
  opacity: 0;
  transition: opacity 200ms var(--ease-out);
}
.sweep rect {
  width: calc(100% - 1px);
  height: calc(100% - 1px);
  rx: 3.5rem;
  ry: 3.5rem;
  fill: none;
  stroke: var(--oc-gray-7);
  stroke-width: 1.5;
  stroke-linecap: round;
  stroke-dasharray: 25 75;
}
.pill.is-sweeping .sweep { opacity: 1; }
/* One lap every 3.2s. Measured first-card latency is 15-19s, so that is five
   or six unhurried laps; the changelog's 2.5s was tuned against 9-13s and a
   faster sweep gets irritating by the second lap. */
.pill.is-sweeping .sweep rect { animation: sweep 3.2s linear infinite; }

@keyframes sweep { to { stroke-dashoffset: -100; } }


/* ── Intro ───────────────────────────────────────────────────────────── */

.screen--intro { display: flex; flex-direction: column; justify-content: space-between; }

.intro { max-width: 110rem; }

.intro__copy {
  font-size: 3rem;
  line-height: 3.875rem;
  color: var(--ink);
}
.intro__copy + .intro__copy { margin-top: 1.5rem; }
.intro__copy b { font-weight: 500; }

.logo {
  width: 2.1875rem;
  height: 2.1875rem;
  margin-right: 0.75rem;
  color: var(--ink);
}

/* Sits in the text flow: no label, no box, body size. */
.key {
  display: block;
  width: 100%;
  margin-top: 1.5rem;
  font-size: 3rem;
  line-height: 3.875rem;
  color: var(--oc-gray-6);
  outline: none;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: clip;
}
.key::placeholder { color: var(--oc-gray-5); opacity: 1; }

/* The fade belongs to the placeholder treatment only. A real entered value
   renders un-faded, which is why this is gated on .is-empty.

   Measured off the frame rather than eyeballed: the alpha there falls in a
   straight line from full at the first character to nothing at the last --
   1.00 at the start, 0.61 at 500px, 0.32 at 800px, 0 by 1130px. The first
   version held fully opaque for 48rem and then dropped, which is what made it
   look displaced. One stop at 0 and one at the string's own width. */
.key.is-empty {
  -webkit-mask-image: linear-gradient(to right, #000 0, transparent 67rem);
  mask-image: linear-gradient(to right, #000 0, transparent 67rem);
}

/* Empty submit nudges the placeholder. Once a value exists there is no
   placeholder to nudge, so this fires on exactly one condition. */
.key.is-nudged { animation: nudge 360ms var(--ease-out); }

/* Empty submit on the compose screen nudges its placeholder too. The class was
   being applied from the start and had nothing to match; the textarea is empty
   at that moment, so shaking it reads as shaking the placeholder. */
.decision-input.is-nudged { animation: nudge 360ms var(--ease-out); }

@keyframes nudge {
  0%, 100% { transform: translateX(0) }
  22%      { transform: translateX(-0.75rem) }
  58%      { transform: translateX(0.5rem) }
  84%      { transform: translateX(-0.2rem) }
}

/* Colour points at the wrong thing rather than at a sentence about it. */
.key.is-invalid { color: var(--alert); }

.intro__go {
  display: flex;
  align-items: center;
  gap: 1.5rem;
  margin-top: 1.5rem;
}

.note {
  font-size: 1.5rem;
  color: var(--oc-gray-6);
}
.note[hidden] { display: none; }

.helper {
  font-size: 1.5rem;
  color: var(--oc-gray-7);
  padding-bottom: 0;
  margin-bottom: -1.25rem;
}
/* Thin and dimmed, as the frame draws it: a gray-5 rule under gray-7 text,
   measured at ~0.25em below the baseline. It had been dropped to none. */
.helper a {
  color: inherit;
  font-weight: 500;
  text-decoration: underline;
  text-decoration-thickness: 1px;
  text-underline-offset: 0.25em;
  text-decoration-color: var(--oc-gray-5);
  transition: color 140ms ease, text-decoration-color 140ms ease;
}
.helper a:hover { color: var(--ink); text-decoration-color: var(--oc-gray-7); }


/* ── Empty state / Loading ───────────────────────────────────────────── */

.screen--compose { display: flex; flex-direction: column; }

.compose__field { display: flex; flex: 1; min-height: 0; cursor: text; }

.decision-input {
  width: 100%;
  height: 100%;
  font-size: 3rem;
  line-height: 3.875rem;
  color: var(--ink);
}
.decision-input::placeholder { color: var(--oc-gray-6); opacity: 1; }

.compose__foot {
  display: flex;
  flex: none;
  flex-direction: column;
  align-items: center;
  padding-bottom: 0;
  margin-bottom: -1rem;
}

.alert {
  margin-bottom: 2.25rem;
  font-size: 1.5rem;
  color: var(--alert);
  text-align: center;
}
.alert[hidden] { display: none; }


/* ── Result ──────────────────────────────────────────────────────────── */

/* Left 56.5%, right 25%, page margin 3.7% a side, which leaves 11.1% of gap
   between the columns. vw rather than percent: a percentage here would resolve
   against the padded content box, not the viewport, and the columns are
   specified against the viewport.

   Scrolls when it has to. The frame's sample reading fits 1389px, but real
   ones run longer, and clipping the move -- the one field that asks for an
   action -- would be a real defect. */
.screen--result {
  display: grid;
  grid-template-columns: 56.5vw 25vw;
  justify-content: space-between;
  align-content: start;
  overflow-y: auto;
}

.reading { display: flex; flex-direction: column; gap: 2rem; align-items: flex-start; }

/* The five blocks are a designed reading order -- question, tell, the body,
   then the turn into the move -- so they arrive in that order rather than as
   one wall of text. 40ms apart, inside the 30-80ms band, and small enough to
   read as settling rather than as a performance: this screen is seen ten or
   twenty times a session, which is the tier where motion is near-imperceptible
   or it should not be there at all.

   claim and prediction deliberately share one step. They are specified as one
   text block with a paragraph break, not two elements, and staggering them
   would contradict that.

   Keyframes rather than a transition, because each shuffle is genuinely a new
   entrance rather than the same element retargeting -- the case the skill's
   transition rule is about. renderCard restarts them by hand. */
.reading.is-entering > * { animation: block-in 240ms var(--ease-out) both; }
.reading.is-entering > :nth-child(2) { animation-delay: 40ms; }
.reading.is-entering > :nth-child(3) { animation-delay: 80ms; }
.reading.is-entering > :nth-child(4) { animation-delay: 120ms; }
.reading.is-entering > :nth-child(5) { animation-delay: 160ms; }

@keyframes block-in {
  from { opacity: 0; transform: translateY(0.5rem); }
  to   { opacity: 1; transform: translateY(0); }
}

.question {
  font-size: 3rem;
  line-height: 3.875rem;
  font-weight: 600;
  color: var(--hue);
}

.tell {
  font-size: 2.25rem;
  font-weight: 500;
  color: var(--hue-light);
}

/* claim and prediction read as one block with a paragraph break, not two
   styled elements. */
.body p + p { margin-top: 1.5rem; }

.turn {
  width: 2.5rem;
  height: 2.5rem;
  color: var(--ink);
  /* Standalone `rotate`, not transform: rotate(). The entrance animates
     `transform` on every child of .reading, and a transform keyframe replaces
     a static transform outright -- which pointed this arrow right instead of
     down. The individual property composes with it instead. */
  rotate: 90deg;
}

.move { font-weight: 600; color: var(--ink); }


/* ── The two cards ───────────────────────────────────────────────────── */

.stack { display: flex; flex-direction: column; align-items: stretch; }

.card {
  display: flex;
  flex-direction: column;
  gap: var(--card-gap);
  padding: var(--card-pad);
  border-radius: var(--radius);
  text-align: left;
  font-size: 1.75rem;
  line-height: 2.5rem;
  cursor: pointer;
  transition: transform 240ms var(--ease-out);
}

.card--model {
  background: var(--hue);
  color: #fff;
  transform: rotate(var(--rot-a));
  z-index: 1;
}

.card--decision {
  margin-top: 1.5rem;
  background: var(--oc-gray-0);
  color: var(--oc-gray-9);
  transform: rotate(var(--rot-b));
}

/* Gated on a real pointer. Touch fires a false hover on tap and then leaves
   it stuck, which on a card this size is a visibly wrong resting state. The
   width query below is not a substitute: a touch laptop is wide. */
@media (hover: hover) and (pointer: fine) {
  .card:hover { transform: rotate(var(--rot-a)) scale(1.015); }
  .card--decision:hover { transform: rotate(var(--rot-b)) scale(1.015); }
}
/* Focus lives on the inner button, and the ring is drawn around the whole
   card so a keyboard user sees the target they are actually about to hit. */
.card:has(.card__act:focus-visible) { outline: 2px solid var(--ink); outline-offset: 6px; }
.card__act:focus-visible { outline: none; }

.model { font-size: 1.75rem; line-height: 2.5rem; font-weight: 600; }
.model a {
  color: inherit;
  text-decoration: underline;
  text-underline-offset: 0.22em;
  text-decoration-thickness: 1px;
  /* The frame draws this rule in pure white; muting it is a deliberate
     departure, asked for because at full strength it competes with the name.
     0.5 keeps it legible on the darker hues without shouting on the light
     ones -- the hue is randomised, so it has to survive both ends. */
  text-decoration-color: rgba(255, 255, 255, 0.5);
  transition: text-decoration-color 140ms ease;
}

/* Hover brings the rule up to strength rather than thickening it: the whole
   point of the muted rest state is that it stays thin. Colour only, so the
   name's own contrast is untouched. */
@media (hover: hover) and (pointer: fine) {
  .model a:hover { text-decoration-color: rgba(255, 255, 255, 0.95); }
}

.gloss { margin-top: 0.5rem; }
/* The name and gloss are one block; the gap belongs between that and the
   action row, which is what the 80px layout gap in the frame is. */
.card__head { display: block; }

.card__act {
  display: flex;
  flex-direction: column;
  gap: 0.25rem;
  align-items: flex-start;
  font: inherit;
  color: inherit;
  text-align: left;
}
.card__act svg { width: 2rem; height: 2rem; }
.card--decision .card__act { color: var(--oc-gray-6); transition: color 140ms ease; }
/* The whole card is the click target, so the label responds to the card, not
   only to the pointer being over the label itself. */
.card--decision:hover .card__act { color: var(--oc-gray-7); }
.card--decision .card__act svg { width: 2.5rem; height: 2.5rem; }

/* Ruled like a notepad: an underline per line box, which is what the design
   draws. text-decoration follows the line, so it survives any wrap. */
.ruled {
  text-decoration: underline;
  text-decoration-color: var(--oc-gray-3);
  text-decoration-thickness: 1px;
  /* Measured off the frame rather than guessed: the rules there repeat at
     exactly 40px, the line-height, and sit 6-8px below the baseline with the
     descenders just grazing them. 0.25em is 7px at 28px, the middle of that.
     The old 0.55em put the rule most of the way into the next line. */
  text-underline-offset: 0.25em;
  white-space: pre-wrap;
}

.result__foot {
  grid-column: 1 / -1;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: flex-end;
}


/* ── Result: loading and error ───────────────────────────────────────── */

/* Existing content and both cards drop back; the loader appears below. */
.screen--result.is-loading .reading,
.screen--result.is-loading .stack { opacity: 0.6; pointer-events: none; }

/* The gray card stays, so the text is still visible and Start over is still
   reachable. Everything else clears and the screen becomes one centred column.

   The foot lands exactly where the compose screen's does -- pill bottom 1270,
   hint bottom 1325 -- and the card is anchored to it rather than centred in
   the space, which is how the frame places it and which also means a longer
   decision grows upward instead of pushing the controls off-screen. */
.screen--result.is-error {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: flex-end;
}
.screen--result.is-error .reading,
.screen--result.is-error .card--model { display: none; }
.screen--result.is-error .stack { display: contents; }
.screen--result.is-error .card--decision {
  width: 25vw;
  margin: 0 0 7.625rem;
}
.screen--result.is-error .result__foot { margin-bottom: -1rem; }

/* Squishy loader, from css-loaders.com. Both keyframes use 13 curve commands
   in matching order; that is what makes the morph interpolate rather than
   snap. If the path is edited the count has to stay matched on both sides.
   Recolouring is just background, since the shape is entirely clip-path. */
.loader {
  height: 3.75rem;
  aspect-ratio: 1;
  background: var(--oc-gray-5);
  animation: squish 1s infinite alternate linear;
}
.loader[hidden] { display: none; }

@keyframes squish {
  0%{clip-path: shape(from 85.0242216976% 60.8470934779%, curve to 80.3178442208% 70.6199870088% with 95.0484433951% 71.6941869559%, curve to 63.3566458721% 84.1460913354% with 65.5872450465% 69.5457870617%, curve to 52.7815116745% 86.5597967068% with 61.1260466978% 98.7463956091%, curve to 31.6312432791% 81.732385964% with 44.4369766511% 74.3731978045%, curve to 23.1506441048% 74.9693338007% with 18.8255099071% 89.0915741234%, curve to 13.7378891512% 55.423546739% with 27.4757783024% 60.8470934779%, curve to 13.7378891512% 44.576453261% with 0% 50%, curve to 23.1506441048% 25.0306661993% with 27.4757783024% 39.1529065221%, curve to 31.6312432791% 18.267614036% with 18.8255099071% 10.9084258766%, curve to 52.7815116745% 13.4402032932% with 44.4369766511% 25.6268021955%, curve to 63.3566458721% 15.8539086646% with 61.1260466978% 1.2536043909%, curve to 80.3178442208% 29.3800129912% with 65.5872450465% 30.4542129383%, curve to 85.0242216976% 39.1529065221% with 95.0484433951% 28.3058130441%, curve to 85.0242216976% 60.8470934779% with 75% 50%)}
  to {clip-path: shape(from 86.2621108488% 55.423546739%, curve to 76.8493558952% 74.9693338007% with 72.5242216976% 60.8470934779%, curve to 68.3687567209% 81.732385964% with 81.1744900929% 89.0915741234%, curve to 47.2184883255% 86.5597967068% with 55.5630233489% 74.3731978045%, curve to 36.6433541279% 84.1460913354% with 38.8739533022% 98.7463956091%, curve to 19.6821557792% 70.6199870088% with 34.4127549535% 69.5457870617%, curve to 14.9757783024% 60.8470934779% with 4.9515566049% 71.6941869559%, curve to 14.9757783024% 39.1529065221% with 25% 50%, curve to 19.6821557792% 29.3800129912% with 4.9515566049% 28.3058130441%, curve to 36.6433541279% 15.8539086646% with 34.4127549535% 30.4542129383%, curve to 47.2184883255% 13.4402032932% with 38.8739533022% 1.2536043909%, curve to 68.3687567209% 18.267614036% with 55.5630233489% 25.6268021955%, curve to 76.8493558952% 25.0306661993% with 81.1744900929% 10.9084258766%, curve to 86.2621108488% 44.576453261% with 72.5242216976% 39.1529065221%, curve to 86.2621108488% 55.423546739% with 100% 50%)}
}


/* ── Below 1024: one column ──────────────────────────────────────────── */

@media (max-width: 1023px) {
  .screen { overflow-y: auto; }

  .screen--result {
    display: flex;
    flex-direction: column;
    gap: 3rem;
  }
  /* Identity first, then content, then what was asked. It also puts both
     controls at the ends of the scroll rather than buried. */
  .stack { display: contents; }
  .card--model    { order: 1; }
  .reading        { order: 2; }
  .card--decision { order: 3; margin-top: 0; }
  .result__foot   { order: 4; }

  /* A rotated card at full-bleed width overflows sideways. Shrinking the
     angle is cheaper than insetting the cards and keeps the gesture. */
  .card--model    { transform: rotate(clamp(-1.5deg, var(--rot-a), 1.5deg)); }
  .card--decision { transform: rotate(clamp(-1.5deg, var(--rot-b), 1.5deg)); }
  @media (hover: hover) and (pointer: fine) {
    .card:hover, .card--decision:hover { transform: none; }
  }

  /* No keyboard, no meaning. The listener stays; the label goes. */
  .hint { display: none; }

  .screen--result.is-error { display: flex; }
}


/* ── Reduced motion ──────────────────────────────────────────────────── */

@media (prefers-reduced-motion: reduce) {
  .screen, .screen--leaving { animation: none; }
  .key.is-nudged,
  .decision-input.is-nudged { animation: none; outline: 2px solid var(--ink); outline-offset: 0.5rem; }
  .card, .card:hover, .card--decision:hover { transition: none; }
  /* The pills signal in colour only, so there is no movement to drop. */
  /* Drop the drift and the stagger; the screen cross-fade still carries the
     arrival, so this is fewer and gentler rather than nothing. */
  .reading.is-entering > * { animation: none; }

  /* A shape morph running ten-plus cycles over a 15-19s wait is exactly what
     this setting exists for. Same size, same colour, no morph. */
  .loader { animation: none; border-radius: 50%; opacity: 0.75; }

  /* The sweep is the only signal the button is working, so it keeps a pulse
     rather than stopping outright. */
  .pill.is-sweeping .sweep rect { animation: sweep-pulse 1.8s ease-in-out infinite; }
  @keyframes sweep-pulse { 0%, 100% { opacity: 0.25 } 50% { opacity: 1 } }
}
