/*
 * TILTED BOARD — a second interface, layered over the live one.
 *
 * Loaded AFTER style.css by tilt.html only. index.html never sees it, so the live client is
 * untouched: this file can only ever change the page it is linked from.
 *
 * It reuses app.js unchanged — the whole engine, every ability, drag and drop, the rail, the
 * pins, the hand. The only thing that differs is how the two mats are PROJECTED.
 *
 * THE ONE RULE THAT MADE THIS WORK: nothing is billboarded. Every card and zone lies ON the
 * tilted table, so they foreshorten by the same factor, a card fills its zone exactly and
 * adjacent zones touch. Counter-rotating card faces was tried and is what broke alignment.
 */
:root{
  --tilt:45deg;          /* chosen */
  --persp:2200px;        /* chosen — depth vs card size; higher = flatter and larger */
}

/* .table is a grid in the base sheet ("foe log" / "score log" / "you log"). The scene now
   holds all three board rows, so it becomes a two-column flex row instead. */
/* :not(.hidden) is essential. `.hidden { display:none !important }` is how the client keeps
   the board off the lobby, and this rule is MORE SPECIFIC — so without the guard it won that
   fight and the whole play area, rail and log showed through behind the lobby. */
/* The tilted board is POSITIONED, never scrolled to — so the page must not scroll at all.
   Any page scroll here means part of your own side starts off-screen and has to be found,
   which is exactly what was happening. */
body.tilt3d{overflow:hidden}   /* height left alone: the base sets min-height:100vh */
body.tilt3d .mat{position:relative}
body.tilt3d .table:not(.hidden){
  overflow:hidden;min-height:0;
  display:flex!important;align-items:stretch;gap:.4rem;
  grid-template-areas:none;grid-template-columns:none;grid-template-rows:none;
}
body.tilt3d .scene3d{
  flex:1 1 auto;min-width:0;position:relative;overflow:hidden;
  perspective:var(--persp);
  /*
   * The vanishing point sits LOW, so the board leans back into the empty area above the
   * opponent instead of floating in the middle with dead space over it. Combined with the
   * fit below, that space becomes board rather than background.
   */
  perspective-origin:50% 72%;
}
body.tilt3d .board3d{
  position:absolute;left:50%;top:50%;
  transform:translate(-50%,-50%) translateY(var(--lift,0px)) rotateX(var(--tilt));
  transform-style:preserve-3d;
  display:flex;flex-direction:column;align-items:center;gap:.15rem;
}
/* The rail keeps its own width and never tilts. */
body.tilt3d .rail{flex:0 0 var(--rail-w,250px);align-self:stretch}

/* The play surface: fills the wedges perspective leaves behind the opponent. Decoration
   only, and pointer-transparent so it can never intercept a drag or a drop. */
body.tilt3d .surface3d{
  position:absolute;left:50%;top:50%;pointer-events:none;z-index:0;
  /* ⭐ 132%, was 168%. [user request 2026-08-25] "The blue tint fade along the left side of the
     screen ends prematurely, leaving the normal black background visible. I do not mind seeing
     more of the black starry background, so we can bring that blue tint area closer to the
     board itself." At 168% the wash reached most of the way to the screen edge and then simply
     stopped — a soft-ish edge in open space, which reads as an unfinished gradient rather than
     a horizon. Narrower, and with the horizontal mask below, it now hugs the board and
     dissolves into the star field instead of ending in it. */
  /*
   * ⭐ IT HAS TO REACH THE BOTTOM OF THE WINDOW. [user request 2026-08-29: "the blue shader is
   * not stretching all the way down to the bottom and does not cover the full background area"]
   *
   * ⚠️ THIS BROKE WHEN THE BOARD WAS RE-CENTRED. Home lift used to be 0; it is now whatever
   * centres the board's projected footprint, which is 47-103px NEGATIVE on a desktop. The
   * surface carries `--lift` too, so it rode up by that much on top of its own -6% bias and
   * left a band of bare star field along the bottom — measured at 146px uncovered at 1440x900.
   *
   * The -6% bias is gone (it existed to sit the horizon above a board that used to be parked
   * low) and the height is enough that the plane still covers the viewport at the most negative
   * lift. Verified by measurement rather than by eye: top <= 0 and bottom >= innerHeight.
   */
  width:132%;height:230%;
  transform:translate(-50%,-50%) translateY(var(--lift,0px)) rotateX(var(--tilt)) translateZ(-2px);
  background:
    radial-gradient(120% 90% at 50% 88%, rgba(8,245,255,.10), transparent 62%),
    linear-gradient(180deg, rgba(8,245,255,.02) 0%, rgba(10,20,34,.55) 42%, rgba(12,24,40,.85) 100%);
  /* No border. The surface is larger than the scene and the scene clips it, so a 1px border
     showed up as a hairline ruled across the top of the page — the surface's own top edge.
     A soft mask fades it out instead, which is what a horizon should do anyway. */
  border:0;border-radius:22px;
  box-shadow:inset 0 0 120px rgba(8,245,255,.06);
  /* ⚠️ TWO MASKS, COMPOSITED — vertical for the horizon (as before) and horizontal so the left
     and right edges fade out instead of ending. A single gradient cannot do both, and it was
     the missing horizontal one that made the left edge look cut off. */
  -webkit-mask-image:linear-gradient(180deg,transparent 0,#000 12%,#000 100%),
                     linear-gradient(90deg,transparent 0,#000 18%,#000 82%,transparent 100%);
  -webkit-mask-composite:source-in;
          mask-image:linear-gradient(180deg,transparent 0,#000 12%,#000 100%),
                     linear-gradient(90deg,transparent 0,#000 18%,#000 82%,transparent 100%);
          mask-composite:intersect;
}
body.tilt3d .board3d{z-index:1}

/* The mats keep every dimension the base sheet gives them. The opponent's mat keeps its
   180deg flip from style.css, and its cards keep the 2D counter-rotation that makes them
   read upright — both already correct there, so neither is restated here. */

/* ⚠️ NO ROTATION IS ADDED TO CARDS. See the note at the top: they lie on the table. */

/*
 * ⚠️ NO IN-PLACE HOVER ZOOM ON THE TILTED BOARD.
 *
 * A card magnified inside the perspective container is still projected: it keeps a keystone
 * from being off the perspective origin, so it reads as tilted and subtly distorted however
 * far it is counter-rotated. tilt.js shows a FLAT preview outside the 3D subtree instead
 * (see .tilt-preview), which is the only way to guarantee true card proportions.
 *
 * ⚠️ AND THE SCOPE MATTERS. An earlier version of this rule was `body.tilt3d .card...:hover`
 * with !important — unscoped, so it also hit cards in the Choose a Card modal, which lives
 * OUTSIDE the scene and has no perspective. A rotateX there is a straight vertical squash:
 * the cards shrank and went square on hover. Everything 3D is now scoped to body.tilt3d #scene3d.
 */
body.tilt3d #scene3d .card.zoomable:hover{transform:none!important}
/* ⚠️ The opponent's cards carry rotate(180deg) from style.css so they read upright to you.
   `transform:none` above STRIPS that, which flipped every opponent card to face away the
   moment it was hovered — it was not an extra rotation, it was the loss of the existing one.
   Restated here so hovering changes nothing about how the card sits. */
body.tilt3d #scene3d .foe-mat .card.zoomable:hover{transform:rotate(180deg)!important}
body.tilt3d #scene3d .card.zoomable:hover>img{transform:none!important;box-shadow:none!important}
body.tilt3d #scene3d .mcell:has(.card.zoomable:hover){overflow:visible;z-index:auto}

/* The flat preview: a sibling of the scene, so no ancestor transform can reach it. */
.tilt-preview{
  position:fixed;z-index:600;pointer-events:none;opacity:0;
  transition:opacity .10s ease;
  border:2px solid var(--cyan);border-radius:8px;overflow:hidden;background:#05080f;
  box-shadow:0 18px 60px rgba(0,0,0,.9),0 0 34px rgba(8,245,255,.4);
}
.tilt-preview.on{opacity:1}
.tilt-preview img{display:block;width:100%;height:100%;object-fit:contain;background:#05080f}

/*
 * ⭐ 3D MUST BE PRESERVED ALL THE WAY DOWN, or none of the counter-rotation below works.
 *
 * transform-style defaults to FLAT, and a flat ancestor collapses its descendants' 3D
 * transforms into its own plane. preserve-3d was only on .board3d, so a rotateX(-45deg) on a
 * label was not standing it up — it was being flattened into a plain vertical squash to 70%
 * height. That is why the labels came out SMALLER and no straighter. Every element between
 * the tilted board and the text has to carry it.
 */
body.tilt3d #scene3d .mat-wrap,body.tilt3d #scene3d .side,body.tilt3d #scene3d .mat,body.tilt3d #scene3d .mcell,body.tilt3d #scene3d .cell,
body.tilt3d #scene3d .midzone,
body.tilt3d #scene3d .locs,
body.tilt3d #scene3d .loc,
body.tilt3d #scene3d .stack,
body.tilt3d #scene3d .upload{
  transform-style:preserve-3d}

/*
 * LABELS FACE THE VIEWER. Cards lie on the table — text should not: laid in the plane it is
 * both foreshortened and slanted, which is why the zone names, Location numbers and hand
 * counts were disappearing into the background. Counter-rotated, enlarged and bolded, and
 * scaled from --card-h so they keep their weight as the board resizes.
 */
body.tilt3d #scene3d .zlabel,body.tilt3d #scene3d .mat-handcount,body.tilt3d #scene3d .zcount,
body.tilt3d #scene3d .mat-tag{
  transform:rotateX(calc(-1 * var(--tilt)));transform-origin:center center;
  font-weight:800;letter-spacing:.06em;
  color:#cfe3f5;opacity:1;
  text-shadow:0 1px 2px #000,0 0 6px rgba(0,0,0,.9);
}
body.tilt3d #scene3d .zlabel{font-size:calc(var(--card-h) / 11);line-height:1.15}
/*
 * "Extra" alone undersells it: the Extra Locations ARE a Location, and cards that say
 * "pick a location" can pick them [OR §16.7]. Spelling it out on the board removes the
 * ambiguity where it matters. Added in CSS so the shared markup in index.html is untouched.
 *
 * ⚠️ The class is `.extra`, NOT `.ext`. An earlier version of these rules used `.ext`, which
 * exists nowhere in the client — so every Extra Location rule here silently matched nothing
 * and the label never moved and never gained its second line.
 */
body.tilt3d #scene3d .extra .zlabel::after{content:'\A Location';white-space:pre}
/* Pushed further into the cell on both sides: this label is two lines now, and on the
   opponent's flipped mat it would otherwise ride the mat edge. */
/*
 * Both labels sit at the VISUAL TOP of their Extra Location — clear of the cards stacked in
 * the zone, which fill it from the far edge down.
 *
 * ⚠️ The two anchors are OPPOSITE because the opponent's mat is rotated 180deg: `top` in that
 * mat's own coordinates renders at the BOTTOM of the screen. Anchoring both to `top` is what
 * put both labels at the bottom. Getting this backwards looks like the rule not applying.
 */
/*
 * CENTRED IN THE ZONE, both mats — and centring is what makes it stop needing adjustment.
 *
 * Anchoring to an edge means the two mats need OPPOSITE values, because the opponent's is
 * rotated 180deg and its `top` is the screen's bottom. That inversion produced three rounds
 * of nudging in the wrong direction. A centred label has no such handedness: top:50% with a
 * translateY(-50%) resolves to the middle of the cell whichever way the mat faces.
 *
 * Still two lines tall, so it keeps the larger clearance — it tips back about twice as far
 * as a single-line label and would otherwise wash out behind the mat panel.
 */
/*
 * ⚠️ IN THE PLANE, and z=0 on purpose — a card placed here must COVER it, as it would on a
 * table. The billboarded version sat 20px in FRONT of the card plane, so the label floated
 * over anything stacked in the zone. Back at z=0 it is coplanar with the cards and DOM order
 * decides: the label comes before the stack, so cards paint over it.
 *
 * The cost is that it is foreshortened like the Location numbers rather than standing up —
 * which is why it is bold and sized from the card, the same treatment those got.
 */
body.tilt3d #scene3d .extra .zlabel{
  /* ⚠️ z-index BELOW the stack. `.mcell .zlabel` is z-index 3 in the base sheet while
     `.stack .card` starts at 1 and counts up — so the label was painting over the first two
     cards placed in the zone. Being coplanar was necessary but not sufficient; the stacking
     order had to give way too. 0 puts it under every card while still above the cell. */
  z-index:0;
  top:50%;bottom:auto;
  transform-origin:center center;
  transform:translateY(-50%);
  font-size:calc(var(--card-h) / 9);font-weight:900;
  color:#e6f2ff;text-shadow:0 2px 4px #000,0 0 8px rgba(0,0,0,.95)}
/*
 * ⚠️ THE POSITION HAS TO BE RESTATED HERE, not just the transform.
 *
* Further down,
`body.tilt3d #scene3d .foe-mat .zlabel{ top:auto; bottom:3px }` pulls the opponent's
 * Celestial and Void labels clear of the scoreboard. It has the SAME specificity as the
 * centring rule above (1,2,0) and comes LATER, so it won and re-anchored this label to an
 * edge — which is why the opponent's Extra label stayed at the top while the player's
 * centred correctly. Naming .extra here makes it (1,3,0) and it wins outright.
 */
body.tilt3d #scene3d .foe-mat .extra .zlabel{
  top:50%;bottom:auto;
  transform-origin:center center;
  transform:translateY(-50%) rotate(180deg)}
/*
 * Location numbers fill their slot and stay IN THE PLANE — deliberately NOT counter-rotated.
 *
 * With preserve-3d the browser paints by 3D POSITION, not z-index. A counter-rotated number
 * tips its top edge toward the viewer, so that half ended up physically in FRONT of the card
 * lying flat in the plane — which is exactly the "upper half shows through" seen here. Left
 * flat it is coplanar with the card, paint order applies again, and it sits properly behind.
 * It is big enough that lying on the table costs nothing in legibility.
 */
body.tilt3d #scene3d .loc .locnum{
  inset:0;top:0;left:0;right:0;bottom:0;z-index:0;
  display:flex;align-items:center;justify-content:center;
  font-family:Orbitron,sans-serif;font-weight:900;
  font-size:calc(var(--card-h) * 0.62);line-height:1;
  color:rgba(8,245,255,.30);opacity:1;
  text-shadow:0 2px 6px rgba(0,0,0,.85);
}
/*
 * ⚠️ `:not(.attached)` IS LOAD-BEARING. An attachment is `position:absolute` in the base sheet
 * so it can be tucked under its carrier. Forcing `position:relative` on every card in a
 * Location made it a FLEX ITEM instead — .loc is display:flex, so carrier and attachment
 * shared one card-width cell and each was squeezed to half. That is the "cut in half, only the
 * centre visible" sliver beside the carrier, and it appeared ONLY in the tilted view because
 * this rule is gated on body.tilt3d. Reported three times (Y8EGX, F3HVR, 537JF).
 */
body.tilt3d #scene3d .loc .card:not(.attached){position:relative;z-index:1}
/*
 * ⭐ ROTATE ABOUT THE BASE, NOT THE CENTRE — this is what stops text being cut in half.
 *
 * Standing text up with the default centre origin swings its TOP half toward the viewer and
 * its BOTTOM half BACKWARDS, through the mat surface — and with preserve-3d the mat then
 * paints over whatever went behind it. That is the "cut off on the bottom half" on the
 * opponent's card count and the half-hidden zone labels; it was never a fade or a clip.
 *
 * Pivoting on the base keeps every part of the glyph at or in front of the plane, so nothing
 * can be occluded and only 1px of clearance is needed. The opponent's mat is flipped, so its
 * base is the element's TOP edge — hence the two different origins.
 */
body.tilt3d #scene3d .mat-handcount,
/*
 * ⭐ THE COUNT MUST STAND PROUD OF THE CARD, OR IT CANNOT BE HOVERED. [report UJEUB]
 *
 * "The hover over the deck numbers on Desktop tilted view only shows 'face-down' and not the
 * 'X number of cards in the Deck'." — and that is the whole diagnosis in one sentence: the
 * tooltip they got was the CARD BACK's, so the pointer was never landing on the counter.
 *
 * ⚠️ `z-index` DOES NOT APPLY HERE. Inside a `preserve-3d` context the painting order comes from
 * Z POSITION, not from z-index — so the counter's `z-index: 3` was simply ignored, it sat at the
 * card's own depth, and the card won both the paint and the hit-test. `.zlabel` and
 * `.mat-handcount` were given `translateZ(8px)` for the same reason; the counter was missed.
 *
 * The counter-rotation keeps the digits facing the player, exactly as the labels do.
 */
body.tilt3d #scene3d .zcount{
  font-size:calc(var(--card-h) / 10);color:var(--gold);
  transform-origin:center bottom;
  transform:translateZ(9px) rotateX(calc(-1 * var(--tilt)))}
