/* Game board and layout styles */

/* Game View Layout */
#game-view {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100vh;
    height: 100dvh; /* mobile URL-bar-safe; older parsers keep the vh line */
    display: flex;
    flex-direction: column;
    overflow: visible;
    /* Keep visible to prevent cropping */
    box-sizing: border-box;
    /* Include padding and border in dimensions */
    padding: 0;
    /* Ensure no padding */
    margin: 0;
    /* Ensure no margin */
}

/* Game notification - non-intrusive alerts */
.game-notification {
    position: fixed;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    background-color: rgba(0, 0, 0, 0.8);
    color: #fff;
    padding: 2.96vmin 5.95vmin;
    border-radius: 8px;
    z-index: 10001;
    /* Above tutorial hints (10000) */
    font-size: 3.57vmin;
    text-align: center;
    pointer-events: none;
    /* Let clicks pass through */
    animation: notification-fade-in 0.42s cubic-bezier(0.22, 1.18, 0.32, 1);
    border: 1px solid rgba(255, 255, 255, 0.3);
    box-shadow: 0 0 20px rgba(0, 0, 0, 0.5);
    color: #eedbf1;
    min-width: 39.63vmin;
    max-width: min(80vw, 60rem);
    white-space: normal;
    overflow-wrap: anywhere;
    line-height: 1.25;
}

.game-notification.fade-out {
    animation: notification-fade-out 0.32s cubic-bezier(0.55, 0, 0.8, 0.36) forwards;
}

@media (prefers-reduced-motion: reduce) {
    .game-notification,
    .game-notification.fade-out {
        animation: none;
    }
}

/* Spring-settled rise-in (the easing above supplies the soft overshoot);
   exit drops away with intent. Every frame carries the full translate pair
   so centering can never be clobbered mid-flight. */
@keyframes notification-fade-in {
    from {
        opacity: 0;
        transform: translate(-50%, -56%) scale(0.94);
    }

    to {
        opacity: 1;
        transform: translate(-50%, -50%) scale(1);
    }
}

@keyframes notification-fade-out {
    from {
        opacity: 1;
        transform: translate(-50%, -50%) scale(1);
    }

    to {
        opacity: 0;
        transform: translate(-50%, -44%) scale(0.97);
    }
}

.player-area {
    flex: 1;
    display: flex;
    flex-direction: column;
    padding: 0;
    /* Removed all padding */
    margin: 0;
    /* Ensure no margins */
    position: relative;
    justify-content: space-between;
    /* Push content to edges */
    overflow: visible;
    /* Allow content to overflow */
    box-sizing: border-box;
    /* Include padding and border in dimensions */
    min-height: 33svh;
    /* Ensure minimum height */
}

/* Areas positioned by the background image */
#opponent-area {
    /* Top area of the game board */
    margin-bottom: 0;
    padding-top: 0;
    display: flex;
    flex-direction: column;
    justify-content: flex-start;
    /* Align content to the top */
    position: relative;
    min-height: 33svh;
    /* Ensure minimum height */
}

/* Container for opponent cards and board, ensures proper spacing */


#player-area {
    /* Bottom area of the game board */
    margin-top: 0;
    padding-bottom: 0;
    /* No padding needed with the new structure */
    display: flex;
    flex-direction: column;
    justify-content: flex-end;
    position: relative;
    min-height: 33svh;
}

/* Container for player cards and board, ensures proper spacing */


/* Elevate whole play areas to create higher stacking context for enlarged cards */
#player-area,
#opponent-area {
    position: relative;
}

/* Removed red/green flash overlays; keeping shake-only feedback */

/* Game Board Middle Section */
#game-board-middle {
    display: flex;
    flex-direction: column;
    justify-content: center;
    /* Center vertically */
    align-items: center;
    padding: 5px 20px;
    /* Removed background to let board image show through */
    height: 120px;
    /* Fixed height to create better symmetry */
    flex-shrink: 0;
    /* Prevent shrinking */
}

#turn-indicator {
    display: none;
    /* Hide the turn indicator */
}

#end-turn-btn {
    position: fixed;
    top: 50%;
    /* Center vertically between the portraits */
    right: var(--end-turn-right, 9.26vmin);
    /* Slightly to the left of the player icons */
    transform: translateY(-50%);
    /* Center vertically only */
    padding: 2.0vmin 4.0vmin;
    /* Slightly smaller padding for a more compact button */
    font-weight: bold;
    font-size: 2.6vmin;
    /* Slightly smaller font size */
    /* CAPPED AGAINST THE BOARD ROW, for the same reason the portrait is — see
       `--end-turn-width` in the rail budget above. The button is
       `position: fixed` off the right edge, so its left edge is
       `100vw - right - width`; a 28vmin command on a 4:3 page box reaches
       across the rightmost slot, which is where the measured
       `#end-turn-btn x .card-slot#4` overlap came from. The vertical solver in
       `seatEndTurnBetweenSummoners` cannot fix that: it only ever moves the
       button UP or DOWN within its lane. */
    min-width: var(--end-turn-width);
    z-index: 20;
}

/* The exit occupies the band under End Turn on every layout now, so End Turn
   yields half of that band and the PAIR stays centred between the summoners —
   the same correction the phone rail makes, applied at desktop scale. */
#end-turn-btn[data-return-layout="below"] {
    top: calc(50% - (var(--mf-touch, 44px) + 1.5vmin) / 2);
}

/* ---------------------------------------------------------------------------
   Turn timer — the server's per-turn safety net, rendered.

   It rides directly above End Turn on the same lane, so it costs the board and
   the hands nothing and stays legible from the same place the player already
   looks before ending a turn. Every vertical offset is expressed as a SIBLING
   of the matching End Turn rule, so the two can never drift apart — which is
   why the markup puts #turn-timer AFTER the button.

   The landscape-phone block further down splits this into two measured tiers;
   read the comments there before moving anything.
   --------------------------------------------------------------------------- */
#turn-timer {
    position: fixed;
    right: var(--end-turn-right, 9.26vmin);
    /* MUST mirror the generic `button` margin in main.css. Both elements are
       fixed at the same `right` with the same width, so the button's own
       margin was the entire reason their centres disagreed — by exactly the
       margin, which is why the gap grew with the viewport (4px at 1400x640,
       8px at 1440p) instead of staying put. Measured, not reasoned:
       `tests/unit/styles/turnTimerEndTurnCentre.test.js` pins the expression
       to main.css so a change there cannot silently pull them apart again. */
    margin-right: clamp(4px, 0.58vmin, 10px);
    /* Bottom edge one gap above End Turn's top edge (half its ~7.1vmin box).

       `--turn-timer-anchor-top` is published by audio-settings.js whenever it
       moves End Turn off its CSS seat to clear a summoner cluster. Expressing
       this as a sibling of the button's CSS rule only keeps the pair together
       while the button IS on that seat; the variable keeps them together when
       it is not. Unset — the ordinary case — this is exactly the old value. */
    top: var(--turn-timer-anchor-top, calc(50% - 4.6vmin));
    transform: translateY(-100%);
    z-index: 20;
    /* The button's own width, by token — see `--end-turn-width`. */
    min-width: var(--end-turn-width);
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 0.5vmin;
    padding: 0.7vmin 1.4vmin 0.9vmin;
    border-radius: var(--mf-timer-radius);
    border: 0.19vmin solid var(--mf-timer-border);
    background: var(--mf-timer-bg);
    color: var(--mf-timer-ink);
    font-variant-numeric: tabular-nums;
    font-size: 2.2vmin;
    font-weight: 700;
    letter-spacing: 0.08vmin;
    line-height: 1.15;
    pointer-events: none;
    text-shadow: 0 0 0.5vmin rgba(0, 0, 0, 0.9);
}

#turn-timer[hidden] {
    display: none;
}

/* Screen-reader context only: the visible board already names the active
   player through the turn banner, so a second persistent label would be noise. */
#turn-timer .turn-timer-label {
    position: absolute;
    width: 1px;
    height: 1px;
    margin: -1px;
    padding: 0;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
}

#turn-timer .turn-timer-value {
    display: block;
}

/* Depleting bar. --turn-timer-progress is 1 at a full budget, 0 at zero. */
#turn-timer .turn-timer-bar {
    display: block;
    width: 100%;
    height: 0.5vmin;
    min-height: 3px;
    border-radius: 999px;
    background: var(--mf-timer-track);
    overflow: hidden;
}

#turn-timer .turn-timer-bar::after {
    content: '';
    display: block;
    height: 100%;
    width: calc(var(--turn-timer-progress, 1) * 100%);
    border-radius: inherit;
    background: var(--mf-timer-fill);
    transition: width 240ms linear, background-color 240ms ease;
}

/* One timed-out turn is already on this seat: the next one ends the match. The
   pill stays up for the WHOLE turn here (see turn-timer.js) and names the
   stake, because the player it concerns is the one who was not watching.

   DECLARED BEFORE the urgency states on purpose: they share this rule's
   specificity, so the later declaration wins. A last-chance turn still has to
   turn red at ten seconds — that is the more urgent signal of the two, and if
   this block sat after them it would quietly repaint their border. */
#turn-timer.is-final-warning {
    border-color: rgba(255, 170, 120, 0.7);
    background: rgba(46, 14, 14, 0.82);
}

#turn-timer .turn-timer-warning {
    display: block;
    /* Never wider than the pill's own content box (28vmin min-width less its
       1.4vmin side padding), so the warning wraps instead of widening the pill
       and shifting a HUD element the player is mid-turn with. */
    max-width: 25vmin;
    font-size: 1.5vmin;
    font-weight: 600;
    letter-spacing: 0.02vmin;
    line-height: 1.25;
    text-align: center;
    text-wrap: balance;
    color: #ffcf9e;
}

#turn-timer .turn-timer-warning[hidden] {
    display: none;
}

#turn-timer.is-urgent {
    border-color: var(--mf-timer-urgent-border);
    color: var(--mf-timer-urgent-ink);
}

#turn-timer.is-urgent .turn-timer-bar::after {
    background: var(--mf-timer-urgent-fill);
}

#turn-timer.is-critical {
    border-color: var(--mf-timer-critical-border);
    color: var(--mf-timer-critical-ink);
    animation: turn-timer-critical 1s ease-in-out infinite;
}

#turn-timer.is-critical .turn-timer-bar::after {
    background: var(--mf-timer-critical-fill);
}

/* Frozen while the server resolves an authoritative action: the number is not
   moving, so say so rather than letting it read as a hang. */
#turn-timer.is-paused {
    opacity: 0.72;
    border-style: dashed;
}

@keyframes turn-timer-critical {
    0%, 100% { box-shadow: 0 0 0 rgba(255, 120, 120, 0); }
    50% { box-shadow: 0 0 1.6vmin rgba(255, 120, 120, 0.5); }
}

/* End Turn yields half the Return-to-Lobby band; the timer follows it exactly. */
#end-turn-btn[data-return-layout="below"] ~ #turn-timer {
    top: var(--turn-timer-anchor-top, calc(50% - 4.6vmin - (var(--mf-touch, 44px) + 1.5vmin) / 2));
}

/* Dev-only manual concede (DEBUG_MODE). Deliberately styled as an obvious
   debug control (dashed red) so it never reads as real HUD. Hidden entirely
   unless the server enabled CLIENT_DEBUG_MODE.

   IT NO LONGER SITS IN THE COMMAND RAIL. This was
   `position: fixed; top: calc(50% + 7vmin); right: var(--end-turn-right)` —
   the band directly under End Turn, which the exit control has owned on every
   layout since the rail was unified. On desktop that pushed the exit up into
   the countdown's band (the reported "skip tutorial button overlaps mulligan
   timer"); on a landscape phone, where the exit is CSS-owned and cannot move,
   the two simply printed on top of each other (248px² at 915x336). There is no
   free band left in that column on a phone, so `chrome-menu.js` adopts this
   button into the menu's Lobby section instead — see `adoptDebugEndMatch()`.
   The row geometry lives in chrome-menu.css; only the debug SKIN is here.

   OUT OF FLOW until the menu adopts it. The element lives inside
   `#game-board-middle`, so a static visible button here would push the board
   around for any frame between CLIENT_DEBUG_MODE unhiding it and
   `adoptDebugEndMatch()` landing it in the panel. `absolute` with no offsets
   keeps it at its static position — visible if it is ever orphaned, which is
   the point: a fallback that hides the control would delete the dev tool
   instead of degrading it — while costing the board nothing. The panel row rule
   sets `position: static` once it arrives. */
#debug-end-match-btn,
#debug-win-match-btn,
#debug-reveal-hand-btn {
    position: absolute;
    padding: 0.9vmin 1.8vmin;
    font-size: 1.7vmin;
    font-weight: bold;
    font-family: inherit;
    letter-spacing: 0.06vmin;
    border: 0.19vmin dashed rgba(255, 120, 120, 0.85);
    border-radius: 1vmin;
    background-color: rgba(60, 12, 18, 0.85);
    color: #ffd9d9;
    cursor: pointer;
    transition: all 0.2s ease;
}

#debug-end-match-btn:hover {
    background-color: rgba(90, 18, 26, 0.95);
}

/* The dev WIN is the concede's mirror and must never be mistaken for it at a
   glance, so it keeps the dashed debug shape but in gold: same family (a dev
   tool, not HUD), opposite result. */
#debug-win-match-btn {
    border-color: rgba(255, 214, 120, 0.85);
    background-color: rgba(58, 42, 8, 0.85);
    color: #ffefc2;
}

#debug-win-match-btn:hover {
    background-color: rgba(88, 62, 12, 0.95);
}

/* The dev REVEAL (the opponent's hand face-up for the requester) keeps the
   dashed debug shape in the reveal glow's own violet, so the three dev rows
   read as one family with three different results. */
#debug-reveal-hand-btn {
    border-color: rgba(196, 150, 255, 0.85);
    background-color: rgba(38, 18, 62, 0.85);
    color: #ead9ff;
}

#debug-reveal-hand-btn:hover {
    background-color: rgba(58, 28, 92, 0.95);
}

#debug-end-match-btn:disabled,
#debug-win-match-btn:disabled,
#debug-reveal-hand-btn:disabled {
    opacity: 0.5;
    cursor: default;
}

/* One-shot golden glow when the turn becomes yours (class toggled by
   socket-handler on isYourTurn transitions; no infinite pulsing). */
#end-turn-btn.end-turn-btn--your-turn {
    border-color: rgba(255, 215, 130, 0.85);
}

#end-turn-btn.end-turn-btn--your-turn::after {
    content: '';
    position: absolute;
    inset: 0;
    border-radius: inherit;
    pointer-events: none;
    box-shadow: 0 0 2.5vmin rgba(255, 215, 130, 0.55);
    opacity: 0;
    animation: end-turn-glow 900ms ease-out;
}

@keyframes end-turn-glow {
    0% {
        opacity: 0;
    }

    30% {
        opacity: 1;
    }

    100% {
        opacity: 0;
    }
}

/* Invalid-action feedback: brief head-shake + red mana flash */
.invalid-shake {
    animation: invalid-shake 380ms ease;
}

@keyframes invalid-shake {
    20%,
    60% {
        transform: translateX(-0.5vmin);
    }

    40%,
    80% {
        transform: translateX(0.5vmin);
    }
}

