    /*body {*/
    /*  min-height: calc(100% + env(safe-area-inset-top));*/
    /*  padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);*/
    /*}*/

    body {
      /* Extend content to edges */
      padding-top: env(safe-area-inset-top);
      padding-bottom: env(safe-area-inset-bottom);
    }

    body {
      margin: 0;
      padding: 0;
    }

/* Force initial scroll position */
/*section[class*="bg-[url"] {*/
/*  margin-top: calc(-1 * env(safe-area-inset-top, 0px));*/
/*  padding-top: env(safe-area-inset-top, 0px);*/
/*  position: relative;*/
/*  top: 0;*/
/*}*/



/*html {*/
/*  overflow-y: scroll;*/
/*  -webkit-overflow-scrolling: touch;*/
/*}*/

/*body {*/
/*  position: relative;*/
/*  min-height: 100vh;*/
/*  min-height: -webkit-fill-available;*/
/*}*/





    button {
        touch-action: manipulation;
    }

/* The two view toggles: focus mode and show-bought-cards.

   The top strip on a phone is up to eight things in a row - home, these two,
   four player chips and the user menu - and it used to read as eight circles,
   with these two the loudest of the lot: solid amber, brighter than anything
   else on the board. That is upside down. They are toggles you touch
   occasionally, and amber is the colour the game uses for its own actions
   (Continue, Confirm move), so they were competing with the moves.

   The strip has three kinds of thing in it, and shape now says which is which:

     circles    identity and navigation - the home logo, your avatar
     squircles  view controls - these two, a matched pair either side
     chips      game state, not controls at all

   Material is the avatar's, so the controls read as chrome rather than as
   another player: translucent black over a blur, faint white edge. The icons
   carry the amber instead of the fill, via currentColor - which is why the
   cards button's three bars are bg-current and the focus icon is
   stroke="currentColor".

   No display here on purpose. #focusButton's display is switched by ID
   (none by default, flex below 768px) and an ID beats a class, but leaving it
   out entirely means this cannot fight that rule however it is reused. */
.view-toggle {
    width: 2.5rem;
    height: 2.5rem;
    border-radius: 0.75rem;
    /* ⚠️ NO backdrop-filter, on purpose - 2026-08-28.
       ------------------------------------------------------------------------
       Andy's PWA loses fixed positioning entirely after the app is switched away
       from and back: the bank HUD and this top strip stop staying put and scroll
       with the page. It survives a reload and affects every game, so it is web
       view state, and only killing the app clears it.

       Every fixed element that misbehaves carried backdrop-filter, and that
       property forces each onto its own compositing layer - exactly the
       machinery iOS has to tear down and rebuild when it snapshots a
       backgrounded PWA and restores it. This file already carries a scar from
       the same property: see the warning above the mobile #sidebar-rail block.

       Four attempts to COMPENSATE for the broken frame all failed. This removes
       a suspected cause instead. Backgrounds were raised to carry the contrast
       the blur used to: 0.4 -> 0.78 here, 0.6 -> 0.94 on #bankArticle - a control surface reads better nearly opaque, and it also helps the contrast finding in the HIG audit.

       ⚠️ It also explains why the readout never caught it: its `containing`
       check walks ANCESTORS for a containing block, and backdrop-filter on the
       element ITSELF does not create one for itself. It reported "-" every time
       while sitting on top of the suspect. */
    background: rgba(0, 0, 0, 0.78);
    border: 1px solid rgba(255, 255, 255, 0.3);
    color: var(--accent, #fbbf24);
    align-items: center;
    justify-content: center;
    box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35);
    transition: background 0.15s ease, border-color 0.15s ease;
}

/* Gated, because a touch browser reports hover: none and would otherwise keep
   the hovered fill stuck on after a tap. :active follows :hover deliberately -
   same specificity, so source order decides which wins while both match. */
@media (hover: hover) {
    .view-toggle:hover {
        background: rgba(0, 0, 0, 0.55);
        border-color: rgba(255, 255, 255, 0.45);
    }
}
.view-toggle:active {
    background: rgba(0, 0, 0, 0.65);
    border-color: rgba(255, 255, 255, 0.5);
}

/* No transform here: toggleCardsPanel adds Tailwind's scale-110 for 150ms as
   a tap acknowledgement, and a transform in this rule would fight it. */


/* Player stat rows in sidebar */
.player-row {
    padding: 0.55rem 0;
    border-bottom: 1px solid rgba(255,255,255,0.06);
}
.player-row:last-child { border-bottom: none; }
.player-row.is-current-mover {
    background: rgba(255, 255, 255, 0.10);
    border-radius: 6px;
    border-bottom-color: transparent;
    /* The side padding insets the fill from the row's text, but on its own it
       also pushed this row's content 6.4px inwards, so the highlighted player's
       name, labels and chips sat out of line with everyone else's. Andy,
       2026-08-29.

       An equal NEGATIVE MARGIN cancels that: the box grows outwards into
       #sidebar-players' own 1rem gutter instead of the content moving inwards,
       so every row keeps the same content x whoever is moving. There is room -
       6.4px of padding plus the 1px outline and its 1px offset is 8.4px into a
       16px gutter.

       The outline itself never mattered here: outlines are painted outside the
       box and take no layout space. */
    padding-left: 0.4rem;
    padding-right: 0.4rem;
    margin-left: -0.4rem;
    margin-right: -0.4rem;
    outline: 1px solid rgba(255, 255, 255, 0.2);
    outline-offset: 1px;
}

/* Three columns, and the outer two are strictly equal - which is the only way
   the totals strip lands in the SAME place in every row. As a flex row with the
   name growing, "centred" would drift with each player's name length and the
   strip would go ragged down the list.

   minmax(0, 1fr), not 1fr: a bare 1fr floors at its content width, so a long
   name would widen its column, push the middle across and break the centring.
   With a 0 floor the name ellipsises instead and the centre holds. */
.player-row-header {
    display: grid;
    grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
    align-items: center;
    gap: 0.4rem;
    margin-bottom: 0.25rem;
}
.player-row-identity {
    display: flex;
    align-items: center;
    gap: 0.4rem;
    min-width: 0;
}
.player-row-badges {
    display: flex;
    align-items: center;
    gap: 0.4rem;
    justify-self: end;
}
.player-row-dot {
    width: 8px;
    height: 8px;
    border-radius: 50%;
    flex-shrink: 0;
}
.player-row-name {
    flex: 1;
    /* All three are load-bearing now that the header carries a totals strip.
       A flex item's default min-width is auto, so without min-width:0 a long
       name refuses to shrink below its text and pushes the totals out of the
       header instead of yielding to them. Same trap as the event panel's
       summary line. */
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    font-size: 0.65rem;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.06em;
    color: rgba(255,255,255,0.85);
}

.player-row-points-badge,
.player-row-prestige-badge {
    font-size: 0.65rem;
    font-weight: 700;
    padding: 0 5px;
    height: 16px;
    display: inline-flex;
    align-items: center;
    border-radius: 4px;
    color: #fff;
    border: 1px solid rgba(0,0,0,0.2);
}
.player-row-points-badge   { background-color: rgb(245 158 11); }  /* amber-500 */
.player-row-prestige-badge { background-color: rgb(124 58 237); }  /* violet-600 */

/* has-prestige-bonus only ever lands on .player-icon-chip - the page sets it
   there and nowhere else. Two .player-row rules used to sit here and could
   never match; removed 2026-08-28. */
.player-icon-chip.has-prestige-bonus {
    outline: 2px solid rgb(124, 58, 237);
    outline-offset: 2px;
}
/* Per-category totals, in the header row: cards + coins, i.e. what the player
   can actually put towards a card of that category. The Cards and Coins rows
   below are the breakdown; this is the number that decides a move.

   The strip is centred in the header, which is what the grid above is for.
   On MOBILE that puts it exactly above the columns: the rail is the full width
   of the phone, the stat rows have a label and a trailing spacer of equal
   width, and both centres land on 195 at 390px.

   On DESKTOP it sits about 24px left of them, and that is inherent to the
   existing layout rather than anything this strip does: the min-width:768px
   block below deliberately hides the stat rows' trailing spacer and narrows
   the label to 3rem, so the cells centre in what is left AFTER the label
   (899) while the header centres on itself (875). Making them agree would
   mean giving the header a 3rem leading column, which is not enough for a
   player's name.

   The cells are 18px, which is what two digits need - a full wallet is
   6 + players, so a real total reaches the mid teens. In the 299px sidebar
   that leaves a plain "Andrew W" fitting its column exactly, with longer names
   ellipsising.

   18px holds two digits, which it has to: a full wallet is 6 + players, so a
   category total reaches the mid teens late in a game.

   Zero is dimmed rather than dropped, so the five slots never move - the same
   reason the rows below print an em dash and a bullet instead of blanking. */
.player-row-totals {
    display: flex;
    /* 2px, not 3. The strip sits in the centre column of the header's grid, so
       every pixel it takes comes out of BOTH outer columns - and in the 299px
       desktop sidebar the identity column is the tight one. At 3px gaps a
       plain "Andrew W" was 2px short and ellipsised; at 2px it fits. Longer
       names still truncate, which is the intended behaviour. */
    gap: 2px;
    flex-shrink: 0;
    /* No margin needed: the header's grid puts a whole flexible column between
       this strip and the badges, which is what keeps the five totals from
       reading as one run of seven chips with the two badges. */
}
.player-total-cell {
    width: 18px;
    height: 18px;
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: 0.6rem;
    font-weight: 700;
    border-radius: 4px;
    flex-shrink: 0;
}
.player-total-cell.is-zero { opacity: 0.35; }

.player-row-stat-line {
    display: flex;
    align-items: center;
    margin-bottom: 0.2rem;
}
.player-stat-label {
    font-size: 0.6rem;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.05em;
    color: rgba(255,255,255,0.62);
    width: 4rem;
    /* Left-aligned everywhere, at the containing panel's content edge. The one
       exception is the player rows below, which step in to meet the name.
       Never set a WIDTH per context - see the note there. */
    text-align: left;
    flex-shrink: 0;
}

/* Cards / Credits start where the player NAME starts, not where the coloured
   dot does. The label box already begins at the dot's x, so the indent is
   exactly the dot's width plus the identity row's gap. Andy, 2026-08-29.

   The BOX WIDTH must not change. .stat-cells is flex:1 and centres the chips
   in whatever is left after this label, so widening it moves the whole chip
   column and breaks the Bank / Yours / Cards / Credits alignment that
   skirmish-hud-column-alignment exists to protect. Only the text moves inside
   the box - measured worst case "CREDITS" is 43px in the 49.6px that remains.

   Scoped to the player rows: #bankArticle's Bank and Yours labels have no name
   to line up with, so they keep their right edge. */
#sidebar-players .player-stat-label {
    padding-left: calc(8px + 0.4rem);
}
.player-stat-cell {
    width: 2rem;
    height: 2rem;
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: 0.75rem;
    font-weight: 600;
    border-radius: 4px;
    flex-shrink: 0;
}
.player-stat-coin { border-radius: 50%; }

.player-icon-chip.is-current-mover {
    box-shadow: 0 0 0 2.5px rgba(255,255,255,0.9), 0 0 8px 3px rgba(255,255,255,0.3);
    transform: scale(1.15);
}