/*
 * ⭐ THE OPPONENT'S COUNTER SANK BEHIND ITS CARD. [reported 2026-08-25]
 *
 * "On the opponent's side I can see you moved it, but it is now underneath the cards — I can't
 * hover over it." The player's own side was fine, which is the tell: the two differ only by the
 * mat's `rotate(180deg)`.
 *
 * ⚠️ THE 180° TURN REVERSES THE X AXIS, so the counter-rotation `rotateX(-tilt)` tips the badge
 * the OPPOSITE way on this mat — and about `center top` that swings its body BACKWARD, through
 * the mat surface and behind the card. The same sink is documented above for `.zlabel`, which
 * survives it only because a one-line label is short enough to stay clear.
 *
 * Two changes, both about clearance rather than cleverness: rotate about the badge's CENTRE so
 * half the sink becomes a rise, and lift further out of the plane than the labels need. The
 * counter has to be not merely visible but HIT-TESTABLE — it is the only element here a player
 * is meant to point at.
 */
body.tilt3d #scene3d .foe-mat .deck .zcount,
body.tilt3d #scene3d .foe-mat .disc .zcount{
  transform-origin:center center;
  transform:translateZ(22px) rotate(180deg) rotateX(calc(-1 * var(--tilt)))}
/* The player's own side needs the same clearance guarantee, for the same reason. */
body.tilt3d #scene3d .you-mat .deck .zcount,
body.tilt3d #scene3d .you-mat .disc .zcount{
  transform-origin:center center;
  transform:translateZ(22px) rotateX(calc(-1 * var(--tilt)))}

/*
 * CLEARANCE MUST COVER HOW FAR THE LABEL TIPS BACK: (height / 2) x sin(tilt).
 *
 * 1px was enough for a one-line label (~6.5px of sink at 45deg) but not for the two-line
 * "Extra / Location", which sinks about 13px — so its lower half ended up BEHIND the mat
 * panel. The panel is semi-transparent, so it did not vanish, it washed out: the "faded"
 * Extra label on the opponent's side. 8px covers a single line with room to spare, and the
 * Extra label gets its own larger figure below.
 */
body.tilt3d #scene3d .zlabel,
body.tilt3d #scene3d .mat-handcount{
  transform-origin:center bottom;
  transform:translateZ(8px) rotateX(calc(-1 * var(--tilt)))}
body.tilt3d #scene3d .foe-mat .zlabel,
body.tilt3d #scene3d .foe-mat .mat-handcount{
  transform-origin:center top;
  transform:translateZ(8px) rotate(180deg) rotateX(calc(-1 * var(--tilt)))}

/*
 * The opponent's mat is upside down, so its row-1 cells (Celestial and Void) render at the
 * BOTTOM of their mat — right against the centre line, where the scoreboard cuts across them.
 * Anchoring their labels to the opposite edge of the cell moves them clear, into the mat.
 */
body.tilt3d #scene3d .foe-mat .zlabel{top:auto;bottom:3px}

/* Give the opponent's hand count room clear of the mat edge. */
body.tilt3d #scene3d .mat-handcount{bottom:6px}
/* ⚠️ The identity band owns the strip the mat reserves for it, so it sits ON that strip rather
   than floating 6px in from the edge — otherwise it drifts up over Skirmish 2. */
/*
 * ⭐ THE OPPONENT'S IDENTITY BAND HAS TO SIT INSIDE THEIR MAT.
 * [user request 2026-08-29: "the opponent's hand size, name, and Opponent label ... are currently
 *  outside of the opponent's playmat. Please reposition them so they sit inside"]
 *
 * ⚠️ TILTED VIEW ONLY, AND ONLY THE OPPONENT. Measured at 1440x900: in the flat board both bands
 * are already inside (the foe's by 1px, yours by 1px); on the tilted board yours is inside by
 * 16px and the opponent's sat 24px ABOVE their mat's top edge.
 *
 * The reason is not a wrong offset — it is that this band does not foreshorten. It is
 * counter-rotated to stay readable, so it projects at a CONSTANT ~24px of screen height, while
 * the strip the mat reserves for it at the FAR end projects to only about 18px. It cannot fit in
 * the space reserved for it, at any board size, and no `bottom:0` will make it.
 *
 * So the opponent's band is inset further into their mat, past Skirmish 2 — which is where the
 * band was always meant to read from (see the note on `.mat-handcount` in style.css). Sized from
 * --card-h because the discrepancy is a projection effect and scales with the board; a fixed px
 * inset is correct at exactly one card size. 0.45 was measured, not guessed: 0.20 still left it
 * 8px outside and 0.32 only just cleared at 1px, while 0.45 puts it 11px inside — comparable to
 * the 16px the near mat has.
 */
/*
 * The band sits in the midzone's outer slack now (see style.css), which is half a card tall
 * instead of the ~30px strip the mat used to reserve — so both seats have room for two lines.
 *
 * ⚠️ THE OPPONENT STILL NEEDS THE EXTRA INSET, for the reason recorded above: the band is
 * counter-rotated to stay readable and therefore does NOT foreshorten, while the far end of the
 * board does. Re-measured against the new geometry rather than carried over.
 */
body.tilt3d #scene3d .mat-ident{bottom:var(--gap)}
body.tilt3d #scene3d .foe-mat .mat-ident{bottom:calc(var(--card-h) * 0.39)}
/*
 * ⚠️ THE BAND'S OWN TYPE SCALE, AND IT HAS TO LIVE HERE. [user request 2026-08-29: "drop the
 * font size a little bit to make sure that it fits there well"]
 *
 * `.mat-ident { font-size: clamp(...) }` in style.css is (0,1,0) and NEVER APPLIED on a tilted
 * board: the shared zone-label rule near the top of this file,
 * `body.tilt3d #scene3d .mat-handcount, ... .zcount { font-size: calc(var(--card-h) / 10) }`,
 * is (1,3,0) and wins. The band carries BOTH classes, so it was being sized as a zone label —
 * 17px at card-h 173, which is why two lines could not fit between the mat edge and Skirmish 2.
 * Measured, not assumed: the browser was asked which declarations matched.
 *
 * /14 rather than /10 buys back the room; the inset above then centres the band in it.
 */
body.tilt3d #scene3d .mat-ident{font-size:calc(var(--card-h) / 14)}

/*
 * ⭐⭐ A FLIPPING CARD MUST NOT BE CUT OFF BY THE SEAM PANEL. [reported 2026-08-31]
 *
 * On the tilted board the context that traps the card is `#board3d` itself: it carries the
 * perspective transform and `preserve-3d`, so nothing inside it can outrank a SIBLING of it —
 * and the scoreboard is now exactly such a sibling, since it was moved out of the board so that
 * ordinary paint order would apply to it.
 *
 * Measured before the fix: `#board3d` sits at z-index 1 against the panel's 60, so every card in
 * the board paints under the panel however high its own z-index. Raised for the turn only;
 * `.revealing-side` is put on the mat by tilt.js when the flip starts and removed when it
 * settles. See the long note on `.mat-wrap:has(.revealing-side)` in style.css.
 */
body.tilt3d #scene3d:has(.revealing-side) #board3d{
  z-index: calc(var(--z-scoreboard) + 1);
}
/*
 * ⚠️ AND THE PANEL FADES WHILE THAT HAPPENS, rather than being left to sink.
 * [reported 2026-09-01]
 *
 * Raising the board necessarily lifts the mats' own translucent surface with it — a stacking
 * context moves as one — so the scoreboard was left visible BEHIND both mats, which reads as it
 * dropping through the board. There is no z-index that fixes this: the card is inside `#board3d`
 * and the panel is outside it, so whichever is on top, the other is under the whole of it.
 *
 * So the panel is taken out of the picture for the turn instead of being half-buried in it. It
 * fades, the card turns unobstructed, and it fades back — which reads as deliberate, where
 * sinking behind the mats reads as a bug.
 *
 * The transition is what keeps a run of reveals from strobing: `.revealing-side` goes on and off
 * per card, and 160ms in/out turns that into a dip rather than a blink.
 */
body.tilt3d #scene3d .scoreboard{ transition: opacity .16s ease; }
body.tilt3d #scene3d:has(.revealing-side) .scoreboard{ opacity: 0; }
/* The opponent's mat is flipped, so its labels need the 180 folded into the same transform. */
/* The opponent's mat is flipped, so its billboarded text needs the 180 folded in. The
   Location NUMBER is excluded on purpose — it stays in the plane (see above), and only needs
   the 180 so it reads the right way up. */
/* ⚠️ KEEPS THE CENTRING TRANSLATE (F9). This rule replaced the whole transform, so it
   dropped the -50%/-50% the base rule sets and the count drifted off the pile. */
body.tilt3d #scene3d .foe-mat .zcount{
  transform:translate(-50%,-50%) rotate(180deg) rotateX(calc(-1 * var(--tilt)))}
body.tilt3d #scene3d .foe-mat .loc .locnum{transform:rotate(180deg)}
body.tilt3d #scene3d .you-mat .loc .locnum{transform:none}
/* .mat-tag sits OUTSIDE the flipped mat, so it needs the tilt cancelled but not the 180. */
body.tilt3d #scene3d .mat-tag{font-size:calc(var(--card-h) / 9);color:#cfe3f5}
body.tilt3d #scene3d .you-tag{color:var(--cyan)}

/*
 * THE SCOREBOARD FACES THE VIEWER — and since 2026-08-29 it does so by NOT BEING IN THE 3D
 * SPACE AT ALL. It is a sibling of `#board3d`, not a child, so it needs no transform here.
 *
 * ⚠️ DO NOT PUT ONE BACK. The old rule counter-rotated it in the plane
 * (`translateZ(6px) rotateX(-tilt)`), which was fine only while the bar sat in a GAP between
 * the mats. On the seam the near mat is directly beneath it, and rotating about the centre
 * puts the panel's lower half BEHIND the board plane — measured at 9.5px behind, against a 6px
 * lift. `preserve-3d` paints by 3D position, so the mat wins and z-index is irrelevant.
 *
 * ⚠️ AND DO NOT "FIX" IT BY RAISING THE LIFT. Under `perspective` a translateZ DISPLACES as
 * well as reorders (see the note at the ~1619 mark in this file). Lifting it until it cleared
 * the plane moved the bar visibly up the screen and scaled it — a worse bug than the one it
 * solved, because the placement was already right.
 *
 * Nothing belongs here any more either: the base rule now scales the panel's type from
 * --card-h for BOTH layouts, so a tilt-only font-size would simply double up.
 */

/*
 * THE MODIFIED-CQ BADGE. It is a fixed .58rem in the base sheet — fine on a flat 118px card,
 * far too small on a 166px card that is also foreshortened by the tilt, which is the one
 * number a player checks constantly. Scaled from --card-h so it grows with the board, and
 * stood upright so the tilt does not squash the digits.
 */