/* Keep invalid-action motion off card rasters. A fast outline/glow pulse reads
   as a rejected play without forcing the transformed card art through another
   compositor resample. */
/* SCOPED TO #game-view. Written bare, these applied to every `.card` and
   `.card-slot` in the product — the deck builder, collection, foundry,
   exchange, card packs and mulligan all render `.card` — and that leak has
   silently reordered those catalogs twice, first via `.card { z-index: 5 }`
   and then via `.card.hover-active { z-index: 9000 !important }`. The rules
   were always battle-only in intent; the comment below even says "IN A
   BATTLE". `#player-hand`, `#player-board` and `#opponent-board` all live
   inside `#game-view`, and `.card-slot` is built only by board.js/fx.js/
   enemy-replay.js, so the battle keeps every one of these and nothing else
   inherits them.

   #mulligan-overlay is listed alongside it because the mulligan is a battle
   surface that sits OUTSIDE #game-view in the DOM. Scoping to #game-view alone
   stripped the hover elevation from mulligan cards and broke enlarging a card
   during the tutorial — caught by guest-tutorial.spec.js, not by reasoning. */
#game-view .card.invalid-shake,
#mulligan-overlay .card.invalid-shake {
    outline: 2px solid transparent;
    outline-offset: 2px;
    animation: invalid-card-pulse 380ms ease;
}

@keyframes invalid-card-pulse {
    0%,
    100% {
        outline-color: transparent;
    }

    35%,
    70% {
        outline-color: rgba(255, 90, 90, 0.92);
        box-shadow: 0 0 1.6vmin rgba(255, 72, 72, 0.88);
    }
}

/* The end-turn button is positioned with translateY(-50%); its shake must
   compose that translate or the button teleports. */
#end-turn-btn.invalid-shake {
    animation: invalid-shake-fixed 380ms ease;
}

@keyframes invalid-shake-fixed {
    20%,
    60% {
        transform: translateY(-50%) translateX(-0.5vmin);
    }

    40%,
    80% {
        transform: translateY(-50%) translateX(0.5vmin);
    }
}

@media (prefers-reduced-motion: reduce) {
    #end-turn-btn.end-turn-btn--your-turn::after,
    .invalid-shake,
    .card.invalid-shake,
    #end-turn-btn.invalid-shake,
    #turn-timer.is-critical {
        animation: none;
    }

    /* The colour change still carries the urgency; only the pulse is dropped,
       and the bar still moves because that IS the information. */
    #turn-timer .turn-timer-bar::after {
        transition: none;
    }
}

/* Battle-entry veil: brief dark cover that fades to reveal the battlefield.
   Fired at the mulligan → board handoff (and non-mulligan entries) so the
   reveal always lands on populated content. */
#game-view.battle-enter::after {
    content: '';
    position: fixed;
    inset: 0;
    background: #0a0512;
    pointer-events: none;
    /* Overlay ladder: battle veil 9380 < #fx-layer 9400 < tutorial veil 9420
       < turn banner 9500. Locked by tests/unit/ui/zLayerLadder.test.js. */
    z-index: 9380;
    animation: battle-veil 550ms ease-out forwards;
}

@keyframes battle-veil {
    from {
        opacity: 1;
    }

    /* Short opaque hold so the cover reads before the reveal begins. */
    15% {
        opacity: 1;
    }

    to {
        opacity: 0;
    }
}

@media (prefers-reduced-motion: reduce) {
    #game-view.battle-enter::after {
        display: none;
    }
}

/* Player Information */
.player-info {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-bottom: 10px;
    background-color: rgba(0, 0, 0, 0.5);
    -webkit-backdrop-filter: blur(3px);
    backdrop-filter: blur(3px);
    padding: 10px;
    border-radius: 4px;
}

/* Target glows must not change the border box: `.valid-target` and
   `.potential-target` are shared with cards and add a 1px border there. */
/* THE HIGHLIGHT FOLLOWS THE SUMMONER, NOT ITS BOX.

   Reported: "there is still a rectangle around summoners on drag."

   This used to be `outline: 2px solid` plus `box-shadow: 0 0 20px`, both of
   which are painted from the BORDER BOX. The portrait's box is 198x147 with an
   arch radius, but the crest that the player actually sees is not that box: the
   nameplate sits at y=133 and runs 28px tall, and the two stat discs sit at
   y=112 and run 60px tall, so all three hang BELOW the box. The outline
   therefore ran down the sides and cut a straight line across the nameplate and
   discs — a rectangle drawn through the middle of the character, which is
   exactly what it looked like.

   `drop-shadow` filters the rendered ALPHA instead, so the glow traces the arch,
   the nameplate and the discs as one silhouette: the summoner lights up rather
   than being boxed. Two stops, tight then wide, so it reads as light rather
   than a sticker outline.

   THE RASTER CONTRACT IS UNCHANGED IN INTENT. `battleRasterStability` exists to
   stop a targeting cue re-laying-out or per-frame-resampling a summoner: there
   is still no `border` (which would inset the padding box), no `transform` and
   no `animation` here, so this is a static one-off filter pass on class add and
   another on removal — not a moving raster. */
.player-portrait.potential-target,
.player-portrait.valid-target {
    border: 0 !important;
    outline: none;
    box-shadow: none !important;
}

.player-portrait.potential-target {
    filter:
        drop-shadow(0 0 6px rgba(255, 165, 0, 0.85))
        drop-shadow(0 0 14px rgba(255, 165, 0, 0.85));
}

/* RED, like every other hovered drop target — not the pale lilac.

   Reported: "if i hover my card over them, all of them gain a red hue, which
   is perfect, except the summoners, which turn white. white is not correct in
   this situation. white is for hovering on them when not in dragging state."

   `#eedbf1` is the app's ordinary hover colour, and it was being reused for a
   completely different meaning: `valid-target` is the drag's "release here"
   state, and cards.css has always painted it `rgba(255, 0, 0, …)` against the
   orange of `potential-target`. The summoner was the one target that broke the
   orange -> red progression by going pale, which reads as an ordinary hover
   right at the moment the player is deciding where to drop. */
.player-portrait.valid-target {
    filter:
        drop-shadow(0 0 6px rgba(255, 0, 0, 0.9))
        drop-shadow(0 0 14px rgba(255, 0, 0, 0.9));
}

/* Board and Hand Containers */
.board-container {
    display: flex;
    justify-content: center;
    align-items: center;
    /* Center items vertically */
    min-height: 80px;
    margin: 0;
    /* Removed background to let board image show through */
    padding: 0;
    position: relative;
    overflow: visible;
    /* Allow cards to overflow without being cut */
    padding: 0.98vmin 3.97vmin;
    width: 100%;
    height: 23.77vmin;
}

/* Position board containers in the middle */
#opponent-board {
    margin-top: 60px !important;
    /* Further reduced margin to lift the board even higher */
    position: relative;
}

#player-board {
    margin-bottom: 120px;
    /* Increased margin to make room for absolute positioned hand */
    position: relative;
}

.hand-container {
    display: flex;
    justify-content: center;
    width: 100%;
    margin: 0;
    /* Removed vertical margin */
    /* Removed background to let board image show through */
    padding: 0;
    /* Removed padding to allow cards to touch the edge */
    position: relative;
    overflow: visible;
    /* Allow cards to overflow without being cut */
    pointer-events: none;
    /* Allow events to pass through the container */
}

/* Position opponent hand at the top edge */


/* Position player hand at the bottom edge */
/* Hand positioning - now matches deck wrapper structure */
#player-hand,
#opponent-hand {
    /* Styles are now set in JS with percentage-based positioning */
    /* This keeps basic styling only in CSS */
    overflow: visible;
    z-index: 10;
    box-sizing: border-box;
    pointer-events: none;
    /* Allow events to pass through the container */
    display: flex;
    justify-content: center;
    align-items: flex-end;
    /* Align cards at the bottom */
    padding-bottom: 10px;
    /* Add some space at the bottom */
}

/* Arched hand layouts */
#player-hand {
    position: relative;
    height: 23.77vmin;
    /* Provide space for the arch */
    margin-bottom: -8.91vmin;
    /* Move cards down to create partial cutoff */
    padding-bottom: 0;
    /* Remove bottom padding to enhance cutoff effect */
}

#opponent-hand {
    position: relative;
    /* Sized in CARD UNITS, not raw vmin — and on desktop those are the same
       thing, so these two lines are a desktop no-op.

       They are not always the same on a phone. Catalogs carry a minimum unit,
       while a live short-landscape battle derives its unit from the page-box
       budget below. The arch's height and negative offset were still pure
       vmin, so the card could change while the cutoff did not. Expressed
       against the card the proportion is identical at every size: the
       container is 107.1% of a card tall and the hand rides 40.1% of a card
       off the top edge, on every viewport.
       23.77/22.2 = 107.1%, 8.91/22.2 = 40.1%. */
    height: calc(23.77 * var(--card-unit));
    /* Provide space for the arch */
    align-items: flex-start;
    /* Align cards at the top (opposite of player) */
    padding-top: 0;
    /* Remove top padding to enhance cutoff effect */
    padding-left: 0.80vmin;
    /* Subtle asymmetrical offset */
    transform: rotate(-0.5deg);
    /* Very slight tilt to the entire hand container */
    margin-top: calc(-8.91 * var(--card-unit));
    /* Move cards up to create partial cutoff */
}

/* The BATTLE card preview is pointer-transparent.
   `.card-hover-preview` is shared with the catalogs, and deck-builder.css gives
   it `pointer-events: auto` so a popup can be tapped there. In a battle that is
   wrong: the popup opens directly over the card it enlarged, so the next press
   landed on the popup and CardDrag never saw a pointerdown — a board card you
   had just inspected became undraggable, with no clone and no error. Traced
   from the drag system reporting `move:no-tracking` for an entire deliberate
   drag.
   Transparent here loses nothing: a tap that falls through lands on the very
   card the popup came from, which is the same second-tap the popup path was
   handling. The act control is a separate element and keeps its own hits. */
.card-hover-preview.game-hover-preview,
.card-hover-preview.game-hover-preview * {
    pointer-events: none;
}

/* Card styling - simplified since wrappers were removed */
/* SCOPED to the battle view on purpose. This lived here unscoped for a long
   time, which meant every `.card` in the app — collection, deck builder,
   foundry, exchange, packs, previews — silently inherited a battle-only
   z-index: 5. That is the root cause behind a run of layering bugs, most
   recently the deck builder's "Selected" badge painting UNDER the card it
   labels. game-board.css has no business styling cards outside the board. */
#game-view .card {
    transition: transform 0.2s ease, z-index 0.2s;
    z-index: 5;
    will-change: auto;
    pointer-events: auto;
    /* Make cards interactive again */
    /* Base card size moved to cards.css */
    margin: 0 -10px;
    /* Negative margin for overlap */
    /* THE BATTLE HAIRLINE IS DRAWN, NEVER LAID OUT.
       This was `border: 1px solid rgba(0, 0, 0, 0.15)`, and `cards.css` explains
       the trap twice already: under the global `box-sizing: border-box` a border
       consumes the PADDING box, while every interior of a card — mana disc,
       title plate, art well, description, status — is positioned as a fraction
       of `--card-width` / `--card-height`, the BORDER box. The frame art paints
       into the padding box too, so the numeral and the gem it sits on resolved
       against boxes that disagreed by a pixel.
       Measured, numeral ink centroid vs the painted lens centre, battle card
       against the identical card outside `#game-view` (CSS px, right/down):
         Chrome  +0.514 / +0.211    Firefox +0.522 / +0.212    WebKit +0.513 / +0.210
       at 1400x640, and the same at a phone page box and at popup size, at DPR 1
       and 2 alike — an ABSOLUTE error, so it is a tenth of the numeral's cap
       height on a 142px hand card and a fortieth on a 540px popup. That is the
       whole of "mana cost is now fixed for enlarged cards, but broken for
       unenlarged cards": the enlarged card is `#card-hover-preview`, appended to
       `document.body`, which this rule never reached. */
    --battle-card-edge: inset 0 0 0 1px rgba(0, 0, 0, 0.15);
    box-shadow: 0 3px 6px rgba(0, 0, 0, 0.25), var(--battle-card-edge);
    /* Stronger shadow for better depth */
}

/* WHERE THE HAIRLINE HAS TO LIVE, and it is not on the card.
   `.card` IS `.card-front` (one element, `cards.js`), and `.card-front::before`
   is an OPAQUE frame image at `z-index: 2` filling the whole padding box. An
   inset shadow on the card paints just above the card's own background and
   below that pseudo-element, so it never reaches the glass: probed with a
   `red` ring and with a `red` `outline-offset: -1px`, both are invisible in
   Chromium, Firefox AND WebKit. The same shadow on the frame layer paints above
   its own background image, which is visible in all three.
   Both rules carry it because they cover different cards: a card BACK
   (`#opponent-hand`) has no frame pseudo-element and takes the line from the
   card, a card FRONT takes it from here. Every state below re-points the one
   token, and the pseudo-element inherits the new value from its originating
   element, so a highlight colour is declared exactly once. */
#game-view .card.card-front::before {
    box-shadow: var(--battle-card-edge);
}

/* Highlight states have to be scoped the SAME way as the base above, or they
   lose to it. Scoping `.card` to `#game-view` (to stop a battle-only z-index
   leaking into every catalog) took its specificity from 0-1-0 to 1-1-0, which
   then outranked `.card.legal-play` at 0-2-0 in cards.css — so the class kept
   being applied to every affordable card and the glow silently stopped
   painting. Reported as "legal card play highlighting was disabled", and
   invisible to any test that only checked the class. `potential-target` and
   `valid-target` already carry !important and were never affected, which is
   why only this one went dark. */
#game-view .card.legal-play {
    /* Drawn, not laid out — see the base rule. This one is worse than a
       constant offset: `legal-play` goes on and off as mana accrues, so with a
       real border the numeral MOVED the moment a card became playable and moved
       back when it stopped. `cards.css` records the same measurement for the
       unscoped copy of this rule: "a full pixel of drift that appears and
       disappears as a card becomes playable". */
    --battle-card-edge: inset 0 0 0 1px rgba(0, 255, 0, 0.4);
    box-shadow: 0 0 8px rgba(0, 255, 0, 0.7), var(--battle-card-edge);
}

/* Fan-out effect for player hand cards */
#player-hand .card {
    margin: 0 -8px;
    /* Reduced overlap for more spacing between cards */
    transform-origin: bottom center;
    /* Rotate from bottom center */
}

/* Messy, human-like arrangement for opponent hand cards */
#opponent-hand .card {
    margin: 0 -6px;
    /* Less overlap for more spacing between cards */
    transform-origin: top center;
    /* Rotate from top center (opposite of player) */
    transition: transform 0.15s ease, box-shadow 0.15s ease;
    /* Slightly faster transitions */
}

/* Apply different rotation angles based on card position - player */
/* More balanced arch with less extreme angles */
/* Create consistent right-to-left overlapping pattern */
#player-hand .card {
    z-index: 1;
}

/* Base z-index */
#player-hand .card:nth-child(1) {
    z-index: 1;
}

#player-hand .card:nth-child(2) {
    z-index: 2;
}

#player-hand .card:nth-child(3) {
    z-index: 3;
}