/* The board's level headings, in the home page's section vocabulary.

   They were text-2xl bold over an indigo underline, which is a different
   language from everything else on the page that has been rebuilt. The shared
   stylesheet's .section-title is the pattern: amber mark, tracked uppercase
   label, and a rule running out to the edge of the section it opens. The h2s
   now carry that class, and this adds the one thing home's headings do not
   have - a trailing count.

   order: 1 is the trick, and it is not cosmetic. The rule is .section-title's
   ::after, and a pseudo-element comes AFTER the element's real children, so
   the count would otherwise sit between the label and the rule and the rule
   would run off the end of the row. Pushing the count past it gives
   mark - label - rule - count, with the rule taking the slack. Everything else
   stays at order 0 and keeps its box order.

   The count is a quieter version of the same label: uppercase like its
   heading, but smaller, dimmer and less tracked, so it reads as an annotation
   to the heading rather than a second heading. flex: none so the rule keeps
   all the give. */
.level-title .level-remaining {
    order: 1;
    flex: none;
    font-size: 0.75rem;
    font-weight: 600;
    letter-spacing: 0.08em;

    /* SOLID, and warm. It was rgba(255,255,255,0.6), and a translucent colour
       over the seasonal artwork does not read as "dim white" - it takes on
       whatever is behind it, which is precisely why Andy saw dull grey. The
       board has no scrim at all: .page-bg is the artwork, fixed and full
       bleed, so the ground under this line changes as the page scrolls.

       Warm off-white rather than a slate grey, for the reason established on
       the marketing hero: a cool grey against warm artwork reads as dirty
       rather than quiet. Hierarchy against the heading comes from size and
       weight now, not from transparency.

       Its own shadow, tighter and doubled, rather than the softer one
       .section-title passes down. Measured over logo_4 (Summer Siege, the
       gold-lit castle and the season live in August) it takes the local ground
       from mean 0.105 to 0.082. */
    color: #efe3cf;
    text-shadow: 0 1px 2px rgba(2, 6, 23, 0.95),
                 0 0 6px rgba(2, 6, 23, 0.85);
}

/* ============================================================
   THE CARD  (Tailwind removal, Task 6 part 2)
   ============================================================

   The card markup exists in FOUR places that must agree: the Card partial,
   the won-cards macro in the game view, and createCardElement and
   addPlayerCard in the game JS. Semantic classes are what make that bearable -
   there is now one token per element instead of a dozen to keep in step.

   Values are the Tailwind ones they replace, not re-designed. The conversion
   is checked by capturing every element's computed style with the CDN loaded
   and again without it; anything that moved would be a regression rather than
   a judgement call. */

.gamecard {
    width: 100%;
    position: relative;
    padding: 0.5rem;                      /* p-2 */
    border-width: 2px;                    /* border-2; colour from cat-border-* */
    border-style: solid;
    border-radius: 0.5rem;                /* rounded-lg */
    box-shadow: 0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -4px rgba(0,0,0,0.1);
    background-repeat: no-repeat;
    background-size: contain;
    background-position: top;
}

/* The two corner badges. Colour comes from .lbl-l*. */
.card-badge {
    position: absolute;
    top: 0.5rem;
    z-index: 10;
    border-width: 1px;
    border-style: solid;
    border-radius: 0.5rem;
    padding: 0.125rem 0.625rem;           /* py-0.5 px-2.5 */
    font-size: 1.5rem;                    /* text-2xl */
    line-height: 2rem;
    font-weight: 600;
}
.card-badge-points   { left: 0.5rem; }
.card-badge-prestige { right: 0.5rem; }

.card-headline {
    display: flex;
    align-items: center;
    gap: 0.75rem;
    padding-top: 3rem;                    /* pt-12 - clears the corner badges */
    padding-bottom: 0.5rem;
}

.card-avatar {
    width: 5rem;
    height: 5rem;
    flex-shrink: 0;
    align-self: center;
    border-width: 2px;
    border-style: solid;
    border-radius: 9999px;
    box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -2px rgba(0,0,0,0.1);
    cursor: pointer;
    transition: transform 150ms cubic-bezier(0.4, 0, 0.2, 1);
}

/* A location's portrait is a rounded square, not a circle.
   ------------------------------------------------------------------------
   Andy, 2026-08-28: with locations now wearing the same card shape as the
   characters, the round portrait made them read as another character. A circle
   is a face; a rounded square is a place.

   0.5rem matches .gamecard's own corner, so the portrait echoes the card it
   sits on instead of introducing a third radius.

   A modifier, and it must stay AFTER .card-avatar in this file: the two have
   equal specificity, so source order is what decides. Everything else about the
   avatar - the category ring, the shadow, the hover scale, the 768-900px size
   ramp - is inherited untouched, which is the whole point of changing only the
   radius. */
.card-avatar-location { border-radius: 0.5rem; }

/* The my-cards view's own title, one step above the Level headings it sits over.
   .section-title is 1rem, so reusing it would have made this read as a fourth
   sibling rather than their parent; .page-title is 36px and belongs to pages,
   not to a panel inside the game. This sits between them. */
.view-title {
    font-size: 1.25rem;
    font-weight: 700;
    margin-bottom: 0.75rem;
    text-shadow: 0 1px 4px rgba(2, 6, 23, 0.9);
}
@media (min-width: 768px) {
    .view-title { font-size: 1.5rem; }
}

/* Shown only while nothing has been won. An empty screen is an invitation to
   act, not a blank panel. */
.won-empty {
    /* On the same dark pill as the board's status line (Andy, 2026-10-16): as
       70%-white small text straight on the season art it was hard to read. No
       backdrop-filter, for the same reason as that pill. */
    width: fit-content;
    max-width: 100%;
    box-sizing: border-box;
    margin: 0 0 1rem;
    padding: 0.375rem 0.875rem;
    border-radius: 1rem;
    background: rgba(15, 23, 42, 0.9);
    border: 1px solid rgba(255, 255, 255, 0.18);
    color: #ffffff;
    font-size: 0.9375rem;
    font-weight: 600;
}

.card-title {
    flex: 1 1 0%;
    padding: 0 0.5rem 0.25rem;            /* px-2 pb-1 */
    margin-bottom: 1rem;
    border-bottom-width: 1px;
    border-bottom-style: solid;
    font-size: 1.5rem;
    font-weight: 700;
    line-height: 1.375;                   /* leading-snug wins over text-2xl's */
    overflow-wrap: break-word;
}

.card-actions {
    display: flex;
    flex-direction: column;
    align-items: center;
}
.card-take-row {
    display: flex;
    margin-top: 1rem;
    margin-bottom: 0.5rem;
}

.takebutton {
    display: inline-flex;
    align-items: center;
    padding: 0.5rem 1.5rem;               /* py-2 px-6 */
    border-radius: 0.5rem;
    font-weight: 600;
    text-align: center;
    color: #ffffff;
    box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -2px rgba(0,0,0,0.1);
    transition: all 150ms cubic-bezier(0.4, 0, 0.2, 1);
}
.takebutton:focus { outline: 2px solid transparent; outline-offset: 2px; }
.takebutton:active { transform: scale(0.95); box-shadow: 0 1px 2px 0 rgba(0,0,0,0.05); }

.card-costs {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 0.5rem;
    padding-bottom: 0.5rem;
}
.card-cost {
    display: flex;
    align-items: center;
    justify-content: center;
    width: 2rem;
    height: 2rem;
    border-width: 1px;
    border-style: solid;
    border-radius: 9999px;
    font-size: 0.875rem;
    line-height: 1.25rem;
    font-weight: 500;
}

@media (hover: hover) {
    .card-avatar:hover { transform: scale(1.05); }
    .takebutton:hover  { box-shadow: 0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -4px rgba(0,0,0,0.1); }
}

@media (min-width: 768px) {
    .card-title    { font-size: 1.25rem; font-weight: 600; }
    .card-take-row { margin-top: 1.5rem; }
}

/* Sidebar players collapse toggle */
#players-list-header {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 0.75rem;
    margin-bottom: 0.5rem;
    padding: 0.25rem 0;
    cursor: pointer;
    color: rgba(255,255,255,0.5);
    transition: color 0.15s;
    user-select: none;
}
#players-list-header:hover { color: rgba(255,255,255,0.8); }
#players-list-header svg { transition: transform 0.2s ease; flex-shrink: 0; }
#players-list-header::after { content: ''; width: 12px; flex-shrink: 0; }
#sidebar-players.collapsed #players-list-header svg { transform: rotate(-90deg); }
#sidebar-players.collapsed .player-row:not(.is-current-player) { display: none; }

.player-icons {
    display: flex;
    flex-direction: row;
    flex-wrap: wrap;
    justify-content: center;
    gap: 10px;
}

.player-icon-chip {
    width: 30px;
    height: 30px;
    border-radius: 50%;
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: 0.72rem;
    font-weight: 700;
    color: white;
    text-shadow: 0 1px 2px rgba(0,0,0,0.5);
    box-shadow: inset 0 2px 6px rgba(0,0,0,0.35), 0 2px 4px rgba(0,0,0,0.15);
    border: 1.5px solid rgba(0,0,0,0.15);
}

/* Desktop-only Confirm move CTA in the bank HUD.
   Deliberately NOT "hidden md:block": the shared stylesheet declares
   .hidden { display: none !important } and Tailwind's md:block carries no
   !important, so that pairing leaves the button invisible at every width.
   Revealed in the min-width:768px block below. */
.skirmish-desktop-cta { display: none; }

/* Sidebar locations section */
#locations-list-header {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 0.5rem;
    padding: 0.4rem 0;
    /* 44pt minimum - HIG audit finding 1. This header IS the collapse control,
       and it was 40.8px. align-items: center was already here, so the
       extra height distributes either side of the row rather than shifting it.

       Applied at every width, unlike the HUD's Confirm move: this is the same
       element in both layouts - a bar across the top on a phone, a column header
       in the sidebar above 768px - with no separate desktop control to leave
       alone. */
    min-height: 44px;
    cursor: pointer;
    color: rgba(255,255,255,0.5);
    transition: color 0.15s;
    user-select: none;
}
#locations-list-header:hover { color: rgba(255,255,255,0.8); }
#locations-list-header svg { transition: transform 0.2s ease; flex-shrink: 0; }
#locations-list-header::after { content: ''; width: 12px; flex-shrink: 0; }
#sidebar-locations.collapsed #locations-list-header svg { transform: rotate(-90deg); }
#sidebar-locations.collapsed #locations-list { display: none; }

.location-icons {
    display: flex;
    flex-direction: row;
    flex-wrap: wrap;
    justify-content: center;
    gap: 6px;
}

.location-sidebar-row {
    position: relative;
    overflow: hidden;
    padding: 0.4rem 0.5rem;
    border-bottom: 1px solid rgba(255,255,255,0.06);
}
.location-sidebar-row:last-child { border-bottom: none; }

/* Mobile: sidebar-rail spans full width at top of page.

   Two things must NOT come back here:

   1. backdrop-filter on #sidebar-rail itself - it creates a containing block
      that traps #bankArticle's position:fixed to the sidebar.
   2. backdrop-filter on the panels INSIDE it (players/locations/events), which
      is where the blur used to live to dodge (1). Each one still made its own
      compositing layer inside a position:sticky parent, which is the suspect in
      the detached-HUD bug: after an app-switch, iOS lays fixed and sticky
      elements out as if static. The opaque backgrounds below replace it. */