body.tilt3d #scene3d .card .cqmod{
  font-size:calc(var(--card-h) / 7);
  padding:0 .28rem;border-width:2px;border-radius:4px;
  top:3px;right:3px;
  transform-origin:top right;
  transform:translateZ(6px) rotateX(calc(-1 * var(--tilt)));
}
/*
 * ⚠️ THERE IS DELIBERATELY NO `.foe-mat` VARIANT OF THIS RULE. Four attempts added one and
 * all four made it worse, because they reasoned from "the foe mat is rotate(180deg)" without
 * measuring. `style.css` COUNTER-rotates the card itself —
 * `.foe-mat .card{transform:rotate(180deg)}` — so mat x card = identity and an opponent's
 * card already sits in ordinary screen space. The badge therefore needs exactly the rule
 * above and nothing else.
 *
 * Measured on a real board (foe unit in loc1, 45deg tilt), badge centre vs card centre:
 *   with the old foe overrides : dx -32.4, dy +31.8  -> LOWER-LEFT, and the computed matrix
 *                                was rotateZ(180)*rotateX(-45)  -> digits upside down
 *   with no foe override       : dx +28.7, dy -35.3  -> UPPER-RIGHT, computed matrix
 *                                matrix3d(1,0,0,0, 0,.707,-.707,0, 0,.707,.707,0, 0,0,6,1),
 *                                BYTE-IDENTICAL to your own card's badge.
 * If you think this needs a foe rule, measure it first.
 */

/* A dragged card is held in the hand's flat space, so it must not inherit the tilt either. */
body.tilt3d .touch-ghost{transform:none}

body.tilt3d .scene3d{cursor:grab}
body.tilt3d .scene3d.dragging-view{cursor:grabbing}

/* The layout override. Rendered in both the lobby and the in-game INFO panel, so it is
   reachable when the choice is actually being made rather than only mid-game. */
.layout-toggle{display:flex;align-items:center;gap:.4rem;color:var(--dim);font-size:.72rem}
.lobby-actions .layout-toggle{justify-content:center;margin-top:.2rem}

/* ------------------------------------------------------------------ card reveal
 *
 * A face-down card being turned over, the way a hand does it: lift it off the mat, turn it,
 * set it back down. The lift is along the mat's NORMAL (translateZ) rather than up the
 * screen, so on a tilted board the card genuinely rises off the table rather than sliding.
 *
 * The turn is a real 180deg flip with two faces, not a fade: a ::before carries the card back
 * and is pre-rotated 180deg, and both faces hide their backside — so the first half of the
 * turn shows the back and the second half shows the art, exactly as a physical flip does.
 * Without that the card would appear mirror-imaged for the first half.
 */
body.tilt3d #scene3d .card.revealing{
  transform-style:preserve-3d;
  z-index:var(--z-card-hover);
  animation:cof-flip .72s cubic-bezier(.34,.9,.32,1) both;
}
body.tilt3d #scene3d .foe-mat .card.revealing{animation-name:cof-flip-foe}

/*
 * ⚠️ THE BACK FACE IS SWAPPED ON A TIMER, NOT BY backface-visibility.
 *
 * Relying on backface-visibility meant the back's visibility depended on the COMPOSED
 * rotation, and on the opponent's mat that composition is rotate(180deg) . rotateY(180deg) —
 * which resolves to a rotation about X, not Y. The back ended up culled and all that showed
 * was the card's own dark background lifting and turning: the "dark blue square".
 *
 * Stepping the back's opacity at the halfway point is deterministic and identical on both
 * mats. It sits above the art and opaque, so the art cannot be seen mirrored underneath it
 * during the first half of the turn.
 */
/*
 * WAITING ITS TURN. Both reveals arrive in one payload, so app.js paints the second card
 * face-up immediately — it was showing its face while queued, half a second before its own
 * flip began. This lays the card back over it until then.
 *
 * Identical to the animated back below except that it does not move, so the hand-off from one
 * to the other is invisible: same image, same border, same position.
 */
body.tilt3d #scene3d .card.reveal-pending::before{
  content:'';position:absolute;inset:0;border-radius:inherit;z-index:3;
  background:url('/cardback.png') center/cover no-repeat,#060c18;
  border:1px solid rgba(8,245,255,.28);
}
body.tilt3d #scene3d .card.reveal-pending .cqmod{opacity:0}

body.tilt3d #scene3d .card.revealing::before{
  content:'';position:absolute;inset:0;border-radius:inherit;z-index:3;
  background:url('/cardback.png') center/cover no-repeat,#060c18;
  border:1px solid rgba(8,245,255,.28);
  animation:cof-back .72s steps(1,end) both;
}
@keyframes cof-back{
  0%     {opacity:1}
  49.99% {opacity:1}
  50%    {opacity:0}
  100%   {opacity:0}
}
/* The CQ badge must not ride the flip — it would read backwards through the turn. */
body.tilt3d #scene3d .card.revealing .cqmod{opacity:0;transition:opacity .15s ease .55s}
body.tilt3d #scene3d .card.revealing.settled .cqmod{opacity:1}

@keyframes cof-flip{
  0%   {transform:translateZ(0)     rotateY(180deg)}
  28%  {transform:translateZ(90px)  rotateY(180deg)}
  72%  {transform:translateZ(90px)  rotateY(0deg)}
  100% {transform:translateZ(0)     rotateY(0deg)}
}
/* The opponent's mat is flipped, so its baseline rotation has to be carried through every
   frame or the card would snap round at the start and end of the animation. */
@keyframes cof-flip-foe{
  0%   {transform:rotate(180deg) translateZ(0)    rotateY(180deg)}
  28%  {transform:rotate(180deg) translateZ(90px) rotateY(180deg)}
  72%  {transform:rotate(180deg) translateZ(90px) rotateY(0deg)}
  100% {transform:rotate(180deg) translateZ(0)    rotateY(0deg)}
}

@media (prefers-reduced-motion: reduce){
  /* Belt and braces only — tilt.js does not add `.revealing` at all under reduced motion, so
     this should never apply. Kept because a card left with the back face showing would be a
     far worse failure than a missing animation, and it costs nothing. */
body.tilt3d #scene3d .card.revealing{animation:none}
body.tilt3d #scene3d .card.revealing::before{display:none}
body.tilt3d #scene3d .card.revealing .cqmod{opacity:1}
}


/* ------------------------------------------------- your own face-down cards
 *
 * A placed card is FACE DOWN on the table, so it should look like it: the card back, not the
 * art greyed out under a "face-down" caption. You are still entitled to know what it is —
 * the art stays in the DOM, hidden, so hovering still pops the real card in the flat preview.
 * That is also what lets the reveal animation work unchanged: the element already carries its
 * art, so the flip has something to turn over to.
 */
body.tilt3d #scene3d .card.own-facedown{
  background:url('/cardback.png') center/cover no-repeat,#060c18;
  border-style:solid;border-color:rgba(8,245,255,.30);
}
body.tilt3d #scene3d .card.own-facedown>img{visibility:hidden}
body.tilt3d #scene3d .card.own-facedown::after{display:none}      /* the "face-down" caption */
body.tilt3d #scene3d .card.own-facedown .cqmod{display:none}      /* its CQ is not public yet */

/* ------------------------------------------------- whose reveal is this
 *
 * THE TIMING IS THE SIGNAL. An earlier version dimmed the other mat while a card flipped,
 * which read as the opponent's board blanking out — a much louder change than the thing it
 * was trying to communicate, and it hid information the player needs continuously.
 *
 * What is left is the pause between reveals plus a soft outline on the mat that is currently
 * flipping. Nothing is hidden, dimmed or removed: both boards stay fully readable the whole
 * time, and the order comes across because only one card is ever in motion.
 */
body.tilt3d #scene3d .mat{transition:box-shadow .25s ease}
body.tilt3d #scene3d .mat.revealing-side{
  box-shadow:0 0 0 2px rgba(8,245,255,.55),0 18px 60px rgba(0,0,0,.55);
}


/* ------------------------------------------------- hold prompts during a reveal
 *
 * The Lightspeed window was appearing while a card was still turning over: the engine asks
 * the moment the reveal resolves, and the flip is a client-side flourish it knows nothing
 * about. Being asked to react to a card you cannot see yet is worse than a short wait, so the
 * prompt is held until the board has finished showing what happened.
 *
 * Visibility only — the choice is live underneath and its timer is app.js's, untouched. The
 * hold is at most one flip plus the gap, well under a second.
 */
/* The HIDE is instant — a fade-out is itself a flash of the prompt, which is the thing being
   prevented. Only the return is eased, so the prompt arrives softly once the card has landed. */
.ls-notice,#choice,.decision{transition:opacity .14s ease}
body.reveal-busy .ls-notice,
body.reveal-busy #choice,
body.reveal-busy .decision{
  opacity:0;pointer-events:none;transition:none;
}
/*
 * ⭐ AND THE BOARD ITSELF, NOT ONLY THE PROMPTS. [user request 2026-08-23]
 *
 * Hiding the decision rail was never enough: placing a card is a click on a Location and on a card
 * in hand, and neither is a prompt surface. So with the Forward Skirmish still animating a player
 * could already lay down cards for the Rear Skirmish — reported exactly that way. A phase is not
 * over until what it did has finished being shown.
 *
 * `pointer-events` only, deliberately: nothing moves, nothing greys out, nothing shifts under the
 * cursor. The board looks entirely normal and simply does not accept a commitment for the moment
 * the animation is still running.
 */
body.reveal-busy:not(.targeting) #hand .card,
body.reveal-busy:not(.targeting) #scene3d .loc,
body.reveal-busy:not(.targeting) #scene3d .mcell{ pointer-events:none; }
/*
 * ⚠️⚠️ EXCEPT A SCENARIO BENCH'S DECK AND DISCARD PILES. [card 3562, reported from Firefox mobile]
 *
 * The hold above exists to stop a player COMMITTING A GAME ACTION while the previous one is still
 * being shown — laying cards down for the Rear Skirmish while the Forward Skirmish animates.
 * Moving a card between your own hand, deck and discard on a bench is not a commitment; it is
 * setting the board up before playing it, and the user asked for it to work AT ANY TIME.
 *
 * ⚠️ THE HAND IS EXEMPTED TOO, not just the piles. The blanket rule kills `#hand .card` as well,
 * so without this the gesture cannot even START — which is a large part of why the feature read
 * as completely dead on a phone.
 *
 * ⚠️ `.loc` IS DELIBERATELY NOT EXEMPTED. Placing a card into a Location IS a commitment, and the
 * hold must keep applying to it — on a bench exactly as anywhere else.
 *
 * Specificity is (1,5,1) and (1,4,1) against the rule's (1,3,1), so this wins outright rather
 * than relying on source order between two stylesheets.
 */
body.bench-mode.reveal-busy:not(.targeting) #scene3d .mcell.bench-drop,
body.bench-mode.reveal-busy:not(.targeting) #hand .card{ pointer-events:auto; }
/*
 * ⚠️ EXCEPT WHILE THE PLAYER IS BEING ASKED TO PICK A TARGET. [reported 2026-08-23, game M89YR]
 *
 * "I played hacktavis vandals, did the random discard and then it didn't allow me to actually
 * select a Target to destroy." The engine offered three targets and the player answered (none) —
 * and the capture shows all three rendered `card facedown targetable`, visible, full opacity, with
 * real hit-boxes. They were pulsing cyan and inviting a tap, and the hold above was eating it.
 *
 * The hold exists to stop a player COMMITTING to the next Skirmish while the last one is still
 * animating. A targeting prompt is the opposite situation: the engine has stopped and is waiting
 * for this exact decision, so there is nothing to commit early to. `body.targeting` is set only
 * for a board-targeting choice that belongs to THIS player, so placement — the case the hold was
 * written for — is untouched.
 */
/*
 * UNIVERSAL, not just Lightspeed. Watchtower Spawn's "choose a card from your opponent's
 * hand" modal opened the instant Reveal was pressed, with the flip still running behind it —
 * so the player was picking a discard before seeing what had been revealed. Any prompt asked
 * as a consequence of a reveal has the same problem, so all three surfaces wait: the
 * Lightspeed notice, the card-selection modal, and the decision rail.
 *
 * pointer-events:none matters as much as the opacity — #choice is a full-screen overlay, and
 * an invisible one that still swallowed clicks would be worse than showing it early.
 *
 * ⚠️ A STUCK HOLD WOULD BLOCK THE WHOLE GAME, which is why .reveal-busy is re-synced on every
 * render and every queue step, and why the animation itself has a 1400ms stall guard. The
 * hold is at most one flip plus the gap.
 */

/* ------------------------------------------------- desktop pin placement
 *
 * The LOG pill is a MOBILE control. On desktop the rail is permanently on screen with the log
 * already in it, so the pill opens something that is never closed — it is pure clutter, and it
 * sits on top of the rail it duplicates. Hidden wherever the rail is visible.
 *
 * The BUG pill moves clear of the rail for the same reason: at right:14px it sat over the
 * decision box and the log. Offset by the rail's own width so it lands beside the board, and
 * driven by --rail-w so it follows if that width ever changes.
 */
@media (min-width: 761px) {
  body.tilt3d .rail-toggle{display:none!important}
  body.tilt3d .bug-toggle{right:calc(var(--rail-w,250px) + 26px);top:10px}
  body.tilt3d .chat-toggle{right:calc(var(--rail-w,250px) + 92px);top:10px}
}

/* ------------------------------------------------- opponent's hand count, again
 *
 * ⚠️ ORIGIN, NOT CLEARANCE. This was pivoted on `center top` for the opponent so the tip would
 * carry it forward — but combined with that mat's own rotate(180deg) the pivot lands on the
 * far edge, and the element swings BACKWARDS through the mat surface instead. The lower half
 * ended up behind a semi-transparent panel, which reads as greyed out rather than hidden.
 *
 * Pivoting on the CENTRE with clearance greater than the maximum possible sink removes the
 * handedness entirely: half the element's height at 45deg is about 7px, so 24px is clear at
 * any angle the slider allows, on either mat.
 */