#player-hand .card:nth-child(4) {
    z-index: 4;
}

#player-hand .card:nth-child(5) {
    z-index: 5;
}

#player-hand .card:nth-child(6) {
    z-index: 6;
}

#player-hand .card:nth-child(7) {
    z-index: 7;
}

#player-hand .card:nth-child(8) {
    z-index: 8;
}

#player-hand .card:nth-child(9) {
    z-index: 9;
}

#player-hand .card:nth-child(10) {
    z-index: 10;
}

/* For 5 cards (most common case) */
#player-hand .card:nth-child(1) {
    --card-base-transform: rotate(-12deg);
}

#player-hand .card:nth-child(2) {
    --card-base-transform: rotate(-6deg);
}

#player-hand .card:nth-child(3) {
    --card-base-transform: rotate(0deg);
}

#player-hand .card:nth-child(4) {
    --card-base-transform: rotate(6deg);
}

#player-hand .card:nth-child(5) {
    --card-base-transform: rotate(12deg);
}

/* For more cards, use smaller increments */
#player-hand .card:nth-child(6) {
    --card-base-transform: rotate(15deg);
}

#player-hand .card:nth-child(7) {
    --card-base-transform: rotate(18deg);
}

#player-hand .card:nth-child(8) {
    --card-base-transform: rotate(21deg);
}

#player-hand .card:nth-child(9) {
    --card-base-transform: rotate(24deg);
}

#player-hand .card:nth-child(10) {
    --card-base-transform: rotate(27deg);
}

/* Apply different rotation angles based on card position - opponent (mirrored) */
/* More balanced arch with less extreme angles */
/* Create consistent left-to-right overlapping pattern (opposite of player) */
#opponent-hand .card {
    z-index: 1;
}

/* Base z-index */
#opponent-hand .card:nth-child(1) {
    z-index: 10;
}

#opponent-hand .card:nth-child(2) {
    z-index: 9;
}

#opponent-hand .card:nth-child(3) {
    z-index: 8;
}

#opponent-hand .card:nth-child(4) {
    z-index: 7;
}

#opponent-hand .card:nth-child(5) {
    z-index: 6;
}

#opponent-hand .card:nth-child(6) {
    z-index: 5;
}

#opponent-hand .card:nth-child(7) {
    z-index: 4;
}

#opponent-hand .card:nth-child(8) {
    z-index: 3;
}

#opponent-hand .card:nth-child(9) {
    z-index: 2;
}

#opponent-hand .card:nth-child(10) {
    z-index: 1;
}

/* Semi-random arch pattern for opponent cards - balanced approach */
#opponent-hand .card:nth-child(1) {
    --card-base-transform: rotate(10deg) translateY(-5px) translateX(1px);
}

#opponent-hand .card:nth-child(2) {
    --card-base-transform: rotate(5deg) translateY(-7px) translateX(-1px);
}

#opponent-hand .card:nth-child(3) {
    --card-base-transform: rotate(0deg) translateY(-8px) translateX(0px);
}

#opponent-hand .card:nth-child(4) {
    --card-base-transform: rotate(-4deg) translateY(-6px) translateX(1px);
}

#opponent-hand .card:nth-child(5) {
    --card-base-transform: rotate(-9deg) translateY(-4px) translateX(-1px);
}

/* More cards follow the arch but with slight irregularities */
#opponent-hand .card:nth-child(6) {
    --card-base-transform: rotate(-12deg) translateY(-5px) translateX(0px);
}

#opponent-hand .card:nth-child(7) {
    --card-base-transform: rotate(-15deg) translateY(-7px) translateX(1px);
}

#opponent-hand .card:nth-child(8) {
    --card-base-transform: rotate(-18deg) translateY(-6px) translateX(-1px);
}

#opponent-hand .card:nth-child(9) {
    --card-base-transform: rotate(-21deg) translateY(-4px) translateX(0px);
}

#opponent-hand .card:nth-child(10) {
    --card-base-transform: rotate(-24deg) translateY(-3px) translateX(1px);
}

/* Remove existing CSS-based hover effects for player hand */
/* Removing obsolete card hover styles that have been replaced by the hover-active class system */

/* Keep opponent hand hover as is (or remove if not needed) - Assuming we keep it */
/* Commenting out original opponent hand hover rule
   #opponent-hand .card:hover {
   transform: translateY(10px) scale(1.05);
   z-index: 10;
   }
*/

/* Enlargement hover for BOARD cards (Player & Opponent) */
/* Hover is detected on the slot (fixed-size parent) to prevent flickering from zoom */
#player-board .card-slot:hover .card:not(.dragging):not(:active),
#opponent-board .card-slot:hover .card:not(.dragging):not(:active) {
    /* JS sets zoom dynamically for crisp rasterization */
    z-index: 1200;
    opacity: 1 !important;
    overflow: visible;
    transition: box-shadow 0.2s ease, z-index 0s ease;
}

/* Card styling with high-quality rendering */
#player-hand .card {
    position: relative;
    /* Ensure positioning works */
    transition: transform 0.15s ease-out, box-shadow 0.15s ease, z-index 0s ease;
}

/* Card zooming with perfect amount of lifting to keep it in screen.
   No :active exclusion: pressing to summon/cast must not snap the card
   back to its fanned rotation mid-click. */
#player-hand .card.hover-active:not(.dragging) {
    /* JS (card-hover.js) applies dynamic transform based on viewport */
    --card-base-transform: scale(1);
    --card-tilt-max: 6;
    --card-tilt-perspective: 1400px;
    transform-origin: bottom center;
    z-index: 1000;
    /* Above normal cards */
    opacity: 1 !important;
    /* Ensure fully opaque even if summoned/used */
    /* Ensure fully opaque even if summoned/used */
    /* Avoid filter compositing, which softens enlarged text/art. */
    filter: none;
    /* Add a strong shadow for better visibility */
    /* `box-shadow` REPLACES, so the edge token has to be restated or a card
       back loses its outline for as long as the pointer is on it. */
    box-shadow: 0 0 25px rgba(0, 0, 0, 0.6), var(--battle-card-edge);
}

/* Preserve green glow when an enlarged hand card is legally playable */
#player-hand .card.hover-active.legal-play:not(.dragging) {
    /* Combine green hue with subtle depth shadow */
    --battle-card-edge: inset 0 0 0 1px rgba(0, 255, 0, 0.6);
    box-shadow: 0 0 14px rgba(0, 255, 0, 0.9), 0 0 20px rgba(0, 0, 0, 0.45), var(--battle-card-edge);
}

/* Subtle movement and highlight for opponent cards without changing z-index rank */
#opponent-hand .card {
    transition: transform 0.2s ease, box-shadow 0.2s ease;
}

#opponent-hand .card:hover {
    /* Apply highlight without scaling */
    /* Restated for the same reason as the hand rule above, and it MATTERS here:
       these are card BACKS, which have no frame pseudo-element, so the card's
       own inset shadow is the only thing drawing their outline. */
    box-shadow: 0 0 8px rgba(238, 219, 241, 0.8), var(--battle-card-edge);
    /* No z-index change to maintain natural overlapping order */
}

/* A `:hover` RULE MUST NOT MOVE ITS OWN HIT BOX.
   This used to stand the hovered card upright as well (`--card-base-transform:
   scale(1)`, dropping the fan rotation), and a rotation swings the card's
   bottom edge — the part on screen — by up to 26px. Two failures came out of
   that, both measured in Chrome:
   - a BACK entered near its lower corner straightened out from under the
     pointer, and because a transform never triggers the browser's hover
     update the back stayed `:hover` while the pointer physically sat on the
     revealed card beside it, which therefore never got its `mouseenter`;
   - a REVEALED card is enlarged by CrispHover on the same pointer, the 5px
     became 12px under the 2.4x zoom while the un-rotation ran on its own clock
     against the grow, the box slid out from under a stationary pointer,
     `:hover` dropped, the card shrank back under it, and the player watched it
     "fighting to grow".
   So a hovered back only settles 5px lower and glows: a downward move never
   uncovers a pointer that is inside the card. The revealed card's upright pose
   is bound to the engine's hold below, never to `:hover`. */
#opponent-hand .card:hover:not(.revealed-in-hand) {
    /* Subtle downward movement */
    --card-hover-transform: translateY(5px);
}

/* A revealed card stands upright for exactly as long as CrispHover holds it
   (`.crisp-hovered`: from the first grow frame to the first shrink frame), so
   the pose and the size move together and neither can un-hover the card.
   THE POSE RIDES THE ENGINE'S CLOCK. The un-rotation is a transform transition
   and the grow is eased per frame by crisp-hover.js; on the fan's generic
   `0.2s ease` the card visibly finished growing and THEN finished straightening
   (and finished shrinking, 150 ms, and THEN re-rotated over 200). These two
   rules restate the engine's own curve and durations — CrispHover.timing:
   ease-out cubic, 200 ms grow, 150 ms shrink — so size, slide and pose are one
   motion in each direction. Locked against the engine's constants by
   tests/unit/styles/enemyHandHoverMotion.test.js. The transition that applies
   is the one of the state being entered: the held rule's 200 ms on the way up,
   the resting rule's 150 ms on the way back. */
#opponent-hand .card.revealed-in-hand {
    transition: transform 150ms cubic-bezier(0.33, 1, 0.68, 1), box-shadow 0.2s ease;
}

#opponent-hand .card.revealed-in-hand.crisp-hovered {
    --card-base-transform: scale(1);
    transition: transform 200ms cubic-bezier(0.33, 1, 0.68, 1), box-shadow 0.2s ease;
}

/* While the engine's path runs (`crisp-moving`: a grow or a shrink) it writes
   the pose and the lean itself, frame by frame, on the size's own ease — no
   transform transition may smooth them a second time. Chrome restarts a
   transform transition on every frame the zoom changes, so the two rules
   above can only ever trail the size; they serve the hold (the lean easing
   after the pointer) and the fallback for a resting pose the engine does not
   parse, and their timing is the engine's. */
#opponent-hand .card.revealed-in-hand.crisp-moving {
    transition: box-shadow 0.2s ease;
}

/* THE OTHER CARDS NEVER MOVE, so the enlarged one lies OVER them: the engine
   holds the card's resting footprint in the row (zoom plus margin
   compensation, as the friendly hand does), and the raise above the
   neighbours cannot hang on `:hover` alone — a transform never re-hit-tests,
   and a card that is not the fan's topmost would drop under the neighbour it
   covers the moment `:hover` flapped. `crisp-zooming` is the engine's: held
   from the first grow frame until the card is back at rest. */
#opponent-hand .card.crisp-zooming {
    z-index: 9000 !important;
}

/* A card taking the enlargement over from its neighbour grows UNDER the one
   shrinking away (both are zooming); the held card is the one on top. */
#opponent-hand .card.crisp-hovered {
    z-index: 9001 !important;
}

/* Card Slots on Board */

/* THE BOARD PUBLISHES ITS OWN GEOMETRY. NOTHING MAY RE-DERIVE IT.
   --------------------------------------------------------------------------
   The five-slot row is centred in the viewport (`.card-slot-container` is
   `width: 100%; justify-content: center`) and every slot is CONTENT-BOX: the
   painted box is `--card-width` plus 2px of socket border per side, and the
   slots are separated by `--board-slot-margin` per side. So the first slot's
   PAINTED left edge is

     50vw - 2.5 * (--card-width + 4px + 2m) + m
       = 50vw - 2.5 * --card-width - 10px - 4m

   Verified against `Board.renderBoard`'s own rects at 1400x640, 900x561,
   900x540, 844x280, 667x275 and 480x260 — the token and the painted edge agree
   to within 0.1px at every one, and story-popup-board-overlap.spec.js asserts
   that agreement on every run rather than trusting this comment.

   The margin is a TOKEN rather than three literals because the narration panel
   has to clear this row, and the first attempt at that guard re-derived the row
   from the wrong tier's margin — it read the landscape-TABLET clamp
   `clamp(3px, .5vmin, 7px)` while the phone board it was protecting uses a flat
   6px, then took the clamp's MINIMUM and called that the strict bound. It is
   the loose one: a narrower margin puts the row's edge further RIGHT. One
   published value cannot disagree with itself. */
:root {
    --board-slot-margin: 20px;
}

/* DECLARED ON `body`, NOT ON `:root`, AND THAT IS LOAD-BEARING.
   A custom property's `var()`s are substituted on the element the property is
   DECLARED on. The phone battle tier raises `--card-width` on
   `body.game-active` — deliberately, so the collection and the deck builder do
   not inherit a battle card size — so a copy of this formula on `:root` reads
   the LOBBY card width instead. Measured: at 844x280 that is 47.9px against the
   battle's 34.0px, and the token lands 34.9px left of the row it is supposed to
   describe. Declared here it substitutes body's own `--card-width`, which is
   the battle value inside a battle and the root value everywhere else, and it
   is defined at all times so no consumer can resolve to an invalid length. */
body {
    --board-row-left: calc(
        50vw
        - 2.5 * var(--card-width)
        - 10px
        - 4 * var(--board-slot-margin)
    );
}

/* ---------------------------------------------------------------------------
   THE RIGHT RAIL'S BUDGET — how much room the centred board row leaves beside
   it, and everything that has to fit inside that room.

   Every rail element is sized in `vmin`, which on a landscape screen is the
   HEIGHT, so the WIDTH each one consumes is `size / aspectRatio`. Tuned at 16:9
   and rendered at 4:3 the two summoner rails grow from 34.9% of the width to
   46.5% and arrive on top of the board row. That is not a missing breakpoint —
   it is a continuous function of the aspect ratio, which is why the answer here
   is a continuous cap rather than one more hard-edged band. The band that did
   exist (960-1366px, aspect <= 8/5) left an iPad Pro 13" and every iPad wearing
   a Safari tab bar outside it, falling 65px off a cliff into the desktop
   layout, while boxes inside it cleared by as little as 2.9px.

   `--board-row-left` is the space to the LEFT of the centred slot row; the row
   is centred, so it is exactly the space to its right as well. The inset, the
   portrait, the disc that protrudes from the portrait's inner corner and a
   clearance all have to fit in it.

   WHEREVER THERE IS ROOM TO SPARE THE DESIGNED SIZE WINS THE `min()` AND
   NOTHING HERE CHANGES. That is the whole point: a cap, not a new layout.
   tests/e2e-playwright/tablet-battle.spec.js asserts both halves — the margin
   at every tablet page box and along a synthetic aspect ladder, and the
   untouched 31vmin/9.4vmin geometry at every widescreen viewport.

   Declared on `body` for the same reason `--board-row-left` is: a custom
   property's `var()`s are substituted on the element it is DECLARED on, and the
   phone tier raises `--card-width` on `body.game-active`. On `:root` this
   formula would read the LOBBY card width and describe a row that is not there.
   ------------------------------------------------------------------------ */