@media (max-width: 767px) {
    #skirmish-content {
        padding-top: 0;
        width: 100%;
    }
    /* display:contents dissolves the rail's own box so that its two children -
       #sidebar-top and #bankArticle - become rows of #skirmish-content's column
       alongside the board, which is their SIBLING in the DOM rather than their
       parent. Without this the HUD could not sit below the board without moving
       it in the markup, and moving it would break the >=768px layout, where the
       HUD genuinely does belong inside the fixed 300px rail.

       Safe here because the mobile rail paints nothing of its own: it carried
       position/z-index/flex-shrink and an inherited colour, and inheritance
       still flows through a display:contents element. The >=768px block gives
       it its box back. */
    #sidebar-rail {
        display: contents;
        color: white;
    }

    #skirmish-content {
        flex: 1;
        min-height: 0;
        display: flex;
        flex-direction: column;
    }

    #sidebar-top { order: 1; }
    .board-main  { order: 2; }
    #bankArticle { order: 3; flex: none;
                   /* Applied unconditionally, not only while the reserve row is
                      open as the old JS did - HIG audit finding 2. */
                   padding-bottom: max(0.5rem, env(safe-area-inset-bottom, 0px)); }

    /* The board is the scroller. overscroll-behavior stops a drag that runs off
       its end from rubber-banding the shell itself. */
    .board-main {
        flex: 1 1 0;
        min-height: 0;
        overflow-y: auto;
        overscroll-behavior-y: contain;
        -webkit-overflow-scrolling: touch;
        /* No margin override here - .board-main's own rule is ~850 lines further
           down this file and would win on source order anyway. Its 2rem top and
           bottom are the spacing in both layouts now. */
    }

    /* The rail scrolls as ONE unit, the same as the >=768px column does.
       Without this there was no scroll container below 768px at all - every
       scroll rule lived in the desktop block - so an expanded Locations or
       Game events list was just 1200px of page content and any drag, wheel or
       finger, scrolled the board instead of the panel.

       max-height, not height: a rail with everything collapsed must still take
       only the room its three headers need.

       One scroller rather than a cap on each list. The panels are stacked, so
       per-list caps would put three competing scroll areas on a phone screen,
       and a drag would land in whichever one the finger happened to be over.

       dvh, not vh: vh is the LARGE viewport height - the height with the URL
       bar hidden - so a vh cap lets the bottom of the rail sit under the bar.
       Same trap as the desktop rail height in fc29d51. vh first as the fallback
       for anything without dvh.

       The cap is the bank HUD, not a fraction of the viewport. It was 60dvh to
       begin with, an arbitrary figure: an expanded panel stopped ~220px short of
       the HUD on a 780px-tall phone and showed a dead band of board through the
       middle, where the >=768px rail runs the whole way down to the HUD. On
       mobile the HUD was position:fixed at the bottom then, so the room the
       rail could take was the viewport less the HUD's height - a height that is
       not constant, since the reserve row opens and closes with the turn. The
       game JS therefore measured #bankArticle and published --bank-hud-height.

       ALL OF THAT IS GONE (2026-08-29). The HUD is a row of the app shell's
       column, so the board takes whatever the rail does not and the two cannot
       disagree. The cap above is simply a share of the column.

       overscroll-behavior-y: contain keeps the gesture here. Touch browsers
       decide at touchstart which scroller owns a drag, so without it a drag
       beginning with the panel already at an end hands the whole gesture to the
       document - which is the bug this fixes, in a subtler form. */
    #sidebar-top {
        /* NO max-height. An expanded panel runs as far as the top of the bank
           HUD if it has the entries to fill it - Andy, 2026-08-29, and it is
           what the pre-shell layout did via calc(100dvh - the HUD's height).

           flex: 0 1 auto is what delivers that, with the board on flex: 1 1 0
           beside it. Short content: this takes its content height and the board
           grows into the rest. Tall content: the shrink lands entirely here
           (the board's basis is 0, so it has nothing to give), this stops at
           the HUD and scrolls internally, and the board is squeezed out.

           A 60dvh cap sat here briefly to stop the board being squeezed to
           nothing. That was the wrong trade: the panels are collapsible, so
           giving one the whole column is a thing the player asked for and can
           undo, whereas a cap makes a long list unreadable with board showing
           below it that nobody was looking at. */
        flex: 0 1 auto;
        overflow-y: auto;
        overscroll-behavior-y: contain;
        -webkit-overflow-scrolling: touch;
    }
    /* The players header shares the top band with four position:fixed buttons -
       home and focus on the left, cards and the user menu on the right - and it
       centres its group across the WHOLE width, so nothing in the layout stops
       the two occupying the same pixels. They just happened to clear each other
       until a fourth chip made the row wide enough to reach.

       Measured at 390px with 4 players: the focus button covered chip 4 by
       33x35px (Andy, on his iPhone), and the cards button was already clipping
       chip 4 at 375px and by 29px at 320px - that one predates focus mode.

       So the header reserves both lanes and centres what is left. Each lane is
       two 2.5rem buttons on a 0.5rem rhythm, i.e. 6.5rem from the edge; the
       panel already supplies 1rem of that, so 5.5rem here. Chips wrap to a
       second row when the remainder genuinely cannot take them, which is
       correct behaviour and cannot collide with anything.

       The ::after spacer goes on mobile as well now. It exists only to balance
       the chevron so .player-icons lands dead centre, and it costs 24px of the
       very width the lanes just took - 4 chips do not fit at 390px with it
       there. The chips end up about 10px right of true centre, which is the
       cheaper of the two prices. The >=768px block at the end of this file
       drops it for its own reason, so the two now agree.

       runner-top-strip.html + top-strip-widths.py hold this: no chip and no
       chevron may overlap any button, at 320/375/390/393/430 and 2-4 players. */
    #players-list-header {
        padding-left: 5.5rem;
        padding-right: 5.5rem;
        gap: 0.5rem;
    }
    #players-list-header::after { content: none; }
    /* The 3px is the current mover's overflow, not decoration. That chip is
       transform: scale(1.15) - 34.5px rendered from a 30px box - and a
       transform does not affect layout, so the reservation above cannot see
       it. Without this the last chip bled 1px into the cards button's lane at
       390px with 4 players: the one width where the row fits on a single line
       with almost nothing to spare. 2.25px each side, rounded up. */
    .player-icons { gap: 8px; padding: 0 3px; }

    /* Spread the header totals onto the SAME PITCH as the columns below, so
       each total sits exactly over the category it belongs to.

       Mobile only, and it works here because two things are true only here:
       the stat rows' label and trailing spacer are the same width (4rem each),
       so the columns centre on the header's own centre; and the header's grid
       centres the strip on that same axis. Two rows sharing a centre line and
       a pitch align at every position - no per-cell arithmetic needed.

       The pitch is the column cell (2rem) plus its gap (0.5rem). Written as
       that minus the total's own width, so the dependency is visible: change
       the cells below and this must follow.

       The >=768px layout deliberately does NOT do this. There the trailing
       spacer is hidden and the label is 3rem, so the columns do not centre on
       the header at all - see the comment on .player-row-totals. */
    .player-row-totals { gap: calc(2.5rem - 18px); }

    #sidebar-players {
        padding: max(1rem, env(safe-area-inset-top, 0px)) 1rem 0;
        background: rgba(0,0,0,0.94);
        border-bottom: 1px solid rgba(255,255,255,0.1);
    }
    #sidebar-locations,
    #sidebar-events {
        padding: 0 1rem;
        background: rgba(0,0,0,0.94);
        border-top: 1px solid rgba(255,255,255,0.1);
    }
}

/* Desktop: fixed right sidebar; body scrolls naturally */
@media (min-width: 768px) {
    /* Reserve space on the right for the fixed sidebar */
    #skirmish-content {
        padding-top: 0;
        padding-right: 300px;
        width: 100%;
    }

    /* Main content: normal flow, body scrolls */
    #skirmish-content > main {
        margin-bottom: 2rem;
        padding-top: max(1rem, env(safe-area-inset-top, 0px));
    }

    /* Sidebar: fixed to viewport right edge */
    #sidebar-rail {
        position: fixed;
        right: 0;
        top: 0;
        /* dvh, not vh: on a mobile browser vh is the LARGE viewport height - the
           height with the URL bar hidden - so a vh-sized rail runs under the bar
           and the bank HUD at its bottom cannot be reached. vh first as the
           fallback for anything without dvh. */
        height: 100vh;
        height: 100dvh;
        width: 300px;
        display: flex;
        flex-direction: column;
        background: rgba(0,0,0,0.94);
        border-left: 1px solid rgba(255,255,255,0.1);
        color: white;
        z-index: 30;
    }

    /* Top portion of sidebar: players + locations, scrolls as one unit */
    #sidebar-top {
        flex: 1;
        min-height: 0;
        overflow-y: auto;
        display: flex;
        flex-direction: column;
        /* Keep a finger drag in the sidebar. Without this the scroll chains to
           the page behind: touch browsers decide at touchstart which scroller
           owns the gesture, so a drag that starts with the sidebar already at
           an end hands the whole gesture to the body and the main page moves
           instead of the locations list. */
        overscroll-behavior-y: contain;
        -webkit-overflow-scrolling: touch;
    }

    /* Players list: natural height, does not expand to fill */
    #sidebar-players {
        flex-shrink: 0;
        padding: max(1rem, env(safe-area-inset-top, 0px)) 1rem 0.5rem;
    }

    /* Locations: sits directly below players, scrolls within sidebar-top.
       Events sits below Locations on the same terms. */
    #sidebar-locations,
    #sidebar-events {
        flex-shrink: 0;
        padding: 0 1rem;
        border-top: 1px solid rgba(255,255,255,0.1);
    }
    #locations-list {
        overflow-y: auto;
        padding-bottom: 0.25rem;
    }

    /* Stat row spacers only align with card cost badges on mobile — hide on desktop */
    #sidebar-players .player-row-stat-line > div:last-child {
        display: none;
    }

    /* Align all row labels (Cards/Coins/Hand/Bank) to same width */
    #sidebar-players .player-stat-label, #bankArticle .player-stat-label {
        width: 3rem;
        flex-shrink: 0;
    }

    /* Andy, 2026-08-29: left-aligned here too, but against the panel's own
       content edge rather than the player name - at 3rem there is no room for
       the name's 14.4px indent ("CREDITS" measures 43px and only 34px would
       remain, and a font wider than that measurement would spill into the
       chips). padding-left:0 still reads as a proper margin: the row is inset
       17px from the rail's edge, and every label then starts on that one line
       down the panel.

       Bank and Yours follow the same line: at >=768px #bankArticle is INSIDE
       the rail, directly below the player rows, so all four labels share one
       column and a right-aligned pair among them read as a mistake. They keep
       their right edge on mobile, where the bank is a separate panel at the
       opposite end of the screen.

       Still nothing here changes the label's WIDTH - see the note on the base
       rule about why .stat-cells makes that load-bearing. */
    #sidebar-players .player-stat-label {
        padding-left: 0;
    }

    .skirmish-desktop-cta { display: block; }

    /* Bank panel: static flex item at bottom of sidebar */
    #bankArticle {
        position: static !important;
        width: 100% !important;
        left: auto !important;
        bottom: auto !important;
        flex-shrink: 0;
        background: transparent !important;
        backdrop-filter: none !important;
        box-shadow: none !important;
        border-top: 1px solid rgba(255,255,255,0.1) !important;
        border-left: none !important;
        border-right: none !important;
        border-bottom: none !important;
        border-radius: 0 !important;
        padding-bottom: max(0.5rem, env(safe-area-inset-bottom, 0px));
    }
}