body.tilt3d #scene3d .mat-handcount,
body.tilt3d #scene3d .foe-mat .mat-handcount{
  transform-origin:center center;
}
body.tilt3d #scene3d .mat-handcount{transform:translateZ(24px) rotateX(calc(-1 * var(--tilt)))}
body.tilt3d #scene3d .foe-mat .mat-handcount{
  transform:translateZ(24px) rotate(180deg) rotateX(calc(-1 * var(--tilt)))}

/* ===== LIGHTSPEED FLIGHT: hand -> Extra Locations ==========================
 * A Lightspeed play is the one deployment that never uses a Location, so it has no square to
 * flip. It is shown as a flight instead: the card leaves the hand face-down, turns over in the
 * air, and lands in the Extra Locations, trailing a light streak.
 *
 * ⚠️ The flyer is appended to <body>, OUTSIDE body.tilt3d #scene3d, deliberately. Inside the tilted plane
 * it would inherit the board's rotateX and a composed rotateY resolves to a rotation about
 * neither axis — the same composition that culled the card back on the foe mat. Out here the
 * flip is a clean rotateY on its own perspective.
 * ------------------------------------------------------------------------- */

/* The real card is already in the stack when the log line arrives — hold it invisible so the
   flyer is what the eye follows, then hand off on landing. */
body.tilt3d .card.ls-pending { opacity: 0 !important; }

.ls-fly {
  position: fixed;
  z-index: 620;                       /* above everything; prompts are held during a flight */
  width: var(--fw); height: var(--fh);
  margin-left: calc(var(--fw) / -2);  /* left/top are the landing CENTRE */
  margin-top: calc(var(--fh) / -2);
  pointer-events: none;
  perspective: 900px;                 /* for .ls-card, which is what actually turns */
  animation: cof-ls-fly var(--ls-ms, 1150ms) ease-in-out forwards;
}
/* The foe's mat is rotated 180deg, so their card lands upside down. Carry that through the
   whole flight rather than snapping to it on arrival. */
.ls-fly.foe { --spin: 180deg; }

.ls-card {
  position: absolute; inset: 0;
  transform-style: preserve-3d;
  animation: cof-ls-turn var(--ls-ms, 1150ms) cubic-bezier(.5,0,.5,1) forwards;
}
.ls-face {
  position: absolute; inset: 0;
  border-radius: 6px;
  overflow: hidden;
  backface-visibility: visible;       /* faces are swapped on OPACITY, not by culling */
  box-shadow:
    0 0 0 1px rgba(8,245,255,.55),
    0 0 22px rgba(8,245,255,.5),
    0 14px 34px rgba(0,0,0,.6);
}
.ls-back  { background: url('/cardback.png') center/cover no-repeat, #060c18;
            animation: cof-ls-back var(--ls-ms, 1150ms) linear forwards; }
.ls-front { background: #060c18; transform: rotateY(180deg); opacity: 0;
            animation: cof-ls-front var(--ls-ms, 1150ms) linear forwards; }
.ls-front img { display: block; width: 100%; height: 100%; object-fit: cover; }

/* The trail. Drawn rightwards from a left-centre origin and rotated to point back at the hand;
   scaleX grows with the flight so the tail stays put while the head follows the card.

   TWO layers on purpose: `filter` applies to an element's pseudo-elements as well, so hanging
   the hard speed lines off the blurred bar blurs them into the very smear they exist to cut
   through. .ls-glow is the blurred bloom; .ls-streak carries the sharp lines, unfiltered. */
.ls-glow, .ls-streak {
  position: absolute; left: 50%; top: 50%;
  width: var(--dist);
  transform-origin: 0 50%;
  opacity: 0;
  pointer-events: none;
}
.ls-glow {
  height: calc(var(--fh) * .52);
  margin-top: calc(var(--fh) * -.26);
  border-radius: 999px;
  filter: blur(5px);
  background: linear-gradient(90deg,
    rgba(255,255,255,.9) 0%, rgba(8,245,255,.62) 12%,
    rgba(8,245,255,.3) 42%, rgba(120,220,255,.1) 74%, transparent 100%);
  animation: cof-ls-streak var(--ls-ms, 1150ms) ease-out forwards;
}
.ls-streak {
  height: calc(var(--fh) * .52);
  margin-top: calc(var(--fh) * -.26);
  animation: cof-ls-streak var(--ls-ms, 1150ms) ease-out forwards;
}
/* Differing lengths — matched lines read as a ladder, not as speed. */
.ls-streak::before, .ls-streak::after {
  content: ''; position: absolute; left: 0; height: 2px; width: 100%;
  transform-origin: 0 50%;
  border-radius: 999px;
  background: linear-gradient(90deg,
    rgba(255,255,255,1) 0%, rgba(140,240,255,.85) 18%,
    rgba(8,245,255,.45) 46%, transparent 82%);
}
.ls-streak::before { top: 18%; transform: scaleX(.88); }
.ls-streak::after  { top: 74%; transform: scaleX(.58); }

/* Arrival: a short cyan bloom on the real card, so the eye lands where the flyer did. */
body.tilt3d .card.ls-landed { animation: cof-ls-land 640ms ease-out; }

/*
 * Shaped with EXPLICIT waypoints rather than one aggressive easing curve. A front-loaded
 * cubic-bezier put the card on its slot by 45% of the duration, so it finished turning over
 * after it had already landed — the two halves of the motion have to stay in step.
 */
@keyframes cof-ls-fly {
  0%   { opacity: 0;
         transform: translate3d(var(--dx), var(--dy), 0) rotate(var(--spin, 0deg)) scale(.5); }
  10%  { opacity: 1; }
  /* Rises and grows through the middle: the arc is what sells it as a throw, not a slide. */
  30%  { transform: translate3d(calc(var(--dx) * .68), calc(var(--dy) * .68 - 18px), 0)
                    rotate(var(--spin, 0deg)) scale(1.28); }
  60%  { transform: translate3d(calc(var(--dx) * .32), calc(var(--dy) * .32 - 34px), 0)
                    rotate(var(--spin, 0deg)) scale(1.5); }
  /* Drops onto the slot from just above it, so the landing has a little weight. */
  86%  { transform: translate3d(calc(var(--dx) * .04), calc(var(--dy) * .04 - 9px), 0)
                    rotate(var(--spin, 0deg)) scale(1.08); }
  100% { opacity: 1;
         transform: translate3d(0, 0, 0) rotate(var(--spin, 0deg)) scale(1); }
}
/* ===== A CARD BEING DRAWN =================================================
 * Deck -> hand, turning face UP on the way. Shares the flyer machinery with the Lightspeed
 * play (.ls-fly, .ls-card, .ls-face) and differs in three ways: it goes the other direction,
 * it runs slower because a draw is information rather than a reaction, and its arc is shallow
 * — a draw is a card being slid across a table, not thrown across it.
 * ------------------------------------------------------------------------- */
.draw-fly { animation-name: cof-draw-fly; animation-timing-function: cubic-bezier(.25,.7,.3,1); }
/* No speed streaks on a draw: they read as urgency, which is the opposite of what this is. */
.draw-fly .ls-glow, .draw-fly .ls-streak { display: none; }
/* The turn is slower than the flight so the face is settled and readable before it lands. */
.draw-fly .ls-card { animation: cof-draw-turn var(--ls-ms, 1500ms) ease-in-out forwards; }
/*
 * ⭐ AN OPPONENT'S DRAW NEVER TURNS OVER. We do not know what they drew and must not appear to —
 * so their flyer stays a card back for the whole flight. Suppressing the turn rather than turning
 * to a blank face, which would read as art that failed to load.
 */
.draw-fly.no-turn .ls-card { animation: none; transform: rotateY(180deg); }
.draw-fly.no-turn .ls-back { animation: none; opacity: 1; }

@keyframes cof-draw-fly {
  0%   { opacity: 0;
         transform: translate3d(var(--dx), var(--dy), 0) scale(.62); }
  12%  { opacity: 1; }
  /* A low, wide arc — lifted just enough to read as "off the table", never thrown. */
  55%  { transform: translate3d(calc(var(--dx) * .42), calc(var(--dy) * .42 - 26px), 0)
                    scale(1.16); }
  88%  { transform: translate3d(calc(var(--dx) * .06), calc(var(--dy) * .06 - 6px), 0)
                    scale(1.04); }
  100% { opacity: 1; transform: translate3d(0, 0, 0) scale(1); }
}
/*
 * Face DOWN to face UP — the reverse of the Lightspeed turn, which leaves the hand already
 * known to its owner and turns over for everyone else. The swap sits at 46%, slightly before
 * the midpoint, so the face has the longer half of the flight to be read in.
 */
@keyframes cof-draw-turn {
  0%, 20%   { transform: rotateY(180deg); }
  74%, 100% { transform: rotateY(0deg); }
}
.draw-fly .ls-back  { animation: cof-draw-back  var(--ls-ms, 1500ms) steps(1, end) forwards; }
.draw-fly .ls-front { animation: cof-draw-front var(--ls-ms, 1500ms) steps(1, end) forwards; }
@keyframes cof-draw-back  { 0%, 45% { opacity: 1 } 46%, 100% { opacity: 0 } }
@keyframes cof-draw-front { 0%, 45% { opacity: 0 } 46%, 100% { opacity: 1 } }

/* ===== A CARD BEING DEPLETED =============================================
 * ⭐ [user request 2026-08-24] Deck -> discard, turning face UP on the way.
 *
 * Shares the flyer machinery with the draw and the Lightspeed play (.ls-fly / .ls-card /
 * .ls-face) and differs deliberately:
 *  · FASTER (780ms) and SHALLOWER than a draw. Several of these run in sequence — Slurk
 *    depletes eight — so each one has to be brief enough that the run does not become the
 *    whole turn. A draw is one card and can afford to linger.
 *  · NO GLOW, NO STREAKS. A depletion is a loss, not a play. The bloom the Lightspeed and
 *    upload flights use reads as power being spent; this should read as a card falling off
 *    the deck.
 *  · TURNS FACE UP FOR BOTH SEATS, unlike a draw. The discard pile is public — fillCell
 *    already draws its top card face-up for everyone — so hiding it in flight would be a
 *    lie contradicted by the next frame.
 *  · A SLIGHT ROTATION on the way down, so a run of them looks like cards being dealt off a
 *    deck onto a pile rather than one card teleporting repeatedly.
 *
 * ⚠️ NO `.foe-mat` VARIANT IS NEEDED and none should be added. The flyer is appended to
 * <body> in SCREEN space, not into the mat — it is never inside the counter-rotated
 * `.foe-mat`, so the standing rule about flat keyframes dropping that mat's 180° rotation
 * (ANIMATION-PORT.md) does not reach it. Every other .ls-fly cue works the same way, which
 * is also why all of this needs no `body.tilt3d` gate: it is view-agnostic and runs on the
 * top-down board unchanged.
 * ------------------------------------------------------------------------- */
.dep-fly {
  animation-name: cof-dep-fly;
  animation-duration: 780ms;                     /* == DEPLETE_MS in tilt.js */
  animation-timing-function: cubic-bezier(.32, .58, .3, 1);
}
.dep-fly .ls-glow, .dep-fly .ls-streak { display: none; }
/* Same turn as a draw — face down to face up — timed to this flight's shorter duration. */
.dep-fly .ls-card  { animation: cof-draw-turn  780ms ease-in-out forwards; }
.dep-fly .ls-back  { animation: cof-draw-back  780ms steps(1, end) forwards; }
.dep-fly .ls-front { animation: cof-draw-front 780ms steps(1, end) forwards; }
/* Art whose identity could not be resolved: a back that never turns, rather than a blank face
   that would read as art which failed to load. Mirrors .draw-fly.no-turn. */
.dep-fly.no-turn .ls-card { animation: none; transform: rotateY(180deg); }
.dep-fly.no-turn .ls-back { animation: none; opacity: 1; }

/*
 * LIFTS FIRST, THEN CROSSES, THEN DROPS — in that order, because that is the sentence the
 * animation has to say: "this came OFF the deck" before "and went ONTO the discard". A single
 * eased slide between the two cells says only "something moved" and is what the two changing
 * counters already failed to communicate.
 */
@keyframes cof-dep-fly {
  0%   { opacity: 0;
         transform: translate3d(var(--dx), var(--dy), 0) rotate(0deg) scale(.94); }
  10%  { opacity: 1; }
  /* Straight up off the top of the deck, barely moving toward the pile yet. */
  30%  { transform: translate3d(calc(var(--dx) * .82), calc(var(--dy) * .82 - 40px), 0)
                    rotate(-5deg) scale(1.2); }
  /* Crossing, at the top of the arc and at full size — the readable part of the flight. */
  62%  { transform: translate3d(calc(var(--dx) * .34), calc(var(--dy) * .34 - 52px), 0)
                    rotate(4deg) scale(1.24); }
  /* Falls onto the pile from above, so the landing has weight. */
  88%  { transform: translate3d(calc(var(--dx) * .05), calc(var(--dy) * .05 - 12px), 0)
                    rotate(1deg) scale(1.04); }
  100% { opacity: 1; transform: translate3d(0, 0, 0) rotate(0deg) scale(1); }
}

/*
 * ⚠️ Reduced motion is handled in JS, not here — playDeplete() returns before creating a single
 * flyer. That is the safe failure for this cue specifically: with `animation: none` the
 * animationend event never fires, and this cue holds the reveal lock, so a CSS-only suppression
 * would stall every cue behind it for the rest of the game. Nothing is lost by skipping it —
 * the cards are already in the discard and both counts are already correct.
 */