body {
    /* The margin the rail must keep off the board row. Small enough that it
       never shrinks anything that was not about to collide, large enough that
       engine rounding at a fractional aspect ratio cannot eat it. */
    --summoner-rail-clearance: 10px;

    /* The portrait's own offset from the viewport edge. Published as a token so
       `#player-portrait`/`#opponent-portrait` and this budget cannot drift
       apart — they were two copies of the same expression before. */
    --summoner-rail-inset: max(calc(4.7vmin + 6px), env(safe-area-inset-right, 0px));

    /* A resource disc is a FRACTION of the portrait's width, inset from the
       frame, then pushed half outside by its own transform — so it reaches
       `width * ratio / 2 - inset` past the frame's edge.

       Expressing the disc against the portrait rather than against `vmin` is
       what stops the two discs from meeting in the middle once the cap starts
       shrinking the frame. At full size `31vmin * 9.4/31` is exactly the 9.4vmin
       the disc has always been, so this is a no-op until the cap engages.

       ONLY THE TIER PARAMETERS LIVE HERE. The disc's actual SIZE is a function
       of the portrait's width, and `--summoner-portrait-width` is declared on
       `.player-portrait` — a `var()` written here would be substituted on
       `body`, find nothing, and silently take the fallback. That is not
       hypothetical: it shipped in this change's first draft and made every
       tablet disc 29% too large (105.8px against 81.9px at 1366x1024), because
       `31vmin * 1/3` is a perfectly valid length and nothing errors. Size and
       reach are therefore derived next to the width, in indicators.css.

       Inline and block insets are SEPARATE because the tablet tier moves the
       discs further in horizontally (14px) while leaving the vertical inset at
       5px. Collapsing them into one token under-reserved the nameplate band by
       9px and pushed both discs off the bottom of every tablet page box — also
       measured, in the same draft. */
    --summoner-disc-ratio: calc(9.4 / 31);
    --summoner-disc-inset-inline: 5px;
    --summoner-disc-inset-block: 5px;

    /* SOLVED, NOT ITERATED. The portrait's width appears on both sides of the
       constraint, because the disc's reach is a fraction of that width:

           inset + W + (W * ratio / 2 - discInset) + clearance <= boardRowLeft

       which rearranges to the closed form below. Whatever the aspect ratio, the
       rail lands exactly `--summoner-rail-clearance` short of the board row —
       or, on a viewport with room, never gets there because the design wins. */
    --summoner-rail-fit: calc(
        (
            var(--board-row-left)
            - var(--summoner-rail-inset)
            + var(--summoner-disc-inset-inline)
            - var(--summoner-rail-clearance)
        )
        / (1 + var(--summoner-disc-ratio) / 2)
    );

    /* ONE WIDTH FOR THE COMMAND PAIR.

       `#turn-timer` rides directly above End Turn at the same `right`, so the
       two must be the same box or their centres disagree — a drift this repo
       has already shipped once (`turnTimerEndTurnCentre.test.js` exists because
       of it). Capping the button alone re-created it immediately: the button
       narrowed on a tablet and the countdown above it did not.

       Same shape as the portrait's cap: 28vmin wherever it fits, never below a
       comfortable touch target, and otherwise as wide as the board row leaves.
       On a widescreen viewport the `clamp()` returns 28vmin exactly. */
    --end-turn-width: clamp(
        112px,
        calc(
            var(--board-row-left)
            - var(--end-turn-right, 9.26vmin)
            - var(--summoner-rail-clearance)
        ),
        28vmin
    );
}

/* Ensure slots stay under cards by default */
#game-view .card-slot {
    z-index: 1;
}

/* When a slot is hovered (because its card is hovered), elevate slot as well to allow child card to exceed sibling slots */
#game-view .card-slot:hover {
    z-index: 8000 !important;
    /* Slightly below card but above other slots */
    overflow: visible;
    /* Allow enlarged card to overflow */
}

/* Any card being hovered/enlarged IN A BATTLE jumps above everything.
   This was unscoped and `!important`, so it leaked into every catalog — and
   because the touch tap-latch leaves `hover-active` on the card it enlarged,
   a deck-builder card sat at z-index 9000 permanently and painted over its own
   "Selected" badge. Same failure mode as the unscoped `.card { z-index: 5 }`
   that leaked out of this file before it. The battle keeps the behaviour; the
   catalogs get their own, modest elevation below. */
#game-view .card.hover-active,
#game-view .card:not(.dragging):not(:active):hover,
#mulligan-overlay .card.hover-active,
#mulligan-overlay .card:not(.dragging):not(:active):hover {
    position: relative;
    z-index: 9000 !important;
    /* Very top, below modal overlays only */
    opacity: 1 !important;
}

/* DELETED: a modest `z-index: 2` for "everywhere ELSE — collection, deck
   builder, mulligan, foundry, exchange".

   It was written unscoped on purpose, to hand the catalogs the elevation the
   leaked battle rule had been giving them by accident. The sweep that scoped
   every bare `.card` rule in this file to `#game-view` (0244c304) scoped this
   one too — the single rule whose whole point was NOT to be — so its selector
   said `#game-view` while its comment still said "everywhere else". Inside
   `#game-view` it is inert: the block above carries the identical selector list
   with `z-index: 9000 !important`, which a later non-important declaration
   cannot beat. Measured on the live battle: an enlarged hand card computes
   9000, never 2.

   Nothing was lost when it stopped reaching the catalogs, which is why this is
   a deletion and not a restoration: every catalog that grows a card on hover
   elevates its own WRAPPER — `.deck-card-item:hover` 1000, `.deck-card-preview`
   10000, `.card.draggable:hover` 10 in cards.css, `#card-hover-preview` at the
   max — and none of them depends on a rule living in the battle stylesheet. */

/* Elevated z-index for any enlarged card (hand hover-active or board hover) */
#game-view .card.hover-active,
#mulligan-overlay .card.hover-active,
#player-board .card-slot:hover .card:not(.dragging):not(:active),
#opponent-board .card-slot:hover .card:not(.dragging):not(:active) {
    position: relative;
    z-index: 9500 !important;
    /* Top level for enlarged cards */
    opacity: 1 !important;
}

/* GATE THE ART WELL, NEVER THE CARD.

   This rule used to read `.card.card-summon-art-pending { opacity: 0 }` — the
   WHOLE card, frame, name, cost and rules text included, held invisible until
   Board marked the raster ready. Measured on the owner's iPhone 13 over the USB
   inspector, at the real 844x280 page box: the description sat at effective
   opacity 0 for 345ms with its text fully intact (90 characters, font-size
   2.07px, box height 12px, never emptied and never clipped), while main-thread
   stalls of 168-181ms landed on top of it.

   That is the defect reported five times: "if i have summoned cards, and i
   summon another, their card description flicker because of the summoning
   animation effects. it dissapears and then comes back in a second", and
   decisively "when i summon card X even its own description vanishes".

   The art looked flawless throughout, which is what hid the cause for so long:
   on an enemy summon EnemyReplay flies a prepared FACE into place while the
   authoritative card waits underneath at opacity 0, so the player watches art
   glide in perfectly while the real card's text is missing.

   CLAUDE.md: "A readiness gate may never delete or withhold UI. Raster/decode/
   settle probes are warm-ups, not permissions... Gate the ART SWAP, never the
   element." docs/asset-loading.md says the same — render text first, gate only
   the art well, behind a placeholder. The well already owns that placeholder on
   `.card-image::before`; `.card-summon-art-error` switching it off explicitly is
   the proof it is there. So the card paints immediately and only the unsettled
   paint node is held back, over a shimmering well.

   The traveller case keeps its whole-card hold, because there a second element
   really is standing in for the card — see the [data-enemy-replay-arrival] rule
   below. That marker is set and cleared by enemy-replay.js around the glide, so
   it covers exactly the window where hiding the card is honest.
   Contract: tests/unit/styles/summonArtGateScope.test.js. */
#game-view :is(#player-board, #opponent-board) .card.card-summon-art-pending .card-image > .card-art-paint {
    opacity: 0 !important;
}

/* EnemyReplay keeps the authoritative card as the real layout/hit target while
   its prepared face travels into place. Once board art settles, the pending
   rule above no longer applies; this ownership marker must still outrank the
   generic desktop hover reveal until the traveler is removed in the same task
   that restores the card. */
#game-view :is(#player-board, #opponent-board) .card[data-enemy-replay-arrival] {
    opacity: 0 !important;
}

.card-slot-container {
    display: flex;
    justify-content: center;
    width: 100%;
    padding: 10px;
}

/* A slot whose card is currently moving in person — landing, striking, or
   being knocked back (UI.FX.moveCard). It has to paint over its neighbours
   and over the hand rail (z 4500) while it travels, and drop straight back
   afterwards, and below #fx-layer (9400).

   This comment used to add "deliberately below the enlarged-card tier
   (9000/9500) so a hovered card still wins". That was FALSE for the hand, and
   it is why the defect below stood: 9000 never escapes `#player-hand`. See the
   rail-elevation rule that follows `fx-lane-reacting`.

   Battle-scoped on purpose: a bare `.card-slot` rule here has leaked into the
   deck builder, collection, foundry and mulligan twice before. */
#game-view .card-slot.fx-slot-lifted {
    z-index: 5000;
}

/* A lane taking the weight of a landing (UI.FX.boardReaction). It is given a
   z-index of its own so the transform's new stacking context cannot drop the
   lane's cards behind the hand for the ~340ms the dip lasts. */
#game-view .board-container.fx-lane-reacting {
    z-index: 5000;
}

/* THE ENLARGED HAND CARD OUTRANKS BOTH OF THOSE — its own z-index cannot.

   `#player-hand` is `position: relative; z-index: 4500`, which makes it a
   STACKING CONTEXT. Every z-index inside it — including the 9000 and 9500 the
   hover rules give an enlarged card — orders cards only WITHIN the rail. To
   anything outside, the whole hand is one flat 4500 layer, so the two 5000
   tiers above paint straight over the card the player is holding open to read.

   Measured on the real battle at 1400x640, pointer holding a hand card
   enlarged: at rest 0 of 50 sampled points on the card's own face belonged to
   anything else; with `fx-lane-reacting` on the opponent lane, 15 of 50 were
   painted by `.card-slot` / `.card-slot-container`. FX.boardReaction runs on
   every summon landing for up to 740ms, and `fx-slot-lifted` does the same for
   a card that strikes or is knocked back — "if a card is summoned or moved ...
   while i'm viewing a card my card becomes transparent during that". The slot
   grooves are translucent, so the card reads see-through rather than covered,
   which is exactly the word the report used.

   So the RAIL rises, for as long as it holds an enlarged card. 8500 clears the
   two 5000 tiers AND `.card-slot:hover` (8000), and stops under #fx-layer
   (9400).

   8000 was in the first version of this rule, at 5200, on the reasoning that a
   hovered BOARD card should still beat the hand. The owner then reported the
   rest of it: "i ve also seen card slots rendered on top of hover enlarged card
   in hand. not sure how to reproduce it but even this happens." He is right,
   and the 5200 reasoning was wrong twice over. A desktop pointer cannot hold
   both states — moving onto a slot clears `hover-active`, measured — but the
   TOUCH tap-latch can: a tap leaves `:hover` latched on whatever was tapped
   before, and `hover-active` latched on the hand card tapped after, so a slot
   sits at 8000 over a card the player is still reading. Reproduced in Chromium,
   Gecko and WebKit by giving an overlapping slot that exact z-index: 10 of 50
   sampled points on the enlarged card's face belonged to the slot, across the 8
   slots the enlarged card overlaps.

   Nothing is lost by letting the rail win. The elevation exists ONLY while a
   hand card is enlarged; the moment the pointer reaches a board slot the class
   is gone and the rail is back at 4500, which is the state every board-hover
   interaction actually runs in.

   `#hover-overlay` also sits at 5000, and is passed here deliberately — it is
   transparent, and `shouldOverlayCaptureForCard` already forces
   `pointer-events: none` for every `#player-hand` card, so it captures nothing
   in exactly this state.

   `:not(.dragging)` is carried here for the same reason every other hand-hover
   rule carries it: once a drag starts, CardDrag owns the card (clone at 15000,
   original hidden) and the rail must not stay lifted behind it. In practice
   `card-drag.js` already strips `hover-active` on drag start, so this is the
   belt to that braces — and `tests/unit/ui/handHoverStyles.test.js` sweeps every
   `#player-hand … .hover-active` selector in this file for it, deliberately
   including one that is only a container.

   `:has()` carries no new engine risk here: `cards.css` already gates this very
   element's art with `#player-hand .card:has(.card-art-loading)`.

   Contract: tests/unit/styles/handHoverElevation.test.js +
   tests/e2e-playwright/hand-hover-elevation.spec.js. */
#game-view #player-hand:has(.card.hover-active:not(.dragging)) {
    z-index: 8500 !important;
}

#game-view .card-slot {
    /* Use the exact same dimensions as .card */
    width: var(--card-width, 42.80vmin);
    /* Use CSS variable for consistency */
    height: var(--card-height, 76.07vmin);
    /* Use CSS variable for consistency */
    margin: 0 var(--board-slot-margin);
    /* Match the card margin to ensure consistent spacing */
    /* AN EMPTY SLOT IS A SOCKET CUT INTO THE TABLE, not a wireframe.

       It was `2px dashed rgba(238, 219, 241, 0.5)` with square corners, and two
       independent art-direction reviews named it unprompted: "1px dashed
       rectangles read as an unstyled placeholder", "the loudest graphic on the
       table … they shout 'your board is empty' instead of quietly inviting a
       drop". On a phone the dashes also alias into dotted noise. Every AAA
       reference paints a recess that belongs to the surface.

       The BORDER WIDTH stays 2px deliberately. The box is `content-box`, so the
       border sits outside the card-sized area and every slot rect this repo
       measures — collision oracles, guide-row registration, the hand-vs-slot
       guard — would shift by 1px per side if it changed. Only the treatment
       changes; the geometry is byte-identical. */
    /* SQUARE, to match the card that fills it. `.card` is `border-radius: 0`
       (cards.css), so a rounded socket leaves visible corner slivers the moment
       a card lands in it. A first pass rounded these and an art review flagged
       the mismatch from a screenshot — it was right about the defect and wrong
       about the direction: the card is square, so the socket is square. */
    border-radius: 0;
    /* AN ENGRAVED GROOVE, not a flat stroke.
       A single fixed-alpha outline is at the mercy of what is behind it: the
       tabletop runs light at the front to dark at the back, so one stroke reads
       clearly on the far row and nearly vanishes on the near planks. A dark line
       on the top/left and a warm light line on the bottom/right is what a routed
       groove in varnished wood actually does, and the PAIR holds its read at any
       underlying value because one of the two is always in contrast.
       Still 2px, still solid: the box is content-box and every slot rect this
       repo measures would shift if the width changed. */
    border-style: solid;
    border-width: 2px;
    border-color:
        rgba(38, 18, 10, 0.34)      /* top    — the shadowed lip */
        rgba(255, 232, 198, 0.20)   /* right  — the lit lip */
        rgba(255, 232, 198, 0.20)   /* bottom — the lit lip */
        rgba(38, 18, 10, 0.34);     /* left   — the shadowed lip */
    box-shadow:
        inset 0 0.5vmin 1.2vmin rgba(24, 10, 34, 0.28),
        inset 0 -1px 0 rgba(255, 246, 225, 0.05);
    position: relative;
    box-sizing: content-box;
    /* Ensure border doesn't affect dimensions */
    flex-shrink: 0;
    /* Lock slot width to the card's width so cards never overlap when the
       row would otherwise be wider than its flex container. */
}