/* Pull-to-refresh indicator (mobile only) */
#pull-refresh-indicator {
    position: fixed;
    top: 0; left: 0; right: 0;
    height: 0;
    overflow: hidden;
    /* Opaque rather than blurred, for the same reason as #bankArticle and the
       overlays: no backdrop-filter on a position:fixed element. */
    background: rgba(0,0,0,0.94);
    z-index: 9999;
    pointer-events: none;
}
.pull-refresh-inner {
    position: absolute;
    bottom: 0; left: 0; right: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 8px;
    padding: 10px;
    color: rgba(255,255,255,0.8);
    font-size: 0.7rem;
    font-weight: 600;
    letter-spacing: 0.06em;
    text-transform: uppercase;
}
#pull-refresh-indicator svg {
    transition: transform 0.2s;
    flex-shrink: 0;
}
#pull-refresh-indicator.ready svg {
    transform: rotate(180deg);
}

/* Skirmish credit HUD */

/* A gained chip - one a credit has just landed in - inverts: the light pastel
   fill becomes slate-900 and the category hue carries the digit, border and
   ring. Across all five pastels that is a luminance flip rather than a shade,
   which a 95%-white ring was not. It is also a different axis from .is-blocked
   below, so the two states cannot be mistaken for one another.

   Inset, not outset. An outer box-shadow occupies no layout space, so it
   always bleeds toward the neighbouring row and reads as an overlap however
   far apart the rows are pushed. Drawing the ring inside the chip keeps the
   indicator strictly within its own 2rem box.

   Two classes beats the single-class Tailwind utilities the chips carry, so
   the inversion wins wherever the CDN injects its style element. */
.credit-chip.is-gained,
#bank-row button.is-gained {
    background-color: #0f172a;
    color: var(--chip-hue);
    border-color: var(--chip-hue);
    box-shadow: inset 0 0 0 2px var(--chip-hue);
}

/* These chips take their colour from Tailwind class-name strings held in
   FreeMarker data, not from a cat-N class, so the hue is carried in by a
   data attribute instead. --cat-1..5 are on :root in the shared stylesheet. */
#bankArticle [data-cat="1"] { --chip-hue: var(--cat-1); }
#bankArticle [data-cat="2"] { --chip-hue: var(--cat-2); }
#bankArticle [data-cat="3"] { --chip-hue: var(--cat-3); }
#bankArticle [data-cat="4"] { --chip-hue: var(--cat-4); }
#bankArticle [data-cat="5"] { --chip-hue: var(--cat-5); }

.credit-chip.is-blocked,
#bank-row button.is-blocked {
    opacity: 0.35;
    cursor: default;
}

/* A gained chip that is also blocked - you returned a credit to a colour you
   can no longer take from - still has to be readable. The inversion is already
   a large luminance drop, so the usual dimming on top of it buries the digit,
   worst of all on cat-5, which is the least saturated hue to begin with.
   The plain blocked state is untouched; it is working. */
.credit-chip.is-gained.is-blocked,
#bank-row button.is-gained.is-blocked {
    opacity: 0.5;
}

.credit-total.is-over-cap {
    color: #fca5a5;
    font-weight: 700;
}

button[disabled]#confirm_move_btn_mobile,
button[disabled]#confirm_move_btn_desktop {
    opacity: 0.4;
    cursor: default;
}

/* Card slot flip.
   A card taken from the board turns edge-on, and its replacement turns back into
   view in the same slot - the slot itself never moves, which is the whole point.

   Done as two half-turns on one element rather than a two-faced 3D card: the
   outgoing card rotates to 90deg (invisible, edge-on), is swapped for the new one
   while hidden, and the new one rotates in from -90deg. No preserve-3d scene, no
   backface handling, and only ever one card in the DOM per slot. */
#cards_list_l1,
#cards_list_l2,
#cards_list_l3 {
  perspective: 900px;
}

.gamecard {
  transition: transform 200ms ease-out;
}

/* Turning away. ease-in so it accelerates out of view. */
.gamecard.card-flip-out {
  transform: rotateY(90deg);
  transition-timing-function: ease-in;
}

/* The starting point for the incoming card; removing the class runs it back to 0
   through the base transition above. */
.gamecard.card-flip-in {
  transform: rotateY(-90deg);
  transition-duration: 0s;
}

/* The slot behaviour is identical without the animation - the card is simply
   replaced or removed outright. */
@media (prefers-reduced-motion: reduce) {
  .gamecard,
  .gamecard.card-flip-out,
  .gamecard.card-flip-in {
    transition: none;
    transform: none;
  }
}

/* Sidebar events section.
   Deliberately a copy of the #sidebar-locations pattern: same header layout,
   same chevron rotation, same 12px ::after spacer balancing the 12px chevron so
   the three panels' chevrons line up in one column down the rail. */
#events-list-header {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 0.5rem;
    padding: 0.4rem 0;
    /* 44pt minimum - HIG audit finding 1. This header IS the collapse control,
       and it was 32.8px. align-items: center was already here, so the
       extra height distributes either side of the row rather than shifting it.

       Applied at every width, unlike the HUD's Confirm move: this is the same
       element in both layouts - a bar across the top on a phone, a column header
       in the sidebar above 768px - with no separate desktop control to leave
       alone. */
    min-height: 44px;
    cursor: pointer;
    color: rgba(255,255,255,0.68);
    transition: color 0.15s;
    user-select: none;
}
#events-list-header:hover { color: rgba(255,255,255,0.8); }
#events-list-header svg { transition: transform 0.2s ease; flex-shrink: 0; }
#events-list-header::after { content: ''; width: 12px; flex-shrink: 0; }
#sidebar-events.collapsed #events-list-header svg { transform: rotate(-90deg); }
#sidebar-events.collapsed #events-list { display: none; }

/* The always-visible summary: the latest event.
   Deliberately NOT flex:1. The three headers centre their whole
   chevron + content + spacer group, so the chevron's position tracks the width
   of the content - Players' and Locations' chevrons already sit at different
   x for that reason. A flex:1 summary would pin this one hard left and make
   the events panel the odd one out, which is the very thing that made the first
   attempt look wrong. min-width:0 still lets a long name ellipsise rather than
   push the header wider than the rail. */
.event-latest {
    min-width: 0;
    display: flex;
    align-items: center;
    gap: 0.375rem;
}
.event-latest-text {
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    font-size: 0.75rem;
}

#events-list {
    padding-bottom: 0.25rem;
}

.event-row {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    padding: 0.4rem 0.5rem;
    border-bottom: 1px solid rgba(255,255,255,0.06);
    font-size: 0.75rem;
    color: rgba(255,255,255,0.75);
}
.event-row:last-child { border-bottom: none; }

.event-text {
    flex: 1;
    min-width: 0;
}
/* An event with no actor - Game Started - has no chip, so its text takes the
   chip's place rather than leaving a gap the other rows do not have. */
.event-text-plain { color: rgba(255,255,255,0.68); font-style: italic; }

.event-time {
    flex-shrink: 0;
    font-size: 0.6875rem;
    color: rgba(255,255,255,0.62);
}

.event-chip {
    flex-shrink: 0;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 1.25rem;
    height: 1.25rem;
    border-radius: 9999px;
    font-size: 0.625rem;
    font-weight: 700;
    line-height: 1;
    color: #fff;
}

/* Rail panel headers: left-aligned in the wide sidebar.
   MUST stay at the end of the stylesheet. Each of the three headers is a
   single-ID selector, and #events-list-header is declared below the
   min-width:768px block above, so an override placed in that block loses to it
   on source order alone and only Players and Locations move.

   The base rule centres each header's whole chevron + content + spacer group,
   which makes every chevron's x track the width of its own content - measured
   at 834 / 854 / 787 in a 300px rail, untidy once the three are stacked.
   Starting the group at the left edge puts all three chevrons in one column.
   The ::after existed only to balance the chevron inside a centred group, so it
   goes with the centring.

   Mobile deliberately keeps the centred base rule: there the rail is a
   full-width bar across the top of the page, not a narrow column. */
@media (min-width: 768px) {
    #players-list-header,
    #locations-list-header,
    #events-list-header {
        justify-content: flex-start;
    }

    /* All three to the same 44pt height in the sidebar layout - Andy, working
       through HIG finding 1. Locations and events already carry it from their
       base rules; players is the one this adds, and stating all three keeps the
       rule self-describing: in THIS layout the rail headers are uniform.

       Mobile is deliberately not uniform. There the rail is a full-width bar
       across the top and the players header carries the chip strip rather than a
       summary line, so it stays at its own 38px - see the assertion in
       touch-targets.tests.js, which is width-aware for exactly this reason. */
    #players-list-header,
    #locations-list-header,
    #events-list-header {
        min-height: 44px;
    }
    #players-list-header::after,
    #locations-list-header::after,
    #events-list-header::after {
        content: none;
    }
    /* Both icon rows wrap. A left-aligned header with a centred wrapped row
       would put the second line back out of alignment. */
    .player-icons,
    .location-icons {
        justify-content: flex-start;
    }
}

/* ---------------------------------------------------------------------------
   Game card internals.

   Kept at the end of the stylesheet for the same reason as the rail headers
   above: these override Tailwind utilities carried on the elements themselves,
   and source order decides ties.

   Two separate defects, both only visible in the >=768px layout between the
   breakpoint and roughly 900px, where the 300px rail leaves the two-column
   grid only ~178px per card:

   1. The title could not fit beside the avatar and SPILLED PAST THE CARD EDGE.
      .card-title is flex-1, but a flex item will not shrink below its
      min-content width - its longest word - and overflow-wrap:break-word does
      NOT reduce that intrinsic size (only overflow-wrap:anywhere does). The
      avatar plus gap takes a fixed 92px of a 158px inner box, leaving 66px for
      a title whose min-content is 93px for "The Blood Ancient" and 160px for
      "Winterwarden". Measured across the whole deck, 410 of 442 card names
      overflowed. min-width:0 lets the box shrink to the space available;
      fitCardTitle then shrinks the font until the longest word fits, with the
      break-word already on the element behind that.

      This used to end with hyphens:auto as a fourth step. It does not any more
      - see the note below. DO NOT ADD IT BACK: it broke words that had no need
      to break, twice, and fitCardTitle covers every word in the deck even in
      the tightest box.

   2. Because the title then took an extra line, Take and the cost strip were
      pushed down and stopped lining up with the neighbouring card - the
      reported symptom. The grid already stretches both cards to the same
      height, so pinning the footer to the bottom of the card is enough to
      align them: the card becomes a flex column and .card-actions takes
      margin-top:auto.
   --------------------------------------------------------------------------- */
.gamecard {
    display: flex;
    flex-direction: column;
}
.gamecard .card-actions { margin-top: auto; }

/* A card IS its artwork (Andy, 2026-10-13): square, in every layout, with the
   badges, portrait, name, Take and costs placed on the art. Its height used to
   come from its content, so on a narrow card (two columns in landscape on a
   phone, or an iPad in portrait: ~210-230px) the content ran past the art and
   left the cost strip in a band of plain colour; on wide cards the art's bottom
   was cut off instead. aspect-ratio fixes the shape; overflow:hidden is what
   stops content setting a taller minimum (an aspect-ratio box with visible
   overflow still grows to fit its content). The column above keeps the Take
   row and costs at the bottom.

   Nothing may be clipped, so the content has to fit the smallest card: narrow
   cards - and only those - tighten their vertical spacing (the card is a size
   container), and fitCardTitle holds names to two lines.
   tests/card-fits-art.py checks every layout with real card names: each card
   square, nothing inside it overflowing. */