/* ===== A CARD BEING UPLOADED [T7] ========================================
 * Hand (or the mat) -> the Upload zone, turning face UP on the way.
 *
 * ⚠️ NOT the Extra Locations, despite how the open item was worded — `upload` and `extra` are
 * different zones with different elements. See uploadStack() in tilt.js.
 *
 * Shares the flyer machinery with the Lightspeed play and differs deliberately:
 *  · SLOWER (1650ms). The card will keep scoring at the end of every remaining Battle, and the
 *    Upload zone is public, so BOTH players need long enough to read what it was.
 *  · NO SPEED LINES. A streak reads as urgency, which is what a Lightspeed reaction is and an
 *    upload is not. The bloom stays — it is what carries the "lifting out of play" feeling.
 *  · TURNS FACE UP FOR BOTH SIDES. There is no `.no-turn` case here, unlike a draw: the moment
 *    it lands the opponent is entitled to see it, so concealing it mid-flight would be a lie
 *    that the very next frame contradicts.
 * ------------------------------------------------------------------------- */
.upload-fly {
  animation-name: cof-up-fly;
  animation-duration: 1650ms;                    /* == UPLOAD_MS in tilt.js */
  animation-timing-function: cubic-bezier(.3, .62, .25, 1);
}
.upload-fly .ls-streak { display: none; }
/* A wider, softer bloom than the Lightspeed one: a glow that grows as the card rises, rather
   than a bow wave in front of something moving fast. */
.upload-fly .ls-glow {
  animation: cof-up-glow 1650ms ease-out forwards;
  background: radial-gradient(closest-side,
              rgba(120, 235, 255, .55), rgba(120, 235, 255, 0) 70%);
}
.upload-fly .ls-card  { animation: cof-draw-turn 1650ms ease-in-out forwards; }
.upload-fly .ls-back  { animation: cof-draw-back  1650ms steps(1, end) forwards; }
.upload-fly .ls-front { animation: cof-draw-front 1650ms steps(1, end) forwards; }

/*
 * A HIGHER arc than either the draw or the Lightspeed play. The card is leaving the table
 * rather than crossing it, so it climbs well above the straight line between the two points
 * before settling — that lift is the whole read of the animation.
 */
@keyframes cof-up-fly {
  0%   { opacity: 0;
         transform: translate3d(var(--dx), var(--dy), 0) rotate(var(--spin, 0deg)) scale(.58); }
  10%  { opacity: 1; }
  40%  { transform: translate3d(calc(var(--dx) * .58), calc(var(--dy) * .58 - 54px), 0)
                    rotate(var(--spin, 0deg)) scale(1.34); }
  70%  { transform: translate3d(calc(var(--dx) * .24), calc(var(--dy) * .24 - 62px), 0)
                    rotate(var(--spin, 0deg)) scale(1.42); }
  /* Settles DOWN onto the pile from above, so the landing has weight rather than a stop. */
  90%  { transform: translate3d(calc(var(--dx) * .05), calc(var(--dy) * .05 - 14px), 0)
                    rotate(var(--spin, 0deg)) scale(1.06); }
  100% { opacity: 1;
         transform: translate3d(0, 0, 0) rotate(var(--spin, 0deg)) scale(1); }
}
@keyframes cof-up-glow {
  0%   { opacity: 0;   transform: scale(.6); }
  35%  { opacity: .85; transform: scale(1.5); }
  100% { opacity: 0;   transform: scale(2.1); }
}

@keyframes cof-ls-turn {
  0%, 18%   { transform: rotateY(0deg); }
  72%, 100% { transform: rotateY(180deg); }
}
@keyframes cof-ls-back  { 0%,49% { opacity: 1 } 50%,100% { opacity: 0 } }
@keyframes cof-ls-front { 0%,49% { opacity: 0 } 50%,100% { opacity: 1 } }
/*
 * The trail is a CHILD of the flyer, so it inherits the flyer's scale — at scale 1.55 a
 * full-length trail would reach half a screen past the hand. These scaleX values are the
 * distance actually travelled DIVIDED BY the flyer's scale at that instant, which keeps the
 * tail pinned to the hand for the whole flight:
 * The per-keyframe arithmetic is written against each waypoint below.
 */
@keyframes cof-ls-streak {
  0%   { opacity: 0;   transform: rotate(var(--ang)) scaleX(0); }
  12%  { opacity: 1; }
  30%  { opacity: 1;   transform: rotate(var(--ang)) scaleX(.25); }   /* .32 travelled / 1.28 */
  60%  { opacity: .95; transform: rotate(var(--ang)) scaleX(.45); }   /* .68 travelled / 1.5  */
  86%  { opacity: .6;  transform: rotate(var(--ang)) scaleX(.89); }   /* .96 travelled / 1.08 */
  100% { opacity: 0;   transform: rotate(var(--ang)) scaleX(1); }
}
@keyframes cof-ls-land {
  0%   { box-shadow: 0 0 30px 12px rgba(8,245,255,.75), 0 0 0 2px rgba(8,245,255,.9); }
  100% { box-shadow: 0 0 0 0 rgba(8,245,255,0), 0 0 0 0 rgba(8,245,255,0); }
}

/* Safety net. JS already skips the queue under reduced motion, so the card is simply there;
   this guarantees nothing can be left hidden or mid-flight if that path is ever missed. */
@media (prefers-reduced-motion: reduce) {
  body.tilt3d .card.ls-pending { opacity: 1 !important; }
  body.tilt3d .card.ls-landed  { animation: none; }
  .ls-fly, .draw-fly { display: none; }
}

/* ===== EFFECT CUES: arcing arrow, then burn-away =============================
 * Players should be able to follow what is happening without reading the log. An effect
 * draws a gold arc from the card causing it to the card receiving it; a destruction plays
 * that arc first and only then sets the card alight, so cause reads before consequence.
 *
 * ⚠️ Both live in FIXED, full-viewport layers appended to <body>, never inside body.tilt3d #scene3d.
 * That container is a 3D-transformed plane — anything placed in it is projected with it, so
 * an arrow would lie flat on the table and skew instead of arcing over it, and the flames
 * would lean with the board.
 * ------------------------------------------------------------------------- */

.fx-arc {
  position: fixed; inset: 0;
  width: 100vw; height: 100vh;
  z-index: 615;                       /* under the Lightspeed flyer (620), over the board */
  pointer-events: none;
  overflow: visible;
}
.fx-arc-line {
  fill: none;
  stroke: #ffcf4d;
  stroke-width: 3.5;
  stroke-linecap: round;
  filter: drop-shadow(0 0 6px rgba(255, 196, 64, .85));
  /* Drawn on rather than faded in: the direction of travel IS the information.
     The dash itself is animated from JS — see drawArc() for why a CSS var cannot do it. */
}
.fx-arc-head {
  fill: #ffe08a;
  opacity: 0;
  filter: drop-shadow(0 0 7px rgba(255, 196, 64, .95));
  /* Lands only once the line reaches it, then holds long enough to be read. */
  animation: fx-arc-head var(--arc-ms, 620ms) ease-out 0ms forwards;
  animation-delay: calc(var(--arc-ms, 620ms) * .62);
}
@keyframes fx-arc-head {
  0%   { opacity: 0; }
  22%  { opacity: 1; }
  70%  { opacity: 1; }
  100% { opacity: 0; }
}