/* Custom Indicators */
.player-health,
.player-mana {
    display: flex;
    align-items: center;
    gap: 5px;
    padding: 5px 10px;
    border-radius: 4px;
    background-color: rgba(0, 0, 0, 0.3);
}

.player-health {
    color: #ff6b6b;
}

.player-mana {
    color: #6b9fff;
}

/* Stack context override to ensure enlarged hand cards render above everything */
#player-area,
#opponent-area {
    position: relative;
    z-index: auto !important;
    /* Allow children to manage stacking context */
}

#player-hand,
#opponent-hand {
    position: relative;
    z-index: 4500 !important;
    /* Direct ancestors of hand cards */
}

/* Drag-triggered action intent panel */

/* THE INPUT SHIELD, NOT A VEIL.
   This used to dim and blur the whole battlefield. Intent is no longer a rare
   6-mana event, so that veil now costs the player exactly what they want while
   options generate: reading the board, their own hand, and the story feed. It
   paints nothing at all.
   Nothing is given up by that. The shield still swallows every pointer event
   (it covers the viewport and cancels `pointerdown`), `isolateBackground()`
   still marks the background `inert` + `aria-hidden`, and the server still
   rejects every action while an intent is pending — three independent layers,
   none of them visual. What replaces the veil is affordance language: see the
   held-board block further down.
   Same reasoning, and the same treatment, as `#story-feed-focus-backdrop`. */
.action-intent-backdrop {
    position: fixed;
    inset: 0;
    /* Never intercepts the pointer. A full-viewport `inset: 0` element that
       eats pointer events stops hover reaching the cards underneath, so the
       player could not enlarge a card to read it while an intent was pending.
       Waiting on an intent is meant to feel like waiting on a story update:
       the board stays readable AND inspectable. Actions are refused by the
       authoritative server, which is the layer that should be saying no. */
    pointer-events: none;
    /* ABOVE the battle chrome it is isolating, not underneath it. At 9199 the
       shield sat below Return to Lobby, the audio control, #fx-layer (9400)
       and the story lanes (9535) / popup (9550) — so a modal that claims the
       screen was being drawn over by the surfaces it had just made `inert`.
       Measured at an 844x288 page box: Return to Lobby printed across the
       dialog's own title. The old scrim hid how wrong that looked; a crisp
       panel does not.
       10008 clears every battle-screen surface that actually reaches the
       dialog: #fx-layer (9400), the story lanes (9535) and popup (9550), and
       the in-game audio control, which soundtrack.css deliberately lifts to
       10002 so it outranks the mulligan veil. That control is `inert` while
       an intent is pending, so the dialog covering the few pixels they share
       costs nothing — and it is not hidden, only overlapped. Still below the
       global banner/confirm ladder (100000). */
    z-index: 10008;
    background: transparent;
    /* The shield — not the card under it — is what gets hit-tested, so one
       declaration makes a pointer read "blocked" anywhere over the board. */
    cursor: not-allowed;
    touch-action: none;
}

.action-intent-panel {
    position: fixed;
    left: 50%;
    bottom: clamp(18px, 4vh, 46px);
    /* One above its own shield; see the shield's note for why the pair moved. */
    z-index: 10009;
    width: min(92vw, 520px);
    transform: translateX(-50%);
    /* Without a scrim the panel has to separate itself from live, animated art
       underneath. It does that with material: a near-solid lit ground, a
       hairline black ring that holds the silhouette against bright card art,
       and a deeper cast shadow so it reads as an object above the board rather
       than a pane drawn onto it. */
    background: linear-gradient(
        180deg,
        rgba(22, 19, 32, 0.985) 0%,
        rgba(11, 10, 17, 0.99) 100%
    );
    border: 1px solid rgba(234, 199, 97, 0.68);
    box-shadow:
        0 0 0 1px rgba(0, 0, 0, 0.58),
        0 22px 52px rgba(0, 0, 0, 0.62),
        0 3px 12px rgba(0, 0, 0, 0.5),
        inset 0 1px 0 rgba(255, 255, 255, 0.1);
    border-radius: 8px;
    padding: 12px;
    display: flex;
    flex-direction: column;
    gap: 10px;
    color: #f8f1df;
    -webkit-backdrop-filter: blur(10px);
    backdrop-filter: blur(10px);
    animation: action-intent-in 260ms cubic-bezier(0.22, 1.12, 0.36, 1);
    box-sizing: border-box;
}

body.action-intent-active #action-in-progress-indicator {
    display: none;
}

.action-intent-panel.is-loading {
    min-height: 86px;
    flex-direction: row;
    align-items: center;
}

.action-intent-panel.is-loading .action-intent-cancel {
    flex: 0 0 auto;
    margin-left: auto;
}

/* MINIMIZE replaced CANCEL. The chip that used to say Cancel let a player throw
   away three model-generated offers and reopen the same action for three new
   ones; the server refuses that now, so the panel needs a way to get out of the
   way WITHOUT releasing the request. Same seat, same metrics as the control it
   replaces, so the landscape grid and the 44px touch target are unaffected. */
.action-intent-panel.is-loading .action-intent-minimize {
    flex: 0 0 auto;
    margin-left: auto;
}

/* Minimized: the panel stops painting and stops taking the pointer, but the
   request behind it is untouched and still pending on the server. */
/* MINIMIZE HAS TO BEAT EVERY LATER `display` RULE, INCLUDING THE PHONE GRID.
   `.action-intent-panel.has-options` in the landscape-phone block below sets
   `display: grid` at the SAME specificity (0,2,0) and appears later in the
   file, so it won. The result was a phone-only trap: pressing Minimize set the
   class, hid nothing, and still mounted the "Resume intent" chip on top of the
   panel it was supposed to have put away. Minimize is the affordance that
   REPLACED Cancel — a pending intent cannot be released by the player, so this
   is the only way to reach the board (and the tutorial's Skip) while one is
   open, and on a phone the dialog covers the whole bottom rail.
   Measured on a 915x336 page box before this fix. The phone rule now carries
   `:not(.is-minimized)`; this stays as the plain statement of intent. */
.action-intent-panel.is-minimized {
    display: none;
}

.action-intent-backdrop.is-minimized {
    pointer-events: none;
    opacity: 0;
}

/* THE CHIP IS SEATED BY MEASUREMENT, NOT BY THIS RULE.
   `left`/`bottom`/`transform` below are only the fallback for a chip the
   solver never reached. action-intent-restore.js measures the live furniture
   the moment the chip mounts and writes inline `left`/`top` (bottom: auto,
   transform: none) in the same task: above the hand, centred on it, one gap
   clear of the slot row; beside the hand when that band is taken; and never
   on End Turn, a portrait, a deck, a nameplate or the story column. The CSS
   seat here — bottom centre of the viewport — is the MIDDLE CARD OF THE HAND
   on every desktop page box (measured 1920x968: chip 884..1048 x 880..924 on
   hand cards 754..1122 x 733..968), which is the defect the solver exists to
   end. Do not "fix" the seat here; fix the solver's candidates. */
.action-intent-restore {
    position: fixed;
    left: 50%;
    bottom: clamp(18px, 4vh, 46px);
    transform: translateX(-50%);
    z-index: 10009;
    /* main.css gives every button a margin of clamp(4px, 0.58vmin, 10px),
       which would offset the painted pill from the solved `left`/`top` by that
       margin on both axes — measured +4px at 1400x640, and it would be +10px at 4K, most of
       the clearance the solver keeps from the slot row. A fixed chip owns no
       flow; it has no margin. */
    margin: 0;
    min-height: 44px;
    padding: 0 18px;
    border-radius: 999px;
    border: 1px solid rgba(234, 199, 97, 0.68);
    background: linear-gradient(180deg, rgba(22, 19, 32, 0.985), rgba(11, 10, 17, 0.99));
    color: #f1d279;
    font-weight: 700;
    letter-spacing: 0.2px;
    cursor: pointer;
    touch-action: manipulation;
    box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.58), 0 22px 52px rgba(0, 0, 0, 0.62);
    /* main.css transitions `all` on every button. On this chip that animated
       `left`/`top` from the CSS fallback seat to the solved one — a 300ms
       slide across the hand on mount, caught by the per-frame stability
       oracle in intent-restore-seat.spec.js (392 -> 264 -> 246 -> 245 on a
       915x336 page box). Only the material may transition. */
    transition: border-color 160ms ease, box-shadow 160ms ease, color 160ms ease, opacity 120ms ease;
}

/* YIELD TO A HOVERED HAND CARD. Minimize sits over the hand, so the moment the
   dialog vanishes the pointer is resting on a hand card, which enlarges to
   near full height — and the chip, seated just above the fan, printed across
   its description text (measured in a real match at 1400x640: enlarged card
   550..850 x 74..616, chip 621..751 x 498..542). While any hand card is
   hover-enlarged the chip fades to a ghost so the card stays readable. It
   keeps its seat and its hit target: it is still the element under the
   pointer at its own position, so gliding up from the card onto it lands,
   the card shrinks, and the chip is whole again — `:not(:hover)` makes that
   return immediate. `:has()` rather than a body class so this cannot drift
   out of sync with the hover module (same choice as soundtrack.css). */
body:has(#player-hand .card.hover-active) .action-intent-restore:not(:hover) {
    opacity: 0.1;
}

.action-intent-restore:hover,
.action-intent-restore:focus-visible {
    border-color: rgba(226, 183, 20, 0.95);
    color: #ffe08a;
    box-shadow:
        0 0 0 1px rgba(0, 0, 0, 0.58),
        0 22px 52px rgba(0, 0, 0, 0.62),
        0 0 14px rgba(234, 199, 97, 0.38);
}

.action-intent-restore:active {
    filter: brightness(0.9);
}

.action-intent-copy {
    min-width: 0;
}

.action-intent-title {
    margin: 0;
    font-size: 15px;
    font-weight: 700;
    color: #f1d279;
    letter-spacing: 0;
}

.action-intent-status {
    margin-top: 3px;
    font-size: 13px;
    line-height: 1.35;
    color: #cfc6dc;
}

.action-intent-options {
    display: grid;
    gap: 8px;
}

.action-intent-option,
.action-intent-cancel,
.action-intent-minimize {
    min-height: 44px;
    touch-action: manipulation;
    border-radius: 8px;
    cursor: pointer;
    transition: transform 160ms ease, background 160ms ease, border-color 160ms ease;
}

.action-intent-option {
    text-align: left;
    border: 1px solid rgba(255, 255, 255, 0.14);
    background: linear-gradient(135deg, rgba(255, 255, 255, 0.075), rgba(64, 172, 166, 0.08));
    color: inherit;
    padding: 10px 12px;
    font-size: 14px;
    line-height: 1.32;
}

.action-intent-option:hover {
    background: linear-gradient(135deg, rgba(234, 199, 97, 0.14), rgba(64, 172, 166, 0.12));
    border-color: rgba(226, 183, 20, 0.7);
    transform: translateY(-2px);
    box-shadow: 0 6px 18px rgba(5, 2, 12, 0.45), 0 0 12px rgba(234, 199, 97, 0.25);
}

/* Discover deal-in: options arrive as staggered tiles */
.action-intent-option--deal {
    animation: action-intent-deal 260ms cubic-bezier(0.2, 0.9, 0.3, 1) var(--option-delay, 0ms) backwards;
}

@keyframes action-intent-deal {
    from {
        opacity: 0;
        transform: translateY(10px);
    }

    to {
        opacity: 1;
        transform: translateY(0);
    }
}

@media (prefers-reduced-motion: reduce) {
    .action-intent-option--deal {
        animation: none;
    }

    .action-intent-option:hover {
        transform: none;
    }
}

.action-intent-option:active,
.action-intent-cancel:active {
    transform: translateY(1px);
}

.action-intent-option:focus-visible,
.action-intent-cancel:focus-visible {
    outline: 2px solid #58d5d0;
    outline-offset: 2px;
}

.action-intent-option:disabled,
.action-intent-cancel:disabled {
    cursor: wait;
    opacity: 0.68;
}

/* LOCKED OFFER — the recorded tutorial's taught turn shows three intents and
   accepts one, so the replay keeps the single future it has a story and a
   manga panel for. This must read as SEALED, not as broken or as loading: the
   `:disabled` rule directly above means "your pick is submitting" and lends it
   a wait cursor, which is the wrong story here and is overridden. The panel's
   own material is kept; what goes is the lift, the glow and the colour. */
.action-intent-option--locked,
.action-intent-option--locked:hover {
    cursor: not-allowed;
    opacity: 0.5;
    filter: saturate(0.35);
    transform: none;
    box-shadow: none;
    background: linear-gradient(135deg, rgba(255, 255, 255, 0.035), rgba(140, 136, 156, 0.05));
    border-color: rgba(255, 255, 255, 0.1);
    border-style: dashed;
}

/* Says WHY. A `title` never reaches a touch device, and a screen reader is not
   guaranteed to announce one on a disabled control — so the reason is real
   text inside the button. */
.action-intent-option-lock {
    display: block;
    position: relative;
    margin-top: 5px;
    padding-left: 1.05em;
    font-size: 0.76em;
    letter-spacing: 0.4px;
    text-transform: uppercase;
    color: #c3b9d8;
}

/* A drawn padlock rather than an emoji: 🔒 renders as a different object on
   every platform and none of them belong to this art direction. Body… */
.action-intent-option-lock::before {
    content: '';
    position: absolute;
    left: 0;
    bottom: 0.04em;
    width: 0.66em;
    height: 0.5em;
    border-radius: 1.5px;
    background: currentColor;
}

/* …and shackle. */
.action-intent-option-lock::after {
    content: '';
    position: absolute;
    left: 0.15em;
    bottom: 0.48em;
    width: 0.36em;
    height: 0.24em;
    border: 1.5px solid currentColor;
    border-bottom: 0;
    border-radius: 0.3em 0.3em 0 0;
}

.action-intent-cancel {
    align-self: flex-end;
    min-width: 86px;
    border: 1px solid rgba(255, 255, 255, 0.16);
    background: rgba(255, 255, 255, 0.06);
    color: #e6deef;
    padding: 8px 12px;
    font-size: 13px;
}

.action-intent-cancel:hover {
    background: rgba(255, 255, 255, 0.1);
    border-color: rgba(255, 255, 255, 0.28);
}

.action-intent-spinner {
    width: 30px;
    height: 30px;
    flex: 0 0 auto;
    border-radius: 50%;
    border: 3px solid rgba(241, 210, 121, 0.22);
    border-top-color: #f1d279;
    animation: action-intent-spin 850ms linear infinite;
}

/* The panel used to fade up 6px onto a scrim that had already claimed the
   screen for it. It now arrives onto a fully lit board and has to announce
   itself, so it rises further, overshoots by 3px, and settles — anticipation,
   follow-through, settle. The X translate is part of the resting transform and
   must be carried through every keyframe or the panel jumps off-centre. */
@keyframes action-intent-in {
    0% {
        opacity: 0;
        transform: translateX(-50%) translateY(14px) scale(0.985);
    }

    62% {
        opacity: 1;
        transform: translateX(-50%) translateY(-3px) scale(1.006);
    }

    100% {
        opacity: 1;
        transform: translateX(-50%) translateY(0) scale(1);
    }
}

@keyframes action-intent-spin {
    to {
        transform: rotate(360deg);
    }
}