.gamecard {
    aspect-ratio: 1 / 1;
    overflow: hidden;
    container-type: inline-size;
}
/* The name and portrait sit on the card's vertical centre line (Andy,
   2026-10-13). The card stacks its three blocks in ONE grid cell instead of a
   column: the name block centred in it, the Take row and the cost strip
   anchored to its bottom - so the name's position no longer depends on the
   badges above it or on what sits below. The Take row stands on the cost
   strip (2rem chips + 0.5rem padding). The badges are absolutely positioned
   and clear the centre on every card size. The name's own bottom margin is
   dropped so the block it centres is just the portrait and the name. */
.gamecard {
    display: grid;
    grid-template-columns: minmax(0, 1fr);
    grid-template-rows: minmax(0, 1fr);
}
.gamecard > .card-headline,
.gamecard > .card-actions,
.gamecard > .card-costs { grid-area: 1 / 1; }
.gamecard > .card-headline { align-self: center; padding-top: 0; padding-bottom: 0; }
.gamecard > .card-headline .card-title { margin-bottom: 0; }
.gamecard > .card-actions { align-self: end; margin-top: 0; margin-bottom: 2.5rem; }
.gamecard > .card-costs { align-self: end; }

@container (max-width: 260px) {
    /* Room for a fixed square, with spare for a phone's own font metrics: the
       content is otherwise clipped, not stretched. Smaller corner badges (they
       end ~1.9rem down) let the headline start at 2rem; a 3rem portrait; tighter
       gaps around the name, the Take row and the costs. */
    .card-badge { font-size: 1.25rem; line-height: 1.5rem; padding: 0.125rem 0.5rem; }
    .card-headline { padding-top: 2rem; padding-bottom: 0; }
    .gamecard .card-avatar { width: 3rem; height: 3rem; }
    .card-title { margin-bottom: 0; padding-bottom: 0.125rem; }
    .card-take-row { margin-top: 0; margin-bottom: 0.125rem; }
    .takebutton { padding: 0.25rem 1.25rem; }
    .card-costs { padding-bottom: 0.25rem; }
    /* Stacked cards (see the centre-line block above): the Take row stands on
       the narrower cost strip, and on a card this small a centred name would
       sit on the Take button, so it rides 0.5rem above the centre line. */
    .gamecard > .card-actions { margin-bottom: 2.25rem; }
    .gamecard > .card-headline { transform: translateY(-0.5rem); }
}

.gamecard .card-title {
    min-width: 0;
    overflow-wrap: break-word;
}

/* The name's tinted band spans the whole card, edge to edge, with the portrait
   sitting on it like a medallion on a ribbon (Andy, 2026-10-16). It used to tint
   only the name's own box, which started a gap after the portrait and stopped
   short of the right edge - "a bit slapped on". The band is the name's ::before,
   stretched far past both sides and cut off by the card's overflow:hidden, so it
   meets the card's edges whatever the portrait or card size. Its colour comes in
   as --name-bg (inline on the h2, from the card's category: data, not a class).
   The underline moves onto the band too, so it also runs the full width.
   The name is a stacking context (z-index 0) so the band can sit behind its
   text but above the card art; the portrait is lifted above the name. */
.gamecard .card-avatar { position: relative; z-index: 1; }
.gamecard .card-title {
    position: relative;
    z-index: 0;
    border-bottom-width: 0;
}
.gamecard .card-title::before {
    content: "";
    position: absolute;
    top: 0;
    bottom: 0;
    left: -100vw;
    right: -100vw;
    z-index: -1;
    background: var(--name-bg, transparent);
    border-bottom: 1px solid;
    border-color: inherit;
    pointer-events: none;
}

/* NO hyphens:auto ANYWHERE. It has now produced the same fault twice:
   "Samira the Surveyor" as "Samira the Survey-" / "or" - on the iPhone
   2026-08-28, then on the iPad 2026-08-29 - because hyphens:auto is
   OPPORTUNISTIC, not a last resort. It hyphenates whenever that fills a line
   better, whether or not the word had to break.

   The first fix scoped it to >=768px, "for the tight 768-900px window". But the
   media query had no upper bound, so it stayed on at 1024 and 1180 where the
   title box is 194px and 272px - hence the iPad.

   Measured 2026-08-29, and the backstop turns out to be unnecessary even at the
   tightest width, so there is nothing to preserve:

     title box            122px at 768px, 127px at 834px
     "Surveyor"     20px   89px  - fits, so it should never have been broken
     "Winterwarden" 20px  144px  - too wide, BUT 93px at fitCardTitle's 13px floor
     "Glitterneedle" 20px 132px  -            and 86px at 13px

   Both of the deck's longest words fit the tightest box once fitCardTitle has
   shrunk them, which is the order Andy asked for when shrink-to-fit was built:
   wrap at spaces -> shrink the font -> break-word. Hyphenation had no job left.

   ⚠️ NOT verifiable headless in either direction. Chrome on Linux ships no
   hyphenation dictionary, so hyphens:auto is completely INERT there - measured
   2026-08-28, a hyphens:auto box and a hyphens:none box laid out identically to
   the pixel. card-layout.tests.js asserts the DECLARATION, which is all that
   can be checked from here. */

/* Reclaim horizontal space in the window where the cards are tightest, so most
   titles still fit whole. At 768px the rail takes 300px of the viewport and
   md:px-12 takes another 96px of what is left, out of 468px - the padding costs
   more than a whole card. Trading it back, plus a smaller avatar, takes the
   title from 66px to 122px.

   Interpolated rather than switched at a breakpoint. A plain
   (max-width: 899.98px) block works, but then crossing 900px makes the cards
   jump from 255px inner width down to 224px - they get SMALLER as the window
   gets bigger, which reads as a glitch. Both values instead ramp from their
   tight setting at 768px to the untouched Tailwind value at 900px, where the
   layout has room again, and clamp there. */
@media (min-width: 768px) {
    /* Both ramps run on the width the layout really has: the viewport less the
       safe-area insets. In landscape a notched iPhone pads both sides (finding 8
       below), so 844px of viewport is ~750px of room - and ramping on 100vw gave
       the board the roomy settings it could not afford: "Rattlebones" broke as
       "Rattlebone"/"s" and long names ran to four lines and stretched their row
       (Andy, 2026-10-13). Insets are 0 everywhere else, so nothing else moves. */
    :root { --layout-vw: calc(100vw - env(safe-area-inset-left, 0px) - env(safe-area-inset-right, 0px)); }
    /* 1rem at 768px -> 3rem (md:px-12) at 900px: slope 32px over 132px. */
    #skirmish-content > main {
        padding-left:  clamp(1rem, calc(1rem + (var(--layout-vw) - 768px) * 0.2424), 3rem);
        padding-right: clamp(1rem, calc(1rem + (var(--layout-vw) - 768px) * 0.2424), 3rem);
    }
    /* 3.5rem at 768px -> 5rem (w-20/h-20) at 900px: slope 24px over 132px. The
       same slope carries on below 768px down to 3rem: --layout-vw only goes
       under 768px here when insets take it there (a Dynamic Island phone in
       landscape has ~726px of room), and the portrait is what can give. */
    .gamecard .card-avatar {
        width:  clamp(3rem, calc(3.5rem + (var(--layout-vw) - 768px) * 0.1818), 5rem);
        height: clamp(3rem, calc(3.5rem + (var(--layout-vw) - 768px) * 0.1818), 5rem);
    }
}


/* ============================================================
   Focus mode - MOBILE ONLY (<768px)
   ============================================================

   A phone screen shows very little board once the rail and the bank HUD have
   taken their share. Focus mode is a fourth button in the top row that clears
   the rail away and leaves the cards.

   What it does NOT touch, deliberately:

   - #bankArticle. It looks like chrome and is not: it holds the Bank credit
     row you tap to take credits, the reserve row that opens on your turn, and
     Confirm move. Hiding it would let a player sit in focus mode unable to
     take their turn.
   - The players strip. Whose turn it is has to stay glanceable, so
     #sidebar-players keeps its .player-icons row - just the chips, with the
     chevron gone and the header inert, so there is nothing to expand.
   - Each panel's own .collapsed state. Focus mode hides Locations and Events
     with its own rules and never writes that class, so whatever the player had
     open comes back exactly as they left it.

   The >=768px sidebar layout is untouched: every rule here is inside the
   mobile media query, and #focusButton is display:none by default.

   ONE box animates: #sidebar-top. Everything else changes instantly and is
   clipped by it.

   That is not a style choice, it is the fix for a stutter Andy saw on the
   first build. That version transitioned max-height on each panel, from a flat
   60rem. Sampled per frame at 390x844, the rail did this:

       0-150ms   807px, motionless   (max-height still above both panels'
                                      real heights, and the rail also pinned
                                      at #sidebar-top's own cap)
       150ms     -61, -90, -87, -80  (starts abruptly, already at speed)
       216ms     -118                (locations, 204px tall, FINALLY drops
                                      below its own max-height and joins in)
       299ms     -11, done

   Two faults, both from animating max-height against content it does not fit.
   Visible height is min(content, max-height), so nothing moves at all while
   max-height is above the content - which also means the ease-in-out is being
   applied to a number nobody can see, and the visible motion starts at full
   speed. And the two panels are different heights (204px and 798px), so they
   start moving at different moments: that is the kink at 216ms, and that is
   the stutter.

   Animating the scroller instead makes the rail's height a single ramp with
   the easing on the thing the eye actually tracks. The panels are hidden
   outright, so no per-panel transition can reintroduce a second stage.

   The JS drives it between two measured pixel values - see setSkirmishFocus in
   the game JS, which also explains why the hiding is deferred to the end of
   the ramp when entering. */

/* Hidden by default so it can never appear in the sidebar layout, whatever
   the media query does. Geometry lives beside #cardsButton in the page's own
   <style> block, which is where the top row's spacing is defined. */
#focusButton { display: none; }