/* --- the destroyed card, burning away ------------------------------------ */
.fx-burn {
  /* The mat is the ghost's offset parent — it outlives every re-render of the rows below it. */
  /*
   * ABSOLUTE inside the card's own cell, not fixed on the viewport. It rides the board's 3D
   * transform exactly as the card did, so it sits on the table at the right size and angle
   * instead of being a flat screen-space rectangle sized from a projected bounding box.
   */
  position: absolute;
  z-index: 6;
  pointer-events: none;
  border-radius: 6px;
  overflow: hidden;
  /*
   * The dissolve is a MASK that sweeps upward, not an opacity fade: a fade dims the whole
   * card evenly and reads as "removed", while a moving edge reads as "consumed". The hard
   * stop next to the soft one is the char line that the ::after glow rides.
   */
  -webkit-mask-image: linear-gradient(to top, transparent 0%, #000 12%, #000 100%);
          mask-image: linear-gradient(to top, transparent 0%, #000 12%, #000 100%);
  -webkit-mask-size: 100% 300%;
          mask-size: 100% 300%;
  -webkit-mask-position: 0 -200%;
          mask-position: 0 -200%;
}
/* Armed only when the arrow lands — see playKill(). Until then the ghost stands in for the
   card the board has already removed, so the target never blinks out mid-cue. */
.fx-burn.burning { animation: fx-burn-away var(--burn-ms, 1250ms) ease-in forwards; }
.fx-burn img { display: block; width: 100%; height: 100%; object-fit: cover; }
/* A face-down victim has no art to burn — show the card back it was showing. */
.fx-burn.facedown { background: url('/cardback.png') center/cover no-repeat, #060c18; }

/* The travelling ember edge, chasing the mask so the card looks alight where it is going. */
.fx-burn::after {
  content: '';
  position: absolute; left: 0; right: 0;
  height: 46%;
  background:
    radial-gradient(60% 100% at 30% 100%, rgba(255, 236, 150, .95), transparent 70%),
    radial-gradient(70% 100% at 68% 100%, rgba(255, 148, 40, .9), transparent 72%),
    linear-gradient(to top, rgba(255, 96, 16, .95), rgba(255, 60, 0, .25) 55%, transparent 100%);
  filter: blur(2px);
  mix-blend-mode: screen;
  opacity: 0;
}
.fx-burn.burning::after { animation: fx-burn-edge var(--burn-ms, 1250ms) ease-in forwards; }

/*
 * The initial catch. This was a circle scaling 0 -> 1 -> 1.5, which read as the CARD expanding
 * and contracting before it vanished rather than as ignition. Now it only brightens: a flare
 * across the card that blooms and dies while the burn-through takes over beneath it. No
 * transform at all, so nothing about the card's size or shape moves.
 */
.fx-burn::before {
  content: '';
  position: absolute; inset: 0;
  opacity: 0;
  background:
    radial-gradient(120% 90% at 50% 100%, rgba(255,238,190,.95), transparent 62%),
    radial-gradient(90% 70% at 32% 88%, rgba(255,170,60,.85), transparent 66%),
    radial-gradient(90% 70% at 70% 92%, rgba(255,110,26,.8), transparent 68%);
  mix-blend-mode: screen;
  pointer-events: none;
}
.fx-burn.burning::before { animation: fx-flare 460ms ease-out forwards; }
@keyframes fx-flare {
  0%   { opacity: 0; }
  18%  { opacity: 1; }
  100% { opacity: 0; }
}

/* A send-to-void is not a fire — it is an erasure, so the same motion runs cold and blue. */
.fx-burn.voided::after {
  background:
    radial-gradient(60% 100% at 40% 100%, rgba(190, 240, 255, .95), transparent 70%),
    linear-gradient(to top, rgba(90, 190, 255, .9), rgba(40, 90, 255, .25) 55%, transparent 100%);
}
.fx-burn.voided::before {
  background:
    radial-gradient(120% 90% at 50% 100%, rgba(238,250,255,.95), transparent 62%),
    radial-gradient(90% 70% at 50% 90%, rgba(110,200,255,.85), transparent 66%);
}

@keyframes fx-burn-away {
  0%   { -webkit-mask-position: 0 -200%; mask-position: 0 -200%; opacity: 1; }
  10%  { -webkit-mask-position: 0 -200%; mask-position: 0 -200%; opacity: 1; }  /* catch, then consume */
  92%  { opacity: 1; }
  100% { -webkit-mask-position: 0 100%;  mask-position: 0 100%;  opacity: 0; }
}
@keyframes fx-burn-edge {
  0%, 8% { bottom: -46%; opacity: 0; }
  22%     { opacity: 1; }
  88%     { bottom: 100%; opacity: 1; }
  100%    { bottom: 130%; opacity: 0; }
}

/*
 * Reduced motion keeps the CUE and drops the spectacle: the arrow still appears and holds so
 * the causal link is not lost, and the card leaves without fire or motion. Removing the cue
 * entirely would take information away from the players most likely to want it.
 */
@media (prefers-reduced-motion: reduce) {
  .fx-arc-head { animation: none; opacity: 1; }
  .fx-burn::before { animation: none; opacity: 0; }
  .fx-burn::after { animation: none; opacity: 0; }
  .fx-burn.burning {
    animation: fx-burn-plain var(--burn-ms, 1250ms) linear forwards;
    -webkit-mask-image: none; mask-image: none;
  }
  @keyframes fx-burn-plain { 0%, 60% { opacity: 1 } 100% { opacity: 0 } }
}

/* ===== EXTRA LOCATIONS: the newest card must sit on top ======================
 * PAINT ORDER INSIDE A 3D PLANE IS BY POSITION, NOT z-index. The base sheet stacks these with
 * `z-index: calc(var(--i) + 1)`, which is simply IGNORED inside transform-style: preserve-3d —
 * so a card arriving by Lightspeed could land underneath the ones already in the zone.
 *
 * Lifting each card off the table by its index restores the intended order physically, which
 * is the only thing that sorts here. 0.8px per card is far too small to see as depth; it
 * exists purely to break the tie.
 *
 * ⚠️ Must be restated on :hover. The rules at the top of this file blank the hover transform
 * outright (`transform:none!important`) to stop cards magnifying on the tilted board, and that
 * would drop a hovered card back beneath its neighbours.
 * ------------------------------------------------------------------------- */
body.tilt3d #scene3d .stack .card,
body.tilt3d #scene3d .stack .card.zoomable:hover{
  transform: translateZ(calc(var(--i, 0) * 0.8px)) !important;
}
/* The foe's mat is rotated 180deg; its cards keep that and only add the lift. */
body.tilt3d #scene3d .foe-mat .stack .card,
body.tilt3d #scene3d .foe-mat .stack .card.zoomable:hover{
  transform: rotate(180deg) translateZ(calc(var(--i, 0) * 0.8px)) !important;
}

/* ===== A CARD ARRIVING: shimmer into existence ==============================
 * Used whenever an effect PUTS a card somewhere — Hydraspawn pulling copies into the Extra
 * Locations, a card deployed from a deck or discard. It runs only after the arrow has landed,
 * so the sequence reads cause -> effect rather than showing the result first.
 *
 * Translucent to solid, with a highlight sweeping across the face. No transform: the card is
 * inside the tilted plane, and scaling it there fights the board's own projection — the same
 * mistake that made a destroyed card look like it was inflating.
 * ------------------------------------------------------------------------- */
.fx-arrive {
  animation: fx-arrive var(--arrive-ms, 700ms) ease-out;
}
.fx-arrive::after {
  content: '';
  position: absolute; inset: 0;
  border-radius: inherit;
  pointer-events: none;
  background: linear-gradient(100deg,
    transparent 25%, rgba(190, 250, 255, .16) 42%,
    rgba(235, 253, 255, .55) 50%, rgba(190, 250, 255, .16) 58%, transparent 75%);
  background-size: 280% 100%;
  mix-blend-mode: screen;
  animation: fx-arrive-sweep var(--arrive-ms, 700ms) ease-out;
}
@keyframes fx-arrive {
  0%   { opacity: 0; filter: brightness(2.2) saturate(.4); }
  35%  { opacity: .75; }
  100% { opacity: 1; filter: none; }
}
@keyframes fx-arrive-sweep {
  0%   { background-position: 180% 0; opacity: 0; }
  20%  { opacity: 1; }
  85%  { opacity: .5; }
  100% { background-position: -80% 0; opacity: 0; }
}

/* Reduced motion: the card simply appears. The arrow already carried the meaning. */
@media (prefers-reduced-motion: reduce) {
  .fx-arrive, .fx-arrive::after { animation: none; }
}

/* ===== A STATUS BEING APPLIED: a light runs round the border ================
 * Force Field blue, Mute red, Plague green. Played ONCE, when the status lands — the standing
 * ring these cards already carry is unchanged, so this marks the moment rather than becoming
 * permanent decoration.
 *
 * ⚠️ THE ANGLE IS A REGISTERED CUSTOM PROPERTY. An unregistered one is not an interpolatable
 * type, so `--a: 0turn -> 1turn` in @keyframes would jump rather than sweep — the identical
 * trap that stopped the effect arrow from ever drawing. @property gives it a type and makes it
 * animate. No transform is used: these cards sit in the tilted 3D plane, where rotating a
 * child risks flattening the parent's projection.
 * ------------------------------------------------------------------------- */
@property --fx-a {
  syntax: '<angle>';
  initial-value: 0turn;
  inherits: false;
}

/*
 * ⭐⭐⭐ AND THE CARD MUST NOT CLIP THE RING BEING DRAWN, EITHER. [player report R-A83VB-FFZ]
 *
 * > "the force field ring being added animation, which happens at the very first step, seems to
 * >  be being rendered below the card itself, so it's basically hidden and almost impossible to
 * >  see ... and at some point the permanent ring effect then becomes visible."
 *
 * There are TWO effects and only one of them was ever fixed. The STANDING ring below got
 * `overflow: visible` (see the long note there: "the card clips the ring it is wearing", and
 * "un-scoping and un-clipping are ONE fix: either alone still leaves the reporter with a
 * sliver"). This one — the LED arc that runs around the border at the MOMENT a status is
 * applied — is a `::after` at `inset: -2px` on a card that still carries `overflow: hidden`,
 * because `st-*` has not landed yet when the cue plays. Of a ring drawn 2px outside the card,
 * only what fell inside survived, which is exactly "almost impossible to see".
 *
 * Measured before this: a card carrying only `fx-status` reported `overflow: hidden` in BOTH a
 * numbered Location and the Extra Locations — so the animation was clipped everywhere, and the
 * Extra Locations report was simply where it was noticed.
 *
 * ⚠️ Same trade as the standing ring: the clip exists to round the ART's corners, so it is handed
 * to the image rather than removed.
 */
.card.fx-status { overflow: visible; }
.card.fx-status > img { border-radius: inherit; }

/*
 * ⭐⭐⭐ …AND IT MUST NOT BE BURIED BY THE PILE IT IS IN. [player report, games A83VB and AFE4G]
 *
 * > "it still looks like it is below the card … you can only see the top left corner of the
 * >  animations … I have this same issue with both force field and plagued."
 *
 * ⚠️ "BELOW THE CARD" MEANT BELOW THE OTHER CARDS, not below its own art — which is why the
 * previous fix (un-clipping it with `overflow: visible`) was necessary and not sufficient, and
 * the reporter saw no change.
 *
 * The Extra Locations and Uploaded zones are `.stack`s: cards are absolutely positioned and
 * overlap, each with `z-index: calc(var(--i) + 1)`. The ring is a `::after` at `z-index: 4`
 * INSIDE its own card's stacking context, so it can never paint above a SIBLING card — and every
 * card added after it sits on top. Measured on a three-card pile: of a 111px ring band, 18px was
 * visible; the rest was behind the two cards stacked over it, leaving exactly the sliver in the
 * report.
 *
 * So the card is lifted for as long as the arc is running, and drops back when `markFx` expires
 * and removes the class. Deliberately visible: the card receiving a status coming to the front of
 * the pile for a moment is the same thing the animation is trying to say.
 *
 * ⚠️ 500 clears any sibling (`--i + 1`, single digits) and stays under the pile counter's 999, so
 * the "how many are in here" badge still reads on top.
 */
.stack .card.fx-status { z-index: 500; }

.fx-status { position: relative; }
.fx-status::after {
  content: '';
  /*
   * ⭐⭐⭐ BIG ENOUGH TO ACTUALLY SEE. [player reports A83VB and AFE4G, third time reported]
   *
   * > "there should be a specific color that gets drawn around the card like a pulsing LED light
   * >  going around it … it's basically hidden and almost impossible to see."
   *
   * ⚠️ THIS IS A SEPARATE FAULT FROM THE OCCLUSION ONE. In the Extra Locations the arc was buried
   * under the cards stacked over it (fixed by lifting the card). In a NUMBERED Location nothing
   * covers it — measured 40 of 40 points on top — and it was STILL invisible, because at
   * `inset: -2px` with a 3px band only about two pixels fall outside the card's own dark border,
   * and a dark arc on a black card edge is nothing. Captured at true phone scale it was a
   * hairline down one side.
   *
   * So: further out, thicker, brighter, and a longer arc. Kept off the neighbours — a numbered
   * Location's gap is `var(--gap)`, so 4px of overhang stays inside it.
   */
  /*
   * ⚠️ THE BAND STRADDLES THE CARD EDGE — it is NOT drawn entirely outside it. [reported again
   * after the widening: "the starting ring animation is more visible but still gets hidden
   * behind the card the effect is on"]
   *
   * `inset: -4px` with a 4px band put the whole ring in the 4px BEYOND the card, touching none
   * of it — which reads as a glow leaking out from behind the card rather than "a light going
   * around it", which is what was asked for. `inset: -3px` with a 6px band spans -3px..+3px, so
   * half of it lies ON the card's own border where it has something to contrast against.
   */
  position: absolute; inset: -3px;
  border-radius: inherit;
  pointer-events: none;
  z-index: 4;
  /* A bright arc on an otherwise transparent ring — the LED, not a full halo. Roughly a quarter
     of the circumference now, so it reads as a light traveling rather than a flicker. */
  background: conic-gradient(from var(--fx-a),
    transparent 0deg, transparent 268deg,
    var(--fx-c, #08f5ff) 300deg, #ffffff 340deg, var(--fx-c, #08f5ff) 356deg,
    transparent 360deg);
  /* Hollow it out so only a border-width ring is painted, not the whole face. */
  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
          mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite: xor;
          mask-composite: exclude;
  padding: 6px;
  filter: drop-shadow(0 0 9px var(--fx-c, #08f5ff))
          drop-shadow(0 0 3px var(--fx-c, #08f5ff));
  animation: fx-status-run var(--status-ms, 1500ms) linear;
}
.fx-status-forcefield { --fx-c: #4aa8ff; }   /* blue  */
.fx-status-muted      { --fx-c: #ff4d5e; }   /* red   */
.fx-status-plagued    { --fx-c: #46e06a; }   /* green */

/* Two laps: one to be noticed, one to be read. */
@keyframes fx-status-run {
  0%   { --fx-a: 0turn;   opacity: 0; }
  8%   { opacity: 1; }
  85%  { opacity: 1; }
  100% { --fx-a: 2turn;   opacity: 0; }
}

/*
 * Reduced motion keeps the SIGNAL and drops the movement: the ring simply flashes in that
 * status's colour. Removing it entirely would take the cue away from the players most likely
 * to want it.
 */
@media (prefers-reduced-motion: reduce) {
  .fx-status::after {
    background: none;
    box-shadow: 0 0 0 2px var(--fx-c, #08f5ff), 0 0 12px var(--fx-c, #08f5ff);
    animation: fx-status-flash var(--status-ms, 1500ms) ease-out;
  }
  @keyframes fx-status-flash { 0%,70% { opacity: 1 } 100% { opacity: 0 } }
}

/* ===== THE NEWEST EXTRA-LOCATION CARD IS FULLY VISIBLE ======================
 * `renderStack` appends in order, so the newest card is ALWAYS :last-child. Keying on that
 * rather than on `--i` makes the claim structural: it cannot drift if the index plumbing
 * changes, and it needs no arithmetic to be right.
 *
 * Both properties are set because the two layouts sort differently and the same stack is used
 * by each: z-index decides it on the flat board, and 3D position decides it inside the tilted
 * plane, where z-index is ignored. Setting only one leaves the other layout to chance — which
 * is what the previous per-index lift did, and it did not hold up in a real game.
 *
 * The lift is 12px rather than a hairline so it cannot be lost to rounding or to a flattened
 * ancestor's coplanar tie-break.
 * ------------------------------------------------------------------------- */
.stack .card:last-child { z-index: 900; }

body.tilt3d #scene3d .stack .card:last-child,
body.tilt3d #scene3d .stack .card.zoomable:last-child:hover {
  z-index: 900 !important;
  transform: translateZ(12px) !important;
}
/* The foe's mat is rotated; keep that and only add the lift, or its cards flip face-about. */
body.tilt3d #scene3d .foe-mat .stack .card:last-child,
body.tilt3d #scene3d .foe-mat .stack .card.zoomable:last-child:hover {
  transform: rotate(180deg) translateZ(12px) !important;
}

/* ===== "Outside of Battle" must clear the score =============================
 * The Upload zone only exists once Church of Zero starts banking Fragments, so it appears
 * mid-game — right where the Player 1 score already sits. Its label is counter-rotated to face
 * the viewer, which lifts its painted position well above its layout box and put it straight
 * over the score.
 *
 * Pushed down inside its own zone rather than moving the score: the score's placement is a
 * fixed part of the HUD that every other layout depends on, and this zone is the newcomer.
 * ------------------------------------------------------------------------- */
body.tilt3d #scene3d .upload .zlabel {
  position: relative;
  top: calc(var(--card-h) * 0.34);
}

/* ===== LABELS MUST NOT EAT THE POINTER =====================================
 * Dropping a card into a Location was reliable only near its dead centre, and noticeably worse
 * in Chrome than Firefox. These labels are counter-rotated to face the viewer and scaled from
 * --card-h — the Location number is 0.62 of a card tall — so their boxes are large and, once
 * rotated, extend past the cell that owns them. A drop landing on one hit-tests to that label
 * rather than to the Location under the cursor, and `closest('.loc[data-drop]')` then resolves
 * to the wrong cell or to nothing.
 *
 * They are decoration and carry no interaction of their own, so nothing is lost by making them
 * transparent to the pointer, and the whole cell becomes a drop target again. Browsers differ
 * in how they hit-test inside a preserve-3d subtree, which is why this showed up in Chrome
 * first rather than being obviously broken everywhere.
 * ------------------------------------------------------------------------- */
body.tilt3d #scene3d .locnum,
body.tilt3d #scene3d .zlabel,
body.tilt3d #scene3d .zcount,
body.tilt3d #scene3d .mat-tag,
body.tilt3d #scene3d .mat-handcount,
body.tilt3d #scene3d .upload-cq {
  pointer-events: none;
}
/*
 * ⭐ EXCEPT THE PILE COUNTERS, WHICH EXIST TO BE HOVERED. [reports UJEUB, VHP29 — 5 attempts]
 *
 * The rule above is right and stays: counter-rotated labels are large, they overhang the cell
 * that owns them, and a drop landing on one hit-tests to the label instead of the Location.
 *
 * ⚠️ BUT IT SWEPT UP `.zcount`, AND THAT IS WHY FIVE ATTEMPTS AT THE HOVER LABEL FAILED. The
 * counter was pointer-transparent in the tilted view all along, so the pointer fell through to
 * the card back beneath — whose `title` is "Face-down". That is the exact string the player kept
 * reporting, and it was the answer the whole time: not a tooltip that failed to appear, but a
 * DIFFERENT element's tooltip appearing instead.
 *
 * ⚠️ SAFE, because the reason for the blanket rule does not apply here: the deck and discard
 * cells are `.mcell` zones, NOT `.loc[data-drop]` Locations. Nothing is ever dropped on them, so
 * there is no drop to steal. `.locnum` and `.zlabel` — the ones that actually overhang a drop
 * target — stay transparent.
 */
body.tilt3d #scene3d .deck .zcount,
body.tilt3d #scene3d .disc .zcount {
  pointer-events: auto;
  cursor: help;
}

/* ===== ATTACHED CARDS ON THE TILTED BOARD ==================================
 * The base rules place an attachment with `translate(-50%, ±80%)` and lift it on hover with
 * `scale(1.9)`. Both are written for a flat board. Inside the tilted plane the 80% offset
 * pushes the card out of its cell and across the zone above it — the screenshot shows it
 * clipped in half over the Celestial cell — and a scaling hover fights the board's own
 * projection, so the card jumps under the cursor and is hard to click. Reported live (Y8EGX).
 *
 * Here it is tucked UNDER the carrier with a small upward peek and lifted in Z rather than
 * scaled: no size change, no lateral jump, and paint order that works in a 3D context where
 * z-index is ignored. The peek is up on both mats — the foe mat's 180° rotation carries it
 * round on its own, so the sliver stays on the side facing that player.
 * ------------------------------------------------------------------------- */
/*
 * ⚠️ THE PEEK DIRECTION IS HONOURED HERE. [H8, game NJ67Z]
 *
 * These three selectors used to be GROUPED into one rule, which threw `data-peek` away: app.js
 * has always computed `up` for Locations 1-4 and `down` for 5-7 (Skirmish 2 has open space
 * BELOW it, and peeking up pushes the sliver over the Skirmish 1 row), and the tilted board
 * quietly ignored that and peeked up everywhere. Reported as "on skirmish 2 the cards should
 * be sticking out the bottom ... not out the top of the card".
 */
/*
 * ⚠️ SAME VARIABLES AS THE FLAT SHEET, so the two views cannot drift. This used to be four rules
 * — two for each mat — each restating the whole transform, and the pair of sheets disagreed about
 * whether an opponent's attachment was counter-rotated. That disagreement WAS the bug: upright on
 * the tilted board, upside down on the flat one. There is now one place per view that composes
 * the offset, the upright rotation and the depth.
 */
body.tilt3d #scene3d .loc .card.attached,
body.tilt3d #scene3d .loc .card.attached[data-peek="up"] {
  top: 0; bottom: auto;
  left: 50%;
  /* ~22% proud of the carrier, matching OR §16.5's "about the top 20% still visible". */
  --peek-shift: -22%;
  --peek-lift: -88%;
  transform: translate(-50%, var(--peek-shift)) rotate(var(--upright)) translateZ(-2px);
}
body.tilt3d #scene3d .loc .card.attached[data-peek="down"] {
  top: auto; bottom: 0;
  left: 50%;
  --peek-shift: 22%;
  --peek-lift: 88%;
}

/*
 * ⭐ HOVER HAS TO ACTUALLY SHOW THE CARD. [H4, game NJ67Z — raised repeatedly]
 *
 * This used to lift 14px in Z with "deliberately NO scale and NO extra offset, so the card the
 * pointer is over does not move out from under the pointer". The cost of that caution was the
 * whole feature: ~78% of the card stays behind its carrier, so a player could hover an
 * opponent's attachment and still not know what it was — "there's no way for me to see what
 * card was attached".
 *
 * It now slides clear in the direction it already peeks and scales up. The pointer is on the
 * sliver, and the enlarged card still covers that spot, so the hover does not drop.
 */
body.tilt3d #scene3d .loc .card.attached:hover,
body.tilt3d #scene3d .loc .card.attached.peeked {
  transform: translate(-50%, var(--peek-lift)) rotate(var(--upright))
             scale(1.55) translateZ(40px);
}
/* Whatever it slides over must not clip it, and it must paint above its neighbours. */
body.tilt3d #scene3d .loc:has(.card.attached:hover),
body.tilt3d #scene3d .loc:has(.card.attached.peeked),
body.tilt3d #scene3d .mat:has(.card.attached:hover),
body.tilt3d #scene3d .mat:has(.card.attached.peeked) { overflow: visible; }

/* The cell clips at these offsets; let the sliver show. */
body.tilt3d #scene3d .loc:has(.card.attached) { overflow: visible; }

/* ===== Opponent's CQ badge ==================================================
 * The rule that used to live here re-pinned the foe badge to bottom-left and turned it about
 * its centre. Both were wrong — see the note beside `.card .cqmod` above: the foe CARD is
 * already counter-rotated, so the badge needs no foe-specific handling at all. Removed after
 * measuring it on a live board. DO NOT REINTRODUCE without a measurement.
 * ------------------------------------------------------------------------- */

/* ===== Opponent's Extra Locations descend from the top ======================
 * The player's pile cascades downward with the newest card lowest and fully visible. The foe
 * mat is rotated 180°, so the same offsets carry that cascade the wrong way for a viewer
 * looking at it from this side. Their pile now hangs downward from the top of the zone with
 * the newest card proud at the bottom of the run — the accordion reads the same way for both
 * seats without either player having to read it upside down.
 * ------------------------------------------------------------------------- */
body.tilt3d #scene3d .foe-mat .stack .card {
  top: auto;
  bottom: calc(var(--i) * var(--card-h) * 0.15);
}

/* ===== The visible sliver of an attachment must be hoverable ================
 * Only ~22% of the card shows above its carrier, and that strip was not reliably picking up
 * the pointer: the attachment sits BEHIND the carrier in Z, and in a 3D subtree a nearer
 * sibling can take the hit over the whole of its own box rather than only where it is opaque.
 *
 * The peek strip is raised in front for HIT TESTING only, via a pseudo-element that carries no
 * paint of its own — so the card still renders behind its carrier while the sliver stays
 * clickable. Hovering then lifts the real card clear.
 * ------------------------------------------------------------------------- */
/*
 * ⚠️ THE HIT STRIP HAS TO BE ON THE END THAT ACTUALLY SHOWS. [reported 2026-08-22]
 *
 * This was pinned to `top: 0` for every attachment, but `data-peek` decides which edge is
 * proud of the carrier: Skirmish 1 (Locations 1-4) peeks UP, Skirmish 2 (5-7) peeks DOWN —
 * the same asymmetry H8 already fixed for the transform. So on the whole of Skirmish 2 the
 * only hoverable strip sat at the hidden end, BEHIND the carrier, and the sliver a player
 * could actually see could not be hovered at all. Half the board had no working peek.
 */
body.tilt3d #scene3d .loc .card.attached::before,
body.tilt3d #scene3d .loc .card.attached[data-peek="up"]::before {
  content: '';
  position: absolute;
  left: 0; right: 0; top: 0; bottom: auto;
  height: 30%;                        /* the strip that shows above the carrier */
  transform: translateZ(20px);        /* in front of the carrier for hit testing */
  pointer-events: auto;
}
body.tilt3d #scene3d .loc .card.attached[data-peek="down"]::before {
  content: '';
  position: absolute;
  left: 0; right: 0; top: auto; bottom: 0;
  height: 30%;
  transform: translateZ(20px);
  pointer-events: auto;
}
body.tilt3d #scene3d .loc .card.attached { pointer-events: auto; }