@media (max-width: 560px) {
    .action-intent-panel {
        bottom: 12px;
        width: calc(100vw - 18px);
        padding: 10px;
    }

    .action-intent-title {
        font-size: 14px;
    }

    .action-intent-option {
        font-size: 13px;
    }
}

@media (prefers-reduced-motion: reduce) {
    .action-intent-panel,
    .action-intent-option,
    .action-intent-cancel,
    .action-intent-spinner {
        animation: none;
        transition: none;
    }
}

/* ============================================================================
   HELD BOARD — an intent is generating, or waiting on the player's pick.
   Contract: tests/unit/styles/actionIntentHeldBoard.test.js.

   Nothing here hides anything. The board, both hands and the story feed stay
   at full clarity; what goes away is every promise that they can be used.
   That is the same signal Hearthstone gives when it takes the turn back: the
   art keeps burning, the glow does not.

   Deliberately absent from this block: `filter`, `opacity` and `transform` on
   a card. Any of the three is the veil returning one card at a time, and it
   would put a card on the battle screen out of step with the same card
   everywhere else (docs/card-rendering-regression-contract.md).
   ========================================================================== */

body.action-intent-active #player-hand .card.legal-play,
body.action-intent-active #player-board .card.legal-play {
    /* The green ring is a promise that this card can act. Withdraw the
       promise; leave the card itself pixel-identical.
       Fading OUT is a courtesy — the board is being taken away, and a snap
       reads as a glitch. Fading back IN is not: authority returning to the
       player should be instant, so the transition lives here and not on the
       resting rule. */
    /* NOT `none`: the card's own frame hairline rides in this same
       box-shadow, so dropping the whole property would erase the card
       edge along with the glow. Re-point the token instead — that is
       exactly the `--battle-card-edge` contract, and the legal-play rule
       above re-points it to the green variant. */
    --battle-card-edge: inset 0 0 0 1px rgba(0, 0, 0, 0.15);
    box-shadow: var(--battle-card-edge);
    transition: box-shadow 180ms ease-out;
}

@media (prefers-reduced-motion: reduce) {
    body.action-intent-active #player-hand .card.legal-play,
    body.action-intent-active #player-board .card.legal-play {
        transition: none;
    }
}

/* ============================================================================
   Phone-landscape battle block — VIEWPORT-driven (short landscape), so it also
   applies in a fine-pointer device emulator, not just on a coarse phone.
   Battle cards use the closed-form page-box budget below; catalogs retain the
   general phone floor from cards.css. Desktop never matches (the QA height is
   640px > 560px), so the desktop board stays byte-identical.
   ========================================================================== */

@media (orientation: landscape) and (max-height: 560px) {
    :root {
        /* `background.webp` is 3752x1611 and body paints it cover/center.
           These projected Y coordinates are the two dotted source rows
           (740px and 808px). Anchoring the slot centres to those semantic art
           guides keeps both rows mirrored through every short-landscape aspect
           ratio. The phone-only closed form below replaces these values only
           through the actual 932px phone tier. */
        --board-guide-upper-y: calc(50dvh - max(4.066dvh, 1.746vw));
        --board-guide-lower-y: calc(50dvh + max(0.155dvh, 0.067vw));
        --board-guide-clearance: clamp(10px, 2.2vmin, 16px);
        --board-slot-outer-height: calc(var(--card-height) + 4px);
        /* Short landscape tightens the gutters; --board-row-left follows. */
        --board-slot-margin: 6px;
    }

    @media (max-height: 320px) {
        :root {
            --board-guide-clearance: 4px;
        }
    }

    .action-intent-panel {
        bottom: max(8px, env(safe-area-inset-bottom, 0px));
        width: min(
            820px,
            calc(
                100vw
                - env(safe-area-inset-left, 0px)
                - env(safe-area-inset-right, 0px)
                - 16px
            )
        );
        max-height: calc(
            100dvh
            - env(safe-area-inset-top, 0px)
            - env(safe-area-inset-bottom, 0px)
            - 16px
        );
        padding: 8px;
        gap: 8px;
        overflow-y: auto;
        overscroll-behavior: contain;
    }

    /* `:not(.is-minimized)` is load-bearing, not decoration — see the
       minimized rule above. Without it this wins on specificity+order and the
       dialog cannot be put away on a phone. */
    .action-intent-panel.has-options:not(.is-minimized) {
        display: grid;
        grid-template-columns: minmax(0, 1fr) auto;
        grid-template-areas:
            "title cancel"
            "options options";
        align-items: center;
    }

    .action-intent-panel.has-options .action-intent-title {
        grid-area: title;
        min-width: 0;
        font-size: 14px;
    }

    .action-intent-panel.has-options .action-intent-options {
        grid-area: options;
        grid-template-columns: repeat(3, minmax(0, 1fr));
        gap: 8px;
        min-width: 0;
    }

    .action-intent-panel.has-options .action-intent-minimize {
        grid-area: cancel;
        align-self: center;
        min-width: 74px;
        margin: 0;
    }

    .action-intent-option {
        min-width: 0;
        padding: 8px 10px;
        font-size: clamp(12px, 2.2vmin, 13px);
        line-height: 1.25;
        white-space: normal;
        overflow: visible;
        overflow-wrap: anywhere;
    }

    .action-intent-panel.is-loading {
        min-height: 0;
    }

    .action-intent-panel.is-loading .action-intent-status {
        display: -webkit-box;
        -webkit-box-orient: vertical;
        -webkit-line-clamp: 2;
        overflow: hidden;
        overflow-wrap: anywhere;
    }

    .action-intent-panel.is-loading .action-intent-cancel {
        min-width: 74px;
    }

    .action-intent-panel.is-loading .action-intent-spinner {
        width: 24px;
        height: 24px;
        border-width: 2px;
    }

    #game-board-middle {
        height: 16px;
        padding: 0;
    }

    #opponent-board {
        position: fixed;
        inset-inline: 0;
        top: calc(
            var(--board-guide-upper-y)
            - var(--board-guide-clearance)
            - var(--board-slot-outer-height) / 2
        );
        transform: translateY(-50%);
        margin: 0 !important;
    }

    #player-board {
        position: fixed;
        inset-inline: 0;
        top: calc(
            var(--board-guide-lower-y)
            + var(--board-guide-clearance)
            + var(--board-slot-outer-height) / 2
        );
        transform: translateY(-50%);
        margin: 0;
    }

    /* Preserve the established short-desktop crop outside the phone-width tier. */
    #player-hand {
        margin-bottom: calc(-12 * var(--card-unit));
    }

    /* The margin itself is now published as --board-slot-margin above, so the
       base `#game-view .card-slot` rule already resolves to 6px here and a
       second literal would be a place for the two to drift apart. */

    #end-turn-btn {
        min-width: 112px;
        min-height: 48px;
        padding: 10px 14px;
        font-size: max(2.6vmin, 12px);
        right: calc(var(--story-lane-w, 56px) + env(safe-area-inset-right, 0px) + 8px);
    }

}

/* Modern short landscape-phone canvases through 932px have only a slim rail on either side
   of the truly centred five-slot board. Publish shared budgets for the command
   banners; the HUD consumes them without shifting the board or hands away from
   the viewport centre. The decks are NOT budgeted here — they sit inset from
   the right edge like desktop, cut off by the top and bottom edges, so they
   cost the layout no reserved band at all. */