@media (max-width: 767px) {
    #focusButton { display: flex; }

    /* The one animated box. The JS sets max-height in pixels for the length of
       the ramp and clears it afterwards, handing the cap in the block above
       back its job.

       The transition is on a class the JS adds for the ramp and takes off at
       the end - NOT on #sidebar-top permanently. It mattered most when the cap
       was calc(100dvh - var(--bank-hud-height)) and a standing transition would
       animate it every time that variable was republished, leaving any
       measurement taken straight afterwards reading the start of the ramp. The
       variable is gone, but keep the transition scoped anyway: a permanent
       transition on a max-height that the layout changes is a trap whatever
       sets it. */
    #sidebar-top.focus-ramping { transition: max-height 0.3s ease-in-out; }

    /* .skirmish-focus-animating is on the body only while the ramp is running,
       and only when ENTERING focus mode. It keeps the rail's contents on screen
       to be clipped by the shrinking box - hide them at the start instead and
       the panels vanish first and the empty gap closes afterwards, which is the
       two-step motion this whole rule set exists to avoid.

       ONLY THE TWO PANELS ARE GATED ON IT, and that distinction matters. The
       gate is for content the shrinking box needs to clip; it is not for
       deciding WHICH player row is on show. Gating the row rules too meant the
       box spent the whole ramp clipped to the top of an unfiltered list - so
       the line left standing was whoever happened to be FIRST in the DOM, and
       it swapped to your own row at the instant the gate lifted. Andy saw
       exactly that. The row rules therefore apply from the first frame, and
       the strip shows your line throughout. */
    body.skirmish-focus:not(.skirmish-focus-animating) #sidebar-locations,
    body.skirmish-focus:not(.skirmish-focus-animating) #sidebar-events {
        display: none;
    }

    /* Your row goes to the TOP of the list the moment focus starts - ungated,
       unlike everything else here.

       This is what makes the ramp show the right line without collapsing the
       rail. The box clips from the bottom, so the row left standing as it
       shrinks is whichever comes first; ordering yours first means that is
       always you. The alternative - hiding the other rows immediately - was
       tried and is worse: the list loses its height instantly, and because the
       rail is driven by max-height, which can only constrain and never extend,
       the rail snaps down to its content and the board jumps. The ramp then
       had nothing left to animate but the last stretch.

       So the rows all stay for the ramp and are clipped away; only their
       ORDER changes. By the time they are hidden at the end of the ramp they
       are already below the clip line, so nothing appears to move.

       #players-list needs to be a flex column for order to mean anything. The
       rows carry padding and no margins, so this does not change its height -
       there is an assertion for that, because a height change here would be
       another jump. */
    body.skirmish-focus #players-list {
        display: flex;
        flex-direction: column;
    }
    body.skirmish-focus #players-list .player-row.is-current-player {
        order: -1;
    }

    /* The players strip stays, stripped of its controls. */
    /* Focus mode keeps YOUR OWN row - name, category totals, points, bonus -
       as one line under the chips. It is the line you price a card against, so
       losing it was the one thing focus mode took that you actually need mid
       turn.

       "Current player" here is .is-current-player, the logged-in user, not
       .is-current-mover. Whose turn it is is already on the chips above; what
       is not available anywhere else in focus mode is what YOU can afford.
       This also matches what the panel's own .collapsed state has always done.

       Only the header survives: the Cards and Coins lines are the breakdown,
       and the header's totals already carry their sum. */
    body.skirmish-focus:not(.skirmish-focus-animating) #sidebar-players .player-row:not(.is-current-player) {
        display: none;
    }
    body.skirmish-focus:not(.skirmish-focus-animating) #sidebar-players .player-row-stat-line {
        display: none;
    }
    /* :last-child is structural, so it still matches the DOM's last row even
       though that row is display:none - which would leave the surviving row
       drawing a rule under itself with nothing beneath it. */
    body.skirmish-focus:not(.skirmish-focus-animating) #sidebar-players .player-row {
        border-bottom: none;
    }

    /* ...and no panel of its own, whatever the game state - and gone from the
       FIRST FRAME, not when the ramp ends.

       Ungated deliberately, like the ordering above, and it is safe to ungate
       for a reason the row hiding is not: these change background, outline and
       HORIZONTAL padding only. No height, so the list cannot shrink and the
       rail cannot snap down.

       Left gated, the highlight sat through the whole ramp and popped out at
       the end - and since your row is the one that STAYS, that pop happened in
       the part of the strip the eye is following. Same for the chevron below:
       the chips header never moves, so anything changing inside it has to
       change as the motion starts, not after it.

       .is-current-mover gives a row a translucent fill, an outline and side
       padding. That earns its keep in the EXPANDED list, where it picks one row
       out of several. In focus mode there is only ever one row, so it
       distinguishes it from nothing - and a boxed single line reads as a second
       panel sitting inside the rail's own. Worse, it came and went with the
       turn, so the strip looked structurally different depending on whose move
       it was. (A .player-row.has-prestige-bonus half was listed here too; that
       class never lands on a row, so it was removed 2026-08-28.)

       The information is not lost: the chips above still ring the mover, and
       the prestige badge is right there in this row's header. */
    body.skirmish-focus #sidebar-players .player-row.is-current-mover {
        background: none;
        outline: none;
        padding-left: 0;
        padding-right: 0;
        /* With the padding gone the negative margin has nothing to cancel, and
           would leave this row 12.8px wider than the panel's content. */
        margin-left: 0;
        margin-right: 0;
    }
    body.skirmish-focus:not(.skirmish-focus-animating) #players-list-header {
        cursor: default;
        pointer-events: none;
    }
    /* visibility, NOT display. The chevron is a flex item in a CENTRED row, so
       display:none takes its 12px box and the 8px gap with it and the chips
       slide 10px left as you press the button - measured exactly that.

       It did not do this before the button lanes went in, because focus mode
       used to remove the ::after spacer at the same time and the two losses
       balanced. That spacer is now gone for the whole mobile layout, so the
       chevron is on its own and has to hold its own space.

       The chevron still disappears, which is what Andy asked for - there is
       nothing to expand in focus mode - it just stops taking the chips with
       it. visibility also removes it from the accessibility tree, which
       opacity would not. */
    body.skirmish-focus #players-list-header svg {
        visibility: hidden;
    }
    /* No ::after rule here any more. The spacer that used to need removing with
       the chevron is now dropped for the whole mobile layout, up in the lane
       reservation - see the comment there. Focus mode inherits that. */

    @media (prefers-reduced-motion: reduce) {
        #sidebar-top.focus-ramping { transition: none; }
    }
}


/* ============================================================
   THE REST OF THE BOARD  (Tailwind removal, Task 6 part 2b)
   ============================================================
   Values are the Tailwind ones they replace, verified by capturing every
   element's computed style with the CDN loaded and again without it. */

/* ----- containers ----- */
#skirmish-content { position: relative; z-index: 10; margin: 0 auto; padding-bottom: 4rem; }
#players-list     { margin-bottom: 0.5rem; }

.board-main {
    flex-grow: 1;
    margin: 2rem auto 6rem;               /* mt-8 mb-24 mx-auto */
    padding: 0 1.5rem;
    max-width: 56rem;
}

/* App shell overrides, and they MUST live here rather than in the max-width:767px
   block 850 lines above: these two containers are declared at the very end of
   this file, so an earlier media query loses to them on source order however
   specific it looks. Both values above exist only to clear a HUD that used to
   float over the page; in the shell it is the column's bottom row. */
@media (max-width: 767px) {
    #skirmish-content { padding-bottom: 0; }

    /* The board is the app shell's scroller, so its box has to BE the gap
       between the two HUDs - edge to edge, in both directions. Its spacing is
       therefore padding, never margin: padding is inside the scroll box,
       margin is outside it. Andy, 2026-08-29, reporting all three symptoms of
       getting that wrong:

         - "the cards just vanish instead of going to the edge": the 2rem
           vertical margins sat outside the box, so it ran 542->753 while the
           gap between the HUDs was 510->785, and content clipped 32px short at
           each end.
         - "the draggable area is essentially just the cards" and "the scrollbar
           appears next to the cards": margin-left/right: auto. A flex item with
           auto cross-axis margins is NOT stretched - it is sized to its content
           and centred - so the board was 357px wide at 17->373, leaving strips
           at each side that scrolled nothing and putting the scroll indicator
           beside the cards instead of at the screen edge.

       Keep both at 0. The stretch is what makes the whole width draggable. */
    .board-main {
        margin: 0;
        /* Horizontal padding only. The 2rem top and bottom used to be here, and
           a flex item CANNOT SHRINK BELOW ITS OWN PADDING - so when an expanded
           panel wanted the whole column, the board still held 64px of nothing
           back and the panel stopped short of the HUD. Andy, 2026-08-29.

           The spacing moves to the children, where it scrolls with the content
           and costs the board no floor. */
        padding: 0 1.5rem;
    }
    .board-main > div { padding-top: 2rem; padding-bottom: 2rem; }
}
.cards-grid {
    width: 100%;
    display: grid;
    grid-template-columns: repeat(1, minmax(0, 1fr));
    gap: 1rem;
    padding-bottom: 1rem;
}

/* ----- the Cards / Coins rows, and the bank's copy of the same shape ----- */
.stat-cells   { flex: 1 1 0%; display: flex; justify-content: center; gap: 0.5rem; }
.stat-spacer  { width: 4rem; flex-shrink: 0; }

/* ----- locations ----- */
/* A circle is a face, a square is a place. The location images in the rail are
   rounded squares for the same reason the location CARDS are
   (.card-avatar-location) - so a location never reads as a character at a
   glance. Andy, 2026-08-29.

   0.25rem rather than the card's 0.5rem: the card avatar is 5rem and its 8px
   radius is 10% of the box, where these are 1.75rem and 2rem, so the same 10%
   would be ~3px and read as a rendering artifact. 4px is 12-14% here, which
   holds the same shape at a glance, and matches .location-cost in this panel. */
.location-icon-lg { width: 1.75rem; height: 1.75rem; border-radius: 0.25rem;
                    object-fit: cover; border-width: 2px; border-style: solid; }
.location-card    { border-left-width: 4px; border-left-style: solid; }
.location-thumb   { width: 2rem; height: 2rem; border-radius: 0.25rem; object-fit: cover;
                    border-width: 1px; border-style: solid; flex-shrink: 0; cursor: pointer;
                    transition: transform 150ms cubic-bezier(0.4, 0, 0.2, 1); }
.location-main    { display: flex; align-items: center; gap: 0.5rem; min-width: 0; }
.location-text    { flex: 1 1 0%; min-width: 0; }
.location-titles  { display: flex; align-items: center; gap: 0.25rem; justify-content: space-between; }
.location-name    { font-size: 0.75rem; line-height: 1rem; font-weight: 600;
                    color: rgba(255,255,255,0.9); overflow: hidden;
                    text-overflow: ellipsis; white-space: nowrap; }
.location-points  { flex-shrink: 0; font-size: 0.75rem; line-height: 1rem; font-weight: 700;
                    padding: 0.125rem 0.375rem; border-radius: 0.25rem;
                    border-width: 1px; border-style: solid; }
.location-claim   { font-size: 10px; color: rgba(255,255,255,0.5); }
.location-costs   { display: flex; justify-content: flex-end; gap: 0.25rem; margin-top: 0.375rem; }
.location-cost    { width: 1.25rem; height: 1.25rem; border-radius: 0.25rem; display: flex;
                    align-items: center; justify-content: center; font-size: 10px;
                    font-weight: 600; border-width: 1px; border-style: solid; }
.muted-label      { font-weight: 500; color: rgba(255,255,255,0.7); }

/* ----- the bank HUD ----- */
/* No bottom-anchoring at all now - the HUD is a row of the app shell's column.
   Before that there were TWO failed attempts to correct for WebKit's broken
   visual viewport, kept here because the reasoning still applies to anything
   tempted to compensate for that bug (measureViewportShortfall, which recorded
   them in the game JS, was deleted with the rest of the machinery):

     bottom: +shortfall  -> the expanded HUD was drawn at the TOP of the screen
     bottom: -shortfall  -> the HUD went off the BOTTOM of the screen

   The quantity is right - it is the same 335px on two devices - but the frame it
   is being applied within is not a stable reference, so no offset within it
   lands the element anywhere reliable. Do not try a third value here. The one
   thing directly confirmed is that PAGE CONTENT paints correctly, which points
   at taking the HUD out of position:fixed rather than at adjusting it. */
/* ⚠️ NO backdrop-filter here, on purpose. See the note on .view-toggle. The
   background is opaque enough to stand on its own without the blur. */
/* NOT position:fixed any more - it is the bottom row of the app shell. See the
   page's inline <style> for why (the detached-HUD bug corrupts the visual
   viewport, which is what fixed positioning resolves against). */