/* ===== OTHER PLAYERS' PILE LABELS FLATTEN ON HOVER =========================
 * [user request 2026-08-25]
 *
 * "Hovering over the discard pile causes the Discard label and card count to pop up in a flat 2D
 * format instead of remaining tilted. I like this concept, but it should only apply to boards
 * that do NOT belong to the current player."
 *
 * The reasoning is sound and worth stating: your OWN pile labels sit at the near edge of the
 * tilted plane, where the foreshortening is mild and you read them constantly — flattening them
 * would make your own board twitch under the cursor for no gain. Everyone ELSE'S labels sit at
 * the far edge, most compressed and most often misread, and you look at them occasionally and
 * deliberately. Flatten those.
 *
 * ⭐ SCOPED `:not(.you-mat)`, NOT `.foe-mat` — DELIBERATELY, FOR MULTIPLAYER.
 * "Any board not owned by the current player should have the hover-to-2D behaviour." A fourth
 * and fifth mat added for a four-player format inherit this with no rule change; written as
 * `.foe-mat` it would silently apply to exactly one opponent and quietly ignore the rest. Same
 * argument as `eachAlly` over `self` in the card vocabulary: author the RULE, not today's
 * instance of it.
 *
 * ⚠️ Applies to BOTH piles now. It was reaching the discard only.
 * ------------------------------------------------------------------------- */
body.tilt3d #scene3d .mat:not(.you-mat) .deck:hover .zlabel,
body.tilt3d #scene3d .mat:not(.you-mat) .disc:hover .zlabel {
  /*
   * ⚠️ ABOVE THE CARD, NOT ON IT. "The Discard label is sometimes cut in half horizontally on
   * certain screen sizes." It sat inside the cell, and a cell is exactly one card tall — so once
   * the label stood upright it needed vertical room the cell did not have and `overflow: hidden`
   * took the rest. Lifting it clear of the cell's top edge is what actually fixes that; making it
   * smaller would only postpone it to the next screen size.
   */
  top: auto;
  bottom: calc(100% + 2px);
  /* FLAT: no rotateX at all, so it faces the screen rather than the table. */
  transform: translateZ(30px) rotate(180deg);
  transform-origin: center center;
  white-space: nowrap;                 /* a wrapped two-line label is what "cut in half" looks like */
  z-index: 6;
}
/*
 * ⭐ THE COUNT STAYS UPRIGHT AND READABLE. [same request]
 *
 * These mats are rotated 180°, so a badge that merely stops being tilted is then upside down —
 * readable geometry, unreadable digits. The counter-rotation has to survive the flattening, which
 * is why this sets `rotate(180deg)` and drops ONLY the `rotateX`.
 */
body.tilt3d #scene3d .mat:not(.you-mat) .deck:hover .zcount,
body.tilt3d #scene3d .mat:not(.you-mat) .disc:hover .zcount {
  transform: translateZ(32px) rotate(180deg);
  transform-origin: center center;
  z-index: 7;
  /* ⚠️ The hover tooltip must keep working here — this is the one element a player points AT,
     and the whole reason the label is worth reading. See the pointer-events exemption above. */
  pointer-events: auto;
}
/*
 * The cell clips to one card, so the lifted label would be cut off at the very moment it becomes
 * legible. Opened only while this cell is hovered, so nothing else on the board changes.
 */
body.tilt3d #scene3d .mat:not(.you-mat) .deck:hover,
body.tilt3d #scene3d .mat:not(.you-mat) .disc:hover {
  overflow: visible;
}

/* ===== THE CQ BREAKDOWN MUST BE READ, NOT DECIPHERED ========================
 * ⭐ [report R-5CVR4] "The CQ breakdown popup is very small and tilted at an angle... the text
 * needs to be larger and shouldn't be tilted when you hover. It should be face-on."
 *
 * ⚠️ The panel hangs off the CQ badge ON A CARD, and cards live inside `#scene3d` — so it
 * inherited the board's `rotateX(var(--tilt))` and was rendered lying down on the play surface,
 * foreshortened, at `font-size: .5rem` (8px). Exactly the same problem the zone labels, Location
 * numbers and pile counts were given the counter-rotation for; this one was simply missed.
 *
 * ⚠️ `transform-origin: top right` — the panel is anchored `top: 100%; right: 0` under the badge,
 * so rotating about its own top-right keeps it hanging from the badge instead of swinging away.
 * ⚠️ The opponent's cards are already turned upright by `.foe-mat .card { rotate(180deg) }`, so
 * a child inherits that and needs only the X-tilt undone here. One rule, both mats.
 * ------------------------------------------------------------------------- */
body.tilt3d #scene3d .cqwhy{
  transform:rotateX(calc(-1 * var(--tilt)));
  transform-origin:top right;
  font-size:.72rem; line-height:1.5;
  min-width:13rem; max-width:18rem;
  padding:.45rem .55rem;
}
body.tilt3d #scene3d .cqwhy-head{font-size:.72rem}

/* ===== VOIDING: AN ERASURE, NOT A FIRE ====================================
 * [user request 2026-08-26]
 *
 * "The voiding animation can be similar to the destruction animation, but it should look wavier
 * and shimmier so it feels distinct from destruction."
 *
 * The burn CONSUMES from one edge, steadily, like something catching. This ripples: the card
 * wobbles horizontally, thins, blurs and brightens as it is unwritten, and a shimmer sweeps
 * across it. Same duration and the same mask sweep underneath, so the two read as siblings —
 * which is right, they are both "this card is leaving" — while never being mistaken for each
 * other. The void is the one zone nothing comes back from, and that deserves its own gesture.
 *
 * ⚠️ OVERRIDES `.fx-burn.burning` BY BEING MORE SPECIFIC (0,3,0 vs 0,2,0), not by source order,
 * so it cannot be undone by a later edit moving these rules around.
 * ------------------------------------------------------------------------- */
.fx-burn.voided.burning {
  animation: fx-void-away var(--burn-ms, 1250ms) cubic-bezier(.35,.05,.5,1) forwards;
}
@keyframes fx-void-away {
  0%   { -webkit-mask-position: 0 -200%; mask-position: 0 -200%;
         opacity: 1; filter: none; transform: translateX(0) scaleX(1); }
  12%  { -webkit-mask-position: 0 -200%; mask-position: 0 -200%;
         opacity: 1; filter: brightness(1.35) saturate(.7); transform: translateX(-2%) scaleX(1.02); }
  30%  { transform: translateX(2.5%) scaleX(.97); filter: blur(.6px) brightness(1.5) saturate(.5); }
  48%  { transform: translateX(-2.5%) scaleX(1.03); filter: blur(1.2px) brightness(1.35) saturate(.4); }
  66%  { transform: translateX(2%) scaleX(.95); filter: blur(2px) brightness(1.5) saturate(.25); }
  84%  { opacity: .55; transform: translateX(-1.2%) scaleX(1.02);
         filter: blur(3px) brightness(1.7) saturate(.15); }
  100% { -webkit-mask-position: 0 100%; mask-position: 0 100%;
         opacity: 0; transform: translateX(0) scaleX(.9);
         filter: blur(5px) brightness(2) saturate(0); }
}
/* The shimmer that sweeps across while it thins — a cold light travelling over the surface. */
.fx-burn.voided.burning::after {
  animation: fx-void-shimmer var(--burn-ms, 1250ms) linear forwards;
}
@keyframes fx-void-shimmer {
  0%   { opacity: 0; bottom: -30%; }
  18%  { opacity: .9; }
  55%  { opacity: .7; }
  100% { opacity: 0; bottom: 120%; }
}

/*
 * ⭐ AND IT REAPPEARS IN THE VOID PILE, FACE UP. [same request]
 *
 * "The card should visually reappear in the Void pile using the same animation in reverse."
 * The void is a PUBLIC zone, so the identity is no longer hidden once it lands — which is
 * exactly why the card may not be shown face-up before this point (see plantGhosts).
 */