@media (orientation: landscape) and (max-height: 560px) and (max-width: 932px) {
    body.game-active {
        /* Four card rows do not fit into a real 275-344px phone page box at the
           general card floor. Preserve the readable phone-card scale and the
           established opponent/board alignment. The complete player fan is
           allowed to overlap the lower tabletop edge instead of being cropped.

           THE ART IS DRAWN AT PLAIN `cover`, EXACTLY LIKE THE MENU. Nothing
           here overrides `background-size`, so the battle inherits main.css's
           `cover` unchanged and shows the identical crop of the identical
           picture the lobby does.

           It used to be drawn at 115% of cover. That cost two things. The
           obvious one is the crop: at 115% only 87% of the ultrawide frame's
           width is on screen, so the books and the bonsai are cut away and the
           battle shows a picture the rest of the product does not. The subtle
           one is that a bigger table makes the five-slot row cover a SMALLER
           fraction of it — the reported "the board slots take only half of the
           wooden part of the board". `cover` is the most zoomed-out the art can
           be drawn without a bare band appearing at an edge, so this is as far
           out as it goes; the row now spans 57-90% of the painted tabletop
           instead of 50-70%.

           The guide terms are the cover projection of the two dotted rows
           painted at source y740/y808 of the 3752x1611 asset:
             guideUpper = H/2 - 0.040658 * renderedH
             guideLower = H/2 + 0.001552 * renderedH
           with renderedH = max(H, 0.42937 * W) because cover takes the larger
           scale; that max is what the dvh/vw pair in each term expresses. Get
           these wrong and the sockets float off the painted table. */
        --mf-phone-guide-rise: max(4.066dvh, 1.746vw);
        --mf-phone-guide-drop: max(0.155dvh, 0.067vw);
        --mf-phone-guide-upper: calc(50dvh - var(--mf-phone-guide-rise));
        --mf-phone-guide-lower: calc(50dvh + var(--mf-phone-guide-drop));
        /* HOW FAR THE ART MAY STILL BE PANNED, and why it may be panned at all.

           Plain cover lands the two painted rows within a pixel or two of the
           page-box CENTRE, which splits the box in half. The game does not need
           halves: above the upper row sits only a cropped enemy fan (0.6056
           cards), below the lower row sit the player's slot row AND his
           complete resting hand (2.2044 cards). Anchoring the rows to dead
           centre therefore starves the player's half and costs 16% of card
           height at 844x288 — measured, not estimated.

           So the art is panned up by the same derived amount as before, but
           only as far as cover's own vertical overhang allows, because past
           that a bare band appears at the top edge (the failure
           battlefield-coverage.spec.js exists to catch). Cover overhangs by
           (renderedH - H)/2 = 21.46885vw - 50dvh on a width-driven box and by
           nothing on a height-driven one; 1px is held back so a sub-pixel
           rounding cannot expose a hairline. The pan is 0 to 27px on a
           362-460px tall image — a small vertical pan of the same crop, not a
           different crop. */
        --mf-phone-art-slack: max(0px, calc(21.46885vw - 50dvh - 1px));
        /* The established whole-box budget: enemy fan crop 0.6056 + both slot
           rows 2 + the player's complete fan 1.2044 = 3.81 card heights. */
        --mf-phone-card-height-full: calc(
            (
                100dvh
                - var(--mf-phone-guide-rise)
                - var(--mf-phone-guide-drop)
                - var(--mf-safe-bottom, 0px)
                - 8px
            ) / 3.81
        );
        --mf-phone-art-shift: max(
            calc(
                1.6056 * var(--mf-phone-card-height-full)
                - var(--mf-phone-guide-upper)
                + 4px
            ),
            calc(-1 * var(--mf-phone-art-slack))
        );
        /* THE CARD IS MEASURED OFF THE PLAYER'S HALF, not off the whole box,
           because once the pan can be clamped the whole-box budget no longer
           describes where the rows ended up. Below the lower painted row sit
           the player's slot row (a card plus its 2px socket lip, top and
           bottom) and then his complete resting fan, whose highest arched card
           stands 1.20006 card-heights above the page's bottom edge (container
           1.0707 + peak rise 0.0717 + gesture lift 0.0577); 2.2 and the 6px
           leave ~2px of daylight between the row and that peak card at every
           page box. Deriving it from the whole box instead is what put the hand
           back on top of the sockets at 852x344 and 667x275, the two boxes
           where cover has almost no overhang to pan into. */
        --mf-phone-card-height: calc(
            (
                50dvh
                - var(--mf-phone-guide-drop)
                - var(--mf-phone-art-shift)
                - var(--mf-safe-bottom, 0px)
                - 6px
            ) / 2.2
        );
        --card-unit: calc(var(--mf-phone-card-height) / 22.2);
        --card-width: calc(12.29 * var(--card-unit));
        --card-height: calc(22.2 * var(--card-unit));
        --board-guide-clearance: 0px;
        --board-guide-upper-y: calc(var(--mf-phone-guide-upper) + var(--mf-phone-art-shift));
        --board-guide-lower-y: calc(var(--mf-phone-guide-lower) + var(--mf-phone-art-shift));
        --board-slot-outer-height: calc(var(--card-height) + 4px);
    }

    /* The sockets are nailed to the painted guide rows, so the painting is
       panned by exactly the same derived amount or they float off the table.
       `background-size` is deliberately NOT restated: the battle draws the art
       at main.css's plain `cover`, at the same scale and the same horizontal
       crop as the menu. */
    body.game-active::before {
        background-position: center calc(50% + var(--mf-phone-art-shift));
    }

    /* NO in-battle override any more. This subtracted a flat 0.4px from the
       anchor (283c185a) — an ABSOLUTE term on a proportional placement, so it
       could only ever be right at one card size, and it was fitted at phone
       battle size. Measuring the frame asset showed why it appeared to help:
       the base anchor put the numeral's ink 0.00276 of card height BELOW the
       lens centre at EVERY size, and 0.4px happens to be roughly that error on
       a ~68px hand card. cards.css now carries the derived anchor (0.10952),
       which lands the ink on the lens at every size, so the battle surface
       needs no special case and matches mulligan and the catalogs exactly. */

    #player-hand {
        height: calc(23.77 * var(--card-unit));
        margin-bottom: 0;
    }

    #player-hand .card {
        margin: 0 calc(-1.25 * var(--card-unit));
    }

    /* Restate only desktop's translations and spacing in card units. Rotation,
       order, normal flow, alignment, and clipping remain the desktop fan. */
    #opponent-hand .card {
        margin: 0 calc(-0.9375 * var(--card-unit));
    }

    #opponent-hand .card:nth-child(1) {
        --card-base-transform: rotate(10deg)
            translateY(calc(-0.78125 * var(--card-unit)))
            translateX(calc(0.15625 * var(--card-unit)));
    }

    #opponent-hand .card:nth-child(2) {
        --card-base-transform: rotate(5deg)
            translateY(calc(-1.09375 * var(--card-unit)))
            translateX(calc(-0.15625 * var(--card-unit)));
    }

    #opponent-hand .card:nth-child(3) {
        --card-base-transform: rotate(0deg)
            translateY(calc(-1.25 * var(--card-unit)))
            translateX(0px);
    }

    #opponent-hand .card:nth-child(4) {
        --card-base-transform: rotate(-4deg)
            translateY(calc(-0.9375 * var(--card-unit)))
            translateX(calc(0.15625 * var(--card-unit)));
    }

    #opponent-hand .card:nth-child(5) {
        --card-base-transform: rotate(-9deg)
            translateY(calc(-0.625 * var(--card-unit)))
            translateX(calc(-0.15625 * var(--card-unit)));
    }


    :root {
        /* The portrait must retain a clear top text shelf above its two 24px
           resource sockets. At 88px the longest real status ledger reached
           those sockets on every short iPhone profile; this still-compact
           range preserves the art ratio while guaranteeing that shelf. */
        --phone-command-banner-width: clamp(98px, calc(30 * var(--mf-dvmin)), 128px);
        /* The summoner crest on short landscape phones.

           A first pass cut this to 56px to open a seam for the Volume+End Turn
           stack, and the owner rejected it on sight: the crests "lost too much
           their original shape and they're way too small". That pass had also
           misdiagnosed the cause. The seam was not being eaten by the crest
           WIDTH — it was eaten by a stray `+ 12px` in status-indicator.css that
           made each portrait 84.7px tall instead of 72.7px, twice over. With
           that fixed, the measured seam at 844x288 is 128px against a 92px
           stack, so the crest can keep a real presence.

           The vmin term is what actually binds on every real page box
           (30vmin = 82.5-103.2 across 275..344), so the floor below is a
           backstop rather than the operating value. */
        /* The FLOOR is set by the summoner status plate, not by taste.

           That plate is bottom-anchored 30px above the crest's lower edge and
           grows UPWARD (status-indicator.css) so it never covers the resource
           discs. The canonical four-row ledger is 50px tall, so the crest must
           be at least 30 + 50 = 80px tall or the biggest ledgers grow off the
           top of the screen — measured at 844x288 with a 76px crest: plate
           top -3.9 against a safe-box top of 0. 92px of width gives 0.742*92 +
           12 = 80.3px of height, which is exactly that floor. */
        /* SIZED BY THE SEAM, not by taste. The command pair is now vertical
           (End Turn + gap + volume trigger = ~92px) and must fit between the two
           summoner clusters, or it steps outboard onto the board — which is
           exactly the reported "volume button is still overlapping boards".

              seam = H - 2 * (0.742*W + 12 + nameplateReserve) >= 92

           Solved at the TIGHTEST real page box (667x275), not at any one
           device. The volume trigger has since moved to the top-right corner
           beneath the exit X (owner-directed), so the seam only has to hold
           End Turn itself rather than a stacked pair — about 44px instead of
           92px. That is what lets the crest grow back here, which in turn gives
           the bottom-anchored status ledger the height it needs. Every other box is looser. Do not raise this
           without re-checking 667x275 AND 844x280 (a real iPhone 13 in Safari,
           which reports ZERO safe-area insets). */
        /* THE FLOOR IS THE STATUS LEDGER'S HEIGHT, solved rather than chosen.

           The plate grows upward from `6px + disc` above the crest's lower edge
           (status-indicator.css), so its room is `crestHeight - 6 - disc`, and
           the crest's height is `width * 23/31 + 12`. The tallest fixture in
           the status audit — 40 unbroken glyphs, Latin or CJK — measures 52px
           on every page box up to 344 tall against a 24px disc, so the crest
           needs 82px of height and therefore 97.1px of width.

           A 72px floor was tried and does not reach it: 33vmin gives 90.8px at
           667x275 and 92.4px at the owner's own 844x280 device box, i.e. 79.3px
           and 80.5px of crest height, and the ledger overflowed BOTH ends —
           off the top when it grew upward, into the health and mana sockets
           when it was flipped to grow downward. Those were the same defect
           reported twice.

           100px is 97.1 plus 4px of margin for Firefox, which gives Han and
           Kana a taller line box than Chrome or WebKit. It spends 5.4px of the
           seam, which has 7.3px to give: at 667x275 the seam measures 51.3px
           against a 44px End Turn. */
        --phone-summoner-portrait-width: clamp(100px, calc(33 * var(--mf-dvmin)), 128px);
        /* THE CORNER LANE. The owner pinned the exit X to the physical
           top-right corner of the screen and the volume trigger directly
           beneath it — 36px each, 8px from the edge (mobile.css) — so the outer
           44px of the right edge belongs to them, top to bottom of both
           controls. */
        --phone-command-corner-lane: 44px;
        /* WHERE THE SUMMONER COLUMN STOPS, and it is not the safe inset any
           more.

           The crest, its status plate, its resource discs and its deck stack
           were all anchored at `env(safe-area-inset-right)`. On a NOTCHED phone
           that inset (47px on the reporting iPhone 13, 59px on the emulated
           profiles) is already wider than the corner lane, so the two controls
           sit outboard of the whole column and nothing collides — which is the
           geometry the owner confirmed on hardware.

           A phone with NO right inset has no such band, and there the same
           anchor put the crest, the status ledger and the opponent's HEALTH
           readout underneath those buttons. Measured at 915x336: the volume
           trigger [871..907 x 52..88] over a health disc at [886..911 x 65..90],
           and the status plate under both controls on every notch-free box.

           `max()` is the whole design: on every notched profile this resolves
           to the inset and the column does not move by a pixel, so nothing
           validated on the device changes. Only the boxes that need it step in.
           Stepping the column UNCONDITIONALLY is the failure recorded in
           mobile.css — it costs 44px of width that a notched 844px page box
           does not have, and pushes the deck stacks onto the board. */
        --phone-summoner-column-right: max(
            var(--mf-safe-right, 0px),
            var(--phone-command-corner-lane)
        );
        /* How far each resource disc hangs outside the crest's lower corners.
           Declared HERE, on :root, and not only on .player-portrait, because
           the deck lane has to reserve room for it and the deck wrapper is not
           a descendant of the portrait — a var() that resolves to nothing
           invalidates the whole declaration, and the decks silently fell back
           to the left edge of the screen. */
        --phone-summoner-protrusion: calc(
            var(--phone-summoner-portrait-width) * 0.18
        );
        /* THE COMMAND COLUMN DOES NOT STEP INBOARD. It was tried, to free the
           literal top-right corner for the exit X, and it cannot work on a real
           device: `viewport-fit=cover` gives a notched iPhone 59pt of safe inset
           on EACH side, so an 844px landscape page box has 726px of usable
           width. The right-hand row already needs board + deck + gap + the 128px
           status rail; a further 54px step pushes the deck onto the board slots,
           which mobile-battle.spec.js reports as "shifted decks clear board
           slots and hand cards" at every notched profile. An inset-free capture
           harness hides this completely — it passed at all five page boxes there
           and failed the moment the real insets were injected.

           So the exit sits INBOARD of the crest, which is the only 44px hole on
           the top edge that exists at every profile. */

    }



    /* Return to Lobby measured at [707,80] — straight through the opponent's
       summoner name at [756,67,88,24]. The right rail has no 44px gap to give
       it (portrait 0-65, name 67-91, End Turn 128-176, player portrait
       193-258), so it takes the free mid-band immediately LEFT of End Turn,
       which is where the desktop rule always intended it — the vmin offset
       simply was not wide enough on a phone to clear the portrait column. */
    /* Measured on an 844x288 page box: the board's leftmost slot starts at
       x=268, so the column between the narration lane and the slots is free
       top to bottom. Moving the exit control merely off the nameplate put it
       in the middle band — which IS the play area, printing it across the
       player's own slots. It lives in the free column instead, low enough to
       clear the narration lane above it. */
    /* SUPERSEDED by the lane below. This pinned the exit to the free column on
       the left, which is the placement the compact rail replaced: the exit now
       shares the summoners' lane, directly under End Turn. Leaving the pin here
       fought the JS placement for one frame on every layout pass, and won
       outright whenever the script had not run yet. Only the no-JS resting
       position remains, and it is the lane. */
    /* The exit's no-JS resting seat went with the floating control: it is a
       menu row now, positioned by the panel. */

    /* End Turn and Audio form one centred command group in the free seam. The
       normal below layout shifts End Turn upward by half (button + gap), while
       AudioSettings marks a collision-driven side-by-side layout so End Turn
       itself returns to the viewport midpoint. */
    #end-turn-btn {
        top: calc(50% - (var(--mf-touch, 44px) + 1.5 * var(--mf-dvmin)) / 2);
        height: var(--mf-touch, 44px);
        min-height: var(--mf-touch, 44px);
        /* The right rail is ONE column. The summoner portraits are fixed to the
           right edge at --phone-command-banner-width, so End Turn takes the
           identical lane and inherits their centre line rather than a hand-tuned
           offset — which measured 23px off at 932x300 and read as a crooked HUD.
           Audio is centred on End Turn by audio-settings.js, so it follows.

           The portraits are anchored at env(safe-area-inset-right), not at the
           raw viewport edge — `right: 0` therefore put the controls UNDER the
           notch on a notched phone and, for the same reason, out of line with
           the portraits they are supposed to match. One inset fixes both.

           That anchor is now `--phone-summoner-column-right`, which is the same
           inset on every notched box and steps in by the corner lane where
           there is no notch. End Turn has to move with the crests or it stops
           being centred on them, which is the one thing about it the owner
           specified. */
        right: var(--phone-summoner-column-right);
        width: var(--phone-command-banner-width);
        min-width: 0;
        max-width: var(--phone-command-banner-width);
        /* A global button margin otherwise pushes the lane 16px off the
           portraits it is meant to line up with. */
        margin: 0;
        padding-inline: 6px;
    }

    #end-turn-btn[data-audio-layout="beside"] {
        top: 50%;
    }

    /* The right rail is FULL on a real phone page box: the opponent banner and
       its nameplate reach ~112px on a 300px page, End Turn's 44px touch target
       starts at ~128px, and the player banner closes the column below it. There
       is no band above End Turn to put a pill in — measuring it at 932x300,
       852x344 and 667x275 put every candidate on top of the opponent nameplate.

       So the phone chip moves onto the End Turn ROW instead, inboard of the
       Audio companion, into the empty strip between the centred five-slot board
       and the command lane. Measured clearance there: 136px at 932x300, 74px at
       852x344, 11px at 667x275 — the narrowest real landscape page box still
       fits the 44px chip with room on both sides. */
    #turn-timer {
        right: calc(
            var(--mf-safe-right, 0px)
            + var(--phone-command-banner-width)
            + var(--mf-touch, 44px)
            + 10px
        );
        top: calc(50% - (var(--mf-touch, 44px) + 1.5 * var(--mf-dvmin)) / 2);
        transform: translateY(-50%);
        /* The desktop rule mirrors the bare-`button` margin so the countdown
           and End Turn share one centre. This tier does NOT co-anchor them —
           End Turn's own margin is zeroed here and the two carry different
           `right` values — so inheriting that margin only pushes the chip 4px
           off the safe-area edge it is deliberately pinned to, and further
           under the opponent nameplate. */
        margin-right: 0;
        width: auto;
        min-width: 44px;
        max-width: 72px;
        padding: 2px 5px 3px;
        gap: 2px;
        border-radius: 6px;
        border-width: 1px;
        font-size: max(calc(2.4 * var(--mf-dvmin)), 11px);
        letter-spacing: 0.2px;
    }

    #turn-timer .turn-timer-bar {
        height: 3px;
    }

    /* The phone chip is a 44-72px box on a right rail that is already FULL
       (see the measurements above) — there is no room for a sentence, and a
       wrapped one would climb straight back under #opponent-summoner-name.
       On a phone the warning is carried by the whole-turn reveal plus the ⚠
       storyboard beat, both of which cost no layout. */
    #turn-timer .turn-timer-warning {
        display: none;
    }

    /* Ride End Turn's vertical position exactly, in both of its phone modes. */
    #end-turn-btn[data-audio-layout="beside"] ~ #turn-timer {
        top: 50%;
    }

    #end-turn-btn[data-return-layout="below"] ~ #turn-timer {
        top: calc(50% - (var(--mf-touch, 44px) + 1.5 * var(--mf-dvmin)) / 2);
    }

    /* Above a ~360px page the seam the desktop pill uses EXISTS again: the
       banner cluster ends around 133px and End Turn's touch target starts
       around 173px. Take it, because the inboard strip is the half of this
       tier that is not safe — the board's five slots grow with viewport height
       and, on WebKit at 844x390, reach far enough right to swallow the chip
       (measured; Chromium at the same box does not, which is exactly why the
       phone claim needed a WebKit seat). The lane above End Turn is the
       summoner column. The BOARD never enters it at any height — but the
       opponent's own nameplate does, and that is what the chip has to clear.
       The plate hangs under a portrait whose column is itself ~73px inside
       this lane, so there is no horizontal dodge: the chip lives in the band
       BETWEEN the plate's bottom edge and End Turn's top edge. */
    @media (min-height: 361px) {
        #turn-timer,
        #end-turn-btn[data-return-layout="below"] ~ #turn-timer {
            right: var(--mf-safe-right, 0px);
            width: var(--phone-command-banner-width);
            max-width: var(--phone-command-banner-width);
            /* One gap above End Turn's TOP, exactly like the `beside` rule
               below. This used to subtract half of End Turn's height a SECOND
               time on top of the half already inside its own `top` — 22px of
               phantom lift that pushed the chip up under #opponent-summoner-
               name and cost a 73x23.5px overlap at 664x390. The button is
               top-anchored here, so there is nothing to re-centre.

               1px, not the 6px its `beside` sibling uses. The band this chip
               has to fit inside is only ~32px tall — plate bottom 140.9 to
               button top 173 at 664x390 — and the chip is ~24.6, so the spare
               ~7.5px has to be SPLIT, not spent entirely below the chip. Every
               px of this offset is a px of chip top, measured: 6px -> top
               139.4 (1.5px INTO the plate), 4px -> 141.4 (0.5px clear),
               1px -> 144.4 (~3.5px clear above, ~3.9px below). Centred is the
               best this lane allows; do not raise it without re-measuring. */
            top: calc(
                50%
                - (var(--mf-touch, 44px) + 1.5 * var(--mf-dvmin)) / 2
                - 1px
            );
            transform: translateY(-100%);
        }

        #end-turn-btn[data-audio-layout="beside"] ~ #turn-timer {
            top: calc(50% - var(--mf-touch, 44px) / 2 - 6px);
        }
    }

    /* Return to Lobby occupies the band under End Turn, so the PAIR is what
       gets centred between the summoners — End Turn yields half of Return's
       height plus the gap. Without this the 44px control has 29px to land in on
       a 300px page box and falls back to the far column. */
    #end-turn-btn[data-return-layout="below"] {
        top: calc(50% - (var(--mf-touch, 44px) + 1.5 * var(--mf-dvmin)) / 2);
    }

    /* The exit's command-lane width went with it too — a menu row takes the
       panel's width like every other row. */
}


/* ============================================================================
   Coarse pointers (touch): no backdrop blur over the live board.
   backdrop-filter forces a per-frame repaint of the animated battlefield
   behind these surfaces — the biggest GPU paint cost on phones. Swap to a
   visually-matched, more-opaque solid background instead; borders and shadows
   stay. Menus/lobby keep their blur (they don't repaint per frame).
   Contract: tests/unit/styles/coarseBackdropSwap.test.js.
   ========================================================================== */

@media (pointer: coarse) {
    /* `.action-intent-backdrop` used to need an entry here — it blurred, and
       a coarse pointer got a more-opaque solid instead. It now paints nothing
       on any pointer type, so there is no blur to disable and no background to
       compensate for; a coarse override would only re-dim the board this
       change exists to keep readable. */

    .action-intent-panel {
        /* The same lit gradient as the base rule, taken to full opacity in
           place of the blur. Repeating the material rather than substituting a
           flat fill keeps the dialog identical on a phone and a desktop —
           dropping to one flat colour here is what would make the two look
           like different objects. */
        background: linear-gradient(
            180deg,
            rgb(22, 19, 32) 0%,
            rgb(11, 10, 17) 100%
        );
        -webkit-backdrop-filter: none;
        backdrop-filter: none;
    }
}