#bankArticle { padding: 0.5rem; background: rgba(0,0,0,0.94);
               border-top: 1px solid rgba(255,255,255,0.1);
               box-shadow: 0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -4px rgba(0,0,0,0.1); }

#bankHint    { padding: 0.25rem 0.5rem 0.125rem; font-size: 0.75rem; line-height: 1rem;
               color: rgba(255,255,255,0.7); }
#bank-row    { display: flex; align-items: center; padding: 0.25rem 0.5rem; }
#reserveRow  { padding: 0 0.5rem; margin-top: 0.5rem;
               transition: all 300ms ease-in-out; }
.bank-chip   { display: flex; align-items: center; justify-content: center;
               width: 2rem; height: 2rem; border-radius: 9999px;
               border-width: 2px; border-style: solid;
               font-size: 0.875rem; line-height: 1.25rem; font-weight: 500; }
.credit-chip { display: flex; align-items: center; justify-content: center;
               width: 2rem; height: 2rem; border-radius: 9999px;
               border-width: 1px; border-style: solid;
               font-size: 0.875rem; line-height: 1.25rem; font-weight: 500; }
.reserve-row-line { display: flex; align-items: center; }
/* Confirm move sits under the Yours chips in the mobile HUD.
   ------------------------------------------------------------------------
   The gap was 0.25rem - 4px between a 32px chip and the button below it, which
   read as the two being one block rather than a row and its action.

   The button was 32px tall: 0.875rem text on 0.375rem of padding. The Apple HIG
   audit of 2026-08-28 measured it as the smallest target in the game that is
   also its primary action. min-height rather than height, so the icon and label
   still set the box if they ever grow.

   Scoped to .confirm-move-wrap, which is the MOBILE wrapper. The desktop CTA
   uses the same .confirm-move-btn class inside .skirmish-desktop-cta and is left
   alone - it is a pointer target in a sidebar, not a thumb target in a fixed bar.

   This makes the HUD taller. That used to move --bank-hud-height and with it
   the rail's cap, which is how the 2026-08-14 rotation bug was reached; in the
   app shell the board simply gives up the space instead. Still worth a look at
   the rail after any further change here. */
.confirm-move-wrap { padding-top: 0.75rem; }
.confirm-move-wrap .confirm-move-btn { min-height: 44px; }
.confirm-move-btn  { width: 100%; display: flex; align-items: center; justify-content: center;
                     gap: 0.5rem; padding: 0.375rem 1rem; border-radius: 0.5rem;
                     font-size: 0.875rem; line-height: 1.25rem; font-weight: 600;
                     color: #ffffff; background-color: #047857;
                     box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -2px rgba(0,0,0,0.1);
                     transition: all 150ms cubic-bezier(0.4, 0, 0.2, 1); }
.confirm-move-btn:active { transform: scale(0.95); }
.skirmish-desktop-cta .confirm-move-btn { padding-top: 0.5rem; padding-bottom: 0.5rem; }
.icon-4 { width: 1rem; height: 1rem; flex-shrink: 0; }

/* ----- the top-row buttons ----- */
.hamburger-line { display: block; width: 1.5rem; height: 0.125rem;
                  background-color: currentColor;
                  --hamburger-gap: 0.25rem;
                  transition: transform 300ms ease-in-out, opacity 300ms ease-in-out; }
.hamburger-line:not(:last-child) { margin-bottom: var(--hamburger-gap); }

/* Tidying the cross, 2026-08-28. Andy, on an iPad: the finished cross showed two
   dots at the left and right edges of its vertical centre, "like leftovers from
   the middle row".

   Measured in the cross state, at the real 40px button:

     middle bar   x 48.0 .. 72.0   (24px, the full bar width)
     the X        x 50.8 .. 69.2   (18.4px - two 24px bars at 45 degrees)

   So the middle bar is 2.8px WIDER on each side than the X that is supposed to
   replace it. It is set to opacity 0, which is enough on every browser here -
   but any residue at all shows up as exactly two stubs, at the vertical centre,
   left and right, because that is the only part of it the X does not cover.
   Rather than chase why iOS leaves residue on a transitioning composited layer,
   collapse the bar as well: at scaleX(0) there is nothing left to leak.

   Scoped to .hamburger-line deliberately - .opacity-0 is a shared utility, also
   used by the image popup and the reserve row, and must keep meaning only
   "opacity: 0". */
.hamburger-line.opacity-0 { transform: scaleX(0); }

/* The second half of the same untidiness: the X was SKEWED.

   The bars are 2px with a 4px gap, so the top and bottom bars sit 6px either
   side of the middle. The cross translates them by 0.5rem = 8px, which
   overshoots by 2px each - measured, line1 ended up centred at y 62 and line3 at
   y 58 in a button whose centre is y 60. The arms crossed off-centre and the
   result read as slightly open.

   Written as the distance it actually is - centre to centre between adjacent
   bars, which is half a bar + the gap + half a bar, i.e. one bar height plus one
   gap - so that changing the height or the gap keeps the cross centred instead
   of silently skewing it again. */
.hamburger-line.translate-y-2  { --tw-translate-y: calc(0.125rem + var(--hamburger-gap)); }
.hamburger-line.-translate-y-2 { --tw-translate-y: calc(-1 * (0.125rem + var(--hamburger-gap))); }

/* ----- overlays and dialogs ----- */
/* Centred with auto margins against left:0/right:0, NOT translateX(-50%): a
   transform on a position:fixed element forces a compositing layer, which is
   what is being cleared off every fixed element on this page while chasing the
   detached-HUD bug. The leftover width is split evenly by the auto margins, so
   the result is identical. */
#notification-panel { position: fixed; left: 0; right: 0;
                      margin-left: auto; margin-right: auto;
                      display: flex; flex-direction: column; align-items: center;
                      gap: 0.5rem; width: calc(100% - 2rem); max-width: 28rem; z-index: 50; }

/* Toasts, built entirely in showNotification() (Notifications JS) and so missed
   by the Tailwind removal for the same reason the confirm dialog was: nothing
   the server sends carries these classes.

   Only the Skirmish view declares #notification-panel, so these live here with
   it rather than in the shared stylesheet - How To Play and the Campaign game
   view load the script but have no panel, and showNotification returns early
   without one.

   Colours are the Tailwind values they replace, resolved once: amber-400/
   yellow-500 over amber-900, and the green/orange/rose 100-200 ramps over their
   900s. The old string also had a missing space - `theme.text + "backdrop-blur-md"`
   produced `text-rose-900backdrop-blur-md` - so the error and warning toasts
   never got their text colour even while the CDN was still there. */
.app-toast {
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 0.5rem 1rem;
    border-radius: 0.5rem;
    border-width: 2px;
    border-style: solid;
    backdrop-filter: blur(12px);
    -webkit-backdrop-filter: blur(12px);
    box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
    font-size: 0.875rem;
    font-weight: 600;
    text-align: center;

    /* Enters from 10px up and fades. .is-shown is added on the next frame and
       removed again when the toast is due to leave, so one class drives both
       halves of the animation. */
    opacity: 0;
    transform: translateY(-10px);
    transition: opacity 300ms ease-out, transform 300ms ease-out;
}
.app-toast.is-shown { opacity: 1; transform: translateY(0); }
/* The panel lets taps through (pointer-events-none), and on touch the toasts
   must too: they sit over the top strip. With a mouse a toast takes the
   pointer, so resting on it holds it (showNotification; HIG audit finding 6). */
@media (hover: hover) and (pointer: fine) { .app-toast { pointer-events: auto; } }

@media (prefers-reduced-motion: reduce) {
    .app-toast { transition: opacity 300ms ease-out; transform: none; }
    .app-toast.is-shown { transform: none; }
}

.app-toast-info {
    background-image: linear-gradient(to bottom, rgba(251, 191, 36, 0.9), rgba(234, 179, 8, 0.9));
    border-color: #f59e0b;
    color: #78350f;
}
.app-toast-success {
    background-image: linear-gradient(to bottom, rgba(220, 252, 231, 0.9), rgba(187, 247, 208, 0.8));
    border-color: #4ade80;
    color: #14532d;
}
.app-toast-warning {
    background-image: linear-gradient(to bottom, rgba(255, 237, 213, 0.9), rgba(254, 215, 170, 0.8));
    border-color: #fb923c;
    color: #7c2d12;
}
.app-toast-error {
    background-image: linear-gradient(to bottom, rgba(255, 228, 230, 0.9), rgba(254, 205, 211, 0.8));
    border-color: #fb7185;
    color: #881337;
}

/* Per-event themes for the Skirmish toasts.
   -------------------------------------------------------------------------
   Four of the five toasts used to be the same amber default, so only the bonus
   ring read as different - which is what Andy noticed and asked to have
   extended to the rest. Each hue is chosen to match where you would look on
   screen for the thing the toast is about, rather than to rank the events:

     turn      the amber accent the buttons use - the act-now colour, and
               byte-identical to .app-toast-info so "Your move!" is unchanged
     card      slate. Cards are claimed constantly; the routine event should
               recede rather than compete with the board
     location  green, a scoring milestone
     bonus     violet #7c3aed - EXACTLY .player-row-prestige-badge and the
               .player-icon-chip.has-prestige-bonus outline. The toast is the
               same colour as the thing it is telling you about

   Same recipe as the four above: a two-stop translucent gradient, a solid
   border, and dark text from the same family. --toast-glow is the border colour
   at low alpha, and exists only so .app-toast-mine can build a ring without
   knowing which event it is decorating. */
.app-toast-turn {
    background-image: linear-gradient(to bottom, rgba(251, 191, 36, 0.9), rgba(234, 179, 8, 0.9));
    border-color: #f59e0b;
    color: #78350f;
    --toast-glow: rgba(245, 158, 11, 0.45);
}
.app-toast-card {
    background-image: linear-gradient(to bottom, rgba(241, 245, 249, 0.9), rgba(226, 232, 240, 0.8));
    border-color: #94a3b8;
    color: #1e293b;
    --toast-glow: rgba(148, 163, 184, 0.45);
}
.app-toast-location {
    background-image: linear-gradient(to bottom, rgba(220, 252, 231, 0.9), rgba(187, 247, 208, 0.8));
    border-color: #4ade80;
    color: #14532d;
    --toast-glow: rgba(74, 222, 128, 0.45);
}
.app-toast-bonus {
    background-image: linear-gradient(to bottom, rgba(237, 233, 254, 0.9), rgba(221, 214, 254, 0.8));
    border-color: #7c3aed;
    color: #4c1d95;
    --toast-glow: rgba(124, 58, 237, 0.4);
}

/* Something YOU did. Emphasis only - it keeps its event's hue and adds a
   heavier border plus a ring in that same colour, so the palette stays four
   colours rather than eight. The base drop shadow is repeated because this
   replaces the whole box-shadow rather than adding to it. */