.fx-burn.void-landing {
  animation: fx-void-arrive var(--burn-ms, 900ms) cubic-bezier(.2,.6,.3,1) forwards;
}
@keyframes fx-void-arrive {
  0%   { opacity: 0; transform: translateX(0) scaleX(.9);
         filter: blur(5px) brightness(2) saturate(0); }
  30%  { opacity: .6; transform: translateX(2%) scaleX(1.02); filter: blur(2px) brightness(1.5); }
  60%  { opacity: .9; transform: translateX(-1.5%) scaleX(.98); filter: blur(.8px) brightness(1.2); }
  100% { opacity: 1; transform: translateX(0) scaleX(1); filter: none; }
}

/* ===== A CARD YOU ARE BEING ASKED TO PICK MUST BE FULLY CLICKABLE ==========
 * [reports P6BJY, AWEL6, TCT8R ×3 — 2026-08-26, four players, one cause]
 *
 *   "CANT CLICK ENEMY CARD BECAUSE IT MOVES OUT OF THE WAY"
 *   "when I try to click, the unit moves out of the way and makes it impossible to select"
 *   "the card would pop up off of the table, still at a tilted angle, and it was very hard to
 *    actually select it. I had to just randomly click continuously until it found the hit spot"
 *   "the clickable area is too small. It appears to only work near the center of the card"
 *   "the arrow is not getting drawn unless I click on the card and hold… then it flashes away"
 *
 * ⭐ ALL FIVE ARE ONE BUG, AND `check-tilt.ts` HAD ALREADY MEASURED IT: a Location on the tilted
 * board reports `LOSES tl,br to div.mcell.celestial`. Inside a `preserve-3d` plane the cells are
 * projected quads that OVERLAP their neighbours, so a card's own corners hit-test to the cell
 * next door and only the middle is really clickable. I saw that line, called it "expected for a
 * tilted plane", and moved on — it was the bug, already on screen, in my own harness output.
 *
 * The arrow report is the same cause seen from the other side: hovering near an edge lands on the
 * neighbour, the pointer-out clears the aim arc, and it "flashes away".
 *
 * FIX: while the player is being ASKED to choose, lift every legal target out of the plane. Z
 * position decides the hit-test here (z-index does not), so a lifted card wins its whole
 * projected area back from its neighbours.
 *
 * ⚠️ ONLY WHILE TARGETING, and only for legal targets. The overlap is harmless the rest of the
 * time and the tilt is the whole look of the board; this buys back the hitbox exactly when a
 * player is trying to hit something, and changes nothing otherwise.
 * ------------------------------------------------------------------------- */
/*
 * ⚠️ THE FIRST ATTEMPT LIFTED THE TARGET — `translateZ(40px)` — AND MADE IT WORSE. Reported
 * straight back: "the arrow is not working and you can't even click on any of the targets."
 * Under `perspective` a translateZ does not merely reorder, it DISPLACES: the card projects
 * larger and off its own footprint, so it moved out from under the cursor — the exact complaint
 * the fix was for. My probe passed it because it re-measured the card's rect AFTER the move and
 * asked about that; it could not see "no longer where the player is aiming".
 *
 * ⭐ SO NOTHING MOVES NOW. Rather than raising the target above its neighbours, the neighbours
 * stand aside: while a choice is open, every cell that does NOT hold a legal target goes
 * transparent to the pointer, so a hit landing on an overlapping projected quad falls through to
 * the target painted behind it. Identical geometry on screen, whole card clickable.
 *
 * ⚠️ Safe precisely BECAUSE a choice is open: during targeting the only legal interaction IS
 * picking a target, so a cell holding none has nothing to offer the pointer anyway.
 */
/*
 * ⚠️ THE TARGETING HITBOX RULES ARE WITHDRAWN — TWO ATTEMPTS, BOTH WORSE THAN THE PROBLEM.
 *
 *   1. Lifting each target `translateZ(40px)` DISPLACED it under perspective (measured: 4,31px),
 *      so it moved out from under the cursor — the exact complaint it was meant to fix.
 *   2. Making every non-target cell `pointer-events: none` was reported as targets that cannot
 *      be hovered OR clicked at all: "nothing happens".
 *
 * Both shipped because the probe behind them marks a DECK CARD BACK as `.targetable` and asks
 * about that. A deck card is never a real target, sits in an `.mcell` rather than a `.loc`, and
 * has no neighbouring units — so it answers a different question from the one that matters.
 *
 * ⚠️ DO NOT RE-ADD EITHER RULE WITHOUT A PROBE THAT DRIVES A REAL CHOICE: a genuine
 * `selectCards` on units standing in Locations, with the client's own `.targetable` classes,
 * on both mats. Until that exists this area is unverifiable and every fix is a guess that
 * reaches the player before it reaches a test.
 *
 * The board is left as it was: harder to click at the edges than it should be (R-HITBOX), which
 * is a real bug — and strictly better than targets that do not respond at all.
 */

/* ===== STATUS RINGS MUST FACE THE VIEWER ===================================
 * [report TCT8R, 2026-08-26]
 *
 * "I muted my opponent's Access Broker and you cannot actually see any of the red outline around
 * it. It kind of pokes out around the edges on one side."
 *
 * ⚠️ AN OUTLINE IS DRAWN IN THE ELEMENT'S OWN PLANE. On the tilted board that plane is raked away
 * from the viewer, so a 2px ring foreshortens to nothing along the top and bottom and survives
 * only left and right — precisely "it pokes out around the edges on one side". Thickening it
 * would only have made the two surviving edges heavier.
 *
 * Same answer the zone labels and pile counters use: a counter-rotated element that stands out of
 * the plane and faces the screen.
 *
 * ⚠️ `pointer-events: none` — this sits over a card the player may be asked to CLICK, and
 * R-HITBOX was four reports about exactly that. It must never take a hit.
 * ------------------------------------------------------------------------- */
/*
 * ⭐⭐ BOTH LAYOUTS, AND THE CARD MUST NOT CLIP ITS OWN RING. [report 5UCXG, mobile Firefox]
 *
 * Two faults, measured by `check-board.ts` rather than argued:
 *
 * 1. **The flat board had NO ring at all** — `inset auto, border 0px, z auto`. These rules were
 *    scoped `body.tilt3d #scene3d`, so everything below was invisible on the layout PHONES USE,
 *    which was left on the old 2px `outline`. The report came from mobile Firefox: "the ring
 *    around it is not large enough… I can't actually see it around the entire card."
 *
 * 2. **`.card` carries `overflow: hidden`, so it clipped the ring it was wearing.** The ring is
 *    drawn at a NEGATIVE inset — deliberately outside the card box — and an element cannot paint
 *    outside a box that clips it. Of a 5px border at `inset: -3px`, only the 2px falling inside
 *    the card survived. That is "still getting hidden" exactly, and it was true on the tilted
 *    board too: the earlier weight increase was partly eaten by this clip.
 *
 * The clip exists to round the ART's corners, so it is handed to the image instead of removed —
 * see `.card.st-* > img` below. Un-scoping and un-clipping are ONE fix: either alone still
 * leaves the reporter with a sliver.
 */
/*
 * ⭐ HELD UNTIL ITS CUE PLAYS. [card 3469] A ring whose cause is still queued would tell the
 * player what is face-down opposite them. `!important` because the ring below is unconditional
 * and this has to beat it without duplicating its every declaration.
 */
.card.st-pending { box-shadow: 0 0 10px rgba(8,245,255,.12) !important; }

.card.st-muted,
.card.st-plagued,
.card.st-forcefield {
  outline: none;
  overflow: visible;        /* or the card clips the ring it is wearing — see above */
  /*
   * ⭐⭐ THE STANDING RING IS A box-shadow, NOT A PSEUDO-ELEMENT. [R-FXSTATUS-CLASH, 2026-08-27]
   *
   * It was an `::after`, and so is `.fx-status::after` — the light that chases round the border
   * when a status LANDS. An element has exactly ONE `::after`, so the two cues were the same box
   * fighting over one set of declarations: the more specific standing ring won and the chase was
   * silently overridden on any card that already carried a status. Neither rule was wrong and
   * nothing errored; one of them simply never rendered.
   *
   * A spread shadow is the better shape for a ring anyway — it traces the card's own
   * border-radius exactly, it is painted OUTSIDE the border box so the card cannot cover it, and
   * it is not clipped by the card's own overflow, which is the bug fixed directly above.
   *
   * ⚠️ Declared HERE, after style.css, so it also beats the `box-shadow: none` that sheet applies
   * to these classes under reduced motion. That override was written for a decorative glow; this
   * ring is INFORMATION — which unit is Muted, which is shielded — and a player who prefers less
   * motion still needs to be able to read the board.
   */
  box-shadow:
    0 0 0 var(--ring-w, 5px) var(--ring, #ff5c5c),
    0 0 22px 6px var(--ring-glow, rgba(255,92,92,.9)),
    inset 0 0 18px var(--ring-glow, rgba(255,92,92,.6));
}
/* The rounded corners the card's own clip used to provide, moved onto the thing being clipped. */
.card.st-muted > img,
.card.st-plagued > img,
.card.st-forcefield > img { border-radius: inherit; }

/*
 * The ring used to be drawn here as an `::after` at `inset: -3px` with a 5px border. It moved to
 * the box-shadow above when it turned out to be sharing that pseudo-element with the
 * status-applied light chase (see the note there). Two things it taught, kept because both were
 * paid for:
 *
 * ⚠️ NO COUNTER-ROTATION. The first version stood the ring up out of the plane, the way the zone
 * labels and pile counters do — reported straight back as "a square that isn't actually around
 * the cards at all". That is inherent, not tuning: a LABEL may face wherever it likes, but a RING
 * has to trace the outline of the thing it rings.
 *
 * ⚠️ IT MUST NEVER TAKE THE CLICK. It sits over a card the player may be asked to select, and
 * R-HITBOX was four reports about exactly that. A box-shadow cannot be hit at all, which is one
 * more reason it is the right primitive here.
 */
.card.st-muted      { --ring: #ff5c5c; --ring-glow: rgba(255,92,92,.8); }
.card.st-forcefield { --ring: #6fd0ff; --ring-glow: rgba(111,208,255,.8); }
/* Plague overrides everything else on a unit [RB p12], so it wins the ring too. */
/*
 * ⚠️ PLAGUE READS BLUE-ISH AT THIS SIZE. [report R47YJ: "barely visible and does not look green
 * enough… closer to blue than green"] The token green sits beside the board's cyan furniture, and
 * a thin ring of it on a dark card borrows that cast. Pushed to a purer, brighter green with a
 * wider glow so it separates from the cyan around it.
 */
.card.st-plagued,
.card.st-plagued.st-muted {
  --ring: #3dff88; --ring-glow: rgba(61,255,136,.95);
}
/* Pushed further out than the others, as asked — a ring that clears the card edge is easier to
   pick out than one sitting on it. */
/* Pushed further out than the others, as asked — a ring that clears the card edge is easier to
   pick out than one sitting on it. */
.card.st-plagued { --ring-w: 7px; }

/* -------------------------------------------------------------------------
 * ⭐ END-OF-BATTLE CLEAN UP — the Battle Zone sliding into the discard.
 * [user request 2026-08-27]
 *
 * "Each card should move in a STRAIGHT LINE from its current battlefield location to the
 *  discard pile, where the cards should visibly pile up."
 *
 * So this is the one flyer here with NO arc and NO turn. `cof-dep-fly` lifts, bows and falls
 * onto the pile, which is right for a card coming off the top of a deck; a sweep is a board
 * being cleared, and the ask was explicitly a straight slide. The only easing is a gentle
 * start and a settle at the end, so a dozen cards converging still reads as deliberate rather
 * than as a jump cut.
 *
 * The pile-up is done in tilt.js, not here: each flyer's landing point is offset a little from
 * the centre of the discard cell, so `left`/`top` already differ per card and the keyframes
 * stay a pure translation to zero.
 *
 * ⚠️ NO `body.tilt3d` GATE, like every other .ls-fly cue — flyers are fixed-position in SCREEN
 * space and are never children of the tilted plane, so this works unchanged on the flat
 * top-down board that mobile gets.
 * ------------------------------------------------------------------------- */
.sweep-fly {
  animation-name: cof-sweep-fly;
  animation-duration: 820ms;                     /* == SWEEP_MS in tilt.js */
  animation-timing-function: cubic-bezier(.36, .06, .22, 1);
}
.sweep-fly .ls-glow, .sweep-fly .ls-streak { display: none; }
/*
 * The flyer carries exactly ONE face and never turns, so both faces have to be forced visible
 * and unanimated. `.ls-front` ships `opacity:0` + `rotateY(180deg)` + an animation because it
 * normally sits behind a back waiting to be flipped to — inherited here it would fly an
 * invisible card, which looks precisely like the cards vanishing with no animation at all.
 */
.sweep-fly .ls-card  { animation: none; transform: none; }
.sweep-fly .ls-front { animation: none; opacity: 1; transform: none; }
.sweep-fly .ls-back  { animation: none; opacity: 1; }

@keyframes cof-sweep-fly {
  /* Starts where the card stood — --dx/--dy are the offset BACK to the Location, the same
     convention every flyer here uses — and ends at 0, which is the pile. */
  0%   { opacity: 1;
         transform: translate3d(var(--dx), var(--dy), 0) rotate(var(--spin, 0deg)) scale(1); }
  /* Shrinks very slightly on the way in, so the card reads as settling onto a pile rather than
     being laid down at full size on top of the board. */
  80%  { opacity: 1; }
  100% { opacity: .92;
         transform: translate3d(0, 0, 0) rotate(var(--spin, 0deg)) scale(.94); }
}

/* Reduced motion: playSweep() returns before creating a single flyer, so there is nothing to
   suppress here. Recorded so the absence is not read as an oversight. */