/* Landscape tablets keep the established full-width centered board. Only the
   spacing between its five physical slots tightens enough to stay clear of the
   left deck and right portrait clusters at the 960px edge. */
@media (orientation: landscape) and (min-width: 960px) and (min-height: 561px) and (max-width: 1560px) and (max-aspect-ratio: 17/10) {
    :root {
        --board-slot-margin: clamp(3px, 0.5vmin, 7px);
    }

    /* A desktop-scaled 28vmin button consumes the tablet's entire right
       utility rail. Keep the command substantial, but narrow enough that the
       measured Audio companion can sit beside it without covering slot five.
       The board and hands remain truly centred. */
    #end-turn-btn {
        min-width: clamp(144px, 18vmin, 184px);
        min-height: 48px;
        padding-inline: clamp(18px, 2.4vmin, 28px);
    }

    #player-board .card-slot-container,
    #opponent-board .card-slot-container {
        min-width: 0;
        padding-inline: clamp(6px, 0.8vmin, 10px);
    }

    /* The tightened gutter is published as --board-slot-margin above; the base
       `#game-view .card-slot` rule consumes it, so the row's painted edge and
       everything derived from it (--board-row-left) stay in agreement. */
}

/* The reduced-motion acknowledgement for a landed Spell. Every animated FX
   primitive opts itself out under the preference, which would have left a
   targeted Spell with no feedback at all — losing information, not just
   polish. A still, fading glow over the target says "this was hit" without
   travel, shake, scatter or scale. */
/* AN ELLIPSE, AND THE GRADIENT MUST CLOSE INSIDE IT. Read this before touching
   `border-radius` or the last colour stop.

   Reported: "targetting enemy summoners creates visible rectangle for a
   second, looks unpolished". `spellStrike` sizes this element to the thing it
   hit — `max(target.width, 16vmin/…) x max(target.height, 14vmin/…)` — so its
   border box is a literal rectangle laid over the target, and anything that
   paints to that box's edge draws the target's outline in light.

   TWO separate things did. The obvious one was `box-shadow: 0 0 28px`, which
   is painted from the BORDER BOX and traced a glowing rounded rectangle around
   the portrait for the 900ms the tail runs. Removing it was not enough, and a
   captured A/B is what showed why:

   `radial-gradient(ellipse at 50% 50%, ...)` defaults to `farthest-corner`, so
   100% is the CORNER, not the edge. The box edge midpoints therefore sit at
   1/sqrt(2) = 70.7% of the gradient radius, and every stop beyond that is
   CLIPPED BY THE BOX into a straight edge. The old `transparent 78%` — and a
   first fix that only tightened it to 72% — were both still outside 70.7%, so
   the fill itself kept painting a hard-edged rectangle with or without the
   shadow. On a card that rectangle coincided with the card and passed as the
   card lighting up; on a summoner portrait it was a box.

   So: `border-radius: 50%` clips the element to the inscribed ellipse (which
   makes the box-shadow elliptical too, and it can stay — the halo is the "light
   being ADDED" this beat is for), and the last stop closes at 62%, comfortably
   inside where the clip would bite. Both are needed: the radius alone still
   leaves the shadow doing the work, and the stop alone still leaves a
   rounded-rect fill.

   62% rather than a hair under 70.71% is deliberate and measured: at 70% the
   fill still carried alpha ~0.035 three pixels inside the edge — invisible, but
   enough that a pixel oracle could not tell it from the defect (6.7 luma
   against the defect's 9-16). Closing at 62% puts a true zero at the box edge
   in both engines, which is what makes the browser contract falsifiable.
   Contract: tests/unit/styles/spellStrikeMarkShape.test.js (the geometry) +
   tests/e2e-playwright/spell-strike-beat.spec.js (the pixels). */
#fx-layer .fx-spell-mark {
    position: absolute;
    transform: translate(-50%, -50%);
    border-radius: 50%;
    pointer-events: none;
    /* Warm core against a cool board: a purple glow over an already-purple
       portrait was invisible on the recording. The gold reads as light being
       ADDED, which is what an impact is. */
    background: radial-gradient(
        ellipse at 50% 50%,
        rgba(255, 244, 214, 0.78) 0%,
        rgba(236, 198, 128, 0.5) 26%,
        rgba(168, 122, 226, 0.34) 46%,
        rgba(168, 122, 226, 0.08) 56%,
        transparent 62%
    );
    box-shadow: 0 0 28px rgba(255, 226, 168, 0.5);
    mix-blend-mode: screen;
    opacity: 0.95;
    transition: opacity 480ms linear;
}

/* ===========================================================================
   A SPELL REACHING AN ALLY — the touch.

   The strike above is hot gold and the vocabulary of a blow. Drawn on the
   caster's own unit it read as a hit ("it looks like a hit regardless"), and a
   spell on your own side usually is not one — usually: the storyteller decides
   later, and it may still wound. So the ally beat is neither the strike nor a
   heal (no green, no plus). It is MOONLIGHT: a cool silver-lilac bloom, light
   gathered in before it lands, and motes that rise off the ally instead of
   dust kicked off the floor. Positive-leaning, still open.

   Scoped under #fx-layer, which is pointer-events:none for its whole subtree,
   so none of this can ever intercept a click.
   =========================================================================== */

/* The moonlight variant of the mark. Same element, same sizing (to the target
   it lands on), so the SAME ellipse rule binds it: `border-radius: 50%` and a
   gradient that closes at 62%, inside the 70.71% where the box would clip it —
   or the summoner box comes back wearing silver. Contract:
   tests/unit/styles/spellStrikeMarkShape.test.js. */
#fx-layer .fx-spell-mark--touch {
    /* ICE-WHITE at the core, not lilac. Captured on the real board: a
       silver-lilac bloom over Moonfall's lilac-framed purple cards had
       almost no contrast and the beat read as nothing having happened. Cold
       white-blue is still moonlight, still neither the heal's green nor the
       damage's red, and it is legible on the cards it lands on. */
    background: radial-gradient(
        ellipse at 50% 50%,
        rgba(244, 250, 255, 0.92) 0%,
        rgba(204, 228, 255, 0.62) 26%,
        rgba(150, 140, 240, 0.36) 46%,
        rgba(150, 140, 240, 0.08) 56%,
        transparent 62%
    );
    box-shadow: 0 0 32px rgba(196, 220, 255, 0.55);
}

/* Light gathered IN: a disc wider than the target that closes onto it (fx.js
   drives the contraction) with a thin bright rim, so the convergence is
   something the eye can follow rather than a faint wash. */
#fx-layer .fx-touch-gather {
    position: absolute;
    margin: 0;
    transform: translate(-50%, -50%) scale(1.45);
    border-radius: 50%;
    /* A rim the eye can follow, kept faint: a crisp closing ring reads as a
       target lock, and this is light arriving, not a sight settling. */
    border: 1px solid rgba(224, 238, 255, 0.42);
    pointer-events: none;
    mix-blend-mode: screen;
    background: radial-gradient(
        circle at 50% 50%,
        rgba(244, 250, 255, 0.5) 0%,
        rgba(184, 212, 255, 0.4) 38%,
        rgba(150, 140, 240, 0.22) 58%,
        transparent 70%
    );
    box-shadow:
        0 0 16px rgba(196, 220, 255, 0.5),
        inset 0 0 20px rgba(244, 250, 255, 0.4);
    opacity: 0;
    contain: paint;
}

/* A band of light climbing through the target (fx.js drives the climb): a
   soft horizontal glint, foot to head. Elliptical and closed inside its own
   box, so it never draws a rectangle over the card. */
#fx-layer .fx-touch-sweep {
    position: absolute;
    margin: 0;
    transform: translate(-50%, -50%);
    border-radius: 50%;
    pointer-events: none;
    mix-blend-mode: screen;
    background: radial-gradient(
        ellipse at 50% 50%,
        rgba(244, 250, 255, 0.92) 0%,
        rgba(204, 228, 255, 0.55) 34%,
        rgba(150, 140, 240, 0.2) 52%,
        transparent 62%
    );
    opacity: 0;
    contain: paint;
}

#fx-layer .fx-touch-sweep.fx-overlay-fallback {
    animation: fx-touch-sweep-fallback 460ms cubic-bezier(0.2, 0.7, 0.3, 1);
}

@keyframes fx-touch-sweep-fallback {
    0% { transform: translate(-50%, -50%) scaleX(0.7); opacity: 0; }
    20% { transform: translate(-50%, -50%) scaleX(1); opacity: 0.95; }
    100% { transform: translate(-50%, calc(-50% + var(--sweep-climb, -120px))) scaleX(0.9); opacity: 0; }
}

/* Motes rising off the ally (UI.FX rise): matter given, where the strike's
   debris is matter thrown. Each mote animates from its own custom properties
   and its own delay/duration set inline, so a dozen cost no per-frame JS and
   never move in lockstep. Cool and pale, with the same dark drop under the
   glow every mote in this file carries — a pale mote over warm wood has no
   edge without it. */
#fx-layer .fx-rise {
    width: 0;
    height: 0;
}

#fx-layer .fx-rise-mote {
    position: absolute;
    width: calc(clamp(4px, 0.7vmin, 9px) * var(--s, 1));
    height: calc(clamp(4px, 0.7vmin, 9px) * var(--s, 1));
    border-radius: 50%;
    background: radial-gradient(circle, rgba(250, 253, 255, 1), rgba(184, 212, 255, 0.6));
    box-shadow:
        0 0 9px rgba(210, 232, 255, 0.9),
        0 1px 2px rgba(10, 5, 18, 0.6);
    opacity: 0;
    animation: fx-rise-mote 720ms cubic-bezier(0.2, 0.7, 0.3, 1) forwards;
}

/* Up, slowing, and out — brightest just after it leaves, gone before it
   stops, so nothing ever hangs in the air over the card's name. */
@keyframes fx-rise-mote {
    0% {
        transform: translate(0, 0) scale(0.6);
        opacity: 0;
    }

    14% {
        transform: translate(calc(var(--dx, 0px) * 0.12), calc(var(--dy, -40px) * 0.12)) scale(1);
        opacity: 1;
    }

    60% {
        transform: translate(calc(var(--dx, 0px) * 0.62), calc(var(--dy, -40px) * 0.7)) scale(0.9);
        opacity: 0.75;
    }

    100% {
        transform: translate(var(--dx, 0px), var(--dy, -40px)) scale(0.35);
        opacity: 0;
    }
}

/* No-WAAPI fallback for the gather (fx.js drives it by WAAPI elsewhere). */
#fx-layer .fx-touch-gather.fx-overlay-fallback {
    animation: fx-touch-gather-fallback 220ms cubic-bezier(0.4, 0, 0.2, 1);
}

@keyframes fx-touch-gather-fallback {
    0% { transform: translate(-50%, -50%) scale(1.45); opacity: 0; }
    30% { transform: translate(-50%, -50%) scale(1.22); opacity: 0.85; }
    100% { transform: translate(-50%, -50%) scale(0.78); opacity: 0; }
}

/* Reduced motion: fx.js draws only the still mark, but the stylesheet must
   agree — nothing here may animate under the preference. */
@media (prefers-reduced-motion: reduce) {
    #fx-layer .fx-touch-gather,
    #fx-layer .fx-touch-sweep,
    #fx-layer .fx-rise {
        display: none;
    }

    #fx-layer .fx-rise-mote {
        animation: none;
        opacity: 0;
    }
}

/* ===========================================================================
   AN UNTARGETED SPELL — the surge.

   A spell with no declared target has nothing to land on, so it gets a beat
   with no direction: it starts at the caster and crosses the whole board as an
   expanding front. Nothing recoils and nothing is singled out, because an
   untargetable spell may affect an unbounded number of things and pointing at
   one of them (the enemy summoner, as the shared resolver used to) is a
   smaller truth than pointing at none.

   Scoped under #fx-layer, which is pointer-events:none for its whole subtree,
   so none of this can ever intercept a click.
   =========================================================================== */
#fx-layer .fx-surge-gather {
    position: absolute;
    width: var(--surge-size, 120px);
    height: var(--surge-size, 120px);
    margin: 0;
    transform: translate(-50%, -50%) scale(1);
    border-radius: 50%;
    pointer-events: none;
    mix-blend-mode: screen;
    background: radial-gradient(
        circle at 50% 50%,
        rgba(255, 244, 214, 0.5) 0%,
        rgba(186, 148, 244, 0.34) 46%,
        transparent 72%
    );
    animation: fx-surge-gather 240ms cubic-bezier(0.4, 0, 0.2, 1) forwards;
}

/* The front itself: a thin ring that grows to the far corner and thins as it
   goes, so it reads as one wave passing rather than a shape appearing. */
#fx-layer .fx-surge-front {
    position: absolute;
    width: 0;
    height: 0;
    margin: 0;
    transform: translate(-50%, -50%);
    border-radius: 50%;
    pointer-events: none;
    mix-blend-mode: screen;
    border: 2px solid rgba(226, 208, 255, 0.62);
    box-shadow:
        0 0 22px rgba(186, 148, 244, 0.42),
        inset 0 0 18px rgba(255, 244, 214, 0.3);
    opacity: 0;
    animation: fx-surge-front 620ms cubic-bezier(0.16, 0.8, 0.3, 1) forwards;
}

/* Follow-through at the origin, outliving the fronts so the beat settles. */
#fx-layer .fx-surge-mark {
    position: absolute;
    width: 12vmin;
    height: 12vmin;
    margin: 0;
    transform: translate(-50%, -50%);
    border-radius: 50%;
    pointer-events: none;
    mix-blend-mode: screen;
    background: radial-gradient(
        circle at 50% 50%,
        rgba(255, 244, 214, 0.6) 0%,
        rgba(186, 148, 244, 0.32) 44%,
        transparent 74%
    );
    opacity: 0.9;
    transition: opacity 620ms linear;
    animation: fx-surge-mark 720ms ease-out forwards;
}

@keyframes fx-surge-gather {
    0% { transform: translate(-50%, -50%) scale(0.35); opacity: 0; }
    55% { transform: translate(-50%, -50%) scale(1.05); opacity: 1; }
    100% { transform: translate(-50%, -50%) scale(0.6); opacity: 0; }
}

@keyframes fx-surge-front {
    0% { width: 0; height: 0; opacity: 0; border-width: 3px; }
    12% { opacity: 0.95; }
    100% {
        width: calc(var(--surge-reach, 600px) * 2);
        height: calc(var(--surge-reach, 600px) * 2);
        opacity: 0;
        border-width: 1px;
    }
}

@keyframes fx-surge-mark {
    0% { transform: translate(-50%, -50%) scale(0.5); opacity: 0; }
    28% { transform: translate(-50%, -50%) scale(1); opacity: 0.9; }
    100% { transform: translate(-50%, -50%) scale(1.25); opacity: 0; }
}

/* Reduced motion: the still bloom only. spellSurge() draws just the mark, and
   the animation is dropped so it appears, holds and fades. */
@media (prefers-reduced-motion: reduce) {
    #fx-layer .fx-surge-gather,
    #fx-layer .fx-surge-front {
        display: none;
    }

    #fx-layer .fx-surge-mark {
        animation: none;
        opacity: 0.9;
    }
}