.app-toast-mine {
    border-width: 3px;
    box-shadow: 0 0 0 3px var(--toast-glow, rgba(255, 255, 255, 0.25)),
                0 10px 15px -3px rgba(0, 0, 0, 0.1),
                0 4px 6px -4px rgba(0, 0, 0, 0.1);
}
/* The three overlays (#image-popup, #game-over-popup, #confirm-popup) are
   hidden with .opacity-0, NOT display:none - so before 2026-08-29 all three sat
   over the page PERMANENTLY as full-viewport, position:fixed, backdrop-filtered
   layers, opacity 0 but still laid out and still composited. Measured at rest:
   390x850 each, visibility visible, backdrop-filter blur(4px).

   That is the same fixed + backdrop-filter arrangement removed from the HUD and
   the navbar (59bf8a1, 8305b38, 3ea11be) while chasing the detached-HUD bug, and
   it was dismissed then on the false premise that these were transient.

   visibility:hidden stops them being painted, so the blur layer is not built
   while they are closed. The fade still works: visibility flips instantly when
   OPENING (0s delay) and waits out the fade when CLOSING (delay = the fade),
   which is what the two transition lines below are for. Change one, change both.

   --overlay-fade carries the duration so the delay cannot drift from it;
   #game-over-popup sets its own 500ms rather than overriding transition-duration,
   which would have applied to the visibility half as well. */
.overlay { position: fixed; inset: 0; display: flex; align-items: center;
           justify-content: center; z-index: 50; background: rgba(0,0,0,0.7);
           backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
           --overlay-fade: 300ms;
           transition: opacity var(--overlay-fade) cubic-bezier(0.4, 0, 0.2, 1),
                       visibility 0s; }
.overlay.opacity-0 {
           visibility: hidden;
           transition: opacity var(--overlay-fade) cubic-bezier(0.4, 0, 0.2, 1),
                       visibility 0s linear var(--overlay-fade); }
#game-over-popup { --overlay-fade: 500ms; }
#popup-image { max-width: 100%; max-height: 90vh; border-radius: 0.5rem;
               border-width: 4px; border-style: solid; object-fit: contain;
               transition: all 300ms cubic-bezier(0.4, 0, 0.2, 1); }
.dialog-panel { position: relative; width: 91.666667%; padding: 1.5rem;
                background-color: #fef3c7; border-radius: 0.75rem;
                border: 4px solid #b45309;
                box-shadow: 0 25px 50px -12px rgba(0,0,0,0.25); }
.dialog-panel-md { max-width: 28rem; }
.dialog-panel-sm { max-width: 24rem; }
.dialog-title { text-align: center; color: #78350f; font-weight: 800; }
.dialog-title-lg { font-size: 1.875rem; line-height: 2.25rem; margin-bottom: 1rem; }
.dialog-title-md { font-size: 1.5rem; line-height: 2rem; margin-bottom: 0.75rem; }
.dialog-body { margin-bottom: 1.25rem; }
.dialog-buttons { display: flex; gap: 0.75rem; }
.dialog-scroll { max-height: 60vh; overflow-y: auto; padding-right: 0.5rem; }
.dialog-scroll > * + * { margin-top: 0.75rem; }

/* ----- dialog content built in JavaScript -----

   Everything below exists because the Tailwind removal converted the dialog's
   MARKUP and missed the DOM that showConfirm() builds at runtime. Those nodes
   went on carrying `flex items-center w-8 h-8 text-sm ...` after the CDN was
   dropped, so the confirmation dialog - which fronts every credit take, every
   card purchase and every pass - rendered as unstyled text. tw-compare.py
   could not have caught it: it strips every external script before capturing,
   so no JS-built element was ever in either snapshot.

   The dialog panel is light amber (#fef3c7), so the type here is amber-900 to
   match .dialog-title rather than the app's usual light-on-dark. */

/* A plain-sentence body, as opposed to one holding a card preview or the
   credit summary. Was `text-amber-900 text-center`. */
.dialog-body-text { text-align: center; color: #78350f; }

/* The cloned board card previewed inside the take-card dialog.

   The clone deliberately DROPS .gamecard - setCardTakeButtonsActiveDisabled
   walks every .gamecard on the page and reads .takebutton and #take_btn_bg out
   of it, both of which this clone has stripped, so leaving the class on throws
   on the next turn change. That is why the card's look has to be restated here
   rather than inherited.

   It is .gamecard minus the transform transition: the clone also strips
   .card-flip-in / .card-flip-out, and a preview that is not on the board has
   nothing to animate. The border COLOUR still comes from the cloned
   cat-border-* class, so only the width and style are set here. */
.confirm-card-preview {
    width: 100%;
    position: relative;
    display: flex;
    flex-direction: column;
    padding: 0.5rem;
    border-width: 2px;
    border-style: solid;
    border-radius: 0.5rem;
    box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
    background-repeat: no-repeat;
    background-size: contain;
    background-position: top;
}

/* The credit move summary - "Taking [3 chips] / Returning [1 chip] /
   Holding 9 of 10". */
.credit-summary-row {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    margin-bottom: 0.5rem;
}

/* Fixed width so "Taking" and "Returning" leave their chips on the same left
   edge; without it the longer word pushes its row out of line with the other. */
.credit-summary-label {
    width: 5rem;
    flex-shrink: 0;
    font-size: 0.875rem;
    font-weight: 600;
    color: #78350f;
}

/* Left-aligned, not centred: the dialog lists one or two short rows, and
   centring each row's chips in the leftover space made them drift right, away
   from the label they belong to. */
.credit-summary-chips {
    display: flex;
    flex: 1 1 0%;
    justify-content: flex-start;
    gap: 0.5rem;
}

/* Chips reuse cat-bg-* / cat-text-* / cat-border-* for colour, so only the
   shape is here. border-width is load-bearing: cat-border-* sets a colour
   only, and a colour on a zero-width border paints nothing - which is exactly
   how these were rendering as bare digits. */
.credit-summary-chip {
    display: flex;
    align-items: center;
    justify-content: center;
    width: 2rem;
    height: 2rem;
    border-radius: 9999px;
    border-width: 1px;
    border-style: solid;
    font-size: 0.875rem;
    font-weight: 500;
}

.credit-summary-total {
    text-align: center;
    padding-top: 0.5rem;
    font-size: 0.875rem;
    font-weight: 600;
    color: rgba(120, 53, 15, 0.7);   /* was text-amber-900/70 */
}
.dialog-btn { padding: 0.5rem 0; border-radius: 0.5rem; font-weight: 600;
              box-shadow: 0 1px 3px 0 rgba(0,0,0,0.1), 0 1px 2px -1px rgba(0,0,0,0.1); }
.dialog-btn-primary { background-color: #b45309; color: #ffffff; }
.dialog-btn-secondary { background-color: #fde68a; color: #78350f; }
.dialog-btn-wide { width: 100%; margin-top: 1.5rem; }
@media (hover: hover) {
    .dialog-btn-primary:hover   { background-color: #92400e; }
    .dialog-btn-secondary:hover { background-color: #fcd34d; }
    .location-thumb:hover       { transform: scale(1.05); }
}

@media (min-width: 768px) {
    .board-main  { padding-left: 3rem; padding-right: 3rem; }
    .cards-grid  { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}

/* The cards button stacks its three bars, unlike the focus button's single
   icon; both share .view-toggle's material and shape. */
.view-toggle-stack { flex-direction: column; padding: 0.75rem; }

/* Was md:hidden. The bank HUD carries three of these: the mobile layout keeps
   a trailing spacer and its own Confirm move, the sidebar layout does not. */
@media (min-width: 768px) { .skirmish-mobile-only { display: none; } }


/* --- corrections found by the computed-style oracle --- */

/* py-16 applies only where the mobile block does not zero it. */
@media (min-width: 768px) { #skirmish-content { padding-top: 4rem; } }

/* A zero-cost location badge. Was the opacity-20 utility. */
.opacity-20 { opacity: 0.2; }

/* The bank's trailing spacer was w-16 md:hidden - no shrink-0, unlike the
   player rows' spacer, so it keeps the default flex-shrink. */
.bank-spacer { width: 4rem; }

/* The mobile credit total is a spacer AND a label. */
.credit-total.bank-spacer {
    text-align: center;
    font-size: 0.75rem;
    line-height: 1rem;
    font-weight: 600;
    color: rgba(255, 255, 255, 0.7);
}

/* The cards button needs the flex its utilities used to give it; the focus
   button gets display from its own ID rule. */
#cardsButton { display: flex; }

/* Tailwind's preflight gave every border the same default colour. The popup
   image is the one element on this page that relied on it. */
#popup-image { border-color: #e5e7eb; }

.dialog-btn-half { flex: 1 1 0%; }

/* Every dialog button to the 44pt minimum - HIG finding 1. They were 40px:
   0.5rem of padding on a 24px line.

   This started life as #confirm-popup .dialog-btn, because only the move
   dialog's Cancel and Confirm were in scope and the Game Over dialog's Close
   wore the same class. Close is in scope now, and .dialog-btn has exactly three
   users - those two and Close, all in the Skirmish view - so the ID scope was
   scaffolding rather than a rule. Removed rather than duplicated: two ID-scoped
   copies would have implied a distinction that no longer exists.

   Placement is untouched - cancel bottom left, commit bottom right. */
.dialog-btn { min-height: 44px; }

/* The mobile credit total is a fixed slot, like the spacer it sits opposite. */
.credit-total.bank-spacer { flex-shrink: 0; }

/* The desktop CTA block and its total line. */
.skirmish-desktop-cta { padding-top: 0.5rem; padding-bottom: 0.25rem; }
.skirmish-desktop-cta .credit-total {
    padding-bottom: 0.25rem;
    text-align: center;
    font-size: 0.75rem;
    line-height: 1rem;
    font-weight: 600;
    color: rgba(255, 255, 255, 0.7);
}

/* HIG audit finding 8: in landscape a notched iPhone painted content under the
   camera housing. Both sides are padded by max(existing padding, inset), in CSS
   alone. A first version padded only the camera side, found from
   window.orientation in a script - but iOS draws the first landscape frame
   before that script runs, so the layout visibly jumped (Andy, 2026-10-13). iOS
   reports the inset on both sides and CSS cannot tell which one the camera is
   on, so padding both is the only way the first frame is already right. On an
   iPhone 13/14 the board gives up ~44px in all. In portrait, on iPads and on
   phones without a notch the insets are 0 and nothing changes.
   tests/landscape-notch.py measures it with faked insets. */
@media (min-width: 768px) {
    /* Left: the board's own 1rem-3rem ramp or the inset, whichever is larger. */
    #skirmish-content > main {
        padding-left: max(clamp(1rem, calc(1rem + (var(--layout-vw) - 768px) * 0.2424), 3rem), env(safe-area-inset-left, 0px));
    }
    .nav-home { left: max(1rem, env(safe-area-inset-left, 0px)); }

    /* Right: the panel's contents clear the inset... */
    #sidebar-players,
    #sidebar-locations,
    #sidebar-events { padding-right: max(1rem, env(safe-area-inset-right, 0px)); }
    #bankArticle { padding-right: max(0.5rem, env(safe-area-inset-right, 0px)); }
    .nav-menu-wrap { right: max(1rem, env(safe-area-inset-right, 0px)); }

    /* ...and the panel grows by what the inset takes beyond its own 1rem, so its
       rows - and the players' names - keep their width; the board gives up those
       pixels instead, and its card titles already shrink to fit. Every place the
       300px is spent moves together: the panel, the board's reserved padding and
       the cards button just left of the panel. */
    :root { --notch-extra: max(0px, calc(env(safe-area-inset-right, 0px) - 1rem)); }
    #sidebar-rail { width: calc(300px + var(--notch-extra)); }
    #skirmish-content { padding-right: calc(300px + var(--notch-extra)); }
    /* :root for specificity: the page's inline <style> also positions #cardsButton
       and comes later, so a bare #cardsButton here would lose to it. */
    :root #cardsButton { right: calc(300px + var(--notch-extra) + 0.5rem); }
}
