/* ============================================================ tokens */

:root {
  /* neutrals - one cool ramp, no pure black or white */
  --bg:        #12141a;
  --surface:   #1a1d26;
  --surface-2: #22262f;
  --surface-3: #2b303b;
  --line:      #2e3340;
  --line-soft: #262b35;
  --text:      #e7eaf1;
  --text-dim:  #a3aab9;
  /*
   * The third step of the de-emphasis ramp, and the quietest text on the page.
   *
   * This was #6f7788 and failed WCAG AA everywhere it was read: 3.37:1 on a
   * chip, 2.94:1 on --surface-3, against the 4.5:1 small text needs. Not
   * incidental text either -- it is every field placeholder, every card's tier,
   * the roster counts, the empty-state lines and the version pill.
   *
   * It was lifted to almost exactly this value once before, to #8f97a8, and
   * reverted on looks. That attempt also raised a --line-strong of #666b78 for
   * every control border in the same change, and the two together visibly
   * brightened the roster, which is the densest surface in the app. The note
   * left behind asked that whoever came back move the quiet text and the loud
   * borders separately and judge it on the roster rather than on a button.
   *
   * So: text only this time. The borders are untouched and still under 3:1,
   * which remains a real 1.4.11 gap and a separate job.
   *
   * #8f99aa is the smallest step that clears 4.5:1 on --surface-3, the lightest
   * surface this can land on: 4.60 there, 5.27 on a chip, 6.40 on the page. It
   * stays a step below --text-dim rather than collapsing into it -- 4.60 against
   * 5.67 on the same surface -- so the ramp still has three rungs.
   */
  --text-mute: #8f99aa;

  --accent:      #ffc93c;
  --accent-ink:  #2a2005;
  /*
   * The accent, again, for when it is the ink rather than the fill.
   *
   * On a dark page one value does both jobs. On a light one it cannot: #f0ab00
   * is a fine button fill behind dark ink and 1.99:1 as text on white, which
   * made every link and -- worse -- every focus ring effectively invisible in
   * light mode. Same hue, enough darkness to be read.
   */
  --accent-ink-on: #ffc93c;
  --danger:      #f0654f;
  --ok:          #55c98a;

  /* Player identity in co-op. Deliberately outside the elemental palette so a
     P1 marker never reads as a Water badge. */
  --p1: #4d9dff;
  --p2: #ff5fa8;

  /*
   * The same two for more-contrast mode, and a different problem from the
   * elements. P1 and P2 are both mid-tone, so the only thing telling one team's
   * outline from the other's is hue -- blue against pink -- which is the one
   * channel a reader in this mode is least able to lean on. Hue stays (a blue
   * team and a pink team is the mental model), but the pair is pulled hard apart
   * on lightness so the split survives with the hue collapsed: P1 goes notably
   * light, P2 notably dark. Theme-independent, same as the base pair.
   *
   * P2's fill flips to light ink to match -- see [data-contrast="more"].
   */
  --hc-p1: #8fc4ff;
  --hc-p2: #a02363;

  --water: #37a7e6;
  --fire: #e8453c;
  --grass: #57c14a;
  --lightning: #f2be16;
  --rock: #a2762f;

  /*
   * The same five for anyone who cannot tell them apart, dark-theme values.
   *
   * The five above are a real accessibility bug, not a matter of taste. Run
   * through Viénot dichromacy simulation, Fire #e8453c and Rock #a2762f land
   * 8.9 apart in CIE dE — under about 15 two colours have stopped being two, so
   * for a deuteranope, roughly one man in twenty, those are one colour. It is
   * Grass/Lightning at 17.7 for a protanope and Water/Grass at 12.1 for a
   * tritanope.
   *
   * What fixes it is lightness, not hue. Dichromats keep full luminance
   * perception, so five colours spread across L* stay five things even when hue
   * collapses. That is why Rock stops being brown and becomes a cool grey: it is
   * what pulls it off Fire. Worst pair here is 24.9 across all three
   * dichromacies, and every one clears 6.5:1 on --bg.
   *
   * Applied at the bottom of this block, not here — see [data-contrast="more"].
   */
  --hc-water: #3aa0f0;
  --hc-fire: #ff7a5e;
  --hc-grass: #8ce87a;
  --hc-lightning: #f5cc2e;
  --hc-rock: #9fa6b5;

  /* Status ink, same treatment. Heal-green against debuff-red is the textbook
     red/green pair, so these lift too rather than only the elements. */
  --hc-ok: #6fe0a4;
  --hc-danger: #ff8a72;

  /* 4px scale */
  --s1: 4px;  --s2: 8px;  --s3: 12px; --s4: 16px;
  --s5: 24px; --s6: 32px; --s7: 48px;

  --r-sm: 6px; --r-md: 10px; --r-lg: 14px;

  /* elevation: hairlines for structure, soft stacks for things that float */
  --lift-1: 0 1px 2px rgb(0 0 0 / .28), 0 2px 8px rgb(0 0 0 / .18);
  --lift-2: 0 2px 6px rgb(0 0 0 / .32), 0 12px 32px rgb(0 0 0 / .28);

  /*
   * --z-drag is 100 and the rest are small on purpose.
   *
   * The thing under your finger has to be the thing on top, always, and it is
   * the one layer that has no business being ordered against anything else. The
   * others are a 10/20/30/40 scale for the desktop page -- but the phone shell
   * stacks on its own numbers, dock 62, sheets 70, app bar 75, and a ghost at
   * 10 was drawn behind the sheet it was being dragged inside. You picked a
   * chip up and saw nothing move, which reads as a drag that does not work,
   * because from where you are sitting that is what it is.
   */
  --z-drag: 100; --z-sticky: 20; --z-modal: 30; --z-toast: 40;

  --ease-out: cubic-bezier(.2, .7, .3, 1);
  color-scheme: dark;
}

/*
 * Light, twice, and it has to be twice.
 *
 * The page follows the system by default and can be overridden to either theme,
 * which is two conditions -- "the system says light and nobody overrode it" and
 * "somebody chose light" -- and CSS has no way to hang one declaration block off
 * both. A media query cannot be part of a selector list, and there are no mixins.
 *
 * The alternative was to resolve the theme in JavaScript and always write
 * data-theme, leaving one block here. That trades this duplication for an inline
 * script in the head of all three pages, and makes the default path depend on JS
 * running at all. Preferring the duplication keeps the system case pure CSS,
 * which is the case almost everyone is in.
 *
 * >> The two blocks must stay in step. Change a value in one, change it in the
 *    other. The only difference between them is the selector.
 */
@media (prefers-color-scheme: light) {
  /* The system's answer, unless it has been overruled. */
  :root:not([data-theme="dark"]) {
    --bg:        #f3f4f8;
    --surface:   #ffffff;
    --surface-2: #f7f8fb;
    --surface-3: #eceef4;
    --line:      #d9dde7;
    --line-soft: #e6e9f0;
    --text:      #1b1e26;
    --text-dim:  #565d6d;
    --text-mute: #646c7b;      /* 4.56:1 on --surface-3; see the dark note above */
    --accent:      #f0ab00;
    --accent-ink:  #241a00;
    --accent-ink-on: #a05b00;  /* the fill colour is 1.99:1 as text on white */
    --danger:      #d1402a;
    --ok:          #229a5c;
    --lift-1: 0 1px 2px rgb(21 24 34 / .06), 0 2px 8px rgb(21 24 34 / .06);
    --lift-2: 0 2px 6px rgb(21 24 34 / .08), 0 12px 32px rgb(21 24 34 / .12);
    --hc-water: #0b64a8;
    --hc-fire: #d63b22;
    --hc-grass: #358f24;
    --hc-lightning: #8a5f00;
    --hc-rock: #4a5160;
    --hc-ok: #14683c;
    --hc-danger: #9e2a15;
    color-scheme: light;
  }
}

/* Chosen outright, whatever the system says. Same values as above. */
:root[data-theme="light"] {
  --bg:        #f3f4f8;
  --surface:   #ffffff;
  --surface-2: #f7f8fb;
  --surface-3: #eceef4;
  --line:      #d9dde7;
  --line-soft: #e6e9f0;
  --text:      #1b1e26;
  --text-dim:  #565d6d;
  --text-mute: #646c7b;
  --accent:      #f0ab00;
  --accent-ink:  #241a00;
  --accent-ink-on: #a05b00;
  --danger:      #d1402a;
  --ok:          #229a5c;
  --lift-1: 0 1px 2px rgb(21 24 34 / .06), 0 2px 8px rgb(21 24 34 / .06);
  --lift-2: 0 2px 6px rgb(21 24 34 / .08), 0 12px 32px rgb(21 24 34 / .12);
  /*
   * The colourblind-safe five, light-theme values, and the harder half of the
   * problem. On white every one of them has to be dark enough to hold contrast,
   * which compresses the lightness range that was doing the work in dark mode:
   * the best separation available here is 11.3 dE against dark's 24.9. Lightning
   * becomes a deep amber because a readable yellow on white does not exist.
   *
   * That number is why the element letters exist. In light mode the colour is
   * assisting the glyph rather than the other way round.
   */
  --hc-water: #0b64a8;
  --hc-fire: #d63b22;
  --hc-grass: #358f24;
  --hc-lightning: #8a5f00;
  --hc-rock: #4a5160;
  --hc-ok: #14683c;
  --hc-danger: #9e2a15;
  color-scheme: light;
}

/*
 * The swap itself, in one place.
 *
 * The five --hc-* values are theme-specific and set by whichever theme block
 * won above; this only decides whether they are in play. Routing it through
 * separate names rather than redefining --water in each theme block is what
 * keeps this free of a specificity fight: nothing else in the file sets --water
 * beyond the plain :root, so (0,1,1) here beats (0,1,0) there regardless of
 * source order.
 */
:root[data-contrast="more"] {
  --water: var(--hc-water);
  --fire: var(--hc-fire);
  --grass: var(--hc-grass);
  --lightning: var(--hc-lightning);
  --rock: var(--hc-rock);
}

/*
 * The player pair, swapped by its own switch as well as by High contrast.
 *
 * The two teams' outlines separate only on hue, which is the harder half of the
 * contrast problem to isolate from the other -- so it gets a switch of its own,
 * data-player-contrast, for testing that half without the element palette and
 * letters coming along. High contrast still pulls it in, so turning the big
 * switch on is a superset: nothing here is lost when data-contrast="more".
 */
:root[data-contrast="more"],
:root[data-player-contrast="more"] {
  --p1: var(--hc-p1);
  --p2: var(--hc-p2);
}

/*
 * P2's contrast colour is dark enough that the near-black owner ink it shares
 * with P1 stops reading on its filled badges. P1 stays light and keeps the dark
 * ink; only P2 flips. Scoped under the player attribute so --owner-ink lands on
 * the same elements --owner does. Both switches carry it, same as the pair above.
 */
:root[data-contrast="more"] [data-player="2"],
:root[data-player-contrast="more"] [data-player="2"] { --owner-ink: #fff2f8; }

[data-player="1"] { --owner: var(--p1); }
[data-player="2"] { --owner: var(--p2); }

/*
 * Ink for text sitting on a player colour. Both player colours are mid-tone and
 * do not change between themes, so this does not either -- but it was written
 * out five times as a raw hex, which is exactly how a value drifts. One name,
 * one place to change it.
 */
:root { --owner-ink: #0e1118; }

[data-type="Water"]     { --type-color: var(--water); }
[data-type="Fire"]      { --type-color: var(--fire); }
[data-type="Grass"]     { --type-color: var(--grass); }
[data-type="Lightning"] { --type-color: var(--lightning); }
[data-type="Rock"]      { --type-color: var(--rock); }

/* ============================================================ base */

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

/* Author `display` rules outrank the UA's [hidden] rule, so state this once and
   loudly - otherwise every `el.hidden = true` on a flex/grid element is a no-op. */
[hidden] { display: none !important; }

html { -webkit-text-size-adjust: 100%; }

/*
 * Three faces, each doing one job.
 * ================================
 *
 * From the redesign canvas. --font-ui is what you read, --font-display is what
 * you skim past to find where you are, and --font-mono is the small uppercase
 * labels and anything that has to line up in a column.
 *
 * Every one of them ends in the stack this app shipped with, so the webfont is
 * an improvement and never a dependency: no network, a blocked CDN, or a phone
 * that gives up mid-download all leave the app looking exactly as it did.
 */
:root {
  --font-ui: "Instrument Sans", ui-sans-serif, system-ui, "Segoe UI", Roboto, sans-serif;
  --font-display: "Bricolage Grotesque", "Instrument Sans", ui-sans-serif, system-ui, sans-serif;
  --font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}

body {
  margin: 0;
  background: var(--bg);
  color: var(--text);
  font: 15px/1.55 var(--font-ui);
  font-variant-numeric: tabular-nums;
}

/*
 * Headings get the display face, and it is tighter than the old one -- Bricolage
 * at 700 is a heavier, narrower letter than the system stack at 650, so the
 * sizes below hold while the weight and tracking do the work.
 */
h1, h2, h3 {
  margin: 0;
  font-family: var(--font-display);
  line-height: 1.12; font-weight: 700; letter-spacing: -.02em;
}
h1 { font-size: 20px; }
h2 { font-size: 15px; }
h3 { font-size: 13px; }

/*
 * The small uppercase labels the design uses to head a region -- FORMATION,
 * BENCH, LEVEL-UP PRIORITY. Mono, widely tracked, muted: a label you read once
 * to know what you are looking at and never again, which is exactly what should
 * not be set in the same face as the content under it.
 */
.eyebrow {
  font-family: var(--font-mono);
  font-size: 11px; font-weight: 500;
  letter-spacing: .12em; text-transform: uppercase;
  color: var(--text-mute);
}

a { color: var(--accent-ink-on); text-decoration-color: color-mix(in srgb, var(--accent-ink-on) 45%, transparent); }
a:hover { text-decoration-color: currentColor; }

.muted { color: var(--text-mute); font-weight: 450; }
.hint  { margin: 0 0 var(--s3); color: var(--text-dim); font-size: 12.5px; max-width: 68ch; }
/*
 * The field's hint fills its column.
 *
 * 68ch is a reading measure, and it earns its keep on the pages made of prose --
 * contribute, community, the changelog. Above the board it stopped earning it
 * once that column went wide: the text broke at 566px inside a 768px panel and
 * left 201px of nothing beside three ragged lines, which reads as a layout
 * fault rather than as a considered measure.
 *
 * It is four sentences of instructions above a grid, not something anybody
 * settles in to read, so filling the column costs it nothing and takes it to two
 * lines. Scoped here, so the prose pages keep their measure.
 */
.panel--field .hint { max-width: none; }
/* The touch wording is swapped in under @media (hover: none), further down. */
.hint--touch { display: none; }

kbd {
  padding: 1px 5px; border: 1px solid var(--line); border-radius: var(--r-sm);
  background: var(--surface-3); font: inherit; font-size: 11px;
}

:focus-visible {
  outline: 2px solid var(--accent-ink-on);
  outline-offset: 2px;
  border-radius: var(--r-sm);
}

/* The draggable surfaces. Selecting a sprite is never the intent, and the flash
   of highlight when you double-click a token to take it off the field reads as
   a glitch. -webkit-user-drag stops the browser's own image drag on top of
   ours; the dragstart guard in dnd.js covers the browsers that ignore it.

   -webkit-touch-callout is the phone half of the same problem. A drag here
   starts on a long press, and a long press on an <img> is also how iOS offers
   Save Image / Copy — so the press on a bench chip raised a sheet about the
   sprite instead of lifting the Tatari. The property is inherited, so naming
   the chip covers the image inside it. Sprites elsewhere (the share preview,
   a posted card) keep the callout, because saving those is the point. Chrome
   on Android ignores this entirely; dnd.js refuses the event for that one. */
.card, .cell, .benchchip, .prio, .token, .drag-ghost {
  user-select: none;
  -webkit-user-select: none;
  -webkit-touch-callout: none;
}
img { -webkit-user-drag: none; }

/* ============================================================ topbar */

.topbar {
  position: sticky; top: 0; z-index: var(--z-sticky);
  display: flex; align-items: center; gap: var(--s4);
  padding: var(--s3) var(--s5);
  background: color-mix(in srgb, var(--surface) 92%, transparent);
  backdrop-filter: blur(10px);
  border-bottom: 1px solid var(--line);
}

.topbar__title { display: flex; align-items: baseline; gap: var(--s2); }

/* Who made it, in the header where it is seen on arrival. */
.byline { display: inline-flex; align-items: center; gap: 6px; color: var(--text-dim); font-size: 12px; }
.byline b { color: var(--text); font-weight: 650; }
.byline__avatar {
  width: 26px; height: 26px;
  border-radius: 50%;
  object-fit: cover;
  border: 1px solid var(--line);
}

/* The tip jar riding the credit. Text-dim and borderless at rest so it reads as
   the tail of "by jacc6475" rather than a ninth control in a header that
   already has eight; the accent is spent on the cup alone, and the rest of it
   only warms up once someone has actually pointed at it. */
.byline__tip {
  display: inline-flex; align-items: center; gap: 5px;
  padding: 3px 6px;
  border-radius: var(--r-sm);
  color: var(--text-dim);
  text-decoration: none;
  transition: background-color 120ms ease, color 120ms ease;
}
/* A hairline, not a bullet: enough to stop "jacc6475" and "Say thanks" reading
   as one run-on phrase, without adding a character to a 12px line. */
.byline__tip::before {
  content: "";
  width: 1px; height: 12px;
  margin-right: 3px;
  background: var(--line);
}
.byline__tip svg { flex: none; color: var(--accent-ink-on); }
.byline__tip:hover,
.byline__tip:focus-visible {
  color: var(--text);
  background: color-mix(in srgb, var(--accent) 14%, transparent);
}
.topbar__sub { color: var(--text-mute); font-size: 11.5px; text-transform: uppercase; letter-spacing: .08em; }

/*
 * The two items in this row that leave the page, set apart from the four that
 * act on the formation rather than sitting fifth and sixth in their sequence.
 * On narrow screens those four share the width equally (`flex: 1 1 0` further
 * down); these keep their own, because a link squeezed to "Record a…" is a link
 * nobody follows.
 */

/* Narrow screens: two tight rows that scroll away instead of a sticky slab. */
@media (max-width: 760px) {
  /*
   * The roster head, on one line instead of three.
   * ==============================================
   *
   * "Roster 230", the Tatari/Zobos/Chips tabs and the Cards/List switch each
   * took a line of their own: 79px of head, and 162px of chrome before the
   * first card, in a sheet whose entire job is showing cards.
   *
   * The word "Roster" goes (the tab you pressed to get here says it, and the
   * heading keeps it for screen readers) and the count stays -- that one is
   * load-bearing, since "6 of 230" is how you see a filter is on. Then the two
   * segmented controls shrink enough to sit beside it.
   */
  .panel--roster .panel__head {
    margin-bottom: var(--s2);
    flex-wrap: nowrap; align-items: center; gap: var(--s2);
  }
  .panel--roster .panel__head .panel__headword {
    position: absolute; width: 1px; height: 1px;
    margin: -1px; padding: 0; overflow: hidden;
    clip-path: inset(50%); white-space: nowrap;
  }
  .panel--roster .panel__head h2 { margin-right: 0; flex: 0 0 auto; font-size: 13px; }
  .panel--roster .panel__head .segmented--tabs { margin-left: 0; flex: 0 1 auto; min-width: 0; }
  .panel--roster .panel__head .segmented--tabs:first-of-type { margin-left: auto; }
  .panel--roster .panel__head .segmented--tabs .segmented__btn {
    padding: 5px var(--s2); font-size: 12px;
  }
  .topbar {
    position: static;
    flex-wrap: wrap;
    gap: var(--s2) var(--s3);
    padding: var(--s3) var(--s4);
  }
  .topbar__sub { display: none; }
  .topbar__title { flex: 0 0 auto; }
  #mode-switch { margin-left: auto; }
  /* Kept, but tightened: it is worth a line of header to say who made this,
     not worth the line being tall. */
  .byline { font-size: 11.5px; gap: 5px; }
  .byline__avatar { width: 22px; height: 22px; }
  /*
   * The label stays on phones too.
   *
   * It was hidden here first, on the assumption that a 390px title row could not
   * spare the words. Measured, it can: at the 11.5px this block sets, "Say
   * thanks" costs the byline 59px and the row count does not change. A cup on
   * its own is a guess the reader has to make, and most of the traffic is here,
   * so the guess is worth 59px.
   */
  .byline__tip { padding: 5px 7px; position: relative; }
  /*
   * The tap target, grown without growing the header.
   *
   * The link is 29px tall, under any thumb and well under the 44px the rest of
   * this bar is held to. Raising min-height would work and would also drag the
   * title row taller on the screen that can least spare the height, so the
   * target is stretched by an overlay instead: no box changes size, and the
   * finger gets its 44px. ::before is the hairline, so this is ::after.
   */
  .byline__tip::after {
    content: "";
    position: absolute;
    inset: -8px -6px;
  }
  /*
   * Three rows: title + mode, the name field, then the actions on a line of
   * their own. Five controls cannot share a line with the name field without
   * one of them being illegible, and the whole point of them being here rather
   * than in a menu is that they can be read.
   */
  .field--name { order: 1; flex: 1 1 auto; min-width: 0; }
  /*
   * min-height, not just padding. This selector is 0,2,0 and the touch-size
   * rule further down is 0,1,0, so whatever is set here wins.
   */
}

/* ============================================================ section nav */

/*
 * Desktop shows the field, roster and plan side by side, so there is nothing to
 * jump to. Narrow screens stack them into one long page (with the roster's 218
 * cards in the middle of it), so this is the way back to the field without a
 * long scroll.
 */
.secnav { display: none; }

@media (max-width: 1000px) {
  .secnav {
    position: sticky; top: 0; z-index: var(--z-sticky);
    display: flex; gap: var(--s1);
    padding: var(--s2) var(--s3);
    background: color-mix(in srgb, var(--surface) 94%, transparent);
    backdrop-filter: blur(10px);
    border-bottom: 1px solid var(--line);
  }
  .secnav a {
    flex: 1;
    padding: 9px var(--s2);
    border-radius: var(--r-sm);
    background: var(--surface-2);
    color: var(--text-dim);
    font-size: 12.5px; font-weight: 600; text-align: center;
    text-decoration: none;
  }
  .secnav a:active { background: var(--surface-3); color: var(--text); }
}

/* ============================================================ controls */

.btn {
  --btn-bg: var(--surface-2);
  /* Normalised so a <label class="btn"> sits on the same line as real buttons. */
  display: inline-flex; align-items: center; justify-content: center;
  padding: 7px var(--s3);
  border: 1px solid var(--line);
  border-radius: var(--r-sm);
  background: var(--btn-bg);
  color: var(--text);
  font: inherit; font-size: 13px; font-weight: 550;
  white-space: nowrap;
  /* An <a> wearing this is a button as far as the eye is concerned, and the
     underline the base link style gives it says otherwise. */
  text-decoration: none;
  cursor: pointer;
  transition: background-color 140ms var(--ease-out), border-color 140ms var(--ease-out),
              transform 90ms var(--ease-out);
}
.btn:hover  { --btn-bg: var(--surface-3); border-color: color-mix(in srgb, var(--line) 60%, var(--text-mute)); }
.btn:active { transform: translateY(1px); }
.btn:disabled { opacity: .45; cursor: not-allowed; }
.btn--tiny  { padding: 4px var(--s2); font-size: 12px; }
.btn--quiet { --btn-bg: transparent; color: var(--text-dim); }
.btn--quiet:hover { color: var(--text); }
.btn--primary {
  --btn-bg: var(--accent); color: var(--accent-ink);
  border-color: color-mix(in srgb, var(--accent) 80%, #000);
}
.btn--primary:hover { --btn-bg: color-mix(in srgb, var(--accent) 88%, #fff); }
.btn[aria-pressed="true"] { --btn-bg: var(--surface-3); border-color: var(--accent); color: var(--text); }

.field {
  padding: 7px var(--s3);
  border: 1px solid var(--line);
  border-radius: var(--r-sm);
  background: var(--bg);
  color: var(--text);
  font: inherit; font-size: 13.5px;
  transition: border-color 140ms var(--ease-out);
}
.field::placeholder { color: var(--text-mute); }
.field:hover { border-color: color-mix(in srgb, var(--line) 55%, var(--text-mute)); }
.field:focus-visible { border-color: var(--accent); outline-offset: 1px; }
.field--name { flex: 0 1 240px; min-width: 120px; }
.field--select { padding-right: var(--s2); }
.field--tiny { padding: 4px var(--s2); font-size: 12px; }

.check {
  display: inline-flex; align-items: center; gap: 6px;
  color: var(--text-dim); font-size: 12.5px; cursor: pointer; user-select: none;
  /* A two-word label broke across lines mid-phrase in a wrapping toolbar. */
  white-space: nowrap;
}
.check input { accent-color: var(--accent); margin: 0; }

/*
 * `body[data-sandbox]` and `body[data-zobo]` are stamped by renderAll() for
 * anything that needs to style against either state. Nothing is hidden on them:
 * every one of these toggles is worth reaching for on its own, which is the
 * point of having split them. A .sandbox-only class lived here briefly and gated
 * the second pull, which turned out to be useful without Sandbox — most lanes are
 * deep enough that both rounds land on the field.
 */
.check:hover { color: var(--text); }

/* Marks a feature whose data is still being filled in, so nobody reads a gap
   in it as the tool saying there is nothing there. */
.wip {
  padding: 1px 5px;
  border: 1px solid var(--line);
  border-radius: 999px;
  background: var(--surface-2);
  color: var(--text-mute);
  font-size: 9.5px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase;
}
/* Once it is on, the badge should read as part of an active control. */
.check:has(#opt-ranges:checked) .wip { border-color: var(--accent); color: var(--accent); }

.sortby { display: inline-flex; align-items: center; gap: 6px; color: var(--text-dim); font-size: 12.5px; }

/* ============================================================ layout */

.layout {
  display: grid;
  grid-template-columns: minmax(320px, 440px) minmax(340px, 1fr);
  grid-template-areas: "field roster" "priority roster";
  align-items: start;
  gap: var(--s4);
  padding: var(--s4) var(--s5) var(--s6);
  max-width: 1500px; margin: 0 auto;
}
/*
 * The summary beside the board, paid for out of the margin.
 * =========================================================
 *
 * Below 1700px this does nothing: .fieldrow is one column, so the board and the
 * summary stack exactly as they always have, and the layout is untouched.
 *
 * Above it, the page stops capping itself at 1500px -- which on a 1920 screen
 * was leaving 210px of empty margin on each side -- and spends that width on a
 * second column inside the field panel. The roster's own column is 1fr and keeps
 * what it had: 996px and eleven card columns at 1500, a little more at 1820.
 *
 * That is the whole difference from the version of this that was reverted. The
 * summary needs about 340px and it has to come from somewhere; taking it from
 * the roster left 230 cards in a four-column rail, so it comes out of the page's
 * unused edge instead. Nothing else moves.
 */
@media (min-width: 1700px) {
  .layout {
    /* 1860, not 1820: at 1820 the roster came out 956px and dropped from eleven
       card columns to ten, which is exactly the thing this is not allowed to
       cost. The extra 40px puts it back on 996 and eleven. */
    max-width: 1860px;
    grid-template-columns: minmax(320px, 800px) minmax(340px, 1fr);
  }
  .fieldrow {
    display: grid;
    grid-template-columns: minmax(300px, 440px) minmax(0, 1fr);
    align-items: start;
    gap: var(--s4);
  }
}

.panel--field    { grid-area: field; }
.panel--priority { grid-area: priority; }
.panel--roster   { grid-area: roster; position: sticky; top: 74px; }

/* Dragging a Tatari back to where it came from takes it off the field, or off
   the bench. Marked in the danger colour because it is a removal. */
.panel--roster.is-returning {
  border-color: color-mix(in srgb, var(--danger) 60%, var(--line));
  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--danger) 30%, transparent);
}

/*
 * The bench accepting a drop. Deliberately the accent and not the danger colour
 * the roster uses: dropping here keeps a Tatari, and the two targets sit inches
 * apart during the same drag — one taking it away and one keeping it — so they
 * must not look alike.
 */
.bench__player.is-taking {
  border-radius: var(--r-sm);
  box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--accent) 55%, transparent);
  background: color-mix(in srgb, var(--accent) 7%, transparent);
}

@media (max-width: 1000px) {
  .layout {
    grid-template-columns: minmax(0, 1fr);
    grid-template-areas: "field" "roster" "priority";
    padding: var(--s3);
    gap: var(--s3);
  }
  .panel--roster { position: static; }
  .panel { padding: var(--s3); }
  /* Clear of the sticky section nav when jumped to. */
  .panel { scroll-margin-top: 52px; }
}

.panel {
  isolation: isolate;
  padding: var(--s4);
  border: 1px solid var(--line);
  border-radius: var(--r-lg);
  background: var(--surface);
}
/*
 * Wrapping matters here: a head that cannot wrap takes its min-content width
 * from the whole toolbar in one line, and because the panels share one grid
 * column that width became the page's, which put a horizontal scrollbar on
 * every phone. Wrapping keeps the minimum down to the widest single control.
 */
.panel__head {
  display: flex; align-items: center; flex-wrap: wrap; gap: var(--s2) var(--s3);
  margin-bottom: var(--s2);
}
.panel__head h2 { margin-right: auto; }
.panel__tools { display: flex; align-items: center; flex-wrap: wrap; gap: var(--s1) var(--s2); }

/* ============================================================ battlefield */

.field-frame {
  border: 1px solid var(--line-soft);
  border-radius: var(--r-md);
  background: var(--surface-2);
  overflow: hidden;
}
/*
 * The frame's header: FORMATION on the left, the credit on the right.
 *
 * Inside the frame because the frame is the artefact -- it is what "Just the
 * grid" leaves standing and what ends up in the screenshot people post. The
 * byline sat in the page header for a year and was cropped out of every one of
 * those; here it travels with the picture.
 */
.field-frame__head {
  display: flex; align-items: baseline; gap: var(--s2);
  padding: var(--s2) var(--s3) 6px;
}
/*
 * The name, on the frame, editable in place.
 *
 * Borderless and transparent so it reads as the board's title rather than as a
 * form field -- this is inside the thing people screenshot, and a text input
 * with a box around it would ride along in every posted picture. It grows a
 * surface only on focus, which is the moment it is a control rather than a
 * label.
 */
.field-frame__name {
  flex: 1 1 auto; min-width: 0;
  padding: 2px 4px; margin: -2px -4px;
  border: 1px solid transparent; border-radius: var(--r-sm);
  background: transparent; color: var(--text);
  font-family: var(--font-display);
  font-size: 15px; font-weight: 700; letter-spacing: -.01em;
  text-overflow: ellipsis;
}
.field-frame__name::placeholder { color: var(--text-mute); font-weight: 500; }
.field-frame__name:hover { border-color: var(--line); }
.field-frame__name:focus {
  outline: none;
  border-color: var(--accent);
  background: var(--surface-3);
}
/* Clean view is for the screenshot, so the name is a title and nothing else. */
body.is-clean .field-frame__name { pointer-events: none; border-color: transparent; }

.field-frame__enemy, .field-frame__base {
  padding: 5px var(--s3);
  font-family: var(--font-mono);
  font-size: 10.5px; font-weight: 500; letter-spacing: .1em; text-transform: uppercase;
  color: var(--text-mute);
}

/* The Zobo half of the field. Hatched so it never reads as placeable. */
.field-frame__enemy {
  display: flex; align-items: center; justify-content: center; gap: var(--s2);
  min-height: 42px;
  border-bottom: 1px solid var(--line-soft);
  background:
    repeating-linear-gradient(-45deg,
      color-mix(in srgb, var(--text-mute) 16%, transparent) 0 6px,
      transparent 6px 12px),
    var(--surface-2);
  text-align: center;
}
.field-frame__enemy::after {
  content: "▼";
  font-size: 9px; letter-spacing: 0;
}
/*
 * "Your base" sits at the right, under the end of the row a Zobo would reach
 * last. It used to share this row with the site address and was pushed there by
 * space-between; with the address gone it needs saying outright.
 */
.field-frame__base {
  display: flex; align-items: baseline; justify-content: flex-end; gap: var(--s2);
  border-top: 1px solid var(--line-soft);
}
.field-frame__base > span { white-space: nowrap; }

/*
 * The rows the boss pull opens past the contact line.
 *
 * These are Zobo-ground rows borrowed one at a time — real cells, so what stands
 * on them can be dragged, dropped on and moved along like anything else. The
 * tint is what says they are not ordinary ground: it is the danger colour the
 * old pull strip wore, kept because it is the same event, and it stops at the
 * rows the pull actually opened so an untouched Zobo row never wears it.
 *
 * Not applied when the Zobo ground is open on its own: then those rows are just
 * ground you chose to use, and colouring them for a pull that may not have
 * happened would be a warning about nothing.
 */
.grid__row--pull > .cell {
  border-style: solid;
  border-color: color-mix(in srgb, var(--danger) 45%, var(--line-soft));
  background: color-mix(in srgb, var(--danger) 10%, var(--surface-2));
}
/* The cell the boss actually put somebody in, as against the row it opened to
   do it. Stronger, so a pull into an already-open Zobo row still reads. */
.cell.is-pulled {
  border-color: color-mix(in srgb, var(--danger) 70%, transparent);
  background: color-mix(in srgb, var(--danger) 18%, transparent);
}
/* One continuous band: a grey rule between the spawn line and a pull row above
   it would read as two separate things. */
.field-frame:has(.grid__row--pull) .field-frame__enemy {
  border-bottom-color: color-mix(in srgb, var(--danger) 45%, var(--line-soft));
}

/* The editable "looking for", above the frame. */
.lf-input {
  display: flex; align-items: center; flex-wrap: wrap; gap: var(--s2);
  margin-bottom: var(--s2);
}
/*
 * Two lines share one editor. The pair says which you are adding to, and each
 * half carries a count so the one you are not looking at still shows it has
 * something on it — the alternative was two full rows of controls for what is
 * really one sentence.
 */
.lf-mode {
  display: inline-flex;
  border: 1px solid var(--line);
  border-radius: var(--r-sm);
  overflow: hidden;
}
.lf-mode__btn {
  display: inline-flex; align-items: center; gap: 4px;
  padding: 4px var(--s2);
  border: 0;
  background: transparent;
  color: var(--text-mute);
  font: inherit; font-size: 10.5px; font-weight: 750; letter-spacing: .08em;
  cursor: pointer;
  transition: background-color 140ms var(--ease-out), color 140ms var(--ease-out);
}
.lf-mode__btn + .lf-mode__btn { border-left: 1px solid var(--line); }
.lf-mode__btn:hover { color: var(--text-dim); }
.lf-mode__btn[data-side="lf"][aria-pressed="true"] {
  background: color-mix(in srgb, var(--accent) 18%, transparent); color: var(--accent);
}
.lf-mode__btn[data-side="have"][aria-pressed="true"] {
  background: color-mix(in srgb, var(--ok) 18%, transparent); color: var(--ok);
}
.lf-mode__n {
  min-width: 14px; padding: 0 3px;
  border-radius: 999px;
  background: color-mix(in srgb, currentColor 22%, transparent);
  font-size: 9.5px; letter-spacing: 0; text-align: center;
}
.lf-input__note { flex: 1 1 160px; min-width: 0; }

/* Suggestions float, so the field below does not jump every keystroke. */
.lf-search { position: relative; flex: 1 1 150px; min-width: 0; }
.lf-search .field { width: 100%; }
.lf-suggest {
  position: absolute; z-index: var(--z-sticky);
  top: calc(100% + 4px); left: 0; right: 0;
  margin: 0; padding: var(--s1);
  list-style: none;
  border: 1px solid var(--line);
  border-radius: var(--r-md);
  background: var(--surface-3);
  box-shadow: var(--lift-2);
  max-height: 280px; overflow-y: auto;
  scrollbar-width: thin;
}
.lf-suggest li {
  display: flex; align-items: center; gap: var(--s2);
  padding: 5px var(--s2);
  border-radius: var(--r-sm);
  border-left: 3px solid var(--type-color, transparent);
  font-size: 12.5px;
  cursor: pointer;
}
.lf-suggest li:hover, .lf-suggest li.is-on { background: var(--surface); }
.lf-suggest img { width: 30px; height: 30px; object-fit: contain; }
.lf-suggest__name { font-weight: 600; }
.lf-suggest__meta {
  display: inline-flex; align-items: center; gap: 3px;
  margin-left: auto;
  color: var(--text-mute); font-size: 10.5px; font-weight: 600;
}
.lf-suggest__meta img { width: 14px; height: 14px; }

/* ---------------------------------------------- roster effect markers */

/*
 * Solid: it has this from the start. Hollow with a number: only once levelled
 * that far. Solid with a number: has it now and gains more later. That is the
 * difference between "brings a heal" and "could bring a heal at 5", which is
 * exactly the call you are making while drafting.
 */
/* The band itself. Row 1 of the art box, always present so every sprite below
   it is the same size, and never overlapping the artwork. */
.card__fxrow {
  grid-row: 1;
  display: flex; align-items: center; justify-content: center; gap: 3px;
  width: 100%; height: 15px;
  min-width: 0; max-width: 100%;   /* see .card__art: never wider than the box */
  pointer-events: none;
}
/*
 * One fixed size for all of them. Sizing to content made a badge carrying a
 * level wider than one without, so a Tatari with both (Blitzmane has a buff
 * from the start and a debuff that grows at 3) showed a ragged column of
 * mismatched boxes rather than a tidy stack.
 */
.card__fx {
  display: inline-flex; align-items: center; justify-content: center; gap: 1px;
  width: 20px; height: 13px; padding: 0;
  border: 1px solid currentColor;
  border-radius: 3px;
  font-size: 8px; font-weight: 800; line-height: 1;
}
.card__fx b { font-size: 7.5px; font-weight: 800; }
.card__fx[data-base="true"]  { background: color-mix(in srgb, currentColor 30%, var(--bg)); }
.card__fx[data-base="false"] { background: color-mix(in srgb, var(--bg) 82%, transparent); border-style: dashed; }
.card__fx[data-fx="heal"]   { color: var(--ok); }
.card__fx[data-fx="buff"]   { color: var(--accent); }
.card__fx[data-fx="debuff"] { color: var(--danger); }

/*
 * The patch marker, deliberately unlike the effect badges above it.
 *
 * Those are rounded rects stacked down the top-left, most of them carrying a
 * number. This is a single disc in the opposite corner with nothing beside it,
 * so the two never read as members of one set even at card size -- which they
 * must not, because ▲ there and ↑ here mean completely different things.
 *
 * The arrow carries the meaning and the colour only agrees with it, so the card
 * still reads with the hues stripped out. Opaque background rather than a tint:
 * this sits over artwork, and a translucent badge on a pale sprite loses its
 * arrow entirely.
 */
/*
 * The "this changed in the last update" mark, arrived at by being wrong twice
 * in opposite directions.
 *
 * It began as a filled 15px disc, which put a coloured circle 18px from the 17px
 * circular type badge in the same row. The card reserves circles for what a
 * Tatari permanently is -- its element, its role -- and the disc borrowed that
 * idiom for the most temporary fact on it, the one the next update wipes. Colour
 * made it worse: a nerfed Fire Tatari wore red beside a red Fire badge, a buffed
 * Grass one green beside green. Same shape, same size, unrelated meanings.
 *
 * Stripping the container fixed that and overshot: a lone glyph beside two
 * filled circles loses the fight for attention and was simply not noticed.
 *
 * So a rect, because nothing else in this row is one; a border, so it has an
 * edge to be seen by; and an 18% wash rather than a solid fill, because solid is
 * what made it shout beside a same-coloured type badge. The effect badges are
 * outlined rects too, which is fine: those live in the band inside the art, this
 * lives in the meta row, and the two never appear side by side.
 *
 * In the meta row rather than on the art at all, because no corner of a sprite
 * is reliably empty and these cards get screenshotted -- the same argument the
 * note above .card__meta makes for tier, type and role.
 */
.card__patch {
  flex: none;
  display: inline-flex; align-items: center; justify-content: center;
  /* 15px, not wider. At 19 the meta row overflowed 69px of card and wrapped,
     taking every card from 118px to 122. The circular look came from a 6px
     radius on a small box, not from the width. */
  min-width: 15px; height: 14px;
  padding: 0 1px;
  border: 1px solid currentColor;
  /* 3px, matching .card__fx rather than --r-sm. At 6px on a 15px box the corners
     meet and the tag reads as a circle again, which is the thing this stopped
     being. Wider than tall for the same reason: a square with rounded corners is
     a circle at this size, a rect is not. */
  border-radius: 3px;
  /*
   * Mixed toward --bg, not toward transparent.
   *
   * An 18% wash of the tag's own colour over the card lightens the panel under a
   * light glyph, and --danger has almost no room to give: the arrow fell to
   * 3.79:1 against its own tint, and even 8% only reached 4.37. Mixing the same
   * hue into the page colour instead darkens the panel, so the tag reads as
   * recessed and the glyph gains contrast rather than losing it. Measured at
   * 16%: buffed 6.62:1, nerfed 4.79:1, adjusted 5.99:1.
   */
  background: color-mix(in srgb, currentColor 16%, var(--bg));
  font-size: 11px; font-weight: 800; line-height: 1;
}
/*
 * The arrow is --text, not the direction's hue.
 *
 * Colouring the glyph looked right in dark and broke in light, where --ok is
 * about 3.9:1 as text before anything is done to it: on its own 16% wash the
 * buffed arrow fell to 2.75:1 and the nerfed one to 3.43:1. Putting the hue in
 * the border and the panel and leaving the glyph neutral holds 11-13:1 in both
 * themes, and the colour still arrives -- from the edge and the fill, which are
 * non-text and answer to 3:1 rather than 4.5:1.
 */
.card__patch::after { content: attr(data-glyph); color: var(--text); }
.card__patch[data-patch="buff"]     { color: var(--ok); }
.card__patch[data-patch="nerf"]     { color: var(--danger); }
.card__patch[data-patch="adjusted"] { color: var(--text-dim); }


.lf-wants { display: flex; flex-wrap: wrap; gap: var(--s1); }
.lf-wants:empty { display: none; }
.want {
  display: inline-flex; align-items: center; gap: 4px;
  padding: 2px 2px 2px 3px;
  border: 1px solid color-mix(in srgb, var(--type-color, var(--line)) 55%, var(--line));
  border-radius: 999px;
  background: var(--surface-2);
  font-size: 11.5px; font-weight: 600;
}
.want img { width: 22px; height: 22px; object-fit: contain; }
.want__x {
  width: 18px; height: 18px;
  display: grid; place-items: center;
  border: 0; border-radius: 50%;
  background: transparent; color: var(--text-mute);
  font: inherit; font-size: 13px; line-height: 1; cursor: pointer;
}
.want__x:hover { background: var(--surface-3); color: var(--danger); }

/*
 * And the read-only copy inside the frame. Deliberately loud: this exists to
 * be legible in a screenshot of the grid posted to a chat, which is how these
 * formations actually get shared, so it has to read at whatever size the
 * screenshot ends up.
 */
.field-frame__lf {
  display: flex; flex-direction: column; align-items: stretch;
  gap: 0;
  margin: 0;
  padding: 5px var(--s3);
  /* Neutral: the band can hold both lines at once, so the colour belongs to
     each line rather than to the strip they share. */
  border-top: 1px solid var(--line-soft);
  background: var(--surface-2);
  font-size: 13px; font-weight: 700; letter-spacing: .01em;
  overflow-wrap: anywhere;
}
.field-lf__tag { letter-spacing: .06em; }

/* Offering reads green, asking reads amber, so the two are told apart at a
   glance in a screenshot without reading the word. */
.field-lf__line {
  display: flex; align-items: center; justify-content: center;
  flex-wrap: wrap; gap: var(--s1) var(--s2);
  width: 100%;
  padding: 4px var(--s2);
  border-radius: var(--r-sm);
  background: color-mix(in srgb, var(--accent) 14%, transparent);
  color: color-mix(in srgb, var(--accent) 80%, var(--text));
}
.field-lf__line[data-mode="have"] {
  background: color-mix(in srgb, var(--ok) 14%, transparent);
  color: color-mix(in srgb, var(--ok) 80%, var(--text));
}
.field-lf__line + .field-lf__line { margin-top: 3px; }
.field-lf__line[data-mode="have"] .field-lf__want {
  border-color: color-mix(in srgb, var(--type-color, var(--ok)) 55%, transparent);
}
/* The sprite is the point: a screenshot is read at a glance and in whatever
   language the reader has, and the art says "toucan" faster than the name. */
.field-lf__want {
  display: inline-flex; align-items: center; gap: 4px;
  padding: 1px 7px 1px 2px;
  border: 1px solid color-mix(in srgb, var(--type-color, var(--accent)) 55%, transparent);
  border-radius: 999px;
  background: color-mix(in srgb, var(--bg) 55%, transparent);
}
.field-lf__want img { width: 24px; height: 24px; object-fit: contain; }
.field-lf__note { font-weight: 650; opacity: .92; }

.grid {
  display: grid;
  grid-template-columns: repeat(6, 1fr);
  gap: var(--s1);
  padding: var(--s2);
}

/*
 * Gold Rush and Arena: 5 x 5, no draft. The off-board tiles carry [hidden] from
 * renderGrid(); everything here is what that board has no use for: the bench
 * (what you bring is what is placed), the plan and its level badges (levels are
 * fixed), the Zobo ground, its line and Sandbox (nothing walks in, no caps to lift).
 */
#grid .cell[hidden] { display: none; }
body[data-goldrush="true"] #grid { grid-template-columns: repeat(5, 1fr); }
body[data-goldrush="true"] :is(#chipbench, #sec-priority, #enemy, .field-frame__enemy,
  [data-sheet="priority"], label:has(#opt-zobo), label:has(#opt-sandbox), .field-frame__base) { display: none !important; }
body[data-goldrush="true"] #grid :is(.token__rank, .token__add) { display: none; }

/*
 * Sandbox's extra ground, beyond the contact line.
 *
 * Hatched with the same stripes contribute.css uses for the same piece of
 * ground, so the two pages agree about what it is, and so it reads as one band
 * with the "Zobos spawn beyond this line" strip immediately below it.
 *
 * Where this deliberately parts company with contribute is the cursor. There,
 * hovering a Zobo tile is refused, because a range recorded from the wrong side
 * of the line is recorded against the wrong origin. Here the whole point is
 * that you can stand on it, so the tiles take the same hover and drop states as
 * any other cell and only the ground under them looks borrowed.
 *
 * The bottom padding is dropped so the last Zobo row sits tight against the
 * line, rather than floating a gap above the thing it is supposed to be
 * touching.
 */
.grid--enemy {
  padding-bottom: 0;
  background:
    repeating-linear-gradient(-45deg,
      color-mix(in srgb, var(--text-mute) 16%, transparent) 0 6px,
      transparent 6px 12px),
    var(--surface-2);
}

/* Solid rather than the field's dashed outline: these tiles are real ground in
   Sandbox, they are just not yours. */
.cell--enemy {
  border-style: solid;
  border-color: color-mix(in srgb, var(--line) 65%, transparent);
  background: color-mix(in srgb, var(--bg) 45%, transparent);
}

/*
 * A tile the boss has dragged someone onto.
 *
 * Wears the danger tint the old pull strip did, because it is the same event —
 * the colour is the only thing left carrying "the boss put them here" now that
 * they are standing in ordinary cells.
 *
 * On field cells as well as Zobo ones: a pull lands in front of the frontmost
 * Tatari in its lane, which is usually a row of the field and only reaches past
 * the spawn line when that lane's front rank is already at row 0. Listed after
 * .cell--enemy so it wins on the tiles that have their own background.
 *
 * Still a drop target. The pull is held rather than re-derived now, so the board
 * stays editable underneath it, and a drop here lands on the cell as it really
 * is — the marker belongs to a Tatari standing somewhere else.
 */
.cell.is-pulled,
.cell--enemy.is-pulled {
  border-style: solid;
  border-color: color-mix(in srgb, var(--danger) 45%, var(--line-soft));
  background: color-mix(in srgb, var(--danger) 13%, var(--surface-2));
}

.cell {
  position: relative;
  aspect-ratio: 1;
  display: grid; place-items: center;
  border: 1px dashed var(--line);
  border-radius: var(--r-sm);
  background: var(--bg);
  cursor: default;
  transition: background-color 140ms var(--ease-out), border-color 140ms var(--ease-out);
}
/* Only the valid drop target is signalled; an ineligible cell gets no feedback. */
.cell.is-over {
  border-color: var(--accent);
  border-style: solid;
  background: color-mix(in srgb, var(--accent) 14%, var(--bg));
}
.cell.is-filled { border-style: solid; border-color: var(--line-soft); cursor: grab; }
.cell.is-filled:active { cursor: grabbing; }
.cell.is-dragging { opacity: .35; }

/* the occupant */
.token {
  position: absolute; inset: 0;
  display: grid; place-items: center;
  border-radius: var(--r-sm);
  background: color-mix(in srgb, var(--type-color, var(--line)) 16%, var(--surface-2));
  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--type-color, var(--line)) 45%, transparent);
}
/* Sprites are pre-normalised to a 200x200 box with their own padding baked in
   (tools/normalize_images.py), so they fill their container rather than being
   inset again here. The clip lives on the sprite rather than on the token: a
   token-wide `overflow: hidden` also takes a bite out of every corner badge. */
.token img {
  width: 100%; height: 100%; object-fit: contain;
  border-radius: inherit;
  pointer-events: none;
}
.token__fallback {
  font-size: 10px; font-weight: 650; text-align: center; line-height: 1.2;
  padding: 2px; color: var(--text-dim); pointer-events: none;
}
.token__tier {
  position: absolute; top: 2px; left: 3px;
  font-size: 9px; font-weight: 700; color: var(--text-dim);
  pointer-events: none;
}
.token__rank {
  position: absolute; bottom: 1px; right: 3px;
  display: inline-flex; align-items: center; gap: 2px;
  min-width: 13px; padding: 0 3px;
  border-radius: 3px;
  background: var(--accent); color: var(--accent-ink);
  font-size: 9px; font-weight: 700; text-align: center;
  pointer-events: none;
}

/*
 * The step number, riding on the level badge rather than claiming a corner of
 * its own — every corner of a token is already spoken for by tier, role, owner
 * and level. Dark on the accent so the order reads first and the level second:
 * which one you level next is the question the plan answers.
 */
.token__seq {
  display: grid; place-items: center;
  min-width: 11px; height: 11px;
  padding: 0 1px;
  border-radius: 999px;
  background: color-mix(in srgb, var(--accent-ink) 82%, transparent);
  color: var(--accent);
  font-size: 8px; font-weight: 800; line-height: 1;
}

/* Only offered while a Tatari has no planned level-ups. */
.token__add {
  position: absolute; bottom: 1px; right: 2px;
  width: 15px; height: 15px; padding: 0;
  display: grid; place-items: center;
  border: 1px solid var(--line);
  border-radius: 3px;
  background: var(--surface-3);
  color: var(--text-dim);
  font: inherit; font-size: 11px; font-weight: 700; line-height: 1;
  cursor: pointer;
  transition: background-color 140ms var(--ease-out), color 140ms var(--ease-out);
}
.token__add:hover { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); }
.token__role { position: absolute; top: 2px; right: 2px; pointer-events: none; }

/*
 * The element letter: the half of "which element is this" that is not colour.
 *
 * Top-centre because it is the only part of a token nothing else claims. All
 * four corners are spoken for — tier and role along the top, owner and level
 * along the bottom — and on a 61px cell the gap between tier and role is 27px,
 * which fits this with room to spare. It is free on a Zobo too, where the boss
 * star takes the top-right.
 *
 * Neutral ink on a near-opaque chip, deliberately: this exists because hue
 * failed, so printing the letter in the element's own colour would put it back
 * on the channel it is meant to replace. The tint and the ring keep saying it in
 * colour underneath, for everyone who reads that fine.
 *
 * Off unless asked for, and set with display so toggling it needs no re-render.
 */
.token__elem {
  position: absolute; top: 2px; left: 50%;
  transform: translateX(-50%);
  display: none;
  min-width: 13px; padding: 0 2px;
  border-radius: 3px;
  /*
   * A definite chip rather than a floating letter. The fill has to be opaque
   * enough to work over a sprite as well as over the tint — a translucent one
   * disappeared against pale artwork in light mode — and the hairline is what
   * keeps a white chip from vanishing into a white token.
   */
  background: var(--surface);
  box-shadow: 0 0 0 1px color-mix(in srgb, var(--line) 75%, transparent);
  color: var(--text);
  font-size: 9px; font-weight: 800; line-height: 13px; letter-spacing: .02em;
  text-align: center;
  pointer-events: none;
}
:root[data-contrast="more"] .token__elem { display: block; }

/*
 * The heal / buff / debuff marks, which are the app's one red-versus-green pair.
 *
 * The glyphs already carry the meaning without colour — + for a heal, up and
 * down triangles for a buff and a debuff — and solid versus dashed already says
 * "has it" versus "gets it later". So the shape work was done. What failed was
 * legibility: measured at 3.2:1 in light mode, under the 4.5 an 8px glyph needs.
 *
 * The cause is structural rather than a badly chosen green. A solid mark fills
 * itself with 30% of its own ink, so the fill tracks the text and the ratio
 * stays near 3.2 whatever hue you pick. Lightening the fill is what actually
 * moves it; the dashed border keeps the base/level distinction intact.
 *
 * Buff switches to --accent-ink-on for the reason recorded where that variable
 * is defined: the accent is a fill colour, and is 1.99:1 as text on white.
 */
:root[data-contrast="more"] .card__fx[data-base="true"] {
  background: color-mix(in srgb, currentColor 12%, var(--surface));
}
:root[data-contrast="more"] .card__fx[data-fx="heal"]   { color: var(--hc-ok); }
:root[data-contrast="more"] .card__fx[data-fx="buff"]   { color: var(--accent-ink-on); }
:root[data-contrast="more"] .card__fx[data-fx="debuff"] { color: var(--hc-danger); }

/*
 * A stronger wash to go with the safer palette.
 *
 * Swapping the five colours accomplished almost nothing on its own, because a
 * token shows its element at 16% over the surface — enough to hint at a hue you
 * can already see, nowhere near enough to carry one you cannot. Fixing the
 * colours without raising the dose would have been a measurement that never
 * reached the screen.
 *
 * The ring is left alone: in co-op it belongs to the player who brought the
 * Tatari, and taking that over would trade one reader's problem for another's.
 */
:root[data-contrast="more"] .token {
  background: color-mix(in srgb, var(--type-color, var(--line)) 32%, var(--surface-2));
}
/*
 * The tier badge has to come up with the wash.
 *
 * Measured: at 16% the badge is --text-dim on a pale tile and passes; at 32% the
 * tile has darkened enough to drop it to 3.8:1, under the 4.5 WCAG asks of 9px
 * text. Raising the wash without raising this would have fixed one reader's
 * problem by giving them another. --text clears it in both themes.
 */
:root[data-contrast="more"] .token__tier { color: var(--text); }

/*
 * Pre-existing, and only corrected here.
 *
 * The version pill is --text-mute on --surface-2, which is 3.2:1 — short of 4.5
 * for 10.5px text in every theme, with or without this mode. Left alone in
 * normal mode because "normal mode looks unchanged" was the brief and this is
 * one of the deliberate de-emphasis choices documented at --text-mute. Anyone
 * who has asked for more contrast has asked for exactly this, though.
 */
:root[data-contrast="more"] .ver-pill { color: var(--text-dim); }

/*
 * The filter chips, for the reader who has asked for contrast.
 *
 * An unpressed chip label is --text-mute, which measures 3.37:1 on the chip in
 * dark and 2.95:1 in light -- short of the 4.5:1 WCAG asks for at 11px, and the
 * count badge is 9.5px. The note beside --text-mute explains why the token
 * itself has not been lifted: 42 selectors inherit it and raising them together
 * visibly brightened the roster. That argument does not reach here. Somebody
 * with High contrast on has said in as many words that they would rather have
 * the contrast than the restraint, and until now this switch only swapped the
 * five element hues and left every filter label exactly as dim as before.
 *
 * --text-dim measures 6.5:1 on the same chip. Nothing else changes: the pressed
 * states were already 12-13:1 and are left alone.
 */
/* Scoped through .chips rather than hitting .chip directly. At equal weight the
   plain .chip colour kept winning, and chasing why is not worth a cascade
   argument when every chip in this app lives in a .chips row anyway. */
:root[data-contrast="more"] .chips .chip,
:root[data-contrast="more"] .chips .fxcaret,
:root[data-contrast="more"] .chips__label,
:root[data-contrast="more"] .chips--sub .chip b { color: var(--text-dim); }
:root[data-contrast="more"] .chips .chip:hover { color: var(--text); }
/* The dimmed "nothing reaches this under the current ceiling" state relies on
   opacity, which drags the label back under the floor. Kept legible, and told
   apart by its border instead. */
:root[data-contrast="more"] .chips--sub .chip[data-empty="true"] {
  opacity: 1;
  color: var(--text-mute);
  border-style: dashed;
}

/* Owner marker. Only rendered in co-op, so solo tokens stay uncluttered. */
.token__owner {
  position: absolute; bottom: 1px; left: 2px;
  padding: 0 3px;
  border-radius: 3px;
  background: var(--owner); color: var(--owner-ink);
  font-size: 8.5px; font-weight: 800; letter-spacing: .02em;
  pointer-events: none;
}
body[data-mode="coop"] .token {
  box-shadow: inset 0 0 0 2px var(--owner, var(--line));
}

/* ============================================================ player tabs */

/* Wraps: two player tabs and "Switch player" are wider than a 320px phone, and
   unwrapped they pushed the whole page sideways in co-op. */
.ptabs { display: flex; flex-wrap: wrap; gap: var(--s2); margin-bottom: var(--s3); }

.ptab {
  flex: 1 1 0;
  display: flex; align-items: center; gap: var(--s2);
  padding: var(--s2) var(--s3);
  border: 1px solid var(--line);
  border-radius: var(--r-sm);
  background: var(--surface-2);
  color: var(--text-dim);
  font: inherit; text-align: left;
  cursor: pointer;
  transition: background-color 140ms var(--ease-out), border-color 140ms var(--ease-out),
              color 140ms var(--ease-out);
}
.ptab:hover { background: var(--surface-3); }
/* Same weight as the bench's own active state, so the two agree about who is
   selected instead of each hinting at it differently. */
.ptab[data-active="true"] {
  border-color: var(--owner);
  box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--owner) 55%, transparent);
  background: color-mix(in srgb, var(--owner) 16%, var(--surface-2));
  color: var(--text);
}
.ptab__who {
  flex: none;
  padding: 1px var(--s2);
  border-radius: 3px;
  background: var(--owner); color: var(--owner-ink);
  font-size: 11px; font-weight: 800;
}
.ptab__nums { display: grid; gap: 1px; font-size: 11px; line-height: 1.25; }
.ptab__nums b { color: var(--text); font-weight: 700; }

/* ============================================================ bench */

.bench { display: grid; gap: var(--s3); margin-top: var(--s3); }
.bench__player { display: grid; gap: var(--s2); }
/*
 * Both benches carry their owner's bar; only its strength says which one you
 * are filling. The two states used to differ by a border-colour mix alone,
 * which is not enough to answer "whose bench am I adding to" at a glance —
 * the question the mode exists to keep straight.
 *
 * Border widths are identical in both states on purpose: changing them would
 * shift the contents sideways every time you switched.
 */
body[data-mode="coop"] .bench__player {
  padding: var(--s2);
  border: 1px solid var(--line-soft);
  border-left: 4px solid color-mix(in srgb, var(--owner) 30%, transparent);
  border-radius: var(--r-sm);
  background: var(--surface-2);
  transition: border-color 140ms var(--ease-out), background-color 140ms var(--ease-out),
              box-shadow 140ms var(--ease-out);
}
body[data-mode="coop"] .bench__player[data-active="true"] {
  border-color: color-mix(in srgb, var(--owner) 55%, var(--line));
  border-left-color: var(--owner);
  background: color-mix(in srgb, var(--owner) 14%, var(--surface-2));
  box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--owner) 55%, transparent);
}
.bench__head { display: flex; align-items: baseline; flex-wrap: wrap; gap: var(--s2); }
/* Only the first button takes the slack, so the pair sits together at the right
   rather than one drifting to the middle. */
.bench__head .btn { margin-left: auto; }
.bench__head .btn ~ .btn { margin-left: 0; }
.bench__count { color: var(--text-dim); font-size: 11.5px; }
.bench__count b { color: var(--text); font-weight: 700; }
.bench__empty { margin: 0; color: var(--text-mute); font-size: 12px; }

.bench__strip { display: flex; flex-wrap: wrap; gap: var(--s1); }

.benchchip {
  position: relative;
  width: 46px; height: 46px;
  display: grid; place-items: center;
  border: 1px solid var(--line-soft);
  border-radius: var(--r-sm);
  background: color-mix(in srgb, var(--type-color) 14%, var(--surface-2));
  cursor: grab;
  transition: border-color 140ms var(--ease-out), transform 110ms var(--ease-out);
}
.benchchip img { width: 100%; height: 100%; object-fit: contain; }
.benchchip:hover { border-color: var(--accent); transform: translateY(-2px); }
.benchchip.is-dragging { opacity: .4; }
.benchchip__x {
  position: absolute; top: 1px; right: 1px;
  width: 16px; height: 16px; padding: 0;
  display: grid; place-items: center;
  border: 1px solid var(--line);
  border-radius: 999px;
  background: var(--surface-3);
  color: var(--text-mute);
  font: inherit; font-size: 11px; line-height: 1;
  cursor: pointer;
  opacity: 0; pointer-events: none;
  transition: opacity 140ms var(--ease-out), color 140ms var(--ease-out);
}
.benchchip:hover .benchchip__x, .benchchip__x:focus-visible { opacity: 1; pointer-events: auto; }
.benchchip__x:hover { color: color-mix(in srgb, var(--danger) 85%, var(--text)); }
.benchchip__owner {
  position: absolute; bottom: 1px; left: 1px;
  padding: 0 3px;
  border-radius: 2px;
  background: var(--owner); color: var(--owner-ink);
  font-size: 8.5px; font-weight: 800;
  pointer-events: none;
}

/* ============================================================ segmented */

.segmented {
  display: inline-flex;
  padding: 2px;
  border: 1px solid var(--line);
  border-radius: var(--r-sm);
  background: var(--bg);
}
.segmented__btn {
  padding: 4px var(--s3);
  border: 0; border-radius: 4px;
  background: transparent;
  color: var(--text-dim);
  font: inherit; font-size: 12.5px; font-weight: 600;
  cursor: pointer;
  transition: background-color 140ms var(--ease-out), color 140ms var(--ease-out);
}
.segmented__btn:hover { color: var(--text); }
/*
 * Both attributes, because the same control says "on" two different ways. A
 * group of toggle buttons uses aria-pressed; a tablist uses aria-selected, and
 * the roster's Tatari/Zobos pair is a real tablist. Styling only the first left
 * the selected tab looking exactly like the unselected one — the state was in
 * the accessibility tree and nowhere on screen.
 */
.segmented__btn[aria-pressed="true"],
.segmented__btn[aria-selected="true"] {
  background: var(--surface-3);
  color: var(--text);
  box-shadow: inset 0 0 0 1px var(--line);
}

/*
 * A segmented control sized to sit in a row of checkboxes.
 *
 * The theme switch shares .panel__tools with six labels on a phone, where that
 * row is already at 11.5px and 6px of column gap. At full size this one control
 * would be taller than everything beside it and would force the row to wrap on
 * its own account.
 */
.segmented--mini .segmented__btn { padding: 2px var(--s2); font-size: 11.5px; }

/* ============================================================ badges */

/* The type and role icons are the game's own art, and each already carries its
   disc or plate. So these wrappers only reserve a box and keep every icon the
   same apparent size — they draw no background or ring of their own. */

.badge, .icon { display: inline-grid; place-items: center; flex: none; }
.badge { width: 17px; height: 17px; }
.icon { width: 15px; height: 15px; }
.icon__art { display: block; width: 100%; height: 100%; object-fit: contain; }

/* ============================================================ summary */

/*
 * No rule above TYPES.
 *
 * The line was separating the summary from the bench above it, back when both
 * were stacked in the same column. On a wide screen the summary is beside the
 * board now, so the rule was drawing a division across the top of its own
 * column with nothing above it to divide from -- and where it does still stack,
 * the gap does the separating on its own.
 */
.summary {
  display: flex; flex-direction: column; gap: var(--s2);
  margin-top: var(--s4);
}
/*
 * Denser, because co-op has two of everything.
 * ============================================
 *
 * The summary column is 310px, so each group wraps: Types over two lines, a
 * nine-chip Buffs row over four. Solo that is fine. Co-op draws the whole thing
 * twice, and it came to 924px -- you scrolled past all of P1 to reach P2, which
 * is exactly the comparison co-op exists to let you make.
 *
 * Nothing is removed. The chips lose padding and a point of type, the rows lose
 * the gaps they did not need, and more fits per line -- every line saved is
 * saved twice. Together with the empty effect panel collapsing, that is the
 * scroll gone rather than merely shortened.
 */
.summary__player { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s1) var(--s3); }
/* Six role tallies do not fit a narrow panel on one line, and a flex item that
   cannot wrap runs straight out through the panel border instead. */
.summary__group { display: flex; flex-wrap: wrap; align-items: center; gap: 3px 5px; min-width: 0; }
.summary__label {
  font-size: 10.5px; font-weight: 650; letter-spacing: .08em;
  text-transform: uppercase; color: var(--text-mute);
}
.summary__label[data-player] {
  padding: 1px var(--s2);
  border-radius: 3px;
  background: var(--owner); color: var(--owner-ink);
  letter-spacing: .02em;
}

/*
 * In co-op the bench's own P1 / P2 pill is a button that switches which
 * teammate you are planning for. Only the button chrome is reset — the pill's
 * size, weight and owner colour still come from the rules above, so the control
 * looks like the label it replaced.
 */
button.summary__label { border: 0; font-family: inherit; cursor: pointer; }
button.summary__label:hover,
button.summary__label:focus-visible {
  box-shadow: 0 0 0 2px color-mix(in srgb, var(--owner) 45%, transparent);
}
button.summary__label[aria-pressed="true"] { cursor: default; }

/* The whole inactive bench is the target, not just the pill on it. */
body[data-mode="coop"] .bench__player[data-active="false"] { cursor: pointer; }
.tally {
  display: inline-flex; align-items: center; gap: 5px;
  padding: 1px 6px 1px 4px;
  border: 1px solid var(--line);
  border-radius: 999px;
  background: var(--surface-2);
  font-size: 11px; font-weight: 600;
}
.tally[data-empty="true"] { opacity: .35; }

/*
 * The element tally as the canvas draws it: a coloured dot, the name, the count.
 *
 * The dot carries --type-color off the data-type attribute, the same variable
 * the tokens and cards use, so an element is one colour everywhere and High
 * Contrast repaints this along with everything else. The name is what makes it
 * work without the colour at all, which is the point -- the icon version needed
 * High Contrast turned on before Fire and Rock could be told apart.
 */
.tally--named {
  gap: 5px;
  padding: 1px 6px;
  font-weight: 500;
  color: var(--text-dim, var(--text));
}
.tally--named b { font-weight: 700; color: var(--text); font-variant-numeric: tabular-nums; }
.tally__dot {
  width: 8px; height: 8px; flex: none;
  border-radius: 50%;
  background: var(--type-color, var(--text-mute));
}

/* Heals, buffs and debuffs, coloured by what they do to you rather than by type. */
.tally--effect { gap: 3px; padding: 1px 6px; font-size: 10.5px; font-weight: 600; }
.tally--effect b { font-weight: 800; }
.tally--effect[data-effect="heal"] {
  color: color-mix(in srgb, var(--ok) 85%, var(--text));
  border-color: color-mix(in srgb, var(--ok) 45%, var(--line));
}
.tally--effect[data-effect="buff"] {
  color: color-mix(in srgb, var(--accent) 88%, var(--text));
  border-color: color-mix(in srgb, var(--accent) 45%, var(--line));
}
.tally--effect[data-effect="debuff"] {
  color: color-mix(in srgb, var(--danger) 85%, var(--text));
  border-color: color-mix(in srgb, var(--danger) 45%, var(--line));
}
/*
 * An effect that only arrives with a level-up skill is drawn as an outline
 * rather than a solid, because the formation does not have it yet — it is a
 * thing you could have if you spend the levels.
 */
.tally__lv {
  padding: 0 4px;
  border-radius: 999px;
  background: color-mix(in srgb, currentColor 18%, transparent);
  font-size: 9.5px; font-weight: 800; letter-spacing: .03em;
}
.tally--effect[data-only-level="true"] { border-style: dashed; opacity: .85; }

/*
 * Tapping an effect names who brings it — a tooltip could not, because a touch
 * screen never shows one, and that is exactly where the question gets asked.
 *
 * The answer goes in a slot of its own, always present and always the same
 * height. Opening it inside the row of tallies re-wrapped that row and shoved
 * every other effect sideways, so reading a second one meant finding where it
 * had just moved to. Nothing moves now: only the slot's contents change.
 */
button.tally--effect { font: inherit; font-size: 11px; font-weight: 600; cursor: pointer; }
.tally--effect:hover { border-color: currentColor; }
.tally--effect[aria-pressed="true"] {
  background: color-mix(in srgb, currentColor 16%, transparent);
  border-color: currentColor;
}

.effect__panel {
  /* The summary is a column flex, so flex-basis here would mean height and
     would fight the fixed size. The whole point is that it never changes. */
  flex: 0 0 auto;
  align-self: stretch;
  box-sizing: border-box;
  height: 96px;
  overflow-y: auto;
  margin-top: var(--s1);
  padding: var(--s2) var(--s3);
  border: 1px solid var(--line-soft);
  border-radius: var(--r-sm);
  background: var(--surface-2);
  color: var(--text-dim); font-size: 11.5px;
  scrollbar-width: thin;
}
/*
 * Empty, it is a prompt and not a panel.
 *
 * The fixed 96px exists so that opening an effect does not re-wrap the row of
 * tallies above it -- that is worth keeping, and it still holds, because the
 * height is fixed the moment there is something in it. What it was also doing
 * was reserving 96px of empty box per player: in co-op, two of them, 192px of
 * the 924px you had to scroll through to reach P2's half.
 *
 * So the empty state collapses to its one line of text. Tapping an effect grows
 * it to the fixed height and everything below moves down once; the tallies,
 * which are above it, never move at all.
 */
.effect__panel[data-empty="true"] {
  height: auto;
  padding: 4px var(--s3);
  border-color: transparent;
  color: var(--text-mute); font-style: italic; font-size: 12px;
}
.effect__panelhead {
  display: flex; align-items: center; gap: var(--s2);
  margin-bottom: 3px; color: var(--text); font-size: 11px;
}

/* The wiki names these effects but never says what they do, so this does. */
.fx-info {
  width: 15px; height: 15px;
  display: grid; place-items: center;
  border: 1px solid var(--line); border-radius: 50%;
  background: var(--surface-3); color: var(--text-dim);
  font: inherit; font-size: 9.5px; font-weight: 800; font-style: italic;
  line-height: 1; cursor: pointer;
}
.fx-info:hover { border-color: var(--accent); color: var(--accent); }
.fx-info[aria-expanded="true"] { border-color: var(--accent); background: var(--accent); color: var(--accent-ink); }
.effect__help {
  margin: 0 0 var(--s1);
  padding: 5px var(--s2);
  border-left: 2px solid var(--accent);
  background: var(--surface);
  border-radius: 0 var(--r-sm) var(--r-sm) 0;
  color: var(--text-dim);
}
.effect__helpnote { display: block; margin-top: 2px; color: var(--text-mute); font-size: 10px; font-style: italic; }
.effect__help[data-missing="true"] { border-left-color: var(--text-mute); color: var(--text-mute); }

.effect__who { margin: 0; padding: 0; list-style: none; }
.effect__who li { display: flex; align-items: center; gap: 5px; padding: 2px 0; }
/* The sprite, because that is how you recognise a Tatari you picked by its art. */
.who__art { display: inline-flex; flex: 0 0 auto; }
.who__art img { width: 22px; height: 22px; object-fit: contain; }
.who__lv { color: var(--text-mute); }

.summary__player--effects { gap: var(--s2) var(--s4); }
.detail__effects { display: flex; flex-wrap: wrap; gap: var(--s1); margin-top: 5px; }
.summary__note { flex-basis: 100%; margin: 0; color: var(--text-dim); font-size: 12.5px; }
.summary__note b { color: var(--text); font-weight: 600; }

/* ============================================================ priority */

.priority {
  list-style: none; margin: 0; padding: 2px; display: grid; gap: var(--s1);
  max-height: min(46vh, 440px); overflow-y: auto; scrollbar-width: thin;
}
.priority:empty::after {
  content: "No level-ups planned yet. Add a step above, or hit + on a Tatari in the formation.";
  display: block; padding: var(--s4) 0; color: var(--text-mute); font-size: 12.5px;
}

.step-adder {
  display: grid; gap: var(--s2);
  margin-bottom: var(--s3);
  padding: var(--s2);
  border: 1px solid var(--line-soft);
  border-radius: var(--r-sm);
  background: var(--surface-2);
}
/* Grid items refuse to go below their min-content width by default, and the
   selection line's min-content is a whole list of Tatari names on one line -
   which pushes the whole adder out through the panel. */
.step-adder__row { min-width: 0; }
.step-adder__row--go { display: flex; align-items: center; gap: var(--s2); }
.step-adder__name {
  flex: 1 1 auto; min-width: 0;
  font-size: 12.5px; font-weight: 600;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.step-adder__level { flex: 0 0 88px; display: grid; }
.step-adder__level select { width: 100%; }
.step-adder__note { display: grid; }
.step-adder__note input { width: 100%; }

/* Picking by sprite rather than by name - you recognise the art faster. */
.step-picker {
  display: flex; flex-wrap: wrap; gap: var(--s1);
  max-height: 116px; overflow-y: auto;
  scrollbar-width: thin;
}
.step-pick {
  position: relative;
  width: 38px; height: 38px; padding: 0;
  display: grid; place-items: center;
  border: 1px solid var(--line-soft);
  border-radius: var(--r-sm);
  background: color-mix(in srgb, var(--type-color) 14%, var(--surface-3));
  cursor: pointer;
  transition: border-color 140ms var(--ease-out), transform 110ms var(--ease-out);
}
.step-pick img { width: 100%; height: 100%; object-fit: contain; }
.step-pick:hover { transform: translateY(-2px); }
.step-pick[aria-pressed="true"] {
  border-color: var(--accent);
  box-shadow: inset 0 0 0 1px var(--accent);
}
.step-pick__done {
  position: absolute; top: 1px; right: 1px;
  padding: 0 2px;
  border-radius: 2px;
  background: var(--accent); color: var(--accent-ink);
  font-size: 8px; font-weight: 800;
}

.sr-only {
  position: absolute; width: 1px; height: 1px;
  margin: -1px; padding: 0; overflow: hidden;
  clip-path: inset(50%); white-space: nowrap;
}

/*
 * A step for a Tatari that is brought but not on the field.
 *
 * Faded rather than disabled, and deliberately: the step is still yours to
 * reorder, re-level and annotate, it simply cannot happen until the Tatari is
 * standing somewhere. Opacity alone would take the text under contrast; the
 * sprites carry most of the fading and the words stay legible.
 *
 * A dashed border rather than a solid one, matching how an empty cell is drawn
 * on the field: the same "nothing is standing here" the board already uses.
 */
.prio.is-benched {
  border-style: dashed;
  background: transparent;
}
.prio.is-benched .prio__art { opacity: .45; }
.prio.is-benched .prio__label { color: var(--text-dim); }

.prio {
  /* Past the rank column, so wrapped lines start under the sprites. */
  --prio-indent: 26px;
  display: flex; align-items: center; flex-wrap: wrap;
  /* Tight between lines: the names and the note are continuations of the row
     above them, not separate entries. */
  gap: 3px 6px;
  padding: 5px var(--s2) 5px 5px;
  border: 1px solid var(--line-soft);
  border-radius: var(--r-sm);
  background: var(--surface-2);
  cursor: grab;
  transition: background-color 140ms var(--ease-out), border-color 140ms var(--ease-out);
}
.prio:hover { background: var(--surface-3); }
.prio.is-dragging { opacity: .4; }
.prio.is-over { border-color: var(--accent); }
.prio__rank {
  flex: none; width: 20px; text-align: center;
  font-size: 12px; font-weight: 700; color: var(--text-mute);
}
.prio__arts { flex: 0 1 auto; display: flex; flex-wrap: wrap; align-items: center; gap: 2px; }
.prio__art {
  position: relative;
  flex: none; width: 30px; height: 30px;
  display: grid; place-items: center;
  border-radius: var(--r-sm);
  background: color-mix(in srgb, var(--type-color, var(--line)) 16%, var(--surface-3));
}
.prio__art img { width: 100%; height: 100%; object-fit: contain; }

/*
 * The element letter on a plan step. Same job as .token__elem, same neutral ink
 * for the same reason: it exists because hue failed, so it cannot be drawn in
 * the element's hue.
 *
 * Bottom-right rather than the token's top-centre, because a 30px art has no
 * middle to speak of and the corner is the only part of it that is not sprite.
 * It sits under .prio__artx, which is a full-bleed overlay on grouped steps —
 * that button only paints on hover, and covering the letter at the moment
 * somebody is aiming at a remove button costs nothing.
 */
.prio__elem {
  position: absolute; right: -1px; bottom: -1px;
  display: none;
  min-width: 11px; padding: 0 1px;
  border-radius: 3px;
  background: var(--surface);
  box-shadow: 0 0 0 1px color-mix(in srgb, var(--line) 75%, transparent);
  color: var(--text);
  font-size: 8.5px; font-weight: 800; line-height: 12px; text-align: center;
  pointer-events: none;
}
:root[data-contrast="more"] .prio__elem { display: block; }
/* Same dose as the token, for the same reason. */
:root[data-contrast="more"] .prio__art {
  background: color-mix(in srgb, var(--type-color, var(--line)) 32%, var(--surface-3));
}

/* A step naming several Tatari shows them all, so each one gives up some size,
   and the row it cannot fit them all on gives the names a line of their own. */
.prio--group .prio__art { width: 24px; height: 24px; }
.prio--group .prio__name {
  order: 1; flex: 1 1 100%;
  margin-left: var(--prio-indent);
  color: var(--text-dim); font-size: 11.5px;
}

/* Each member of a group can leave on its own; a lone Tatari uses the row's
   own remove button instead. */
.prio__artx {
  position: absolute; inset: 0;
  display: grid; place-items: center;
  padding: 0;
  border: 0; border-radius: var(--r-sm);
  background: color-mix(in srgb, var(--bg) 74%, transparent);
  color: color-mix(in srgb, var(--danger) 85%, var(--text));
  font: inherit; font-size: 13px; font-weight: 700; line-height: 1;
  cursor: pointer;
  opacity: 0; pointer-events: none;
  transition: opacity 120ms var(--ease-out);
}
.prio__art:hover .prio__artx, .prio__artx:focus-visible { opacity: 1; pointer-events: auto; }
/* Nothing flexible is left on a group's first line, so the level and the
   actions are pushed to the far end themselves. */
.prio--group .prio__levelwrap { margin-left: auto; }
/* Basis 0, not auto: a flex line wraps before it shrinks, so a name sized to
   its own text pushes the remove button onto a second line the moment the
   Tatari is called something long. */
.prio__name {
  min-width: 0; flex: 1 1 0;
  display: flex; align-items: baseline; gap: 5px;
  font-size: 13px; font-weight: 550;
  overflow: hidden;
}
.prio__label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.prio__repeat { flex: none; color: var(--text-mute); font-size: 10.5px; font-weight: 650; }
/* The skill this step's level teaches — the reason the step exists. */
.prio__unlock {
  flex: 0 1 auto; min-width: 0;
  padding: 0 5px;
  border-radius: 3px;
  background: color-mix(in srgb, var(--accent) 16%, transparent);
  color: var(--accent);
  font-size: 10.5px; font-weight: 650;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}

.prio__levelwrap { flex: none; display: grid; }
.prio__level {
  padding: 3px var(--s1) 3px 5px;
  font-size: 11.5px; font-weight: 650;
  background: var(--surface-3);
  border-color: color-mix(in srgb, var(--accent) 45%, var(--line));
}

.prio__drop {
  flex: none;
  width: 18px; height: 18px; padding: 0;
  display: grid; place-items: center;
  border: 1px solid transparent; border-radius: var(--r-sm);
  background: transparent;
  color: var(--text-mute);
  font: inherit; font-size: 14px; line-height: 1;
  cursor: pointer;
  transition: background-color 140ms var(--ease-out), color 140ms var(--ease-out);
}
.prio__drop:hover {
  background: color-mix(in srgb, var(--danger) 16%, transparent);
  color: color-mix(in srgb, var(--danger) 85%, var(--text));
}

.prio__notebtn {
  flex: none;
  width: 18px; height: 18px; padding: 0;
  display: grid; place-items: center;
  border: 1px solid transparent; border-radius: var(--r-sm);
  background: transparent;
  color: var(--text-mute);
  font: inherit; font-size: 11px; line-height: 1;
  cursor: pointer;
  opacity: 0; pointer-events: none;
  transition: opacity 140ms var(--ease-out), color 140ms var(--ease-out);
}
.prio:hover .prio__notebtn, .prio:focus-within .prio__notebtn,
.prio.has-note .prio__notebtn { opacity: 1; pointer-events: auto; }
.prio__notebtn:hover { color: var(--text); }
.prio.has-note .prio__notebtn, .prio.is-noting .prio__notebtn { color: var(--accent); }

/* The note takes a line of its own, and only when there is one to read or the
   step has been opened for editing - an empty field under every step would be
   more furniture than the plan is worth. */
.prio__note {
  display: none;
  order: 2; flex: 1 1 100%;
  width: auto;
  margin-left: var(--prio-indent);
  padding: 1px 5px;
  border: 0; border-left: 2px solid color-mix(in srgb, var(--type-color, var(--line)) 50%, transparent);
  border-radius: 0;
  background: transparent;
  color: var(--text-dim);
  font: inherit; font-size: 11.5px; font-style: italic; line-height: 1.4;
}
.prio.has-note .prio__note, .prio.is-noting .prio__note { display: block; }
.prio__note::placeholder { color: var(--text-mute); font-style: normal; }
.prio__note:hover { border-left-color: var(--text-mute); }
.prio__note:focus-visible {
  outline-offset: 0;
  border-left-color: var(--accent);
  color: var(--text);
  font-style: normal;
}

.prio--ghost .prio__levelchip {
  padding: 1px 5px;
  border-radius: 3px;
  background: var(--accent); color: var(--accent-ink);
  font-size: 10.5px; font-weight: 700;
}

/* ============================================================ roster */

.filters { display: grid; gap: var(--s2); margin-bottom: var(--s3); }
.filters .field[type="search"] { width: 100%; }
.filters__row { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s3); }
.filters__row--meta { gap: var(--s4); }

/*
 * The Filter pill and what sits beside it.
 * ========================================
 *
 * One row under the search: the pill that opens the chips, the words saying
 * which are on, the sort, and Reset. The nine chip groups fold behind the pill
 * at every width now, not only on a phone -- the desktop was carrying the whole
 * wall permanently for the sake of the two chips anybody presses.
 *
 * The summary is load-bearing, not decoration. Folding controls away is the
 * thing 1.8.0 argued against, and rightly: a control you cannot see is one most
 * players never learn exists. What makes this fold honest rather than a
 * regression is that the closed state still says what is on -- the count on the
 * pill, the names beside it. Take the summary out and this becomes the bad
 * version of itself.
 */
.filterbar {
  display: flex; flex-wrap: wrap; align-items: center;
  gap: var(--s2);
  min-width: 0;
}
.pill {
  display: inline-flex; align-items: center; gap: 6px;
  height: 32px; padding: 0 12px;
  border: 1px solid var(--line); border-radius: 999px;
  background: var(--surface-2, var(--surface-3));
  color: var(--text);
  font: inherit; font-size: 13px;
  cursor: pointer;
}
.pill:hover { border-color: var(--text-mute); }
.pill--filter[aria-expanded="true"] { background: var(--surface-3); border-color: var(--text-mute); }
.pill--filter.is-on { border-color: var(--accent); }
/* The count is the whole point of the closed state: a filter that is on and out
   of sight is worse than no filter at all. */
.pill__n {
  display: inline-grid; place-items: center;
  min-width: 16px; height: 16px; padding: 0 4px;
  border-radius: 999px;
  background: var(--accent); color: var(--accent-ink);
  font-size: 10px; font-weight: 800;
}
.pill--sort { padding-right: 6px; color: var(--text-mute); }
.pill__label { flex: none; }
.pill__select {
  border: 0; background: transparent; color: var(--text);
  font: inherit; font-size: 13px;
  padding: 0 2px; cursor: pointer;
}
/* Which filters are on, in words. Truncates rather than wrapping the row: the
   count on the pill is the part that must never be lost. */
.filterbar__active {
  flex: 1 1 auto; min-width: 0;
  color: var(--text-mute); font-size: 12px;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.filterbar__active[hidden] { display: none; }

body:not([data-filters="open"]) .filters__body { display: none; }
.filters__body { display: grid; gap: var(--s2); }

/*
 * The filters open above the roster, in the flow, and stay out of its way.
 * ========================================================================
 *
 * This was a popover, hanging over the cards. It was the wrong shape for a
 * filter and it was reported as such: you set a chip, and then you had to close
 * the drawer to find out what it did. A control whose entire purpose is to
 * change what is below it must never be the thing covering what is below it --
 * the loop is toggle, look, toggle again, and an overlay breaks it every time
 * round.
 *
 * So the chips take their own space above the roster instead. Yes, the roster
 * moves down when the drawer opens; that is a single deliberate shift at the
 * moment you asked for it, and afterwards you can see the cards changing as you
 * press the chips, which is the whole job. The earlier reasoning here -- that
 * opening the drawer moved a card you were reaching for -- was about a moment
 * nobody is in: you are aiming at the drawer you just opened, not at a card.
 *
 * Still a bordered panel rather than loose chips, because it is a disclosure and
 * should read as one, and still no taller than it has to be.
 */
/* The wrapper only exists to keep the drawer next to its pill in the markup;
   dissolved, both become items of .filterbar and the drawer takes a row of its
   own under the pill row. */
.drawer-anchor { display: contents; }
.filters__body {
  /* Last in the row whatever its position in the markup, so the pill keeps its
     line with the summary, Sort and Reset and the chips open underneath the
     lot -- the wrapper puts it next to its pill in the DOM, which is a
     different question from where it belongs on screen. */
  order: 9;
  flex: 1 1 100%;
  padding: var(--s3);
  border: 1px solid var(--line);
  border-radius: var(--r-md);
  background: var(--surface-2);
}
}

/*
 * The field's Advanced drawer.
 *
 * The same pill and the same bargain as the filters: seven switches folded
 * away, a count on the pill so none of them can be on without saying so. It
 * takes the panel's own flex row when open rather than becoming a box, so the
 * switches sit exactly where they always did and only their visibility changed.
 */
/*
 * "12 of 15 placed · 2 flex", under the formation's name.
 *
 * Sits with the name in the header rather than with the board, because it is a
 * property of the formation and not of the grid, and because on a phone the
 * name is the first thing on the screen and this is the second thing you want
 * to know about it.
 */
.formation-status {
  margin: 0;
  color: var(--text-mute);
  font-family: var(--font-mono);
  font-size: 11.5px; line-height: 1.3;
  font-variant-numeric: tabular-nums;
}
.formation-status[hidden] { display: none; }

/* A group the field has none of. Said, rather than left out -- see effectRows.
   Muted, because it is worth knowing and not worth alarm. */
.summary__none { color: var(--text-mute); font-size: 12px; font-style: italic; }
.summary__group--none .summary__label { opacity: .8; }

.pill--advanced { border-style: dashed; color: var(--text-mute); }
.pill--advanced.is-on { border-style: solid; border-color: var(--accent); color: var(--text); }
.pill--advanced[aria-expanded="true"] { background: var(--surface-3); color: var(--text); }
/*
 * The Advanced drawer hangs under its button. It does not push the board.
 * =======================================================================
 *
 * Opened in the flow, the seven switches wrapped onto two more lines of the
 * tools row and moved the field down 57px -- so the board jumped out from under
 * the cursor at the exact moment you reached for a setting that changes it, and
 * you had to find it again to see what the setting did. A control whose whole
 * purpose is "look at the board differently" must not move the board.
 *
 * So it is a popover: absolutely positioned inside .tools__advwrap, which exists
 * only to be its anchor, and it takes no space in the row at all. Right-aligned
 * because the button sits at the right-hand end of the tools row on a wide
 * screen, and clamped to the viewport on a narrow one.
 *
 * z-index sits above the field. .panel carries isolation: isolate, so this
 * cannot escape its own panel and land on top of the roster.
 */
.tools__advwrap { position: relative; display: inline-flex; }
.tools__adv {
  position: absolute; z-index: 5;
  top: calc(100% + 6px); right: 0;
  display: grid; gap: var(--s2);
  min-width: 210px; max-width: min(320px, 86vw);
  padding: var(--s3);
  border: 1px solid var(--line);
  border-radius: var(--r-md);
  background: var(--surface-2);
  box-shadow: 0 12px 32px rgb(0 0 0 / .38);
}
body:not([data-advanced="open"]) .tools__adv { display: none; }

/*
 * Zobos answer none of the drafting questions — they have no role, no tier, no
 * bench and bring no heals — so those controls go while their tab is up rather
 * than sitting there doing nothing. The element chips and the search stay,
 * because "show me the Lightning ones" is worth asking of either list.
 */
/*
 * The mirror of the rules below: a control that only means something on the
 * Zobo tab.
 *
 * Written as "hide unless" rather than "show when", because these elements also
 * carry .chips, which sets display:flex further down the file and beats a bare
 * .zobo-only at equal specificity — the chip stayed visible on the Tatari tab.
 * The :not() carries the extra weight without needing !important.
 */
body:not([data-roster-list="zobos"]) .zobo-only { display: none; }

body[data-roster-list="zobos"] #filter-roles,
body[data-roster-list="zobos"] #filter-effects,
body[data-roster-list="zobos"] #filter-tiers,
body[data-roster-list="zobos"] .filters__row--meta .check,
body[data-roster-list="zobos"] .filters__row--meta .sortby { display: none; }

/* Tabs, not a mode switch: same control, but it selects a view rather than
   changing what a formation is. */
/*
 * Louder than the Solo/Co-op switch next to it, and deliberately so.
 *
 * Those two pick a setting you already know you chose. These two swap the list
 * under them for a completely different one, and if you cannot tell at a glance
 * which is showing then a roster of 50 enemies where you expected 224 Tatari
 * reads as the search being broken. The grey-on-grey treatment the shared
 * component gives was enough for a setting and not for a place.
 *
 * So the selected tab takes the accent rather than a surface tint, and the pair
 * gets a little more room to be seen.
 */
.segmented--tabs { margin-left: auto; }

/*
 * The slot the three view switches share.
 *
 * One is visible at a time and they are three different widths, so loose in the
 * head each pushed the Tatari/Zobos/Chips tabs somewhere different -- the tabs
 * slid 31px sideways as you moved between them, under the pointer that was
 * clicking them. Reserving the width of the widest holds the tabs still.
 *
 * Right-aligned inside the slot, so the switch itself stays against the panel
 * edge where it has always been rather than floating in the middle of its own
 * reservation. min-width rather than width: a language with longer words should
 * push the slot wider, not clip it.
 */
.panel__views {
  display: flex; justify-content: flex-end;
  flex: none;
  min-width: 152px;
}
/* Inside the slot the auto margin has nothing to push against and would only
   fight the justification above. */
.panel__views .segmented--tabs { margin-left: 0; }
.segmented--tabs .segmented__btn { padding: 5px var(--s4); }
.segmented--tabs .segmented__btn[aria-selected="true"] {
  background: var(--accent);
  color: var(--accent-ink);
  box-shadow: none;
}
.segmented--tabs .segmented__btn[aria-selected="true"]:hover { color: var(--accent-ink); }

/*
 * The switch-player control, and what the board looks like while it is armed.
 *
 * The armed state has to be visible on the *board*, not only on the button: the
 * next tap does something different from what it usually does, and a mode you
 * cannot see is a mode that surprises you. Tokens take the accent outline the
 * rest of the app uses for "this is the thing you are about to act on".
 */
.ptabs__switch { margin-left: auto; }
.ptabs__switch[aria-pressed="true"] {
  border-color: var(--accent);
  background: color-mix(in srgb, var(--accent) 18%, var(--surface-2));
  color: var(--text);
}
body[data-switching="true"] .cell.is-filled,
body[data-switching="true"] .benchchip {
  cursor: pointer;
  box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--accent) 60%, transparent);
}
/* A Zobo is nobody's, so it is not one of the things about to change hands. */
body[data-switching="true"] .cell[data-zobo] { box-shadow: none; cursor: default; }

.chips { display: flex; gap: var(--s1); }

/*
 * A Zobo card. Same size and grid as a Tatari's, because the two lists swap in
 * the same panel and a different card size would make the panel jump.
 */
.card--zobo .card__meta { justify-content: center; gap: var(--s1); }

.card__boss, .token__boss {
  color: var(--warn, var(--accent));
  font-size: 11px; line-height: 1;
}
/*
 * The waves view is sized to show all 25 on one screen beside the field, on a
 * 1080p desktop: the search and filters step aside (the element tiles are this
 * view's filter), the header is three short lines, and the wave number sits
 * beside its bosses rather than over them. The phone sheet keeps its own
 * scroll (.panel--roster .roster, later in the file).
 */
.roster[data-zobo-view="waves"] { display: block; max-height: none; overflow-y: visible; container-type: inline-size; }
.panel--roster:has(#roster[data-zobo-view="waves"]) .filters { display: none; }

/* Above the waves: season and credits, what each element beats, your field. */
.wavehead { display: grid; gap: 6px; margin-bottom: var(--s2); }
.wavehead__top { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 4px var(--s3); }
.wavehead__flags { flex-wrap: wrap; gap: 6px; margin-right: auto; }
.wavehead__credits { margin: 0; font-size: 11.5px; color: var(--text-mute); }
.wavehead__credits b { color: var(--text-dim); }
.wavehead__beats { display: grid; grid-template-columns: auto repeat(5, minmax(0, 1fr)); gap: 6px; }
.wavehead__cap {
  align-self: center; width: 4.5em;
  color: var(--text-mute);
  font-size: 10px; font-weight: 700; letter-spacing: .06em; line-height: 1.25; text-transform: uppercase;
  cursor: help;
}
.wavehead__cap em, .wavebeat__w em, .wavehead__team em { color: var(--danger); font-style: normal; font-weight: 800; }
/* What an element BEATS, a different question from the element chips (what a
   boss IS), so these are a control of their own. */
.wavebeat {
  display: grid; grid-template-columns: auto minmax(0, 1fr); column-gap: 6px; align-items: center;
  min-width: 0;
  padding: 5px 8px;
  border: 1px solid var(--line-soft); border-left: 3px solid var(--type-color);
  border-radius: var(--r-sm);
  background: color-mix(in srgb, var(--type-color) 13%, transparent);
  color: var(--text);
  font: inherit; text-align: left;
  cursor: pointer;
}
.wavebeat > .badge { grid-row: 1 / 4; }
.wavebeat__n { display: flex; align-items: baseline; gap: 4px; font-size: 12px; color: var(--text-dim); white-space: nowrap; }
.wavebeat__n b { font-size: 16px; color: var(--text); }
.wavebeat__hard {
  justify-self: start; margin-top: 3px;
  padding: 0 7px; border-radius: 999px;
  background: color-mix(in srgb, var(--danger) 22%, transparent);
  color: var(--danger);
  font-size: 10px; font-weight: 700; white-space: nowrap;
}
.wavebeat__hard b { color: var(--text); }
.wavebeat__w { font-size: 10.5px; color: var(--text-mute); line-height: 1.3; }
.wavebeat:hover { border-color: color-mix(in srgb, var(--type-color) 55%, var(--line)); border-left-color: var(--type-color); }
.wavebeat[aria-pressed="true"] { border-color: var(--type-color); box-shadow: inset 0 0 0 1px var(--type-color); }
.wavehead__team { margin: 0; font-size: 12.5px; color: var(--text-dim); }
.wavehead__team.is-all { color: var(--ok); }

/* One box per wave, read across then down like the boss order picture. */
.wavecols {
  display: grid;
  /* Five across, as on the picture: wave 1 to 5 is the first line at any
     desktop width. Three on a phone (the media query below). */
  grid-template-columns: repeat(5, minmax(0, 1fr));
  gap: 6px;
}
.waverow {
  display: grid; grid-template-columns: 16px minmax(0, 1fr); gap: 2px;
  min-width: 0;
  padding: 5px;
  border: 1px solid var(--line-soft);
  border-radius: var(--r-md);
  background: var(--surface);
}
.waverow__n { color: var(--text); font-size: 15px; font-weight: 800; font-variant-numeric: tabular-nums; }
/* A wave nothing on your field beats. */
.waverow.is-open {
  border-color: color-mix(in srgb, var(--danger) 60%, transparent);
  background: color-mix(in srgb, var(--danger) 7%, var(--surface));
}
.waverow.is-open .waverow__n { color: var(--danger); }
.waverow__cards { display: flex; gap: 4px; justify-content: center; align-items: start; min-width: 0; }
/* No frame of its own: the wave box is the frame, so the art can fill it. */
.waverow__cards .card--zobo { flex: 1 1 0; min-width: 0; max-width: 96px; padding: 0; border: 0; background: none; }
.waverow__cards .card--zobo:hover { background: none; }
.waverow__cards .card__meta { display: none; }
.waverow__cards .card__art { grid-template-rows: 1fr; padding: 3px; }
.waverow__cards .card__name { margin-top: 3px; font-size: 10.5px; line-height: 1.15; white-space: normal; }
/* One boss: art on the left, name and weakness beside it, so a single boss
   fills the box's width instead of standing in the middle of it. Wide panels
   only: on a phone-narrow one there is no room beside the art. */
@container (min-width: 701px) {
  .waverow__cards:not(:has(> :nth-child(2))) .card--zobo {
    max-width: none;
    grid-template-columns: minmax(0, 80px) minmax(0, 1fr); grid-template-rows: 1fr 1fr;
    column-gap: 8px; align-items: center;
    text-align: left;
  }
  .waverow__cards:not(:has(> :nth-child(2))) .card__art { grid-row: 1 / 3; }
  .waverow__cards:not(:has(> :nth-child(2))) .card__name { grid-column: 2; grid-row: 1; align-self: end; margin: 0; font-size: 12px; }
  .waverow__cards:not(:has(> :nth-child(2))) .card__weak { grid-column: 2; grid-row: 2; align-self: start; justify-content: flex-start; }
}
/* What a boss is weak to, under its name -- not on the art, whose tint is the
   boss's OWN element. */
.card__weak {
  display: flex; align-items: center; justify-content: center; gap: 3px;
  margin-top: 2px;
  color: var(--text-mute);
  font-size: 8.5px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase;
  white-space: nowrap;
}
.card__weak .badge { width: 13px; height: 13px; }
/* Three in one wave leaves each card ~44px: one line of name, and the weak-to
   element as its icon alone (the card's title still says it in words). */
.waverow__cards:has(> :nth-child(3)) .card__name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.waverow__cards:has(> :nth-child(3)) .card__weak { font-size: 0; }
/* By the panel's width, not the screen's: a tablet's two columns leave it as
   narrow as a phone. */
@container (max-width: 700px) {
  .wavehead__beats { grid-template-columns: repeat(3, minmax(0, 1fr)); }
  .wavehead__cap { grid-column: 1 / -1; width: auto; }
  /* Too narrow for the number beside three bosses: it goes back on top. */
  .waverow { grid-template-columns: minmax(0, 1fr); }
  .wavecols { grid-template-columns: repeat(3, minmax(0, 1fr)); }
  /* Two or three bosses in a ~100px box: one line of name each, and the
     weakness as its icon alone (the card's title still says it in words). */
  .waverow__cards:has(> :nth-child(2)) .card__name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
  .waverow__cards:has(> :nth-child(2)) .card__weak { font-size: 0; }
  .wavebeat { padding: 5px 6px; column-gap: 5px; }
  .wavebeat > .badge { grid-row: 1 / 3; }
  .wavebeat__hard { grid-column: 1 / -1; padding: 0 5px; font-size: 9.5px; }
}
/* Every boss filtered out of every stage. Says which of the two it is -- no
   match at all -- rather than leaving an empty panel that reads as broken. */
.roster__empty {
  grid-column: 1 / -1;
  margin: 0; padding: var(--s3) var(--s2);
  color: var(--text-mute); font-size: 12px;
}
/*
 * The tip line under the share buttons.
 *
 * Deliberately not a button and not accented: it appears next to the row you
 * just pressed something in, and anything with a fill on it there would read as
 * a fifth thing to do rather than as a sentence. Muted, one line, the link
 * carrying the only colour -- the same restraint the header's tip jar uses, and
 * for the same reason.
 */
.share__thanks {
  margin: var(--s2) 0 0;
  color: var(--text-mute);
  font-size: 12.5px; line-height: 1.5;
}
.share__thanks[hidden] { display: none; }
.share__thanks a { color: var(--accent-ink-on); }

/* Under the Zobo list, saying what the numbers are and who counted them. */
.roster__note {
  margin: 0;
  padding: var(--s1) var(--s2) 0;
  color: var(--text-mute);
  font-size: 11px; line-height: 1.4;
}
.token__boss { position: absolute; top: 2px; right: 2px; }
/* Marked as not-yours in the same language the Zobo ground uses: solid outline,
   borrowed background. It is the other side of the board standing on your tiles. */
.cell[data-zobo] {
  border-style: solid;
  border-color: color-mix(in srgb, var(--danger) 35%, var(--line));
}
.chip {
  display: inline-grid; place-items: center;
  font: inherit; /* a <button>: the site's font, not the browser's Arial */
  width: 30px; height: 26px;
  border: 1px solid var(--line);
  border-radius: var(--r-sm);
  background: var(--surface-2);
  color: var(--text-mute);
  cursor: pointer;
  transition: background-color 140ms var(--ease-out), color 140ms var(--ease-out),
              border-color 140ms var(--ease-out);
}

.chip:hover { color: var(--text-dim); background: var(--surface-3); }
/* Both, because the budgets are radios and everything else is a toggle. Left as
   one rule rather than two: "this chip is the one that is on" looks the same
   either way, and splitting it invites the two halves to drift apart. */
.chip[aria-pressed="true"],
.chip[aria-checked="true"] { color: var(--text); border-color: currentColor; }
.chip--type[aria-pressed="true"] {
  color: var(--type-color);
  background: color-mix(in srgb, var(--type-color) 16%, var(--surface-2));
}
.chip--role[aria-pressed="true"] { color: var(--text); background: var(--surface-3); border-color: var(--text-mute); }
.chips--text .chip { width: auto; padding: 0 var(--s2); font-size: 11.5px; font-weight: 650; }

/*
 * The caret that opens a group's specific effects, drawn as the right-hand half
 * of the chip it follows rather than as a control of its own. Two separate
 * buttons is what the markup has to be -- a button inside a button is not a
 * thing -- but "filter by debuffs" and "show me which debuffs" are one idea with
 * two halves, and the join is what says so. Hence the negative margin and the
 * flattened inner corners.
 */
.fxcaret {
  display: inline-grid; place-items: center;
  width: 26px; min-height: 26px;
  margin-left: -1px;
  border: 1px solid var(--line);
  /* A drawn divider rather than a transparent edge. The first version left the
     inner border invisible, and with a 9px glyph the whole control read as a gap
     between chips instead of as the thing that opens them. */
  border-left-color: var(--line);
  border-radius: 0 var(--r-sm) var(--r-sm) 0;
  background: color-mix(in srgb, var(--text) 7%, var(--surface-2));
  color: var(--text-dim);
  font: inherit; font-size: 13px; line-height: 1;
  cursor: pointer;
  transition: background-color 140ms var(--ease-out), color 140ms var(--ease-out);
}
.fxcaret::after { content: "▾"; }
.fxcaret:hover { color: var(--text-dim); background: var(--surface-3); }
.fxcaret[aria-expanded="true"] {
  color: var(--accent-ink-on);
  background: color-mix(in srgb, var(--accent) 20%, var(--surface-2));
  border-color: color-mix(in srgb, var(--accent) 45%, var(--line));
}
/*
 * A group carrying a level ceiling, while its row is shut.
 *
 * The three budgets are independent now, so at any moment up to three of these
 * are narrowing the roster from behind a closed caret. Without a mark, the only
 * way to find out which was to open all three, and a filter you cannot see is
 * how people end up convinced the roster is broken. The dot says a ceiling is
 * set; the caret's title says which, and opening it shows the chip pressed.
 */
.fxcaret[data-budgeted]::before {
  content: "";
  position: absolute; top: 3px; right: 3px;
  width: 4px; height: 4px;
  border-radius: 50%;
  background: var(--accent);
}
.fxcaret { position: relative; }
.fxcaret[aria-expanded="true"]::after { content: "▴"; }

/* The opened row. Indented and wrapping, so it reads as belonging to the chip
   above it rather than as a fourth filter row of equal weight. */
.chips--sub {
  flex-wrap: wrap;
  margin-top: calc(var(--s1) * -1 + 2px);
  padding-left: var(--s3);
  border-left: 1px solid var(--line-soft);
}
.chips--sub .chip { width: auto; padding: 0 var(--s2); font-size: 11px; font-weight: 600; }
/* The count, quieter than the name it follows: it is there to stop a wasted tap,
   not to be read first. */
.chips--sub .chip b {
  margin-left: 4px;
  color: var(--text-mute); font-size: 9.5px; font-weight: 700;
}
.chips--sub .chip[aria-pressed="true"] b,
.chips--sub .chip[aria-checked="true"] b { color: inherit; }

/* "Needs", naming what the budget chips beside it are for. Without it the four
   of them read as four more effects. */
/* Each half of the opened row is its own labelled group, so the four budgets
   read as a choose-one set and the effects as an independent one. */
.chips__group { display: inline-flex; align-items: center; gap: 3px; flex-wrap: wrap; }
.chips__label {
  align-self: center;
  color: var(--text-mute); font-size: 10px; font-weight: 700;
  text-transform: uppercase; letter-spacing: .06em;
}
/*
 * The patch chips. A word and a count like the effect chips, with the card
 * marker's own glyph in front so the chip and the disc on 46 cards read as one
 * statement rather than two vocabularies that happen to agree.
 *
 * The glyph carries the direction's colour; the label stays the row's own
 * neutral. Colouring the whole chip would have made three of them shout across
 * a row where every other chip is quiet until pressed, and green and red at
 * rest is the roster's loudest possible way of saying "nothing is filtered".
 */
/* A flex row, for the same reason .chip--effect is one: the base .chip is an
   inline-grid built for a single centred glyph, so a chip with three children
   stacks them down the column and spills into the row below. */
.chip--changed {
  display: inline-flex; align-items: center; justify-content: center; gap: 5px;
  width: auto; height: auto; min-height: 26px;
  padding: 3px var(--s2);
  font-size: 11.5px; font-weight: 650; line-height: 1.2;
  white-space: nowrap;
}
.chip--changed b {
  margin-left: 1px;
  color: var(--text-mute); font-size: 9.5px; font-weight: 700;
}
.chip--changed[aria-checked="true"] b { color: inherit; }
/*
 * The same mark the cards wear, at chip size.
 *
 * It was a filled disc, which is what .card__patch was when this was written and
 * is not what it is now: the disc mimicked the circular type and role badges and
 * was replaced by a tinted rect. Leaving this one round meant the chip you press
 * and the mark you then see on 135 cards were two different shapes claiming to
 * be the same statement.
 */
.chip__glyph {
  display: inline-grid; place-items: center;
  min-width: 15px; height: 14px;
  padding: 0 1px;
  border: 1px solid currentColor;
  border-radius: 3px;
  background: color-mix(in srgb, currentColor 16%, var(--bg));
  font-size: 11px; font-weight: 800; line-height: 1;
}
/* Neutral, not the direction's hue: --ok is about 3.9:1 as text in the light
   theme and vanishes on its own wash. The colour lives in the edge and the fill,
   which are non-text and answer to 3:1. Same reasoning as .card__patch. */
.chip__glyph::after { content: attr(data-glyph); color: var(--text); }
.chip__glyph[data-patch="buff"]     { color: var(--ok); }
.chip__glyph[data-patch="nerf"]     { color: var(--danger); }
.chip__glyph[data-patch="adjusted"] { color: var(--text-dim); }
.chip--changed[aria-checked="true"] { border-color: currentColor; color: var(--text); }

/* The way out to the full notes. A link, not a chip: it leaves the page, and
   dressing it as a chip would put it in the same set as three things that
   filter in place. */
.chips__seeall {
  align-self: center;
  margin-left: var(--s1);
  color: var(--text-dim); font-size: 11px; font-weight: 650;
  text-decoration: none; white-space: nowrap;
}
.chips__seeall:hover { color: var(--text); text-decoration: underline; }

/* Between the budgets and the effects they qualify: two questions, one row. */
.chips__split {
  align-self: stretch;
  width: 1px; margin: 2px var(--s1);
  background: var(--line);
}
.chips--sub .chip--budget { font-weight: 700; }
.chips--sub .chip--budget[aria-checked="true"] {
  color: var(--accent-ink-on);
  border-color: color-mix(in srgb, var(--accent) 55%, var(--line));
  background: color-mix(in srgb, var(--accent) 16%, var(--surface-2));
}
/*
 * An effect nothing reaches under the current ceiling. Dimmed rather than
 * removed: that it went to zero is the answer, and a chip that vanished would
 * look like it had never been there.
 *
 * Not reachable on today's data, and left in deliberately. Every named effect
 * currently has at least one Tatari that brings it unlevelled, so the lowest
 * ceiling still finds something for all 23 -- checked across every group and
 * budget. One patch moving the last base source of Sting or DMG Boost (Solo)
 * behind a level would produce it. This is a state the data can enter, not dead
 * code; deleting it would leave a bare "0" looking like any other chip.
 */
.chips--sub .chip[data-empty="true"],
.chip--changed[data-empty="true"] { opacity: .4; }

/* These carry a word, since a bare triangle means nothing — so they need to be
   a flex row rather than the base chip's centred inline-grid, and to size to
   their text rather than to the 30px icon box. Defined after .chip on purpose:
   same specificity, so order is what decides. */
.chip--effect {
  display: inline-flex; align-items: center; justify-content: center; gap: 5px;
  width: auto; height: auto; min-height: 26px;
  padding: 3px var(--s2);
  font-size: 11.5px; font-weight: 650; line-height: 1.2;
  white-space: nowrap;
}
.chip--effect[data-value="heal"][aria-pressed="true"]   { border-color: var(--ok); color: var(--ok); }
.chip--effect[data-value="buff"][aria-pressed="true"]   { border-color: var(--accent); color: var(--accent); }
.chip--effect[data-value="debuff"][aria-pressed="true"] { border-color: var(--danger); color: var(--danger); }

/*
 * Two things in one chip, so it needs .chip--effect's layout rather than the
 * base chip's.
 *
 * `.chip` is an inline-grid sized to a 30x26 icon box, which is right for a
 * single glyph and wrong for a glyph beside a word: the star and the label
 * became two grid rows and spilled out of a box that was never going to grow.
 * Flex puts them on one line and the height follows the content.
 *
 * Wears the star it filters for, so the chip and the badge on the card read as
 * the same thing rather than two conventions.
 */
.chip--boss {
  display: inline-flex; align-items: center; justify-content: center; gap: 5px;
  width: auto; height: auto; min-height: 26px;
  padding: 3px var(--s2);
  font-size: 11.5px; font-weight: 650; line-height: 1.2;
  white-space: nowrap;
}
.chip--boss[aria-pressed="true"] {
  border-color: var(--accent);
  color: var(--accent);
}

.roster {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(76px, 1fr));
  gap: var(--s2);
  max-height: min(62vh, 660px);
  overflow-y: auto;
  /* A little room on the right so the cards are not jammed against the
     scrollbar. The gutter reserves the scrollbar's width; this is the gap
     between the cards and it, so a scrolling list has a margin to breathe. */
  padding: 2px var(--s3) 2px 2px;
  scrollbar-width: thin;
  /*
   * Two rules that between them stop the cards ever leaving this box -- the bug
   * that has been fixed and come back more than once, where the rightmost column
   * spills past the panel or the squares appear to slide behind each other.
   *
   * The layout is provably exact at every width (auto-fill sizes the tracks so
   * they sum to the content box), so this is not fixing a miscount. It is fixing
   * two things the count cannot see:
   *
   *   scrollbar-gutter: stable -- the vertical scrollbar appears and vanishes as
   *   filtering and navigation change how many cards show, and every time it does
   *   the content width jumps by its ~10px and the whole grid reflows sideways.
   *   Reserving the gutter for good means the column maths is computed against one
   *   width forever, so a series of clicks never shoves the cards around. This is
   *   the "after a series of clicks or navigation" half of the report.
   *
   *   overflow-x: clip -- a hard floor under all of it. Fractional display
   *   scaling (125%/150%, the norm on a Windows laptop) and browser zoom round
   *   each 1fr track to the device-pixel grid independently, and the rounding can
   *   land the last edge a pixel past the padding box -- which reads as a card
   *   over the margin or behind its neighbour. Clip removes that pixel at the
   *   boundary rather than trusting every future change to the card size, gap or
   *   padding to keep the sum honest. (It computes to overflow-x: hidden here,
   *   because the other axis scrolls; on the phone below it stays clip.)
   */
  overflow-x: clip;
  scrollbar-gutter: stable;
}

/*
 * On a phone the roster is not a sticky column beside the field, it is a
 * section of one long page — and a scrolling box inside a scrolling page means
 * a flick either moves the list or the page depending on where your thumb
 * landed. It runs its full length here and the page does the scrolling, with
 * the section nav to get back out of it.
 */
@media (max-width: 1000px) {
  /* The page does the scrolling here, so the box gives up its own scroll -- but
     it keeps the horizontal clip, so the same spill guard holds on a phone.
     clip + visible is a legal pair, so overflow-y stays genuinely visible rather
     than being forced back into an inner scroll. */
  .roster { max-height: none; overflow-y: visible; overflow-x: clip; }
}
.roster:empty::after {
  content: "Nothing matches those filters.";
  grid-column: 1 / -1;
  padding: var(--s6) 0; text-align: center; color: var(--text-mute); font-size: 13px;
}

.card {
  position: relative;
  /*
   * Every row auto and the column minmax(0, 1fr), so the card is sized by its
   * width and only its width.
   *
   * It was 1fr for the art row and an implicit auto column, and both leaked.
   * The art box has aspect-ratio: 1, so a 1fr row handed it whatever height the
   * roster row had, and its width followed: a taller neighbour anywhere in the
   * row made every sprite in that row bigger. And an auto column sizes to the
   * widest child's min-content, so the 135 cards wearing a patch disc in the
   * meta row were 2px wider inside than the 95 without, and their art with
   * them. Rows came out at three heights and arts at three sizes, depending on
   * who happened to share a row. Now the box is exactly the card's inner width,
   * square, on all 230.
   */
  display: grid; grid-template-rows: auto auto auto; grid-template-columns: minmax(0, 1fr);
  align-content: start;
  padding: var(--s1);
  border: 1px solid var(--line-soft);
  border-radius: var(--r-md);
  background: var(--surface-2);
  cursor: grab;
  text-align: center;
  overflow: hidden;
  transition: background-color 140ms var(--ease-out), border-color 140ms var(--ease-out),
              transform 110ms var(--ease-out);
}
.card:hover {
  background: var(--surface-3);
  border-color: color-mix(in srgb, var(--type-color) 55%, var(--line));
  transform: translateY(-2px);
}
.card:active { cursor: grabbing; transform: translateY(0); }
.card.is-dragging { opacity: .4; }

/*
 * Two rows: a band for the effect badges, then the sprite in what is left.
 *
 * The badges were on top of the artwork, then briefly a row of their own below
 * it. On top covered the critter, which matters because people screenshot these
 * cards and post them; below it added 15px to all 230 and left a visible gap on
 * the 34 with no effects to show. Reserving the space *inside* the art box costs
 * neither: the box keeps its square footprint and its height, the badges sit on
 * the type tint rather than on a Tatari, and the sprite is simply drawn smaller
 * to fit under them. Roughly a fifth smaller, which is the price of the rule
 * that nothing this app draws is ever on top of the art.
 */
/*
 * The sprite sits inside the square with room to spare.
 *
 * Two changes met here and neither was wrong on its own. The sprite used to be
 * inset to 92% of this box; that became 100% when the framing was normalised.
 * Then the effect badges were given a band of their own inside the box rather
 * than under it, which took 15px off the top of the sprite's row. Between them
 * the artwork ended up flush against the bottom edge of the tint -- measured,
 * exactly zero pixels of gap -- so anything drawn near the bottom of its canvas
 * looked like it was falling out of the square. It never actually overflowed;
 * object-fit: contain cannot. It just had nowhere left to breathe.
 *
 * The padding is the gap, put back where the 92% used to hold it, and stated as
 * a length so it does not quietly change with the card size again.
 */
.card__art {
  position: relative;
  display: grid; grid-template-rows: 15px 1fr; place-items: center;
  aspect-ratio: 1;
  /*
   * min-width: 0, and the same on the badge band below. Grid items default to
   * min-width: auto, so anything inside this box that is wider than the card
   * (three effect badges plus the 17px the swap marker pushes them over by --
   * 83px in a 71px box) widened the box past the card, aspect-ratio then made it
   * as tall as it was wide, and the whole roster row stretched to fit: bench one
   * Tatari and every card in its row grew by 17px. Now the band shrinks to fit
   * and the box never sizes to its contents.
   */
  min-width: 0;
  padding: 0 4px 5px;
  border-radius: var(--r-sm);
  background: color-mix(in srgb, var(--type-color) 13%, transparent);
}
/* min-height:0 so the sprite shrinks into its track rather than pushing the box
   past the aspect-ratio and re-growing the card. */
.card__art img,
.card__art .token__fallback { grid-row: 2; min-height: 0; }
/*
 * The sprite gets a guaranteed inset of its own, on top of the box padding.
 *
 * The PNGs are not framed alike: some critters fill their 200x200 canvas
 * edge-to-edge and some sit small inside it. With the sprite at 100% the tightly
 * cropped ones reached the tint edge -- worst at the bottom, where the 15px badge
 * band up top leaves only a few pixels -- and read as bursting out of the square.
 * Padding the image (border-box, so it eats into the contained area) shrinks the
 * letterbox a touch and hands every sprite the same breathing room, a little more
 * at the bottom to answer the band pulling it down. Small ones only get smaller;
 * the full ones stop touching the edge, which is the whole point.
 */
.card__art img {
  width: 100%; height: 100%;
  object-fit: contain;
  box-sizing: border-box;
  padding: 2px 6px 6px;
}

/* Tier, type and role sit under the sprite rather than on it. Measured across
   all 215 sprites, every in-art spot big enough for two badges is covered by
   artwork 23-31% of the time on average and more than a quarter covered on
   between a third and two thirds of the roster - there is no corner that is
   reliably empty, so the only uniform answer is to stay off the art. */
.card__meta {
  display: flex; align-items: center; justify-content: center; gap: 3px;
  padding-top: 4px;
  line-height: 1;
  min-width: 0;
}
.card__tier { font-size: 9px; font-weight: 700; color: var(--text-mute); }
.card__name {
  padding-top: 2px;
  font-size: 11px; font-weight: 550; line-height: 1.25;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.card__info {
  position: absolute; top: 2px; right: 2px;
  width: 17px; height: 17px; padding: 0;
  display: grid; place-items: center;
  border: 1px solid var(--line);
  border-radius: 999px;
  background: var(--surface);
  color: var(--text-mute);
  font-size: 10px; font-weight: 700;
  cursor: pointer;
  opacity: 0; pointer-events: none;
  transition: opacity 140ms var(--ease-out), color 140ms var(--ease-out);
}
.card:hover .card__info, .card__info:focus-visible { opacity: 1; pointer-events: auto; }
.card__info:hover { color: var(--text); }

/* Brought but not placed, versus actually standing on the field. */
.card.is-benched { border-color: color-mix(in srgb, var(--ok) 55%, var(--line)); }
.card.is-deployed::after {
  content: "";
  position: absolute; inset: 0;
  border-radius: var(--r-md);
  box-shadow: inset 0 0 0 2px var(--ok);
  pointer-events: none;
}

/*
 * The other player is bringing this one too - allowed, worth knowing.
 *
 * In the badge band at the top of the art, top-right, mirroring the swap
 * marker's top-left: the meta row under the sprite is 69px wide and full once
 * a patch disc, the tier and the two icons are in it, so a fifth thing there
 * was cut in half at the card's edge. The band has a spare corner; the effect
 * badges keep out of it the same way they keep out of the marker's.
 */
.card__other {
  position: absolute; right: 5px; top: 5px;
  display: grid; place-items: center;
  height: 15px; padding: 0 3px;
  border-radius: var(--r-sm);
  background: var(--owner); color: var(--owner-ink);
  font-size: 8.5px; font-weight: 800; line-height: 1;
}
.card:has(.card__other) .card__fxrow { padding-right: 17px; }
/*
 * "You already have one of these", not "you may not have this".
 *
 * These cards used to be .is-blocked: 42% opacity and a not-allowed cursor,
 * which said the card was inert when in fact it is the one control that can
 * change your mind about a tier. Dimmed enough to read as secondary, and
 * clickable, because tapping it swaps the line to this tier and keeps the cell,
 * the bench slot and the level-up plan.
 */
.card.is-swap { opacity: .62; }
.card.is-swap:hover { opacity: 1; }
.card.is-swap .card__lock {
  background: color-mix(in srgb, var(--accent) 34%, var(--surface));
  color: var(--text);
}
/*
 * The swap marker, which used to be a caption and could not be one.
 *
 * It was `inset: auto 0 0` -- a full-width bar pinned to the bottom edge, which
 * is exactly where .card__name sits. So on every card in a line you already
 * bring, the bar covered the name, and then its own text ("Switch from
 * Frostique") was far too long for a 90px card and truncated to "SWITCH
 * FROM...". Both facts were lost at once: which Tatari the card is, and which
 * one you would be switching from. Three cards in a row of the same line all
 * read the same nine characters.
 *
 * No text fits there, so it stops being text. The glyph sits beside the name
 * rather than over it, and the sentence it was trying to say is on the card's
 * own title and in an .sr-only span -- both of which could already carry it,
 * and neither of which has 90px to fit in.
 */
/*
 * The marker sits in the badge band at the top of the art.
 *
 * It has been three places now. A bar across the bottom covered the name. A
 * glyph beside the name with a reserved margin ate a letter off it. Sharing a
 * flex row stopped the overlap but still took 18px out of a 71px line, and six
 * names in the roster need between 55 and 64 -- so the row was never big enough
 * for both, whatever the arrangement.
 *
 * The band is the top 15px of the art box: space already reserved for the
 * effect badges, above the sprite rather than over it, and nothing else wants
 * the left corner. The name gets its whole row back.
 */
.card__lock {
  position: absolute; left: 5px; top: 5px;
  display: grid; place-items: center;
  width: 15px; height: 15px;
  border-radius: var(--r-sm);
  background: color-mix(in srgb, var(--accent) 26%, var(--surface));
  color: var(--accent-ink-on);
  font-size: 11px; line-height: 1;
}
/*
 * Margin, not padding.
 *
 * `overflow: hidden` clips at the padding box, so padding-right reserves space
 * the text is still free to paint across on its way to being clipped -- a long
 * name would run under the badge and stop there. A margin shrinks the box
 * itself, so the ellipsis lands before the badge and cannot reach it. The name
 * re-centres in what is left, which is what the card's text-align already wants.
 */
/* The name has no gutter reserved beside it: the marker is not in its row. */
/* The effect badges keep out of the marker's corner when there is one. */
.card.is-swap .card__fxrow { padding-left: 17px; }

/* ============================================================ modals */

.modal {
  isolation: isolate;
  width: min(560px, calc(100vw - 2 * var(--s4)));
  /*
   * svh, not vh. On iOS Safari 100vh is the *large* viewport (the height the
   * page would have if the toolbar were collapsed) so a tall dialog runs under
   * the toolbar and its action row lands off screen with no way to reach it.
   * The phone sheets learned this already (see the svh note in the 760px
   * block); the modals had not.
   */
  max-height: calc(100svh - 2 * var(--s5));
  padding: var(--s5);
  border: 1px solid var(--line);
  border-radius: var(--r-lg);
  background: var(--surface);
  color: var(--text);
  box-shadow: var(--lift-2);
}
/* Mixed from the page background like every other backdrop here, rather than a
   raw near-black. The two newer modals already did this; this one had not
   caught up, which is two treatments for one job. */
.modal::backdrop { background: color-mix(in srgb, var(--bg) 72%, transparent); }
.modal h2 { margin-bottom: var(--s2); font-size: 17px; }
.modal__actions { display: flex; flex-wrap: wrap; gap: var(--s2); margin-top: var(--s4); }

/* share sheet */
.modal--share { width: min(760px, calc(100vw - 2 * var(--s4))); overflow-y: auto; }
.share__preview {
  display: grid; place-items: center;
  min-height: 120px;
  margin: var(--s3) 0;
  padding: var(--s2);
  border: 1px solid var(--line-soft); border-radius: var(--r-md);
  background: var(--bg);
}
.share__preview .hint { margin: 0; color: var(--text-mute); }
/* The card is drawn at twice its logical size, so it only ever scales down. */
.share__preview img { display: block; width: 100%; height: auto; border-radius: var(--r-sm); }
.share__name { display: grid; gap: 5px; }
.share__name input { width: 100%; }

/*
 * Posting, below the four ways of taking a copy away and separated from them by
 * a rule. Those four are reversible by closing a tab; this one puts a formation
 * in front of strangers under your name. The line is the whole point: it says
 * "different kind of thing" before a single word of the label is read, and it
 * costs a mis-click a deliberate crossing rather than a stray thumb.
 */
.share__post {
  display: flex; flex-wrap: wrap; align-items: center; gap: var(--s2) var(--s3);
  margin-top: var(--s4);
  padding-top: var(--s4);
  border-top: 1px solid var(--line-soft);
}
/* The hint takes the rest of the row on a wide sheet and its own line on a
   narrow one, rather than squeezing the button to fit beside it. */
.share__post .hint { flex: 1 1 16rem; margin: 0; }

/* help */
.modal--help { width: min(620px, calc(100vw - 2 * var(--s4))); overflow-y: auto; }
.help__steps {
  display: grid; gap: var(--s3);
  margin: var(--s3) 0 var(--s4);
  padding-left: var(--s5);
  color: var(--text);
  line-height: 1.55;
}
.help__steps li { padding-left: var(--s1); }
.help__steps b { color: var(--text); font-weight: 650; }

/* A question mark is a small target at button width, so it is squared off
   deliberately rather than left as a wide button with one character in it. */
.topbar__help { min-width: 2.25rem; padding-inline: var(--s2); font-weight: 650; }

/* ============================================================ live sessions */

/*
 * The Live button carries a dot only while a session is running, so the row
 * reads exactly as it always did on every visit that is not a shared one. The
 * dot is drawn on the button rather than beside it because the two are one
 * statement — this control, currently on.
 */
.topbar__live { display: inline-flex; align-items: center; gap: var(--s2); }
body.is-live .topbar__live { border-color: var(--accent); color: var(--accent); }
body.is-live .topbar__live::before {
  content: "";
  width: 7px; height: 7px;
  border-radius: 50%;
  background: var(--accent);
}
/* Reconnecting. Hollow rather than a second colour: the session is not gone,
   so it should not read as an error, only as not-quite-now. */
body.is-live-down .topbar__live::before { background: transparent; box-shadow: inset 0 0 0 1px var(--accent); }

/*
 * Who is here, as initials. Small enough to sit in the action row without
 * taking a line from it, and only ever visible during a session.
 */
.who-strip { display: inline-flex; align-items: center; gap: calc(var(--s1) * -1); }
.who-strip[hidden] { display: none; }
.who {
  display: grid; place-items: center;
  width: 22px; height: 22px;
  border: 1px solid var(--surface);
  border-radius: 50%;
  background: hsl(var(--hue) 45% 42%);
  color: #fff;
  font-size: 11px; font-weight: 650; line-height: 1;
}
body.is-live-down .who-strip { opacity: .45; }

.modal--live { width: min(540px, calc(100vw - 2 * var(--s4))); }
/* One sheet, three panels; the dialog's state says which. All three are in the
   markup so none has to be assembled in JavaScript. */
.modal--live .live__off, .modal--live .live__ask, .modal--live .live__on { display: none; }
.modal--live[data-state="off"] .live__off,
.modal--live[data-state="ask"] .live__ask,
.modal--live[data-state="on"] .live__on { display: block; }

/*
 * Who is in the room. A list rather than a row of initials, because this is the
 * one place a name has to be readable rather than merely distinguishable — it is
 * where you decide whether the person called Guest 2 is somebody you meant to
 * invite.
 */
.peers { display: grid; gap: var(--s1); margin: var(--s3) 0 var(--s4); padding: 0; list-style: none; }
.peer {
  display: flex; align-items: center; gap: var(--s2);
  padding: var(--s1) var(--s2);
  border-radius: var(--r-sm);
  background: var(--surface-2);
}
.peer__name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.peer .muted { font-size: 11px; }
.live__link, .live__name { display: grid; gap: 5px; margin-top: var(--s4); }
.live__link input, .live__name input { width: 100%; }
.live__link input { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; }

/*
 * Remote pointers.
 *
 * Fixed and body-level, following .drag-ghost above, for the same two reasons:
 * .field-frame clips its overflow and .grid is not a positioned ancestor, so
 * anything drawn inside the field would be cut off at its edge. Above the drag
 * ghost would put somebody else's pointer over the token you are carrying, so
 * it sits just below it.
 */
.cursors { position: fixed; inset: 0; z-index: calc(var(--z-drag) - 1); pointer-events: none; }
.cursor {
  position: absolute; top: 0; left: 0;
  display: flex; align-items: flex-start; gap: var(--s1);
  /* Only the position animates. A peer's pointer moves one square at a time
     now rather than arriving as a stream of coordinates, so this is what turns
     a hop between cells into something that reads as a hand crossing them. It
     is eased rather than linear for the same reason: a discrete jump wants to
     look like it was travelled, not cut to. */
  transition: transform 130ms var(--ease-out);
  will-change: transform;
}
.cursor[hidden] { display: none; }
.cursor svg { width: 14px; height: 20px; fill: hsl(var(--hue) 55% 55%); flex: none; }
.cursor__name {
  padding: 2px var(--s2);
  border-radius: var(--r-sm);
  background: hsl(var(--hue) 45% 40%);
  color: #fff;
  font-size: 11px; font-weight: 600; line-height: 1.5;
  white-space: nowrap;
}

/*
 * The square somebody else is over. A class on the cell and an ::after rather
 * than an injected element, because renderGrid() rewrites every filled cell's
 * innerHTML — a child node would survive until the next paint and no longer.
 * Dashed, to read as somebody's intention rather than as your own is-over,
 * which is solid.
 */
.cell.is-peer-over::after {
  content: "";
  position: absolute; inset: 2px;
  border: 2px dashed var(--accent);
  border-radius: 4px;
  pointer-events: none;
}

/* detail sheet */
.detail__head { display: flex; align-items: center; gap: var(--s3); margin-bottom: var(--s3); }
.detail__art {
  flex: none; width: 88px; height: 88px;
  display: grid; place-items: center;
  border-radius: var(--r-md);
  background: color-mix(in srgb, var(--type-color) 14%, var(--surface-2));
}
.detail__art img { width: 100%; height: 100%; object-fit: contain; }
.detail__title { display: grid; gap: 4px; }
.detail__tags { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s2); }
.tag {
  display: inline-flex; align-items: center; gap: 5px;
  padding: 2px var(--s2);
  border: 1px solid var(--line); border-radius: 999px;
  background: var(--surface-2);
  font-size: 11.5px; font-weight: 600; color: var(--text-dim);
}
.detail__heading {
  margin: var(--s4) 0 var(--s2);
  font-size: 11px; font-weight: 700; letter-spacing: .08em;
  text-transform: uppercase; color: var(--text-mute);
}
.detail__note, .detail__pending { margin: var(--s1) 0 0; font-size: 11.5px; }

/* What each planned level teaches. The level is the thing you plan, so it leads. */
.learns { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--s1); }
.learn {
  display: flex; align-items: baseline; gap: var(--s2);
  padding: var(--s2);
  border-radius: var(--r-sm);
  background: var(--surface-2);
}
.learn__level {
  flex: none; min-width: 34px;
  padding: 1px 5px;
  border-radius: 3px;
  background: var(--surface-3); color: var(--accent);
  font-size: 11px; font-weight: 800; text-align: center;
}
.learn__body { font-size: 12.5px; line-height: 1.45; color: var(--text-dim); }
.learn__body b { color: var(--text); font-weight: 650; }

/* Skill numbers, laid out like the game's panel: label left, value right, one box per effect. */
.detail dl.numbers {
  grid-template-columns: 1fr auto; gap: 0; margin: 0;
  border-radius: var(--r-sm); background: var(--surface-2); overflow: hidden;
}
.detail dl.numbers dt, .detail dl.numbers dd {
  margin: 0; padding: 5px var(--s2);
  border-top: 1px solid var(--line);
  font-size: 12.5px; letter-spacing: 0; text-transform: none;
}
.detail dl.numbers dt:first-of-type, .detail dl.numbers dt:first-of-type + dd { border-top: 0; }
.detail dl.numbers dt { color: var(--text-dim); font-weight: 500; }
.detail dl.numbers dd { color: var(--text); font-weight: 650; font-variant-numeric: tabular-nums; text-align: right; }
.numbers__heading {
  display: flex; align-items: center; gap: var(--s2);
  margin: var(--s3) 0 var(--s1); font-size: 12.5px; font-weight: 700; color: var(--text);
}
.numbers__text { margin: 0 0 var(--s1); font-size: 12px; line-height: 1.45; }

.rangefig { margin: 0; }
.rangefig img {
  display: block; width: 100%; max-width: 300px; height: auto;
  border: 1px solid var(--line); border-radius: var(--r-sm);
}
.rangefig figcaption { margin-top: var(--s1); font-size: 11.5px; }

.detail dl { display: grid; grid-template-columns: auto 1fr; gap: var(--s2) var(--s3); margin: var(--s4) 0; }
.detail dt { font-size: 11px; font-weight: 650; letter-spacing: .06em; text-transform: uppercase; color: var(--text-mute); }
.detail dd { margin: 0; font-size: 13px; max-width: 62ch; }
.detail__line { display: flex; flex-wrap: wrap; gap: var(--s1); }
.detail__step {
  padding: 2px var(--s2);
  border: 1px solid var(--line-soft); border-radius: var(--r-sm);
  background: var(--surface-2); font-size: 12px;
}
.detail__step[aria-current="true"] { border-color: var(--accent); color: var(--text); font-weight: 650; }
.detail__step[data-deployed="true"] { border-color: var(--ok); }

/* ============================================================ dragging */

/*
 * The ghost is a position, not a shape.
 *
 * It used to be a 62px square with a shadow, which is the size and the look of
 * a Tatari token -- the first thing that was ever dragged. Everything dragged
 * since brings its own shape: a chip brings a pill with its name on it, a
 * level-up step brings a row. Those sat inside a 62px shadowed box that had
 * nothing to do with them, so dragging a chip drew a stray rectangle behind
 * the name. The box carries a coordinate now and the thing inside it decides
 * what it looks like.
 */
.drag-ghost {
  position: fixed; top: 0; left: 0; z-index: var(--z-drag);
  width: max-content; height: auto;
  pointer-events: none;
  will-change: transform;
}
/* The token keeps the square and the shadow it always had, on itself. */
.drag-ghost .token {
  position: static;
  width: 62px; height: 62px;
  border-radius: var(--r-sm);
  box-shadow: var(--lift-2);
}
.drag-ghost .prio--ghost {
  display: flex; align-items: center; gap: var(--s2);
  width: max-content; height: auto; padding: var(--s1) var(--s3) var(--s1) var(--s1);
  border: 1px solid var(--accent); border-radius: var(--r-sm);
  background: var(--surface-3);
}

/* Stops text selection and the iOS callout from firing mid-drag. */
body.is-dragging-active {
  user-select: none;
  -webkit-user-select: none;
  cursor: grabbing;
}

/* ============================================================ range */

/*
 * Tiles a Tatari can reach. Yellow because that is how the game itself shows
 * range, so the diagrams on the detail sheet and the field agree.
 */
.cell.is-inrange {
  background: color-mix(in srgb, var(--accent) 26%, transparent);
  border-color: color-mix(in srgb, var(--accent) 70%, transparent);
  border-style: solid;
}
.cell.is-inrange.is-filled { box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--accent) 55%, transparent); }

/*
 * Coverage: how many Tatari reach each tile, deepening with the count. Nothing
 * marks the uncovered ones — with a handful of Tatari most of the field is
 * uncovered by definition, and flagging 25 tiles as a problem reads as alarm
 * rather than information. The gaps are the tiles left dark.
 */
.grid.shows-coverage .cell[data-cover="1"] { background: color-mix(in srgb, var(--accent) 16%, transparent); }
.grid.shows-coverage .cell[data-cover="2"] { background: color-mix(in srgb, var(--accent) 29%, transparent); }
.grid.shows-coverage .cell[data-cover="3"] { background: color-mix(in srgb, var(--accent) 42%, transparent); }
.grid.shows-coverage .cell[data-cover="4"] { background: color-mix(in srgb, var(--accent) 55%, transparent); }

/*
 * How many reach it, in a numeral.
 *
 * The four shades are ~1.45:1 apart, which is not a difference anyone can read
 * -- least of all the difference between one and none, which is the whole
 * question. The tint stays as the at-a-glance shape of the coverage; the digit
 * is what makes it answerable. Bottom-left, so it never sits under the level
 * badge or the plan-step number in the other corners.
 */
.grid.shows-coverage .cell[data-cover]::before {
  content: attr(data-cover-n);
  position: absolute; left: 3px; bottom: 2px;
  color: var(--text-dim);
  font-size: 9px; font-weight: 700; font-variant-numeric: tabular-nums;
  line-height: 1;
  pointer-events: none;
}

.cell.is-carried {
  border-color: var(--accent);
  border-style: solid;
  background: color-mix(in srgb, var(--accent) 12%, var(--bg));
}

/*
 * What the coverage shading is not showing, and the one thing a player can do
 * about it. Only on screen while the overlay is on — see grid.js for why it
 * counts this field rather than the roster.
 *
 * Read as a note, not a banner: the same left rule .effect__help and .prio__note
 * carry, because it is the same kind of thing. An accent-filled call to action
 * under the field would compete with the field, and this is a footnote to it. The
 * link is the only part allowed to look like a link.
 */
.rangegap {
  display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--s1) var(--s3);
  margin: var(--s2) 0 0;
  padding: var(--s2) var(--s3);
  border-left: 2px solid color-mix(in srgb, var(--accent) 55%, var(--line));
  border-radius: 0 var(--r-sm) var(--r-sm) 0;
  background: var(--surface-2);
  color: var(--text-dim);
  font-size: 12px; line-height: 1.5;
}
.rangegap > span { flex: 1 1 20ch; }
.rangegap__go {
  flex: none;
  font-weight: 650;
  white-space: nowrap;
}

/* ============================================================ file drop */

.drophint {
  position: fixed; z-index: var(--z-modal);
  inset: var(--s4);
  display: grid; place-items: center;
  border: 2px dashed var(--accent);
  border-radius: var(--r-lg);
  background: color-mix(in srgb, var(--bg) 88%, transparent);
  color: var(--text);
  font-size: 16px; font-weight: 600;
  pointer-events: none;
}
.drophint code {
  padding: 1px 5px;
  border-radius: var(--r-sm);
  background: var(--surface-3);
  font-size: 14px;
}

/* ============================================================ toast */

.toast {
  position: fixed; z-index: var(--z-toast);
  /*
   * left:50% with the centring done by transform means shrink-to-fit had only
   * the right half of the screen to work with -- a 320px phone capped every
   * message at 160px, so "Tap again to place it there, or tap the cell you
   * want" wrapped to five lines. Sizing to content with an explicit ceiling
   * lets a sentence be a sentence.
   */
  left: 50%; bottom: var(--s5);
  width: max-content; max-width: min(92vw, 480px);
  padding: var(--s2) var(--s4);
  border: 1px solid var(--line);
  border-radius: 999px;
  background: var(--surface-3);
  box-shadow: var(--lift-1);
  font-size: 13px; font-weight: 550;
  opacity: 0; transform: translate(-50%, 8px);
  pointer-events: none;
  transition: opacity 160ms var(--ease-out), transform 160ms var(--ease-out);
}
.toast.is-shown { opacity: 1; transform: translate(-50%, 0); }
.toast[data-kind="error"] { border-color: var(--danger); color: color-mix(in srgb, var(--danger) 80%, var(--text)); }
.toast[data-kind="ok"]    { border-color: var(--ok); }

/* A toast carrying an Undo accepts the pointer; a plain one stays untouchable
   so it never eats a click meant for what is behind it. */
.toast.has-act {
  pointer-events: auto;
  display: flex; align-items: center; gap: var(--s3);
  max-width: min(92vw, 480px);
}
.toast__act {
  flex: none;
  padding: 3px 10px;
  border: 1px solid color-mix(in srgb, var(--accent) 60%, var(--line));
  border-radius: 999px;
  background: transparent;
  color: var(--accent);
  font: inherit; font-size: 12px; font-weight: 700;
  cursor: pointer;
  transition: background-color 140ms var(--ease-out);
}
.toast__act:hover { background: color-mix(in srgb, var(--accent) 14%, transparent); }

/* ==================================================== saved formations */

/*
 * Snapshots of the whole working state, kept in localStorage next to the
 * autosave. The autosave is the working copy and every change overwrites it;
 * these are decisions, and only saving the same name again touches one.
 *
 * Desktop keeps the list in a drawer off the right edge, behind a tab that is
 * always in view without costing the field or the roster any room. The phone
 * opens the same element as a sheet from the app bar — those rules live with
 * the other sheets in the phone block below.
 */

.saves-handle {
  position: fixed; top: 50%; right: 0; z-index: var(--z-sticky);
  transform: translateY(-50%);
  display: inline-flex; flex-direction: column; align-items: center; gap: var(--s2);
  padding: var(--s3) 5px var(--s3) 7px;
  border: 1px solid var(--line); border-right: 0;
  border-radius: var(--r-md) 0 0 var(--r-md);
  background: var(--surface-2);
  color: var(--text-dim);
  font: inherit; font-size: 12px; font-weight: 650; letter-spacing: .04em;
  cursor: pointer;
  writing-mode: vertical-rl;
  transition: background-color 140ms var(--ease-out), color 140ms var(--ease-out),
              border-color 140ms var(--ease-out);
}
.saves-handle:hover { background: var(--surface-3); color: var(--text); }
.saves-handle[aria-expanded="true"] { border-color: var(--accent); color: var(--text); }
/* The count keeps its own writing mode: a rotated digit reads as a mistake. */
.saves-handle__n {
  writing-mode: horizontal-tb;
  min-width: 18px; padding: 1px 5px;
  border-radius: 999px;
  background: var(--surface-3);
  color: var(--text-dim);
  font-size: 11px; font-weight: 700; letter-spacing: 0;
  text-align: center;
}
.saves-handle__n:empty { display: none; }

@media (min-width: 761px) {
  .panel--saves {
    position: fixed; top: 0; right: 0; bottom: 0; z-index: var(--z-modal);
    display: flex; flex-direction: column;
    width: min(380px, 92vw);
    border: 0; border-left: 1px solid var(--line);
    border-radius: 0;
    box-shadow: var(--lift-2);
    transform: translateX(103%);
    visibility: hidden;
    transition: transform 220ms var(--ease-out), visibility 220ms var(--ease-out);
  }
  body.saves-open .panel--saves { transform: none; visibility: visible; }
}

.saves__keep { width: 100%; }
/* Horde | Gold Rush & Arena: full width, two equal halves, under Save. */
.saves__tabs { display: flex; width: 100%; margin-top: var(--s2); }
.saves__tabs .segmented__btn { flex: 1 1 0; justify-content: center; gap: 5px; }

.saves__empty {
  margin: var(--s3) 0 0;
  color: var(--text-dim); font-size: 12.5px;
}
.saves__empty b { font-weight: 650; }

.saves {
  list-style: none;
  margin: var(--s3) 0 0; padding: 0;
  flex: 1 1 auto; min-height: 0;
  display: flex; flex-direction: column; gap: var(--s2);
  overflow-y: auto;
  overscroll-behavior: contain;
}

.save { position: relative; }

/* The whole card loads — the map and the name are one target, because "which
   one is it" and "open it" are answered by the same glance. */
.save__main {
  display: flex; align-items: center; gap: var(--s3);
  width: 100%;
  padding: var(--s2) 30px var(--s2) var(--s2);   /* right gap clears the × */
  border: 1px solid var(--line-soft);
  border-radius: var(--r-md);
  background: var(--surface-2);
  color: var(--text);
  font: inherit; text-align: left;
  cursor: pointer;
  transition: background-color 140ms var(--ease-out), border-color 140ms var(--ease-out);
}
.save__main:hover {
  background: var(--surface-3);
  border-color: color-mix(in srgb, var(--line) 60%, var(--text-mute));
}
.save.is-current .save__main { border-color: color-mix(in srgb, var(--accent) 55%, var(--line)); }

/*
 * The formation itself, sprite by sprite on a 6×6 grid the size of a stamp.
 * Art reads faster than names: a build is recognised by its shape and its
 * sprites long before its title. The hairline gradients draw the empty field
 * behind them so five sprites still read as a formation, not a scatter.
 *
 * Named .fmap rather than .save__map because the community gallery and the post
 * dialog draw the same stamp at other sizes — hence --fmap-cell, which is the
 * one number the whole thing is built from.
 */
.fmap {
  --fmap-cell: 13px;
  --fmap-step: calc(var(--fmap-cell) + 1px);
  flex: none;
  display: grid;
  grid-template-columns: repeat(6, var(--fmap-cell));
  grid-template-rows: repeat(6, var(--fmap-cell));
  gap: 1px;
  padding: 3px;
  border-radius: var(--r-sm);
  background: var(--bg);
  background-image:
    repeating-linear-gradient(to right,  transparent 0 var(--fmap-cell), var(--line-soft) var(--fmap-cell) var(--fmap-step)),
    repeating-linear-gradient(to bottom, transparent 0 var(--fmap-cell), var(--line-soft) var(--fmap-cell) var(--fmap-step));
  background-origin: content-box;
  background-clip: content-box;
}
/* Gold Rush's 5 x 5: the same stamp, one column and one row fewer. */
.fmap--gr {
  grid-template-columns: repeat(5, var(--fmap-cell));
  grid-template-rows: repeat(5, var(--fmap-cell));
}
.fmap img { width: var(--fmap-cell); height: var(--fmap-cell); object-fit: contain; }
/* The handful of Tatari with no art yet: an initial, not a broken image. */
.fmap__stub {
  display: grid; place-items: center;
  font-size: calc(var(--fmap-cell) * .62); font-weight: 700; color: var(--text-mute);
}
/*
 * A Tatari that was placed but is not in this roster — a custom one, or one the
 * wiki has dropped. Marked rather than skipped: a fifteen-Tatari build drawing
 * eleven sprites and no gaps quietly claims to be a different build.
 */
.fmap__gap {
  border: 1px dashed color-mix(in srgb, var(--text-mute) 50%, transparent);
  border-radius: 2px;
}

.save__body {
  flex: 1 1 auto; min-width: 0;
  display: flex; flex-direction: column; gap: 3px;
}
.save__name {
  font-size: 13.5px; font-weight: 600;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.save__meta {
  display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 8px;
  color: var(--text-mute); font-size: 11.5px;
}
/* Shown only on the save that is exactly what is on the field right now. */
.save__now { display: none; color: var(--accent); font-size: 10px; font-weight: 700;
  letter-spacing: .06em; text-transform: uppercase; }
.save.is-current .save__now { display: inline; }

/*
 * Post. Quiet until the card is under the pointer, because it is the one
 * irreversible thing in this drawer — everything else here is yours alone, and
 * this hands a formation to strangers. Always visible on touch, where there is
 * no hover to reveal it.
 */
.save__post {
  position: absolute; right: 4px; bottom: 6px;
  padding: 2px var(--s2);
  font-size: 11px;
  opacity: 0;
  transition: opacity 140ms var(--ease-out);
}
.save:hover .save__post, .save:focus-within .save__post { opacity: 1; }
@media (hover: none) { .save__post { opacity: 1; } }

/* Post and × share one right-hand gutter, stacked, so neither can ever land on
   the meta line however long a formation's name or mode string gets. */
.saves--posting .save__main { padding-right: 56px; }
/* Copy sits left of ×, so the name always leaves room for both. */
.save__main { padding-right: 56px; }
.save__x.save__copy { right: 30px; font-size: 14px; }
.save__x.save__copy:hover { color: var(--accent); }

/* Deleting should be present but never the easiest thing to hit — same lesson
   the bench chips learned. Revealed by the pointer; always there for touch. */
.save__x {
  position: absolute; top: 4px; right: 4px;
  width: 24px; height: 24px;
  display: grid; place-items: center;
  border: 0; border-radius: var(--r-sm);
  background: transparent;
  color: var(--text-mute);
  font: inherit; font-size: 15px; line-height: 1;
  cursor: pointer;
  opacity: 0;
  transition: opacity 140ms var(--ease-out), color 140ms var(--ease-out),
              background-color 140ms var(--ease-out);
}
.save:hover .save__x, .save:focus-within .save__x { opacity: 1; }
.save__x:hover { background: var(--surface-3); color: var(--danger); }
@media (hover: none) { .save__x { opacity: 1; } }

/* ==================================================== post to community */

.modal--submit {
  width: min(440px, 92vw);
  padding: var(--s4);
  border: 1px solid var(--line);
  border-radius: var(--r-lg);
  background: var(--surface);
  color: var(--text);
}
.modal--submit::backdrop { background: color-mix(in srgb, var(--bg) 72%, transparent); }

/* The board, first and centred: what you are about to publish, at a size you
   can actually read, before anything asks you to name it. */
.submit__map { display: flex; justify-content: center; margin: var(--s3) 0 var(--s2); }
.submit__facts { text-align: center; margin-bottom: var(--s3); }

.submit__field { display: block; margin-bottom: var(--s3); }
.submit__field .field { width: 100%; margin-top: 3px; }

/*
 * The disclosure. Deliberately not a fine-print row: it sits at body size in
 * the reading flow directly above the confirm, because it is the part that
 * changes what pressing the button means.
 */
.submit__terms {
  margin: 0 0 var(--s4);
  padding: var(--s3);
  border: 1px solid var(--line-soft);
  border-radius: var(--r-md);
  background: var(--surface-2);
  color: var(--text-dim);
  font-size: 12.5px; line-height: 1.5;
}
.submit__terms p { margin: 0 0 var(--s2); }
.submit__terms p:last-child { margin-bottom: 0; }
.submit__terms b { color: var(--text); font-weight: 650; }

/* Why this one cannot go. Stated where the button is, not as a toast after the
   press — the press should never get that far. */
.submit__why {
  margin: 0 0 var(--s3);
  padding: var(--s2) var(--s3);
  border: 1px solid color-mix(in srgb, var(--danger) 45%, var(--line));
  border-radius: var(--r-md);
  color: color-mix(in srgb, var(--danger) 75%, var(--text));
  font-size: 12.5px; line-height: 1.5;
}


/* ================================================== the header action row */

/* Share keeps a place of its own on a wide screen: it is the one thing this
   product measurably fails at, and burying it would be the wrong economy. On a
   phone it is .wide-only and the app bar carries it instead. */
.topbar__share { flex: 0 0 auto; }

/* ============================================================ footer */

.foot {
  display: flex; flex-wrap: wrap; gap: var(--s2) var(--s4);
  padding: var(--s4) var(--s5) var(--s6);
  border-top: 1px solid var(--line);
  color: var(--text-dim); font-size: 12px;
}
.foot span:last-child { margin-left: auto; }

.foot__by { display: inline-flex; align-items: center; gap: var(--s2); }
.foot__by b { color: var(--text); font-weight: 650; }
.foot__byline { display: inline-flex; align-items: baseline; gap: 5px; }

/* Round, with a hairline so a pale photo still reads as a disc against the
   page rather than bleeding into it. */
.foot__avatar {
  width: 34px; height: 34px;
  border-radius: 50%;
  object-fit: cover;
  border: 1px solid var(--line);
  box-shadow: var(--lift-1);
}
/* Sits with the app name, where a version belongs. Baseline-aligned so it
   rides the title's baseline rather than floating above it. */
.ver-pill {
  align-self: baseline;
  padding: 1px 6px;
  border: 1px solid var(--line);
  border-radius: 999px;
  background: var(--surface-2);
  color: var(--text-mute);
  font-size: 10.5px; font-weight: 700; letter-spacing: .04em;
  white-space: nowrap;
}

/* ============================================================ touch */

/*
 * There is no hover on a touch screen, so every control that waits for one is
 * invisible there — and an invisible button that still takes taps is worse than
 * no button: tapping the corner of a bench chip silently unbenched the Tatari
 * instead of placing it. They are all visible here, and big enough to hit.
 */
@media (hover: none) {
  /* The instructions that match the input you actually have. */
  .hint--pointer { display: none; }
  .hint--touch { display: block; }

  /*
   * Controls sized for a cursor are a poor target for a thumb. These are the
   * ones you tap repeatedly while drafting, brought up towards the ~44px the
   * platform guidelines ask for without reflowing the desktop layout.
   */
  /*
   * WCAG 2.5.8 (AA) asks for 24x24 CSS px; 2.5.5 (AAA) asks for 44x44. Padding
   * alone got most of these over 24 and none of them to 44, because a 12px
   * label plus padding tops out in the thirties. min-height finishes the job on
   * the controls that get tapped over and over while drafting, and leaves the
   * ones that live inside a bigger target alone.
   */
  .btn { min-height: 44px; padding: 10px var(--s3); }
  .btn--tiny { min-height: 44px; padding: 7px var(--s3); }
  .field { min-height: 44px; padding: 10px var(--s3); }
  .field--tiny { padding: 7px var(--s2); }
  .check { min-height: 44px; padding: 4px 0; }
  .check input { width: 20px; height: 20px; }
  .segmented__btn { min-height: 44px; }

  /*
   * The filter chips are the exception to the 44px rule above, and the reason is
   * that on a phone they compete with the roster rather than sit beside it.
   * Four rows of them at 44px came to 316px of filters above a 309px roster:
   * more screen spent describing the list than showing it, and about three rows
   * of Tatari visible at a time.
   *
   * 32px is the trade. It clears WCAG 2.5.8 (AA, 24x24) with room to spare and
   * gives up 2.5.5 (AAA, 44x44), which is the same call the rest of this file
   * already makes for .card__info and .benchchip__x. A chip is also a far more
   * forgiving target than those: mistapping Fire for Grass costs one more tap,
   * and the row it sits in has no destructive control anywhere near it.
   *
   * The controls that keep their 44px are the ones where a mistap costs
   * something — Reset throws away every filter you set, and the sort select
   * opens a native picker.
   */
  .chip { min-height: 28px; width: 28px; }
  .chips { gap: 3px; }
  .chips--text .chip { width: auto; padding: 0 6px; }
  .chip--effect { min-height: 28px; padding: 2px 6px; gap: 4px; }
  /*
   * The caret is deliberately not a .chip -- the generic chip handler reads
   * dataset.value off anything wearing that class -- and that also kept it out
   * of every rule in this block. It stayed 26px while the chips it belongs to
   * grew, leaving the narrowest target in the row on the control that reveals
   * everything else. Sized with them now, and 28px still clears WCAG 2.5.8.
   */
  .fxcaret { width: 28px; min-height: 28px; }

  /*
   * The opened row scrolls sideways rather than wrapping.
   *
   * Eleven debuff chips wrap to three or four lines in a 364px sheet, and the
   * comment above this block is a long argument about exactly that cost: four
   * rows of filters came to 316px above a 309px roster, more screen describing
   * the list than showing it. The row is opt-in, so it never costs anyone who
   * does not open it -- but the moment it is open it undoes the trade the rest
   * of this block was written to win.
   *
   * One line that scrolls keeps the cost at 34px however many effects a group
   * has. The budgets stay pinned at the head of it, since they are what you
   * came to set and they should not scroll away from the effects they govern.
   */
  .chips--sub {
    flex-wrap: nowrap;
    overflow-x: auto;
    scrollbar-width: none;
    overscroll-behavior-x: contain;
    -webkit-overflow-scrolling: touch;
  }
  .chips--sub::-webkit-scrollbar { display: none; }
  .chips--sub .chips__group { flex-wrap: nowrap; }
  .chips--sub .chip { flex: none; }

  /*
   * 28px rather than 30 is not cosmetic. The five element chips and the six role
   * chips came to 374px side by side in a 364px sheet — ten pixels over, so the
   * roles wrapped to a second line and the row cost 72px to show eleven things
   * that fit in 32. Two pixels off each chip brings the pair to 352 and the
   * wrap goes away.
   */
  .filters__row { gap: var(--s2); }
  .filters__row--meta { gap: var(--s2); }
  .filters { gap: 6px; margin-bottom: var(--s2); }

  /*
   * The word "Sort" goes, the select stays. Hide unavailable, Glitter, the
   * label and the select and Reset came to 374px on a 364px row — ten over, and
   * a wrap costs 52px of roster to show four controls that nearly fit on one
   * line. The select keeps an aria-label, so the control is still named for
   * anyone who cannot see that it sits in a row of filters.
   */
  .sortby__label { display: none; }
  .secnav a { padding: 12px var(--s2); }

  .card__info, .benchchip__x, .prio__notebtn, .prio__artx {
    opacity: 1;
    pointer-events: auto;
  }

  .card__info { width: 24px; height: 24px; font-size: 12px; }
  /* 22px was under WCAG 2.5.8's 24px floor -- the only control here that was. */
  .benchchip__x { width: 24px; height: 24px; font-size: 13px; }
  .prio__notebtn, .prio__drop { width: 26px; height: 26px; }

  /* A full-cover scrim on every group member would bury the sprites, so on
     touch the remove control is a corner badge — kept inside the tile so it
     cannot be mistaken for the sprite sitting next to it. */
  .prio--group .prio__art { width: 30px; height: 30px; }
  .prio__artx {
    inset: 0 0 auto auto;
    width: 16px; height: 16px;
    border-radius: 0 var(--r-sm) 0 var(--r-sm);
    background: color-mix(in srgb, var(--bg) 82%, transparent);
    font-size: 11px;
  }

  /*
   * Dragging is pointer-driven, so the browser must not read a drag that starts
   * on a token or a bench chip as a scroll and take the gesture away mid-move.
   * Empty cells keep the default, so the page can still be scrolled by starting
   * on the field's blank space.
   */
  .cell.is-filled, .benchchip { touch-action: none; }

  /*
   * Reordering the plan by touch, which until now simply did not happen.
   *
   * .prio was in dnd.js's DRAG_SURFACES from the start, so the long-press menu
   * was suppressed over it and everything looked wired up -- but it never got
   * the touch-action the cells and bench chips have. Without that the browser
   * has claimed the gesture as a scroll of the plan list well before the 180ms
   * hold fires, so setPointerCapture arrives to find the pointer already spoken
   * for and the drag begins over a stream of moves that never come.
   *
   * The whole row cannot have touch-action: none, the way a field cell can. A
   * cell sits in a grid with empty squares left to scroll the page by; the plan
   * is a scrolling list made entirely of these, so claiming every row would
   * leave a long plan with no way to scroll at all.
   *
   * So the rank number becomes the grip, which is the one part of the row that
   * already means order -- you take hold of the number to change the number.
   * Widened and given a dotted grip so it reads as something to grab, and only
   * here: on a mouse the whole row stays draggable, which is what anyone with a
   * cursor will try first.
   */
  .prio__rank {
    position: relative;
    width: 26px; align-self: stretch;
    display: grid; place-items: center;
    touch-action: none;
    cursor: grab;
  }
  .prio__rank::after {
    content: "";
    position: absolute; left: 50%; bottom: 2px;
    width: 10px; height: 3px;
    transform: translateX(-50%);
    background-image: radial-gradient(circle, var(--text-mute) 40%, transparent 45%);
    background-size: 3px 3px;
    opacity: .8;
  }
  /* The grip is the target, so it has to be worth aiming at. Stretched over the
     row's padding rather than made taller, so the row keeps its height. */
  .prio__rank::before {
    content: "";
    position: absolute; inset: -5px -3px -5px -5px;
  }
}

/* ======================================================= phone app shell */

/*
 * Phones are 72% of visitors, and on a phone the field is not a section of a
 * page — it is the page. The roster, the summary and the plan are drawn over
 * it and dismissed again, so the frame never shares the viewport with a list of
 * 218 cards, and picking something is no longer a round trip past everything
 * else.
 *
 * That is also what makes the grid postable. Formations travel as a screenshot
 * far more often than through the Share button, and a screenshot can only carry
 * what was on screen at the time: keeping the frame whole and unscrolled means
 * the phone's own screenshot button is the share feature.
 *
 * None of this applies above 760px. The tablet stack and the desktop three-panel
 * layout are untouched.
 */

/* Phone-only chrome. Desktop reaches clean view from the Formation tools. */
.appbar, .scrim, .bench__clean { display: none; }

@media (max-width: 760px) {
  /* One number, so the app bar's height and the sheets' bottom edge cannot
     drift apart. The inset keeps it clear of a home indicator. */
  /*
   * 60, not 58. The bar is a 44px button plus 8px of padding top and bottom, so
   * the declared height had drifted 2px under the real one -- which put the
   * dock 2px too high and left a sliver of every sheet behind the bar. The
   * comment above this used to claim the two "cannot drift apart"; they had.
   */
  :root { --appbar-h: calc(60px + env(safe-area-inset-bottom, 0px)); }

  /* Body drives the height so the field can claim whatever the header and the
     app bar leave, without either being measured in JavaScript. The padding
     replaces the space the app bar gave up by going fixed, so the footer can
     still be scrolled out from under it. */
  /*
   * --dock-h is measured in shell.js rather than assumed: co-op shows two
   * benches, an empty one collapses, and a wrong guess either hides the last
   * grid row behind the dock or leaves a band of dead space under it.
   */
  body {
    display: flex; flex-direction: column;
    min-height: 100dvh;
    padding-bottom: calc(var(--appbar-h) + var(--dock-h, 120px));
  }

  /*
   * Every row the header takes is a row the field does not get, and on a phone
   * the field is the point. Three rows: title and byline, then the name with
   * the mode switch beside it, then the actions.
   */
  .topbar { padding: var(--s2) var(--s3); gap: var(--s1) var(--s2); }

  /*
   * The formation's name is the heading here, not the product's.
   *
   * 1a leads the phone with what you are building and how far along it is. The
   * app's own name is the least useful thing on a screen you already opened on
   * purpose, so it drops to a quiet label and the name field takes the weight:
   * the display face, 19px, sitting on the page rather than in a box.
   */
  /*
   * Four rows, three of them nearly empty.
   * ======================================
   *
   * The header came to 153px over four lines: the title alone with 230px of
   * nothing beside it, the byline alone with 180px, then the name and the mode
   * switch, then More alone with 300px. Each item's natural width was a shade
   * too wide to share, so every one of them took a line and then shrank to fit
   * it -- the worst of both.
   *
   * Two rows now. The credit line up top, with More at the right-hand end where
   * a menu belongs, and the formation's name across the second with the mode
   * switch beside it. Nothing hidden and nothing moved out of the header.
   */
  .topbar__title h1 { font-size: 14px; font-weight: 600; color: var(--text-mute); letter-spacing: 0; }
  .topbar__title { flex: 0 0 auto; min-width: 0; }
  /*
   * The version pill goes, so the credit can share the title's row.
   *
   * "Horde Drafter" is 93px and the pill another 47. The byline wants about
   * 230, and 148 + 8 + 230 overruns a 366px row by 20 -- so the credit took a
   * line of its own and then rendered at 197 on it, a wrap caused by a width it
   * did not end up using. Without the pill the title is 93 and both fit with
   * room to spare.
   *
   * The pill is the right thing to drop. It answers "which build am I on",
   * which is a question for a bug report and not for drafting, and the About
   * dialog still carries it. "Say thanks" stays: the note below measured it at
   * 59px and worth keeping, and this row is not the one it was costing.
   *
   * flex-basis 0 was tried here first and was worse -- an item that asks for
   * nothing does not block the line, so the name field joined the row too and
   * squeezed the credit to 13px.
   */
  #app-version { display: none; }
  .byline { flex: 0 1 auto; min-width: 0; }
  .byline__text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  /* On the narrowest phones the name came out as "by...", which credits nobody.
     The avatar still links to the same place, and the footer spells it out. */
  @media (max-width: 400px) { .byline__text { display: none; } }
  /* At 320 (Galaxy S9+, iPhone SE) the help button, the avatar and "Say thanks"
     overran the theme switch and covered half of it. The cup stays, and the
     link keeps its "Say thanks" label for anyone reading it out. */
  @media (max-width: 340px) { .byline__tip-text { display: none; } }
  .field--name {
    font-family: var(--font-display);
    font-size: 19px; font-weight: 700; letter-spacing: -.02em;
    background: transparent; border-color: transparent;
    padding-left: 0; padding-right: 0;
  }
  .field--name:focus { background: var(--surface-2); border-color: var(--line); padding-left: var(--s2); }
  /* The header's phone layout is with the rest of the masthead, at the end of this file. */

  /* Three checkboxes and a button were wrapping to two rows of their own. */
  .panel--field .panel__tools { gap: 2px var(--s2); font-size: 12px; }
  /*
   * All four on one line, which needs about 20px it did not have.
   *
   * Measured on a 411px phone: the three switches come to ~277px and Clear
   * field ~84, which with gaps is ~385 against ~367 of panel. Wrapping put one
   * small button alone on a 44px row -- a whole row of the field's height spent
   * on nothing, which is what made it look bulky.
   *
   * The 20px comes from the switches rather than their labels: a size down, a
   * slightly smaller box, and tighter gaps. Every word survives. They are
   * secondary toggles and the 44px touch target below is untouched, so the
   * smaller type costs legibility nothing that matters here.
   */
  .panel--field .panel__tools { column-gap: 6px; }
  .panel--field .panel__tools .check { font-size: 11.5px; gap: 4px; }
  .panel--field .panel__tools .check input { width: 18px; height: 18px; }
  /* Still last and still at the end of the row, so on a narrower phone than
     this it wraps to the right edge rather than stranding itself on the left. */
  .panel--field .panel__tools #btn-clear-field { margin-left: auto; }
  .hint { margin: 0; font-size: 12.5px; }

  /* The three panels are no longer three places on one page, so there is
     nothing left to jump to. */
  .secnav { display: none; }

  .layout {
    flex: 1 0 auto;
    grid-template-columns: minmax(0, 1fr);
    grid-template-areas: "field";
    min-height: 0;
    padding: var(--s2) var(--s2) 0;
    gap: 0;
  }

  /* ---- the field, filling what is left ---- */

  .panel--field {
    display: flex; flex-direction: column; gap: var(--s2);
    min-height: 0;
    padding: var(--s3);
    /*
     * .panel isolates, and the summary sheet is a child of this one — so its
     * z-index was scoped to this panel's stacking context and could not rise
     * above the scrim, which dimmed the sheet you had just opened. The roster
     * and the plan are panels themselves and never had the problem.
     */
    isolation: auto;
  }
  .panel--field .panel__head { margin-bottom: 0; }
  .panel--field .panel__head h2 { font-size: 15px; }

  /*
   * The frame keeps its natural height and is never asked to fill a flex slot.
   * Six cells across a phone's width is already very close to a square, so the
   * grid sizes itself from its width; telling it to fill instead let it be
   * squeezed to a sliver whenever the header above it grew, taking the cells'
   * aspect-ratio down with it.
   */
  .field-frame { flex: 0 0 auto; }

  /* ---- the bench, as a dock ---- */

  /*
   * One row that scrolls sideways instead of a block that grows downward.
   * Fifteen chips wrapping to three rows was taking a third of the screen from
   * the field, and the field is the thing being worked on.
   */
  /*
   * The dock floats just above the app bar instead of sitting at the end of the
   * panel. Whether the field and the header happen to fit a given phone's
   * viewport exactly is not something to bet the bench on — at the end of the
   * panel it was landing a few dozen pixels below the fold on a 923px screen,
   * which put the fifteen you are bringing out of sight on the one screen whose
   * whole job is placing them.
   */
  #dock {
    position: fixed; left: 0; right: 0;
    /* Tucked 2px behind the bar rather than balanced exactly on its edge: two
       fixed elements meeting at a fractional device pixel leave a hairline the
       page shows through, and the overlap is hidden under an opaque bar. */
    bottom: calc(var(--appbar-h) - 2px);
    z-index: 62;
    padding: var(--s2) var(--s3);
    background: var(--surface);
    border-top: 1px solid var(--line);
    box-shadow: 0 -8px 20px -14px rgb(0 0 0 / .55);
  }
  /* min-width: 0 down the chain, or a flex parent grows to fit every chip and
     the strip runs past the screen's edge instead of scrolling inside it, with
     the last chips out of reach. */
  .bench, .bench__player, .bench__head, .bench__strip { min-width: 0; }
  .bench__player { gap: var(--s1); }
  .bench__head { gap: var(--s2); font-size: 11.5px; }
  .bench__strip {
    flex-wrap: nowrap;
    overflow-x: auto;
    overscroll-behavior-x: contain;
    scrollbar-width: none;
    padding-bottom: 2px;
  }
  .bench__strip::-webkit-scrollbar { display: none; }
  /* Bigger than the 52px they started at: these are drag handles on a phone,
     and a sprite you are meant to pick up and carry to a cell needs to be worth
     aiming at. */
  .benchchip { flex: 0 0 auto; width: 64px; height: 64px; }

  /*
   * The remove button only appears once a chip is armed. Touch normally reveals
   * it on every chip, but a 22px × on a 52px tile covers the corner a thumb
   * lands on, and the first real run on a phone removed a Tatari that was meant
   * to be placed. Arming first is harmless; "stop bringing this" is not, and it
   * should not be the easiest thing to hit on the control used most.
   */
  .benchchip__x { opacity: 0; pointer-events: none; }
  .benchchip.is-armed .benchchip__x { opacity: 1; pointer-events: auto; }

  /* The dock and the app bar both moved into the corner the toast used to own. */
  .toast { bottom: calc(var(--appbar-h) + 104px); z-index: 90; }

  /* ---- placing by thumb ---- */

  /*
   * The armed chip and the cell it is aimed at are one statement read in two
   * places: the chip says which Tatari is in hand, the cell says where letting
   * go would put it. Both carry the accent so the pair is obvious at arm's
   * length, and the dashed inner edge marks the cell as a proposal rather than
   * something already placed.
   */
  .benchchip.is-armed {
    border-color: var(--accent);
    box-shadow: 0 2px 12px -6px var(--accent), inset 0 0 0 1px var(--accent);
    transform: translateY(-2px);
  }
  /* What the dock and the app bar cover is not "visible", so scrolling a cell
     into view has to clear them. This is what lets arming scroll by the minimum
     rather than recentring the field. */
  .cell {
    scroll-margin-bottom: calc(var(--appbar-h) + var(--dock-h, 120px) + 8px);
    scroll-margin-top: 8px;
  }

  .cell.is-target {
    position: relative;
    border-color: var(--accent);
    background: color-mix(in srgb, var(--accent) 14%, var(--surface-2));
  }
  .cell.is-target::after {
    content: "";
    position: absolute; inset: 3px;
    border: 1px dashed color-mix(in srgb, var(--accent) 70%, transparent);
    border-radius: 3px;
    pointer-events: none;
  }

  /* ---- roster, summary and plan as sheets over the field ---- */

  /*
   * The sheet stops at the top of the app bar rather than running under it, so
   * the bar stays tappable while a sheet is up and you can go straight from the
   * roster to the plan without closing anything first.
   */
  /* The saved list joins the sheets here: same element the desktop drawer
     uses, but the drawer rules are scoped to wide screens and stand aside. */
  .panel--roster, .panel--priority, .panel--saves, #summary {
    position: fixed; inset: auto 0 var(--appbar-h) 0; z-index: 70;
    display: flex; flex-direction: column;
    /*
     * svh, not dvh. dvh tracks the browser's collapsing toolbar, so scrolling
     * past the end of the roster resized the sheet mid-gesture and left it that
     * way. svh is measured against the largest chrome and never moves.
     */
    max-height: calc(90svh - var(--appbar-h));
    margin: 0;
    padding: var(--s3);
    border: 1px solid var(--line);
    border-bottom: 0;
    border-radius: var(--r-lg) var(--r-lg) 0 0;
    background: var(--surface);
    /* Offset and blur, so the sheet reads as lifted off the field rather than
       outlined against it. */
    box-shadow: 0 -8px 28px -12px rgb(0 0 0 / .55);
    transform: translateY(101%);
    visibility: hidden;
    /* Hidden only once it has slid away... */
    transition: transform 220ms var(--ease-out), visibility 0s linear 220ms;
  }
  /* The grab bar. Decoration would be a bad reason for it; it is here because a
     sheet with no handle reads as a panel that was always there. */
  .panel--roster::before, .panel--priority::before, .panel--saves::before, #summary::before {
    content: "";
    flex: 0 0 auto;
    width: 36px; height: 4px;
    margin: 0 auto var(--s2);
    border-radius: 999px;
    background: var(--line);
  }
  body[data-sheet="roster"]   .panel--roster,
  body[data-sheet="priority"] .panel--priority,
  body[data-sheet="saves"]    .panel--saves,
  body[data-sheet="summary"]  #summary {
    transform: none;
    visibility: visible;
    /* ...but visible the moment it opens, so focus can go into it in the same
       tap (a sheet still hidden at that instant refused focus, and iOS only
       raises the keyboard for a focus made inside the tap). */
    transition: transform 220ms var(--ease-out), visibility 0s;
  }

  /*
   * Peek: the sheet at half height, with the field above it.
   *
   * Picking used to mean closing the roster to find out what you had picked.
   * Tapping a card now arms it and drops the sheet to here, so the board is on
   * screen with an empty square already marked and the next tap places it.
   */
  body.is-peek .panel--roster,
  body.is-peek .panel--priority,
  body.is-peek #summary {
    max-height: min(46svh, 430px);
    box-shadow: 0 -10px 30px -14px rgb(0 0 0 / .6);
  }
  /* The field is being tapped, not read behind a scrim -- see peekSheet(). */
  body.is-peek .scrim { display: none; }
  /* The bottom strip is the half-height sheet now, not the bench dock, so a
     square scrolled "into view" has to land above the sheet to be any use. */
  body.is-peek .cell { scroll-margin-bottom: calc(min(46svh, 430px) + var(--appbar-h) + 12px); }

  /* The app bar's Saved button owns the feature here; the edge tab is
     desktop furniture. */
  .saves-handle { display: none; }

  /* The page behind a sheet does not scroll. Overscrolling it is what collapsed
     the browser's toolbar mid-gesture, and scrolling a field you cannot see was
     never the intent anyway. */
  body[data-sheet] { overflow: hidden; }

  /* The sheet is the scroll container now, not the page. */
  /*
   * grid-auto-rows and align-content are both load-bearing. Inside a sheet the
   * roster has a definite height for the first time, and left to itself the
   * card rows were resolving against it rather than against their own contents
   * — which squashed every sprite to a coloured sliver. Rows size to their
   * cards and stack from the top; the sheet scrolls.
   */
  .panel--roster .roster {
    flex: 1 1 auto; min-height: 0;
    max-height: none; overflow-y: auto;
    overscroll-behavior: contain;
    grid-auto-rows: max-content;
    align-content: start;
  }
  .panel--priority .priority, #summary { overscroll-behavior: contain; }
  .panel--priority .priority { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
  #summary { gap: var(--s2); overflow-y: auto; }

  .scrim:not([hidden]) {
    display: block;
    position: fixed; inset: 0; z-index: 65;
    background: color-mix(in srgb, var(--bg) 62%, transparent);
  }

  /* ---- the app bar ---- */

  .appbar {
    display: flex; gap: var(--s1);
    /*
     * Fixed, not sticky. Sticky leaves the bar in the flow, so scrolling to the
     * footer below it let it reach its natural position and ride up the screen
     * — away from the sheets, which are fixed and stayed where they were.
     */
    position: fixed; left: 0; right: 0; bottom: 0; z-index: 75;
    box-sizing: border-box;
    min-height: var(--appbar-h);
    padding: var(--s2) var(--s3);
    padding-bottom: calc(var(--s2) + env(safe-area-inset-bottom, 0px));
    /*
     * Opaque, and no blur. The bar used to be 94% surface over a blur, which was
     * fine when the page scrolled behind it — but the dock sits directly on top
     * of it now, and a Tatari passing behind that last 6% read as a coloured
     * smear along the seam. Nothing is meant to show through here.
     */
    background: var(--surface);
    border-top: 1px solid var(--line);
  }
  .appbar__btn {
    flex: 1;
    display: inline-flex; align-items: center; justify-content: center; gap: 6px;
    min-height: 44px;
    padding: 8px var(--s2);
    border: 1px solid var(--line);
    border-radius: var(--r-sm);
    background: var(--surface-2);
    color: var(--text-dim);
    font: inherit; font-size: 12.5px; font-weight: 600;
    cursor: pointer;
    transition: background-color 140ms var(--ease-out), color 140ms var(--ease-out),
                border-color 140ms var(--ease-out);
  }
  .appbar__btn[aria-expanded="true"] {
    border-color: var(--accent);
    background: var(--surface-3);
    color: var(--text);
  }
  /* The one button here that produces something rather than opening something,
     and the one the tool is failing at. It gets the accent. */
  .appbar__btn--go {
    border-color: color-mix(in srgb, var(--accent) 80%, #000);
    background: var(--accent);
    color: var(--accent-ink);
  }
  .appbar__btn--go:active { filter: brightness(.94); }
  .appbar__label { overflow: hidden; text-overflow: ellipsis; }

  /* Add is the one pressed most and it sits at the far edge of a thumb's reach,
     so it gets the widest target of the row. */
  .appbar__btn[data-sheet="roster"] { flex: 1.35; }

  /* Five buttons share the bar now that Saved is one of them. On the narrowest
     phones the row gives up padding before it gives up letters. */
  @media (max-width: 430px) {
    .appbar { gap: 3px; padding-left: var(--s2); padding-right: var(--s2); }
    .appbar__btn { padding: 8px 3px; gap: 4px; font-size: 12px; }
  }

  /* Desktop's copy. The dock carries this on a phone. */
  .tools-only { display: none; }

  /*
   * The dock's head has to hold the owner pill, two counts and two buttons on
   * one line. Adding "Just the grid" pushed Clear onto a second row, which made
   * the dock taller and took that height off the field — so the prose in the
   * counts and in Clear bench goes, and the numbers and the verb stay. The full
   * wording survives as the title and the accessible name.
   */
  .wide-only { display: none; }
  .bench__head { flex-wrap: nowrap; gap: var(--s1) var(--s2); }
  /* The count gives way, with an ellipsis, rather than the buttons: on a 320px
     phone in co-op "1 left to place" pushed Clear bench off the edge. */
  .bench__count { min-width: 0; font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

  /*
   * Named, not just an icon: this is the control that makes a screenshot worth
   * posting, and a bare glyph in a row of counts reads as decoration. Outlined
   * in the accent so it is findable without competing with Share, which is the
   * filled one.
   */
  .bench__clean {
    display: inline-flex;
    gap: 5px;
    border-color: color-mix(in srgb, var(--accent) 55%, var(--line));
    color: var(--text);
    white-space: nowrap;
  }

  /*
   * Co-op: only the bench you are filling is open. Two full benches took very
   * nearly half the screen and covered the bottom of the frame, which is where
   * the co-op lines are drawn. The closed one keeps its head (that is still a
   * switch target, since the whole block switches) so the other teammate's
   * counts stay readable and one tap brings their bench back.
   */
  body[data-mode="coop"] .bench__player[data-active="false"] .bench__strip,
  body[data-mode="coop"] .bench__player[data-active="false"] .bench__empty,
  body[data-mode="coop"] .bench__player[data-active="false"] .bench__head .btn {
    display: none;
  }
  .appbar__n {
    min-width: 18px;
    padding: 1px 5px;
    border-radius: 999px;
    background: var(--surface-3);
    color: var(--text-dim);
    font-size: 11px; font-weight: 700;
  }
  .appbar__n:empty { display: none; }
  .appbar__btn[aria-expanded="true"] .appbar__n { background: var(--accent); color: var(--accent-ink); }

  /* Credits and the privacy note still have to be reachable, so they sit below
     the shell rather than being dropped from it. */
  .foot { padding: var(--s3); font-size: 11.5px; }
}

/* ========================================================== clean view */

/*
 * The field and nothing else, so the phone's own screenshot button captures the
 * formation without the dock, the app bar or the header in the frame. Not
 * phone-only: a clean crop is worth having on a desktop too.
 *
 * The LF and HAVE lines drawn inside the frame stay — they are the ask, and the
 * whole reason the frame is composed to survive a crop. Their editor above the
 * frame goes, because it is a control.
 */
body.is-clean .topbar,
body.is-clean .sitetabs,
body.is-clean .formbar,
body.is-clean .secnav,
body.is-clean .appbar,
body.is-clean .scrim,
body.is-clean .foot,
body.is-clean #bench,
body.is-clean #summary,
body.is-clean .panel--roster,
body.is-clean .panel--priority,
body.is-clean .panel--saves,
body.is-clean .saves-handle,
body.is-clean .panel--field .panel__head,
body.is-clean .panel--field .hint,
body.is-clean .panel--field .lf-input,
body.is-clean .panel--field .ptabs { display: none !important; }

body.is-clean { padding-bottom: 0; }

/*
 * The field takes the whole width and sits in the middle of what is left. Six
 * square cells across means the grid is as large as the screen is wide and no
 * larger, so the width is the whole budget — and it was being spent on margins:
 * the frame came out a little over half the screen with the rest of the page
 * empty beneath it, which is a worse picture than the one you get by not
 * bothering with clean view at all.
 */
/*
 * The field is lifted out of the page and pinned to the viewport, rather than
 * being centred inside a layout that still has opinions about its width. Left
 * in the flow it rendered at roughly 60% of the screen with black either side —
 * six square cells across means the grid can only ever be as wide as the screen,
 * so any margin at all is the whole budget being spent on nothing.
 *
 * inset: 0 makes the panel exactly the viewport, whatever the document below it
 * thinks its width is.
 */
body.is-clean .panel--field {
  position: fixed;
  inset: 0;
  z-index: 90;
  display: flex;
  flex-direction: column;
  justify-content: center;
  margin: 0;
  padding: var(--s2);
  border: 0;
  border-radius: 0;
  background: var(--bg);
  overflow: auto;
}
/*
 * Clean view undoes the two-column field.
 *
 * .fieldrow puts the board in a 440px track with the summary beside it, and
 * clean view hides the summary without touching the grid -- so the board stayed
 * pinned at 440px with 1448px of empty column to its right, on the one screen
 * whose entire purpose is showing the board as large as possible. Dissolved
 * here, the frame is free to take the width again.
 */
body.is-clean .fieldrow { display: block; }
/*
 * As big as fits, and centred.
 *
 * Six square cells across means the grid is as wide as it is tall, so on any
 * ordinary screen it is HEIGHT that runs out first: at 100% of a 1920px
 * viewport the board would be 1920 tall and the thing you came here to
 * photograph would need scrolling. The cap is the viewport height less the
 * frame's own furniture -- its name line, the spawn caption, the base caption --
 * so the whole board is on screen at once, and the auto margins put it in the
 * middle of what is left.
 */
body.is-clean .field-frame {
  width: min(100%, calc(100vh - 150px));
  margin-inline: auto;
}

/* Only in clean view, and always below the frame rather than over it. */
.clean-hint { display: none; }
body.is-clean .clean-hint {
  display: block;
  flex: 0 0 auto;
  margin: var(--s3) 0 0;
  color: var(--text-mute);
  font-size: 12.5px; text-align: center;
}

/* A phone gets the whole width; a desktop does not need a metre-wide grid. */
@media (min-width: 760.02px) {
  body.is-clean .field-frame { max-width: 820px; margin-inline: auto; }

  /* The roster's copy of Glitter art. See the note at the end of this file for
     why the breakpoint is 760.02 and not 761. */
  .touch-only { display: none; }
}

/* ============================================================ motion */

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    transition-duration: 1ms !important;
    animation-duration: 1ms !important;
  }
  .card:hover { transform: none; }
}

@media (max-width: 760px) {
  /*
   * The field is the page here, so it does not need announcing — and the row
   * this heading sat on is the row the sixth grid row needed.
   */
  .panel--field .panel__head h2 { display: none; }
}

/* ARIA rows between the grid and its cells. They exist for assistive
   technology and not for layout, so the 6-column CSS grid is unchanged. */
.grid__row { display: contents; }


/* ==================================================== target size (AA) */

/*
 * WCAG 2.5.8 asks for 24x24 CSS px, and these were the controls left under it:
 * 16-20px each. Every one sits on top of a larger target rather than beside it,
 * so the standard's spacing exception does not apply -- a small button on a
 * 55px cell is a mis-tap that hits whatever is underneath instead.
 *
 * Sized with min-width/min-height rather than width/height so the existing grid
 * centring and the icon inside them are untouched.
 *
 * .token__add is deliberately not in this list. It is the one that sits on the
 * face of a fielded Tatari, where 24px covered enough of a 55px cell to change
 * how the board reads; it keeps its 15px from main. That leaves a known 2.5.8
 * gap on exactly one control, which is a judgement about the field being the
 * thing this tool is looked at through, not an oversight -- if it is revisited,
 * the fix is a bigger hit area without a bigger drawn button, not this rule.
 */
.fx-info,
.want__x,
.prio__artx,
.contrib__qx {
  min-width: 24px;
  min-height: 24px;
}

/* On touch it is the only way to take one Tatari out of a group step. */
@media (hover: none) {
  .prio__artx { min-width: 28px; min-height: 28px; }
}

/*
 * .touch-only is hidden under `min-width: 760.02px`, up beside the clean-crop
 * rule. 760.02 and not 761, because at a fractional viewport width — which a
 * zoomed or scaled browser produces routinely — 760.5px matches neither
 * `max-width: 760px` nor `min-width: 761px`, and a class hidden only above 761
 * would render alongside its wide-screen twin in that gap.
 *
 * It carries the roster's copy of Glitter art. Two checkboxes for one setting
 * is a duplicate worth having: the original is in the field toolbar, which on a
 * phone is behind the roster sheet, and glitter is a decision you make while
 * looking at the thumbnails it changes. app.js keeps the pair in sync in both
 * directions, so neither can be left showing the wrong state.
 *
 * The earlier occupant was the phone's copy of Share, which the app bar carries
 * instead. The .wide-only side is safe against the fractional gap for a
 * different reason: at 760.5px the app bar is gone too, so the header's Share
 * is the only one on screen either way.
 */

/* ==========================================================================
 * Contrast Themes
 *
 * Windows Contrast Themes — and Edge's Page colors, which is the same thing
 * scoped to the browser — do not ask the page for a preference the way
 * prefers-color-scheme does. They throw every author colour away and repaint
 * the document from a four-colour system palette. Backgrounds, borders and
 * text are replaced; box-shadow is not replaced, it is simply dropped.
 *
 * That last detail is what this block is mostly about. Nine of this app's
 * states are drawn as an inset box-shadow, which is invisible under a contrast
 * theme rather than merely recoloured. The fixes below are the ones where the
 * lost shadow was the only copy of the information; where a border, a glyph or
 * a word already says the same thing, nothing here touches it. A contrast
 * theme user asked for a simpler page, so recreating the design in system
 * colours would be undoing their setting.
 * ========================================================================== */
@media (forced-colors: active) {
  /*
   * The element letters stop being an option here. Normal mode says the
   * element with a wash and a ring, and [data-contrast="more"] adds the letter
   * as a second channel; a contrast theme deletes the wash and the ring
   * outright, so the letter is not a second channel any more, it is the only
   * one. Show it whether or not the reader also found the toggle.
   *
   * The chips' hairline is a box-shadow, so it goes with everything else — an
   * outline replaces it. Outline rather than border because these chips are
   * absolutely positioned against an edge and a border would shift them.
   */
  .token__elem,
  .prio__elem {
    display: block;
    outline: 1px solid;
  }

  /*
   * Pressed and selected controls. The theme switch, the roster tabs and the
   * player switch each said "on" with a background fill; all three fills
   * collapse to Canvas, leaving the selected button identical to its
   * neighbours. Highlight/HighlightText is the theme's own word for this, so
   * the control ends up reading the way the rest of the OS does.
   */
  .segmented__btn[aria-pressed="true"],
  .segmented__btn[aria-selected="true"],
  .segmented--tabs .segmented__btn[aria-selected="true"],
  .ptabs__switch[aria-pressed="true"] {
    background: Highlight;
    color: HighlightText;
  }

  /*
   * Two affordances that live entirely in a box-shadow, both of them about
   * what is actionable right now: which filled tiles a Tatari reaches, and
   * which tokens are about to change hands mid-switch. An empty in-range tile
   * still reads, because is-inrange turns the dashed border solid — it is the
   * filled one that needs this, since is-filled had already made it solid.
   */
  .cell.is-inrange.is-filled,
  body[data-switching="true"] .cell.is-filled,
  body[data-switching="true"] .benchchip {
    outline: 2px solid Highlight;
    outline-offset: -2px;
  }
  body[data-switching="true"] .cell[data-zobo] { outline: none; }

  /*
   * The two drop targets, which are the worst of the losses: .is-taking is a
   * box-shadow over a 7% wash and vanishes completely, and .is-returning keeps
   * a border whose danger colour is forced back to the same CanvasText every
   * other border gets. They sit inches apart during one drag and they do
   * opposite things — one keeps the Tatari, one removes it — so a contrast
   * theme user would be dropping blind between them.
   *
   * Told apart by outline-style rather than by colour, since the palette here
   * is four colours and two of them are already spoken for. Dashed for the
   * removal reads the same way the empty tiles do.
   */
  .panel--roster.is-returning { outline: 3px dashed; outline-offset: -3px; }
  .bench__player.is-taking { outline: 3px solid Highlight; outline-offset: -3px; }

  /*
   * Sprites are transparent PNGs and a contrast theme does not recolour image
   * content, so a token's art is left sitting on whatever the theme decided
   * Canvas is. On a dark theme the black linework in the art disappears into
   * it; on a light one the pale fills do. These sprites are how you tell one
   * Tatari from another, which is the case forced-color-adjust exists for, so
   * the art keeps the backdrop it was drawn against.
   *
   * The colours are set on the img itself and not left to inherit, because
   * inheritance is exactly what forced-color-adjust: none stops. `color` is
   * set as well as `background-color`: it is what paints the alt text on the
   * days the sprite fails to load.
   */
  .token img,
  .prio__art img {
    forced-color-adjust: none;
    background-color: var(--surface-2);
    color: var(--text);
  }
}

/*
 * The update notice, between the header and the panels.
 *
 * One line, and the only row on this page that is not about the formation. It
 * exists because the way through to the update was a "See all" link at the end
 * of the filter row: findable on a desktop, and on a phone sitting inside a
 * sheet that has to be opened first, which is no way to tell somebody the game
 * changed under them.
 *
 * Sized like a row of chrome rather than a banner. It is news, not an
 * announcement, and it has a close button because the second time you read it
 * it is neither.
 */
.patchnote {
  display: flex; align-items: center; gap: var(--s2);
  margin: 0 var(--s4) var(--s2);
  padding: 6px var(--s2) 6px var(--s3);
  border: 1px solid color-mix(in srgb, var(--accent) 32%, var(--line));
  border-radius: var(--r-sm);
  background: color-mix(in srgb, var(--accent) 7%, var(--surface));
  color: var(--text-dim); font-size: 12.5px;
  text-decoration: none;
}
.patchnote:hover { background: color-mix(in srgb, var(--accent) 12%, var(--surface)); }
.patchnote__body { flex: 1 1 auto; min-width: 0; }
.patchnote__body b { color: var(--text); font-weight: 650; }
/* An arrow, because the row is a link and the button beside it is not. */
.patchnote__body::after { content: " ↗"; color: var(--accent-ink-on); font-weight: 700; }
.patchnote__x {
  flex: none;
  width: 26px; height: 26px;
  border: 0; border-radius: var(--r-sm);
  background: transparent;
  color: var(--text-mute); font: inherit; font-size: 16px; line-height: 1;
  cursor: pointer;
}
.patchnote__x:hover { background: var(--surface-3); color: var(--text); }

@media (max-width: 760px) {
  /* Full bleed on a phone, where the panels are edge to edge anyway. */
  .patchnote { margin: 0 var(--s3) var(--s2); }
}

/* ==================================================== what changed page */

/*
 * The patch-notes page. Its own address so it can be pasted into Discord on the
 * morning an update lands, which is when it is read: once, scrolling, often by
 * somebody who does not have the drafter open.
 *
 * Wide rather than a reading column. It began at 68ch, on the theory that this
 * is prose -- but it is not prose, it is 35 short records, and one column of
 * them was five screens of scrolling with no way to see how much was left. Two
 * columns halve that and let a whole group be taken in at once.
 */
.changes__main {
  max-width: 1180px;
  margin: 0 auto;
  padding: var(--s5) var(--s4) var(--s6);
}
.changes__intro {
  max-width: 72ch;
  margin: 0 0 var(--s5);
  color: var(--text-dim); font-size: 14px; line-height: 1.55;
}
/* Empty on the normal path, so it must not leave its margin behind. */
.changes__intro:empty { display: none; }

/*
 * One tab per set of notes, on the page that normally has only one.
 *
 * .segmented carries the look; this only places it. The [hidden] rule is not
 * optional: .segmented sets display:inline-flex, which beats the browser's
 * default [hidden] { display: none } on specificity, so without this the empty
 * nav still took a row on every copy that has nothing to preview.
 */
.chtabs { margin: 0 0 var(--s5); }
.chtabs[hidden] { display: none; }

/* Sits directly under the tab strip, quiet enough not to compete with the
   first group heading. A credit is worth stating and not worth shouting. */
.chcredit {
  margin: calc(var(--s5) * -1 + var(--s2)) 0 var(--s5);
  color: var(--text-mute); font-size: 12.5px;
}
.chcredit[hidden] { display: none; }

/*
 * "preview" beside a tab's name, not instead of it.
 *
 * The tab has to say which update it is AND that the update is not live, and a
 * reader deciding which tab to press has only the strip to go on. Lighter than
 * the label it sits beside so it reads as a qualifier rather than a second name.
 */
.chtab__flag {
  margin-left: 6px;
  padding: 1px 5px;
  border-radius: 3px;
  background: var(--accent);
  color: var(--accent-ink);
  font-size: 10px; font-weight: 700;
  text-transform: uppercase; letter-spacing: 0.04em;
  vertical-align: 1px;
}

/*
 * What the update did to the mode, above what it did to any Tatari.
 *
 * Two columns of prose on a page that is otherwise a grid of records, so it has
 * to read as a different kind of thing: a panel with its own surface rather than
 * more cards. It is short and read once, which is why it is not collapsible --
 * hiding eleven lines behind a toggle costs more than it saves.
 */
.chmode {
  margin: 0 0 var(--s6);
  padding: var(--s4);
  border: 1px solid var(--line-soft);
  border-radius: var(--r-md);
  background: var(--surface);
}
/* Not every set of notes has mode changes, and the ones that do not were
   drawing an empty bordered panel above the first group. Same rule, and the
   same reason, as .changes__intro:empty. */
.chmode:empty { display: none; }
.chmode__head {
  margin: 0 0 var(--s3);
  font-size: 15px; font-weight: 700;
}
.chmode__groups {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
  gap: var(--s3) var(--s5);
}
.chmode__title {
  margin: 0 0 6px;
  color: var(--text-mute); font-size: 10.5px; font-weight: 700;
  text-transform: uppercase; letter-spacing: .07em;
}
.chmode__list { margin: 0; padding-left: 1.1em; }
.chmode__list li {
  margin-bottom: 5px;
  color: var(--text-dim); font-size: 12.5px; line-height: 1.5;
}
.chmode__list li::marker { color: var(--text-mute); }

.chgroup { margin-bottom: var(--s6); }
.chgroup__head {
  display: flex; align-items: center; gap: var(--s2);
  margin: 0 0 2px;
  font-size: 17px; font-weight: 700;
}
/* The same tag the roster cards wear, at reading size, so the heading and the
   135 markers are recognisably one statement. */
.chgroup__mark {
  flex: none;
  display: inline-grid; place-items: center;
  min-width: 22px; height: 20px;
  border: 1px solid currentColor;
  border-radius: 3px;
  background: color-mix(in srgb, currentColor 16%, var(--bg));
  font-size: 15px; font-weight: 800; line-height: 1;
}
.chgroup__mark::after { content: attr(data-glyph); color: var(--text); }
.chgroup[data-patch="buff"]     .chgroup__mark { color: var(--ok); }
.chgroup[data-patch="nerf"]     .chgroup__mark { color: var(--danger); }
.chgroup[data-patch="adjusted"] .chgroup__mark { color: var(--text-dim); }
.chgroup__count {
  margin-left: auto;
  color: var(--text-mute); font-size: 12px; font-weight: 600;
}
/* Two columns where there is room, one where there is not. auto-fill with a
   floor rather than a fixed count, so a wide window gets a third column free
   and nothing has to know how wide the window is. */
.chgroup__grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(330px, 1fr));
  gap: var(--s3);
}

/*
 * Each family is a card with a real edge.
 *
 * These were rows divided by a hairline, and the page read as one undifferen-
 * tiated column: nothing said where one family stopped and the next began until
 * you had read far enough to notice the names. A border and a surface do that
 * work before anything is read.
 */
.chline {
  display: flex; flex-direction: column;
  padding: var(--s3);
  border: 1px solid var(--line-soft);
  border-radius: var(--r-md);
  background: var(--surface);
}
/*
 * The card takes its element's colour, not its direction's.
 *
 * Direction was here first and it was the wrong thing to spend the card on:
 * every card inside a group already has the same one, so the tint repeated what
 * the heading two lines up had said in words. Element is the fact that actually
 * varies down the grid, and it is how players hold a roster in their heads --
 * "my Water team" is a sentence somebody says, "my nerfed team" is not.
 *
 * Edge and a wash behind the header, both faint. An evolution line is one
 * element throughout, so this is a property of the whole card rather than of
 * any member, and the sprites carry the same hue at greater strength inside it.
 */
.chline { border-color: color-mix(in srgb, var(--type-color, var(--line)) 38%, var(--line-soft)); }
.chline__head {
  background: linear-gradient(
    to bottom,
    color-mix(in srgb, var(--type-color, transparent) 9%, transparent),
    transparent);
  margin: calc(var(--s3) * -1) calc(var(--s3) * -1) var(--s2);
  padding: var(--s3) var(--s3) var(--s2);
  border-radius: var(--r-md) var(--r-md) 0 0;
}

.chline__head {
  display: flex; align-items: center; gap: var(--s2);
  border-bottom: 1px solid color-mix(in srgb, var(--type-color, var(--line)) 26%, var(--line-soft));
}
.chline__arts { display: flex; gap: 3px; flex: none; }
/* Small: the sprite is here to be recognised, not admired. Four at card size
   would push the numbers off a phone, and the numbers are the page. */
.chline__art {
  position: relative;
  width: 34px; height: 34px;
  display: grid; place-items: center;
  border-radius: var(--r-sm);
  /* 22%, up from 14%. On the page's own background 14% read as an empty slot
     rather than as a tinted one, which mattered most on the cards whose sprites
     had not loaded yet -- four grey squares and no clue what element they were. */
  background: color-mix(in srgb, var(--type-color, var(--line)) 22%, transparent);
  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--type-color, var(--line)) 30%, transparent);
}
.chline__art img { width: 100%; height: 100%; object-fit: contain; }
.chline__tier {
  position: absolute; left: 2px; bottom: 0;
  color: var(--text-mute); font-size: 8px; font-weight: 700;
}
.chline__name {
  margin: 0; min-width: 0;
  font-size: 12.5px; font-weight: 650; line-height: 1.3;
}

.chline__changes { margin: 0; padding: 0; list-style: none; }
/* ---- a line as a Skill data panel ------------------------------------------
 * The card is skills.css's .sk-panel, its edge still in the line's element so a
 * grid of them reads Fire, Water, Grass at a glance. Each skill is the Skill
 * data card for it: closed, only the rows this update moved; open, what the
 * skill does and every other row around them, in the same style.
 */
.chexpand { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s2) var(--s3); margin: 0 0 var(--s3); }
.chexpand[hidden] { display: none; }
.chexpand__note { color: var(--text-mute); font-size: 12px; }
/* Floats bottom right, so the deep read is one tap away from anywhere in the
   notes, not only from the top. Clear of the phone's home bar. */
#changes-expand {
  position: fixed; z-index: 30;
  right: max(16px, env(safe-area-inset-right)); bottom: max(16px, env(safe-area-inset-bottom));
  min-height: 44px; padding: 0 var(--s4); border-radius: 999px;
  background: var(--surface-2); border: 1px solid var(--line);
  box-shadow: 0 4px 16px rgb(0 0 0 / .35);
}
#changes-expand[aria-pressed="true"] { border-color: var(--accent); color: var(--accent); }
.chline.sk-panel {
  /* The patch notes' own card (element edge, header wash) around the Skill
     data rows, so the page reads as it always has and the detail is one tap in. */
  gap: var(--s2); padding: var(--s3);
  border: 1px solid color-mix(in srgb, var(--type-color, var(--line)) 38%, var(--line-soft));
  border-radius: var(--r-md); background: var(--surface);
}
.chline.sk-panel .chline__head { margin-bottom: 0; }
.chline.sk-panel .chline__name { color: var(--text); font-size: 12.5px; }
.chline.sk-panel .chline__changes { display: flex; flex-direction: column; }
.chskill { list-style: none; padding: 4px 0; }
.chskill + .chskill { border-top: 1px dashed var(--line-soft); }
.chskill__head { display: flex; align-items: center; gap: 5px; }
.chskill__name { margin: 0; min-width: 0; font-size: 12.5px; font-weight: 600; line-height: 1.45; color: var(--text); }
/* Which horde level teaches it: a level 7 buff means taking the Tatari that far. */
.chline__lv {
  flex: none;
  min-width: 20px; padding: 0 3px;
  border-radius: 3px;
  background: var(--surface-3);
  color: var(--text-dim); font-size: 9.5px; font-weight: 800;
  text-align: center; letter-spacing: .02em;
}
.chskill .sk-list { padding: 0; gap: 0; border: 0; background: none; }
.chskill .sk-rows { display: grid; gap: 0; margin: 0; }
.chskill .sk-row, .chskill .sk-box .sk-row { display: flex; justify-content: space-between; gap: var(--s2); padding: 0; border: 0; border-radius: 0; background: none; }
.chskill .sk-rows dt, .chskill .sk-rows dd { margin: 0; font-size: 12.5px; font-weight: 400; line-height: 1.55; color: var(--text-dim); }
.chskill .sk-rows dt { min-width: 0; }
.chskill .sk-rows dd { white-space: nowrap; text-align: right; color: var(--text); font-variant-numeric: tabular-nums; }
/* An arrow column, so moved rows and the rest share one left edge. */
.chskill .sk-row dt::before { content: ""; display: inline-block; width: 12px; font-size: 10px; font-weight: 800; }
.chskill .chfull__moved dt { color: var(--text); }
.chskill .chfull__moved[data-move="up"] dt::before { content: "\2191"; color: var(--ok); }
.chskill .chfull__moved[data-move="down"] dt::before { content: "\2193"; color: var(--danger); }
/* Pulled toward the text colour, as skills.css does, so they hold 4.5:1 in the light theme too. */
.chskill .chfull__moved dd b { font-weight: 650; color: color-mix(in srgb, var(--ok) 72%, var(--text)); }
.chskill .chfull__moved[data-move="down"] dd b { color: color-mix(in srgb, var(--danger) 72%, var(--text)); }
.chskill .sk-was { display: inline; margin: 0; font-size: inherit; font-weight: 400; color: var(--text-mute); }
.chskill .sk-box { padding: 2px 0 0 12px; border: 0; border-radius: 0; background: none; }
.chskill .sk-box__title { margin: 0; font-size: 11px; font-weight: 700; letter-spacing: .03em; text-transform: uppercase; color: var(--text-mute); }
/* What the skill does: prose, so it reads as a note set apart from the numbers
   -- its own tinted block, italic, before the stats begin. */
.chskill .sk-desc {
  margin: 4px 0 6px; padding: 6px 8px;
  border-left: 2px solid var(--accent); border-radius: 0 var(--r-sm, 4px) var(--r-sm, 4px) 0;
  background: var(--surface-2);
  font-size: 12px; font-style: italic; line-height: 1.45; color: var(--text-dim);
}

.chfull__sum { display: block; cursor: pointer; list-style: none; border-radius: var(--r-sm, 4px); }
.chfull__sum::-webkit-details-marker { display: none; }
/* Closed: only what moved. Open: the whole card, around those same rows. */
.chfull:not([open]) .chfull__rest { display: none; }
.chfull__sum .chskill__name::after {
  content: "\203A"; display: inline-block; margin-left: 5px;
  color: var(--text-mute); transition: transform .15s ease;
}
.chfull[open] .chskill__name::after { transform: rotate(90deg); }
.chfull__sum:hover .chskill__name::after, .chfull__sum:focus-visible .chskill__name::after { color: var(--accent); }
.chfull__sum:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) { .chfull__sum .chskill__name::after { transition: none; } }
/*
 * Which horde level teaches it. The notes never say, and it changes what the
 * number means: a buff to a level 7 skill is a different proposition from one
 * to a level 3, because you have to take the Tatari that far to collect it.
 */
/*
 * One stat per row, each with its own verdict.
 *
 * The arrow is about whether the change helps you, not about which way the
 * number went -- those differ for an interval, where a bigger number is worse.
 * See movement() in changes.js for the rule and the check behind it.
 */
.chstat__mark {
  flex: none;
  width: 9px;
  color: var(--text-mute); font-size: 10px; font-weight: 800; text-align: center;
}
/* The chip cards carry the verdict on the mark itself: one change each, so
   there is no row for it to hang off. */
.chstat__mark[data-move="up"]   { color: var(--ok); }
.chstat__mark[data-move="down"] { color: var(--danger); }
.chstat__mark[data-move="up"]::after   { content: "↑"; }
.chstat__mark[data-move="down"]::after { content: "↓"; }

/* ---- the chips a season moved -------------------------------------------
 * Read the same way as an evolution line: the picture you recognise, the name,
 * and one row that says which way it went. The border carries the verdict, as
 * it does on .chline, so a column of them is scannable without reading a word.
 */
.chmode__title--chips { margin: var(--s4) 0 var(--s2); }
.chchips {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
  gap: var(--s2);
}
.chchip {
  display: flex; align-items: flex-start; gap: var(--s3);
  padding: var(--s2);
  border: 1px solid var(--line-soft); border-radius: var(--r-md);
  background: var(--surface-2);
}
.chchip[data-patch="buff"] { border-color: color-mix(in srgb, var(--ok) 42%, var(--line-soft)); }
.chchip[data-patch="nerf"] { border-color: color-mix(in srgb, var(--danger) 42%, var(--line-soft)); }
.chchip[data-patch="new"] { border-color: color-mix(in srgb, var(--accent) 42%, var(--line-soft)); }
.chchip__new {
  display: inline-block; margin-left: 4px; padding: 1px 6px; border-radius: 999px; vertical-align: 1px;
  background: color-mix(in srgb, var(--accent) 18%, transparent); color: var(--accent);
  font-size: 10px; font-weight: 800; text-transform: uppercase; letter-spacing: .04em;
}
.chchip__art { flex: none; width: 52px; height: 45px; object-fit: contain; border-radius: 6px; }
.chchip__body { min-width: 0; }
.chchip__name { margin: 0 0 2px; font-size: 13.5px; font-weight: 650; }
.chchip__change {
  display: flex; align-items: baseline; gap: 6px;
  margin: 0; color: var(--text-dim); font-size: 12.5px; font-variant-numeric: tabular-nums;
}

@media (max-width: 760px) {
  .changes__main { padding: var(--s4) var(--s3) var(--s6); }
  .chgroup__grid { grid-template-columns: 1fr; gap: var(--s2); }
}

/* =======================================================  chips
 *
 * Two surfaces off one engine: a live block under the Summary in the drafter,
 * and a page of its own. They share .chgroup and its children with the changes
 * page above, deliberately -- both are "here is a body of reference, scored",
 * and a second visual language for the same shape would only be a second thing
 * to keep in step.
 * ======================================================================== */

/* --------------------------------------------------- the drafter block */

.chipsblock {
  margin-top: var(--s4); padding-top: var(--s3);
  border-top: 1px solid var(--line-soft);
}
.chipsblock__head {
  display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--s2);
  margin: 0 0 var(--s2);
  font-size: 12px; font-weight: 650; letter-spacing: .06em; text-transform: uppercase;
  color: var(--text-mute);
}
.chipsblock__sub {
  font-size: 11px; font-weight: 500; letter-spacing: 0; text-transform: none;
  color: var(--text-mute);
}
.chipsblock__empty, .chipsblock__more {
  margin: 0; color: var(--text-mute); font-size: 12.5px; line-height: 1.5;
}
.chipsblock__more { margin-top: var(--s2); }
.chipsblock__list { list-style: none; margin: 0; padding: 0; display: grid; gap: 3px; }

/*
 * One row per chip: the chip, its name, the count, and the Tatari it means.
 *
 * This used to draw the count as a fill across the row. The fill was easy to
 * catch out of the corner of the eye while dragging, which was the argument for
 * it, but it answered the wrong question -- "four of your six" still leaves you
 * looking back at the field to see which four, and that is the whole decision.
 */
.chipbar {
  position: relative;
  display: grid;
  grid-template-columns: auto auto 1fr auto;
  align-items: center;
  gap: 0 var(--s2);
  padding: 4px var(--s2);
  border: 1px solid var(--line-soft);
  border-radius: var(--r-sm);
  font-size: 12.5px;
}
.chipbar__name {
  font-weight: 600; color: var(--text); white-space: nowrap;
}
.chipbar__n { color: var(--text-dim); font-variant-numeric: tabular-nums; white-space: nowrap; }
.chipbar__n b { color: var(--text); }
.chipbar__why {
  grid-column: 3;
  justify-self: start;
  color: var(--text-mute); font-size: 11px;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.chipbar.is-taken {
  border-color: color-mix(in srgb, var(--accent) 55%, var(--line));
}
/*
 * A word in the corner rather than a coloured left edge. A 2px border on one
 * side is the side-tab this project bans everywhere else, and it reads as a nav
 * item rather than as a state.
 */
.chipbar.is-taken::after {
  content: "kept";
  position: absolute; top: 4px; right: var(--s2);
  font-size: 9px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase;
  color: var(--accent-ink-on);
}
.chipbar.is-taken .chipbar__n { padding-right: 30px; }

/* ------------------------------------------------------------- the page */

/*
 * `chipspage`, not `chips`. `.chips` is already the filter-chip row in the
 * roster -- `display: flex` -- so `<body class="chips">` turned the whole
 * document into a flex container and squashed an 1180px page down to 362px
 * inside a 1280px window, looking for all the world like a broken max-width.
 */
.chipspage__main {
  max-width: 1180px;
  margin: 0 auto;
  padding: var(--s5) var(--s4) var(--s6);
}
.chipspage__intro {
  max-width: 72ch;
  margin: 0 0 var(--s5);
  color: var(--text-dim); font-size: 14px; line-height: 1.55;
}
/* Empty on the normal path, so it must not leave its margin behind. */
.chipspage__intro:empty { display: none; }

/* The three you kept, above everything else. Mid-run this is the only part you
   open the page for, so it does not sit below forty-nine cards. */
.kept { margin-bottom: var(--s6); }
.kept__head {
  display: flex; flex-wrap: wrap; align-items: center; gap: var(--s2) var(--s3);
  margin-bottom: var(--s3);
}
.kept__head h2 { margin: 0; font-size: 15px; font-weight: 650; }
.kept__who { display: flex; gap: var(--s1); }
.kept__who .is-on {
  background: var(--accent); color: var(--accent-ink); border-color: transparent;
}
.kept__slots {
  list-style: none; margin: 0; padding: 0;
  display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
  gap: var(--s2);
  counter-reset: slot;
}
.kept__slot {
  counter-increment: slot;
  display: flex; flex-direction: column; gap: 2px;
  min-height: 62px;
  padding: var(--s2) var(--s3);
  border: 1px dashed var(--line);
  border-radius: var(--r-md);
  background: var(--surface);
  font-size: 12.5px;
}
.kept__slot::before {
  content: counter(slot);
  font-size: 10px; font-weight: 700; color: var(--text-mute);
}
.kept__slot.is-filled { border-style: solid; border-color: var(--accent); }
.kept__slot b { color: var(--text); font-size: 13.5px; }
.kept__slot span { color: var(--text-mute); line-height: 1.45; }
.kept__wait { color: var(--text-mute); font-style: italic; }
.kept__total { margin: var(--s2) 0 0; font-size: 12px; }

.chipcard {
  position: relative;
  padding: var(--s3);
  padding-right: var(--s6);
  border: 1px solid var(--line);
  border-radius: var(--r-md);
  background: var(--surface);
}
.chipcard.is-taken { border-color: var(--accent); }
.chipcard__keep {
  position: absolute; top: var(--s2); right: var(--s2);
  width: 26px; height: 26px;
  display: grid; place-items: center;
  border: 1px solid var(--line);
  border-radius: 999px;
  background: var(--surface-2);
  color: var(--text-mute);
  font-size: 14px; line-height: 1;
  cursor: pointer;
}
.chipcard__keep:hover { color: var(--text); border-color: var(--accent); }
.chipcard__keep[aria-pressed="true"] {
  background: var(--accent); border-color: transparent; color: var(--accent-ink);
}
.chipcard__name {
  display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--s2);
  margin: 0 0 var(--s1);
  font-size: 14px; font-weight: 650;
}
/* The gallery colours its tiers, so this does too, but with the numeral as
   well: colour on its own is not a channel every reader has. */
.chipcard__tier {
  padding: 0 5px;
  border-radius: var(--r-sm);
  font-size: 9.5px; font-weight: 700; letter-spacing: .1em;
  background: var(--surface-3); color: var(--text-mute);
}
.chipcard__tier[data-tier="2"] { color: var(--fire); }
.chipcard__tier[data-tier="3"] { color: var(--water); }
.chipcard__text {
  margin: 0; color: var(--text-dim); font-size: 12.5px; line-height: 1.5;
}
.chipcard__score {
  margin: var(--s2) 0 0;
  padding: var(--s1) var(--s2);
  border-radius: var(--r-sm);
  background: color-mix(in srgb, var(--accent) 12%, var(--surface-2));
  font-size: 12.5px; color: var(--text-dim);
}
.chipcard__score b { color: var(--text); font-variant-numeric: tabular-nums; }
/* Zero is a real answer and worth printing, but it should not read as a result. */
.chipcard__score[data-empty="true"] { background: var(--surface-2); color: var(--text-mute); }
.chipcard__score[data-empty="true"] b { color: var(--text-mute); }

@media (max-width: 760px) {
  .chipspage__main { padding: var(--s4) var(--s3) var(--s6); }
  .kept__slots { grid-template-columns: 1fr; }
}

/* Chip icons, cut out of the in-game gallery by tools/cut_chips.py.
 *
 * The tile background comes with them and is kept: the gallery uses it to say
 * which tier a chip is, and throwing it away would leave the numeral as the only
 * thing carrying that. `object-fit: cover` because the crops are not all exactly
 * square -- Mission Reset sits alone on a row the screenshot clipped, so it is a
 * few pixels shorter than the other 48. */
/*
 * One line, not two.
 *
 * The sprites started on a row of their own under the name, which read well and
 * cost 66px a row -- ten rows of that made the field column 1854px tall and
 * pushed the level-up plan below the roster on a desktop, which is a worse
 * answer than any layout of this panel. On one line the sprites take the space
 * the name does not want, and the row is 34px.
 */
.chipbar__art {
  width: 26px; height: 26px;
  border-radius: var(--r-sm);
  object-fit: cover;
  align-self: center;
}
.chipbar__why { grid-column: 2 / -1; }

.chipcard {
  padding-left: 74px;
}
.chipcard__art {
  position: absolute; left: var(--s3); top: var(--s3);
  width: 46px; height: 46px;
  border-radius: var(--r-sm);
  object-fit: cover;
  box-shadow: 0 0 0 1px var(--line);
}
/* The card is short when a chip has no score and no doubts, and a 46px icon
   beside two lines of text then hangs below them. */
.chipcard { min-height: 74px; }

/* Who a chip would actually touch.
 *
 * This replaced a bar filled to the fraction of the board a chip reaches. The
 * bar answered "how much" and left you looking back at the field to find out
 * which -- which is the question, since four Tatari can be the four carrying the
 * run or the four you are about to bench. Faces answer it without the trip.
 *
 * The element ring is the same wash the field tokens use, so a row of Water
 * sprites reads as Water at a glance and Water Form II needs no reading at all.
 */
.chipwho {
  display: flex; flex-wrap: wrap; align-items: center; gap: 3px;
  min-width: 0;
}
.chipwho__art {
  display: grid; place-items: center;
  width: 26px; height: 26px;
  border-radius: var(--r-sm);
  background: var(--surface-3);
  box-shadow: inset 0 0 0 1px var(--line-soft);
}
.chipwho__art img { width: 100%; height: 100%; object-fit: contain; }
.chipwho__art[data-type="Water"]     { background: color-mix(in srgb, var(--water) 22%, var(--surface-3)); }
.chipwho__art[data-type="Fire"]      { background: color-mix(in srgb, var(--fire) 22%, var(--surface-3)); }
.chipwho__art[data-type="Grass"]     { background: color-mix(in srgb, var(--grass) 22%, var(--surface-3)); }
.chipwho__art[data-type="Rock"]      { background: color-mix(in srgb, var(--rock) 22%, var(--surface-3)); }
.chipwho__art[data-type="Lightning"] { background: color-mix(in srgb, var(--lightning) 22%, var(--surface-3)); }
.chipwho__more {
  padding: 0 5px;
  font-size: 11px; font-weight: 650; color: var(--text-mute);
  font-variant-numeric: tabular-nums;
}

/* The panel row, without the fill it used to carry. */
.chipbar {
  background: var(--surface-2);
  padding: var(--s2);
}
/* Takes whatever the name leaves, and scrolls rather than wrapping if a big
   board brings more sprites than the row can hold. */
.chipbar .chipwho {
  flex-wrap: nowrap;
  overflow-x: auto;
  scrollbar-width: none;
  justify-content: flex-end;
}
.chipbar .chipwho::-webkit-scrollbar { display: none; }
.chipbar .chipwho__art { flex: none; width: 22px; height: 22px; }
/* Nothing to draw, so the row says what it was looking for instead, and steps
   back rather than sitting at the same weight as one that found something. */
.chipbar.is-empty { opacity: .62; }

.chipcard__score {
  display: flex; flex-direction: column; gap: var(--s2);
}
.chipcard__count { margin: 0; }
.chipcard__score[data-empty="true"] .chipwho { display: none; }

/* The Form chips get the same row as the board chips.
 *
 * They were a line of pills reading "Fire 1  Grass 1  Lightning 1", which is the
 * element split the summary already shows and not an answer about chips at all.
 * A Form chip touches every Tatari of its element, so the sprites are the
 * answer -- and there is one Form chip per element, so a row per element is a
 * row per chip, exactly like the six above it. */
.chipsblock__head--sub { margin-top: var(--s3); }

/* Stands in for the chip icon on the left of a Form row: the element's own
   colour, rather than one of the fifteen tile crops, since a single row is
   speaking for all three tiers of that element at once. */
.chipbar__elem {
  width: 26px; height: 26px;
  border-radius: var(--r-sm);
  align-self: center;
  box-shadow: inset 0 0 0 1px var(--line-soft);
}
.chipbar__elem[data-type="Water"]     { background: color-mix(in srgb, var(--water) 55%, var(--surface-3)); }
.chipbar__elem[data-type="Fire"]      { background: color-mix(in srgb, var(--fire) 55%, var(--surface-3)); }
.chipbar__elem[data-type="Grass"]     { background: color-mix(in srgb, var(--grass) 55%, var(--surface-3)); }
.chipbar__elem[data-type="Rock"]      { background: color-mix(in srgb, var(--rock) 55%, var(--surface-3)); }
.chipbar__elem[data-type="Lightning"] { background: color-mix(in srgb, var(--lightning) 55%, var(--surface-3)); }

/* Filter by tier, which is what the game calls rarity.
 *
 * One row, three buttons, and the same contract as every filter in the roster:
 * nothing pressed shows everything, pressing the one already on releases it, and
 * the count beside each says how many it would find. Multi-select, because "II
 * or III" is a real question when you are deciding whether an offer is worth
 * taking and the low tier of the same chip is not.
 */
.chipfilters {
  display: flex; flex-wrap: wrap; align-items: center; gap: var(--s2);
  margin-bottom: var(--s5);
  padding-bottom: var(--s3);
  border-bottom: 1px solid var(--line-soft);
}
.chipfilters[hidden] { display: none; }
.chipfilters__label {
  font-size: 10.5px; font-weight: 650; letter-spacing: .08em;
  text-transform: uppercase; color: var(--text-mute);
}
.chipfilters__n {
  margin-left: auto;
  font-size: 12px; color: var(--text-mute); font-variant-numeric: tabular-nums;
}

/* Wider than the roster's square chips, because each carries a numeral and a
   count rather than one glyph. */
.chip--rarity {
  display: inline-flex; align-items: center; gap: var(--s2);
  width: auto; padding: 0 var(--s3);
  font-variant-numeric: tabular-nums;
}
/* The same colour the card badge uses for that tier, so the filter and the thing
   it filters agree without anyone having to learn a second key. The numeral is
   there as well: colour alone is not a channel every reader has. */
.chip__tier {
  font-size: 10px; font-weight: 700; letter-spacing: .1em;
  color: var(--text-mute);
}
.chip__tier[data-tier="1"] { color: var(--text-dim); }
/* Mixed toward the page's own ink: at 10px bold these need 4.5:1, and the raw
   element colours are tuned for a 22px badge on a card, not for small print. */
.chip__tier[data-tier="2"] { color: color-mix(in srgb, var(--fire) 62%, var(--text)); }
.chip__tier[data-tier="3"] { color: color-mix(in srgb, var(--water) 62%, var(--text)); }
.chip--rarity[aria-pressed="true"] .chip__tier { color: inherit; }
.chipspage__none {
  max-width: 60ch;
  color: var(--text-mute); font-size: 13px; line-height: 1.55;
}

/* The three you keep, and the shortlist under them.
 *
 * Three ranked slots plus a list with no limit. The three are what a run gives
 * you, in the order you would take them, so they move. The shortlist is the
 * thinking you did before the offer, which has no reason to be capped at three.
 */
/* The slot IS the row now, so its own flex column layout would stack the sprite,
   the name and the controls on top of each other. */
.kept__slot.keptrow { display: grid; }
.kept__slot:not(.is-filled) { display: flex; align-items: center; }

.keptrow {
  display: grid;
  grid-template-columns: auto 1fr auto;
  align-items: center;
  gap: var(--s2);
  width: 100%;
  min-width: 0;
}
.keptrow__art {
  width: 30px; height: 30px;
  border-radius: var(--r-sm);
  object-fit: cover;
}
.keptrow__name {
  font-size: 13px; font-weight: 600; color: var(--text);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.keptrow__ctl { display: flex; gap: 2px; }

/*
 * 26px, which clears WCAG 2.5.8's 24px floor without turning four controls into
 * 176px of buttons beside a 30px sprite. Mistapping one is cheap: every action
 * here is reversible with the button next to it.
 */
.keptrow__btn {
  width: 26px; height: 26px;
  display: grid; place-items: center;
  border: 1px solid var(--line);
  border-radius: var(--r-sm);
  background: var(--surface-2);
  color: var(--text-dim);
  font-size: 11px; line-height: 1;
  cursor: pointer;
}
.keptrow__btn:hover:not(:disabled) { color: var(--text); border-color: var(--accent); }
/* Disabled rather than absent: the top row's "up" going missing would shift the
   other three controls under the pointer between presses. */
.keptrow__btn:disabled { opacity: .3; cursor: default; }

.kept__extra { margin-top: var(--s4); }
.kept__extra h3 {
  display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--s2);
  margin: 0 0 var(--s2);
  font-size: 13px; font-weight: 650;
}
.kept__hint {
  font-size: 11.5px; font-weight: 500; color: var(--text-mute);
}
.kept__list {
  list-style: none; margin: 0; padding: 0;
  display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
  gap: var(--s1) var(--s2);
}
.kept__list .keptrow {
  padding: var(--s1) var(--s2);
  border: 1px solid var(--line-soft);
  border-radius: var(--r-md);
  background: var(--surface);
}
.kept__none {
  max-width: 62ch;
  margin: 0; color: var(--text-mute); font-size: 12.5px; line-height: 1.5;
}

/* Faces instead of a sentence, on the two groups that have particular Tatari to
   point at. No count line, so the row is the whole answer. */
.chipcard__score[data-faces="true"] {
  background: none; padding: 0;
}

@media (max-width: 760px) {
  .kept__list { grid-template-columns: 1fr; }
}

/* The kept rows drag as well as stepping with their buttons.
 *
 * The sprite is the handle, not the row. `.prio` shipped in dnd.js's
 * DRAG_SURFACES with no `touch-action: none` anywhere, so the browser claimed
 * the gesture as a scroll before the hold fired and reordering the plan by touch
 * never happened at all. The whole row cannot take it either: this is a page you
 * scroll, and a row that refuses to scroll under a thumb is worse than one that
 * does not drag. */
.keptrow__art { cursor: grab; }
.keptrow.is-over { outline: 2px solid var(--accent); outline-offset: 1px; }
.kept__extra.is-over { outline: 2px dashed var(--accent); outline-offset: 4px; border-radius: var(--r-md); }
.keptghost {
  display: inline-block;
  padding: var(--s1) var(--s3);
  border-radius: var(--r-md);
  background: var(--accent);
  color: var(--accent-ink);
  font: 600 13px/1.2 var(--ui, system-ui);
  box-shadow: var(--lift-2);
}

@media (hover: none) {
  /* Without this the drag is armed and then never receives a move. */
  .keptrow__art { touch-action: none; }
  /* A thumb-sized handle without making the row taller. */
  .keptrow__art { width: 34px; height: 34px; }
}

/* ---------------------------------------------------- the chips tab
 *
 * A third list in the roster, next to Tatari and Zobos, because a chip is a
 * third thing you pick and the roster is where picking happens. It also
 * inherits the search box, the card grid and the tab strip, all of which the
 * panel it replaced had grown its own copies of at a cost of 559px under the
 * Summary.
 */
body:not([data-roster-list="chips"]) .chips-only { display: none; }
/* The Tatari tab's own controls, hidden on the other two. `body` carries the
   list the same way it does for the Zobo- and chip-only controls above; the
   default list is Tatari, so this also has to hold before anything is set. */
body[data-roster-list="zobos"] .tatari-only,
body[data-roster-list="chips"] .tatari-only { display: none; }

/*
 * The roster as a list.
 * =====================
 *
 * From the redesign canvas, direction 1a: a row per Tatari, sprite on the left,
 * the name spelled out with its tier, element and role beneath it. The design
 * drew this as the roster; it is a second view instead, because the grid is what
 * anybody who knows the roster actually uses -- 230 faces scan faster than 230
 * names, and the card work of 1.8.6 through 1.8.8 is what makes that grid read.
 *
 * Not a second markup. The same .card the grid draws, re-laid: art in the first
 * column at a fixed size, name and badges stacked in the second. So the drag
 * binding, the bench and field marks, the swap glyph, the co-op tag, the patch
 * disc and the in-place card update all keep working here for free, and nothing
 * has to be taught that a Tatari can be drawn two ways.
 */
.roster[data-tatari-view="list"] {
  grid-template-columns: 1fr;
  gap: var(--s1);
}
.roster[data-tatari-view="list"] .card {
  display: grid;
  grid-template-columns: 44px 1fr auto;
  /*
   * One row, and no row gap.
   *
   * The card's own grid keeps three rows -- art, name, badges -- and putting
   * everything on row 1 leaves the other two at 0px. They still pay the gap
   * either side of them, though, which is 16px of nothing in a row that is only
   * 44px of something: the rows came out 74px tall with a band of empty space
   * under every name. A track with no height should not cost anything.
   */
  grid-template-rows: auto;
  row-gap: 0;
  column-gap: var(--s2);
  align-items: center;
  padding: 6px 10px;
  text-align: left;
}
/* The art box drops its square and its badge band: at 44px the effect marks are
   unreadable anyway, and they have room of their own in the second column. */
.roster[data-tatari-view="list"] .card__art {
  grid-row: 1; aspect-ratio: auto;
  width: 44px; height: 44px;
  grid-template-rows: 1fr;
  padding: 2px;
}
.roster[data-tatari-view="list"] .card__art img { grid-row: 1; padding: 0; }
/* Full name, on one line, at a size meant to be read rather than recognised. */
.roster[data-tatari-view="list"] .card__name {
  grid-column: 2; grid-row: 1;
  padding: 0;
  font-size: 14px; font-weight: 600;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
/* Tier, element and role move to the right-hand end, out of the name's way. */
.roster[data-tatari-view="list"] .card__meta {
  grid-column: 3; grid-row: 1;
  padding: 0;
  justify-content: flex-end;
}
/*
 * The effect marks stay behind on this view.
 *
 * They are a child of .card__art, not of .card -- the wrapper is the art box's
 * own first grid row, and it ships even when empty so the 34 Tatari with no
 * effects do not get a larger sprite than everyone else. So it cannot be moved
 * into a column of the row without either reparenting it or dissolving the art
 * box, and dissolving the box takes the tinted square with it, which is the one
 * thing making the sprite read at 44px.
 *
 * Left out rather than shrunk, and the grid stays the view that shows
 * everything. This one is for reading names, which is what it was asked for;
 * 1a's own list draws no effect marks either.
 */
.roster[data-tatari-view="list"] .card__fxrow { display: none; }
.roster[data-tatari-view="list"] .card__info { top: 50%; transform: translateY(-50%); }

/* Nothing in the Tatari filter block means anything to a chip: no element, no
   role, no evolution tier, no bench. Same approach as the Zobo tab -- the body
   carries the flag and the CSS hides them, rather than each control learning
   about tabs. */
body[data-roster-list="chips"] #filter-types,
body[data-roster-list="chips"] #filter-roles,
body[data-roster-list="chips"] #filter-effects,
body[data-roster-list="chips"] #filter-tiers,
body[data-roster-list="chips"] #filter-changed,
body[data-roster-list="chips"] #filter-boss,
body[data-roster-list="chips"] #filter-effect-types,
body[data-roster-list="chips"] .filters__row--meta .check,
body[data-roster-list="chips"] .filters__row--meta .sortby { display: none; }

/* The tile art is the whole card face: it is already a framed icon on a
   coloured ground, so the card's own padding around it would be a frame on a
   frame. */
.card--chip { cursor: pointer; }
.card--chip .card__art img {
  width: 100%; height: 100%;
  object-fit: cover;
  border-radius: var(--r-sm);
}
.card--chip .card__meta { justify-content: center; gap: var(--s1); }
.card__score {
  padding: 0 4px;
  border-radius: var(--r-sm);
  background: color-mix(in srgb, var(--accent) 22%, var(--surface-3));
  font-size: 10px; font-weight: 700; color: var(--text);
  font-variant-numeric: tabular-nums;
}
/* Nothing on this board qualifies. Still shown, because "0 of your 6" is an
   answer and hiding it would read as the chip having no rule at all. */
.card__score[data-empty="true"] { background: var(--surface-3); color: var(--text-mute); }
/* Which of your three it is, if it is one of them. */
.card__rank {
  min-width: 14px; height: 14px;
  display: grid; place-items: center;
  border-radius: 999px;
  background: var(--accent); color: var(--accent-ink);
  font-size: 9px; font-weight: 700;
}

/* Chips read as rows, not as tiles.
 *
 * A Tatari card works at 82px because a sprite is recognisable at 82px. A chip
 * is a rule -- "Generate 6 single-use lawn mowers at the bottom of the map" --
 * and no amount of grid tuning makes that legible in a tile. So on this tab the
 * roster stops being a grid and becomes a list, and each row has room for the
 * whole sentence, which is the thing you are actually choosing between.
 */
.card--chip {
  position: relative;
  display: grid;
  grid-template-columns: auto 1fr;
  align-items: start;
  gap: 2px var(--s3);
  padding: var(--s2) var(--s3) var(--s2) calc(var(--s3) + 3px);
  text-align: left;
  cursor: pointer;
  isolation: isolate;
}
/*
 * The tier rail. The fastest read on the card and the one that stays coherent:
 * three tiers is three colours, where the seven categories would be a rainbow.
 * Absolute so it is never a grid column, drawn inside the card's own rounded
 * clip, and it is the one thing that thickens on hover so the row answers a
 * pointer without lifting or recolouring.
 */
.card--chip[data-tier="1"] { --tier-color: var(--water); }
.card--chip[data-tier="2"] { --tier-color: var(--fire); }
.card--chip[data-tier="3"] { --tier-color: var(--accent); }
.card--chip::before {
  content: "";
  position: absolute; inset: 0 auto 0 0; width: 3px;
  background: var(--tier-color, var(--line));
  z-index: 1;
  transition: width 140ms var(--ease-out);
}
.chiprow__art {
  width: 42px; height: 42px;
  border-radius: var(--r-sm);
  object-fit: cover;
  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--tier-color, var(--line)) 55%, transparent);
}
.chiprow__body { min-width: 0; grid-column: 2; }
/*
 * Name and category share the top line: the name is the hero, the category tag
 * sits just after it, and the tier medallion is pushed to the far right by
 * `order` so it reads as a fixed marker rather than trailing the name.
 */
.chiprow__head {
  display: flex; flex-wrap: wrap; align-items: center; gap: 4px var(--s2);
  margin: 0 0 3px;
}
.chiprow__head b {
  order: 1;
  font-size: 14px; font-weight: 650; color: var(--text); letter-spacing: -.01em;
}
.chiprow__head .chiptype { order: 2; }
.chiprow__head .chiptier { order: 3; }
.chiprow__text {
  margin: 0;
  max-width: 60ch;
  font-size: 12px; line-height: 1.45; color: var(--text-dim);
}
/* The pick number is a corner medallion in both views, out of the flow so it
   can never change a row's height when you keep a chip. */
.card--chip .card__rank {
  position: absolute; top: 6px; right: 6px; z-index: 3;
  box-shadow: 0 0 0 2px var(--surface-2);
}
/* States. The row answers a pointer by lifting its rail and warming its ground,
   never by translating -- a list that jumps as the pointer runs down it reads as
   broken. Keeping a chip is the accent ring; hover is the ground. */
.card--chip:hover {
  transform: none;
  background: var(--surface-3);
  border-color: var(--line);
}
.card--chip:hover::before { width: 5px; }
.card--chip:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}
.card--chip.is-benched {
  border-color: color-mix(in srgb, var(--accent) 60%, var(--line));
  background: color-mix(in srgb, var(--accent) 8%, var(--surface-2));
  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 45%, transparent);
}
@media (prefers-reduced-motion: reduce) {
  .card--chip::before { transition: none; }
}
.chiprow__score {
  margin: 1px 0 0;
  font-size: 11px; line-height: 1.35; color: var(--text-dim);
}
.chiprow__score b { color: var(--text); font-variant-numeric: tabular-nums; }
.chiprow__score[data-empty="true"] { color: var(--text-mute); }
.chiprow__score[data-empty="true"] b { color: var(--text-mute); }

/*
 * inline-flex, not the base chip's inline-grid.
 *
 * `.chip` is a 30px box with `place-items: center`, which puts every child in
 * the same grid cell -- so the label and its count were drawn on top of each
 * other and the number bled through the word. Widening it did not help, because
 * the problem was never the width.
 */
.chip--group {
  display: inline-flex; align-items: center; gap: 5px;
  width: auto; padding: 0 var(--s2);
}
.chip--group b { font-variant-numeric: tabular-nums; }

@media (max-width: 760px) {
  /* The sprite gives up the most: the sentence is why the row exists. */
  .chiprow__art { width: 30px; height: 30px; }
  .card--chip { gap: var(--s2); padding: 5px var(--s2); }
}

/* ------------------------------------------------ the chip dock
 *
 * The bench answers "what am I bringing" for Tatari. This answers it for chips,
 * and it sits with the bench rather than in the roster so that one screenshot of
 * the field carries the board, the bench and the chips at once. On a phone both
 * ride the same fixed shelf above the app bar, for the same reason.
 */
.dock { display: grid; gap: var(--s2); }

.chipbench { margin-top: var(--s1); }
.chipbench[hidden] { display: none; }
.chipbench__head {
  display: flex; align-items: baseline; gap: var(--s2);
  margin-bottom: var(--s1);
}
.chipbench__label {
  font-size: 10.5px; font-weight: 650; letter-spacing: .08em;
  text-transform: uppercase; color: var(--text-mute);
}
.chipbench__count { color: var(--text-dim); font-size: 11.5px; }
.chipbench__count b { color: var(--text); font-weight: 700; }
.chipbench__strip { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s1); }

.chipbench__chip {
  position: relative;
  width: 34px; height: 34px;
  border-radius: var(--r-sm);
  border: 1px solid var(--line);
  /* No overflow:hidden. It was here to clip the sprite to the rounded corner,
     which the sprite can do for itself -- and it also clipped the insertion bar
     that has to sit in the gap outside this box to mean anything. */
}
.chipbench__chip img {
  width: 100%; height: 100%; object-fit: cover; display: block;
  border-radius: calc(var(--r-sm) - 1px);
}
.chipbench__missing {
  display: grid; place-items: center; width: 100%; height: 100%;
  background: var(--surface-3); color: var(--text-mute); font-size: 13px;
}
/* Which of the three it is. Only the three are numbered; the shortlist is not
   an order, it is a set. */
.chipbench__n {
  position: absolute; left: 1px; top: 1px;
  min-width: 12px; height: 12px;
  display: grid; place-items: center;
  border-radius: 999px;
  background: var(--accent); color: var(--accent-ink);
  font-size: 8px; font-weight: 700;
}
/* A gap, not a heading: the shortlist is the same kind of thing, further down
   your list, and a second label for it would cost more room than it explains. */
.chipbench__gap {
  width: 1px; height: 22px;
  background: var(--line);
  margin: 0 var(--s1);
}
.chipbench .chipbench__chip:not([data-rank]) { opacity: .82; }

/* Clean view is the field and nothing else, so the chips go with the bench. */
body.is-clean #chipbench { display: none !important; }

@media (max-width: 760px) {
  .chipbench { margin-top: 0; }
  .chipbench__strip {
    flex-wrap: nowrap;
    overflow-x: auto;
    overscroll-behavior-x: contain;
    scrollbar-width: none;
  }
  .chipbench__strip::-webkit-scrollbar { display: none; }
  .chipbench__chip { flex: none; width: 30px; height: 30px; }
}

/* ------------------------------------------------ chips: grid or list
 *
 * Two views because there are two questions. The grid is for somebody who knows
 * the chips and is hunting for one by its face; the list is for somebody
 * deciding, who needs the rule. Neither is the beginner view, so the choice is
 * remembered rather than reset on every visit.
 */
#chip-view { flex: none; }
#chip-view .segmented__btn { display: inline-flex; align-items: center; gap: 5px; }
#chip-view svg { display: block; }

/* ---- list ---- */
.roster[data-chip-view="list"] { grid-template-columns: 1fr; gap: var(--s1); }

/* ---- grid ----
 * Back to tiles, the way the gallery shows them. The rule is not on the card at
 * this size and cannot be, which is the whole reason the list exists; the tile
 * carries the face, the tier and whether you kept it.
 */
.roster[data-chip-view="grid"] { grid-template-columns: repeat(auto-fill, minmax(76px, 1fr)); }
.roster[data-chip-view="grid"] .card--chip {
  display: grid;
  /*
   * The columns have to be reset, not just the rows. The list lays the card out
   * as `auto 1fr auto` -- sprite, body, rank -- and overriding only the rows
   * left the tile in that first `auto` column, where `width: 100%` resolves
   * against a column sized to its contents: the icon came out 0x0 and the whole
   * card was 57px of text with no picture in it.
   */
  grid-template-columns: 1fr;
  grid-template-rows: auto auto;
  gap: 2px;
  padding: var(--s1);
  text-align: center;
}
/* The tile is one column, so the body must come back out of the list's second
   column, and the left rail has no place on a centred tile -- the tier reads off
   the icon's ring and the corner medallion here instead. */
.roster[data-chip-view="grid"] .chiprow__body { min-width: 0; grid-column: 1; }
.roster[data-chip-view="grid"] .card--chip::before { display: none; }
.roster[data-chip-view="grid"] .card--chip:hover { transform: translateY(-2px); background: var(--surface-2); }
.roster[data-chip-view="grid"] .chiprow__art {
  width: 100%; height: auto; aspect-ratio: 1;
  border-radius: var(--r-sm);
  box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--tier-color, var(--line)) 60%, transparent);
}
.roster[data-chip-view="grid"] .chiprow__text,
.roster[data-chip-view="grid"] .chipwho,
.roster[data-chip-view="grid"] .chiprow__none,
.roster[data-chip-view="grid"] .chiptype { display: none; }
.roster[data-chip-view="grid"] .chiprow__head {
  flex-direction: column;
  align-items: center;
  gap: 2px;
  margin: 0;
  font-size: 10.5px;
}
/*
 * The name is a label here, not a heading.
 *
 * In the list it sits at 14px and 650 because it heads a row and has the rule
 * text underneath to be heading OF. On a 76px tile it is the only text there
 * is, and at that weight it shouted over the art it is captioning -- the art
 * being the whole reason the grid exists. The head's own 10.5px never reached
 * it, because this `b` carries its own size from the list.
 *
 * 11px and 550, which is exactly what a Tatari card's name uses two tabs over.
 * The two grids are the same object at the same size and should not caption
 * themselves differently.
 */
.roster[data-chip-view="grid"] .chiprow__head b {
  font-size: 11px; font-weight: 550; letter-spacing: 0;
  /*
   * And hyphenated where it has to break. "Photosynthesis" is wider than a 66px
   * tile at any weight, so it breaks whatever we do -- it was coming out as
   * "Photosynthe / sis III", which reads as two words rather than one wrapped.
   * A hyphen says the word continues. <html lang="en"> is already set, which is
   * what makes the browser's dictionary apply.
   */
  hyphens: auto;
}
/*
 * Tier and pick both move to the corners, out of the flow: the tier as a
 * medallion over the icon (top-left, with a fill solid enough to read on the
 * art), the pick number top-right. Neither is a row any more, so keeping a chip
 * or drawing a two-line name cannot make one tile taller than its neighbours.
 */
.roster[data-chip-view="grid"] .card--chip { position: relative; }
.roster[data-chip-view="grid"] .chiptier {
  position: absolute; top: 5px; left: 5px; z-index: 2;
  min-width: 0; padding: 1px 5px;
  background: color-mix(in srgb, var(--tier-color, var(--surface-3)) 40%, var(--surface));
  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--tier-color) 65%, transparent),
              0 1px 3px color-mix(in srgb, #000 35%, transparent);
}
.roster[data-chip-view="grid"] .card__rank {
  top: 5px; right: 5px;
}
/*
 * The name gets the whole width and two lines. overflow-wrap keeps a long single
 * word ("Photosynthesis") wrapping inside the tile rather than being clipped
 * mid-word, which is what it did before; the clamp and the fixed height keep the
 * tiles level whether a name runs one line or two.
 *
 * It still breaks mid-word without a hyphen -- "Photosynthe / sis" -- which is
 * ugly and stays for now. `hyphens: auto` with `overflow-wrap: break-word` is
 * the textbook answer and Chrome ignored it here across three attempts; the
 * line-clamp box appears to be what stops it. Not worth trading the clamp,
 * which is what keeps every tile the same height, for a hyphen.
 * ponytail: mid-word break on long chip names, revisit if the clamp ever goes.
 */
.roster[data-chip-view="grid"] .chiprow__head b {
  width: 100%;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
  overflow-wrap: anywhere;
  max-width: 100%;
  min-height: 2.4em;
  line-height: 1.2;
  text-align: center;
  white-space: normal;
}

/*
 * The tier, said properly.
 *
 * It was grey text at 9.5px, which is the least readable thing on a card that
 * also has to work in High contrast. Now it is a filled pill in the tier's own
 * colour with ink chosen against that fill, and the numeral is still the
 * numeral -- colour is the faster channel but never the only one.
 */
.chiptier {
  flex: none;
  min-width: 22px; padding: 2px 6px;
  display: inline-grid; place-items: center;
  border-radius: 999px;
  font-size: 10px; font-weight: 800; letter-spacing: .1em;
  background: color-mix(in srgb, var(--tier-color, var(--surface-3)) 22%, var(--surface-3));
  color: var(--text);
  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--tier-color, var(--line)) 55%, transparent);
}
/* Self-sufficient, so the medallion is right even if it is ever drawn outside a
   card that set --tier-color. */
.chiptier[data-tier="1"] { --tier-color: var(--water); }
.chiptier[data-tier="2"] { --tier-color: var(--fire); }
.chiptier[data-tier="3"] { --tier-color: var(--accent); }

/*
 * The category, as a tag that carries its own colour.
 *
 * Seven categories is too many to tell apart by fill without becoming a rainbow,
 * so the colour rides a single dot and the word does the naming. Each category
 * maps to one of the app's own hues, so the tag is on-brand and follows the
 * theme and the high-contrast switch without a second palette to maintain.
 */
.chiptype {
  display: inline-flex; align-items: center; gap: 5px;
  padding: 2px 8px 2px 7px;
  border-radius: 999px;
  font-size: 9.5px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase;
  color: var(--text-dim);
  background: color-mix(in srgb, var(--cat, var(--text-mute)) 12%, var(--surface-3));
  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--cat, var(--line)) 38%, var(--line));
}
.chiptype::before {
  content: ""; flex: none;
  width: 6px; height: 6px; border-radius: 999px;
  background: var(--cat, var(--text-mute));
}
.chiptype[data-shape="placement"] { --cat: var(--water); }
.chiptype[data-shape="element"]   { --cat: var(--grass); }
.chiptype[data-shape="level"]     { --cat: var(--lightning); }
.chiptype[data-shape="economy"]   { --cat: var(--accent); }
.chiptype[data-shape="capacity"]  { --cat: var(--ok); }
.chiptype[data-shape="stat"]      { --cat: var(--fire); }
.chiptype[data-shape="map"]       { --cat: var(--rock); }

.chipwho--row {
  display: flex; flex-wrap: wrap; align-items: center; gap: 3px;
  margin: 3px 0 0;
}
.chipwho__n {
  font-size: 11px; font-weight: 700; color: var(--text);
  font-variant-numeric: tabular-nums;
  margin-right: 2px;
}
.chiprow__none { margin: 3px 0 0; font-size: 11px; color: var(--text-mute); }

/*
 * High contrast.
 *
 * The switch exists because colour alone is not a channel everyone has, so the
 * tints above all gain a border and the fills go to full strength. The tier
 * numeral and the type word were already carrying the meaning in text; what
 * changes here is that they stop relying on a wash to be seen at all.
 */
:root[data-contrast="more"] .chiptier {
  box-shadow: inset 0 0 0 1px currentColor;
  color: var(--text);
}
:root[data-contrast="more"] .chiptier[data-tier="1"] { background: color-mix(in srgb, var(--water) 45%, var(--surface)); }
:root[data-contrast="more"] .chiptier[data-tier="2"] { background: color-mix(in srgb, var(--fire) 45%, var(--surface)); }
:root[data-contrast="more"] .chiptier[data-tier="3"] { background: color-mix(in srgb, var(--accent) 50%, var(--surface)); }
:root[data-contrast="more"] .chiptype {
  color: var(--text);
  box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--cat, var(--text-dim)) 75%, var(--line));
}
/* The kept ring is a border colour on a card that is otherwise unchanged, which
   is exactly the kind of single-channel state this switch is for. */
:root[data-contrast="more"] .card--chip.is-benched { box-shadow: inset 0 0 0 2px var(--accent); }
:root[data-contrast="more"] .chipwho__art { box-shadow: inset 0 0 0 1px var(--text-dim); }
:root[data-contrast="more"] .card__rank { box-shadow: 0 0 0 1px var(--text); }
:root[data-contrast="more"] .chipbench__chip { border-color: var(--text-dim); }

@media (max-width: 760px) {
  #chip-view .segmented__btn span { display: none; }
  #chip-view .segmented__btn { padding: 6px 8px; }
}

/* ------------------------------------- the chips tab's filter block
 *
 * Three problems, one shape. The panel head was carrying the panel name, three
 * tabs and a two-button view switch on one line: 366px of controls in 358px of
 * phone. Reset sat alone on a line of its own, because every other control in
 * the meta row is hidden on this tab and it was the only thing left. And the
 * seven type buttons ran off the right edge with no way to reach the last two.
 */

/*
 * The rows that belong to Tatari go entirely, not just their contents. Hiding
 * the chips inside them left two empty rows still spending the grid's gap.
 */
body[data-roster-list="chips"] .filters__row:not(.filters__row--meta) { display: none; }

/*
 * Flex rather than the usual single column, so tier and Reset can share a line.
 * The order is set explicitly because the meta row sits after the type row in
 * the markup, and wrapping follows source order: without it Reset lands under
 * the types instead of beside the tiers.
 */
body[data-roster-list="chips"] .filters {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--s2);
}
body[data-roster-list="chips"] .filters > .field[type="search"] { order: 1; flex: 1 1 100%; }
body[data-roster-list="chips"] #filter-chip-tiers { order: 2; flex: 1 1 auto; }
body[data-roster-list="chips"] .filters__row--meta { order: 3; flex: 0 0 auto; margin-left: auto; }
body[data-roster-list="chips"] #filter-chip-groups { order: 4; flex: 1 1 100%; }

/*
 * Seven types do not fit a phone and never will, so the row scrolls sideways
 * rather than hiding its last two off the edge. Same trade the opened effect
 * row makes: one line that moves beats four that stand still above a roster
 * that wanted the room.
 */
#filter-chip-groups {
  flex-wrap: nowrap;
  overflow-x: auto;
  overscroll-behavior-x: contain;
  scrollbar-width: none;
}
#filter-chip-groups::-webkit-scrollbar { display: none; }
#filter-chip-groups .chip--group { flex: none; }
#filter-chip-groups .chips__label { flex: none; }

@media (max-width: 760px) {
  /*
   * The same crowding, answered where it starts.
   *
   * This gave the heading its own line on the chips tab, because the panel
   * name, three tabs and the view switch came to more than the width. The head
   * now drops the word "Roster" and keeps only the count, on every tab, which
   * buys the room without spending a line -- and this rule would now hand a
   * whole line to the number 49. The margin reset stays: `margin-left: auto` on
   * the first segmented control is what pushes the pair right, and the chips
   * tab wants them tight against the count like the others.
   */
  body[data-roster-list="chips"] .panel--roster .panel__head #roster-tabs { margin-left: auto; }
}

/* ------------------------------------ the filters, folded away on a phone
 *
 * They were 225px of a 700px sheet, so 230 Tatari got three card rows and the
 * rest of the screen went on describing the list rather than showing it. The
 * search stays out because it is the fastest filter and the one people reach
 * for first; everything else folds behind a button that says how much is
 * behind it.
 *
 * Above 760px nothing here applies: .touch-only hides the button, and the rows
 * are a column as they always were.
 */
@media (max-width: 760px) {
  .filters {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--s2);
  }
  .filters > #search { flex: 1 1 auto; width: auto; min-width: 0; order: 1; }

  /*
   * On a phone the bar is not a row of its own.
   *
   * Given one, the closed filters went from 44px to 77px -- the pill row cannot
   * share the search's line, so it takes a whole second one, and that is a third
   * of a card row gone from the surface three quarters of the traffic is on. The
   * mobile-first point of this redesign is to give the roster that space, not to
   * spend it on the controls for narrowing it.
   *
   * display: contents dissolves the bar here, so its children lay out as if they
   * were in .filters directly and `order` can put each one where it belongs: the
   * Filter pill up beside the search, Sort and Reset down past the drawer. Same
   * markup, same single #sort, no phone-only duplicate to keep in step.
   *
   * Sort and Reset are hidden with the drawer, which is where they belong on a
   * phone anyway -- you are already in the drawer when you are choosing how the
   * list reads. The pill and its count never hide, so the closed state still
   * says what is on.
   */
  .filters { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s2); }
  /* Both wrappers dissolve here, so the pill and the drawer become items of
     .filters directly and `order` can still place each of them. The drawer
     opens in the flow on a phone -- see the note in the min-width block. */
  .filterbar, .drawer-anchor { display: contents; }
  .filters__body { position: static; flex: 1 1 100%; order: 4; }

  #filters-toggle { flex: none; order: 2; }
  #filters-active { flex: 1 1 100%; order: 3; }
  .filterbar .pill--sort { order: 5; }
  .filterbar #btn-reset-filters { order: 6; }
  body:not([data-filters="open"]) .filterbar .pill--sort,
  body:not([data-filters="open"]) .filterbar #btn-reset-filters { display: none; }
}

/* ------------------------------------------------- flex slots
 *
 * A square left open on purpose.
 *
 * Formations travel as a screenshot of the grid -- the analytics are blunt
 * about it: 286 opens, 4 card downloads, 0 link copies. In a picture, "I could
 * not fill this" and "bring what you like here" are the same empty square, so
 * the second one, which is the more useful post, could not be made. Dashes say
 * deliberate. The word says what the deliberate thing is, to a reader who has
 * no legend and never opened the tool.
 */
.cell.is-flex {
  border-style: dashed;
  border-color: var(--accent);
  background: color-mix(in srgb, var(--accent) 8%, transparent);
}
.cell__flex {
  font-size: 8.5px; font-weight: 800; letter-spacing: .1em;
  color: var(--accent-ink-on);
  pointer-events: none;
  user-select: none;
}

/*
 * Marking mode. Every empty square becomes a thing to press, so it has to look
 * like one -- otherwise the toggle is on and the board looks exactly as it did,
 * which reads as the toggle being broken.
 */
body.is-flexing .cell:not(.is-filled) {
  border-style: dashed;
  border-color: color-mix(in srgb, var(--accent) 45%, var(--line));
  cursor: pointer;
}
body.is-flexing .cell:not(.is-filled):hover {
  border-color: var(--accent);
  background: color-mix(in srgb, var(--accent) 12%, transparent);
}
/*
 * Filled squares are targets too now, and a different kind: pressing one starts
 * a group rather than marking the square. Outlined rather than dashed, because
 * dashes already mean "nothing here" everywhere else on this board and a filled
 * square is the opposite of that.
 */
body.is-flexing .cell.is-filled { cursor: pointer; }
body.is-flexing .cell.is-filled:hover {
  outline: 2px solid var(--accent); outline-offset: -2px;
}

/* High contrast: the dash pattern is the only thing separating a flex square
   from an empty one, and a dash pattern is a single channel. */
:root[data-contrast="more"] .cell.is-flex {
  border-width: 2px;
  background: color-mix(in srgb, var(--accent) 20%, transparent);
}
:root[data-contrast="more"] .cell__flex { color: var(--text); }

/* The level-up plan with nothing in it.
 *
 * It used to render as an empty <ol> between a heading and a form -- a panel
 * asking you to fill something in without saying what filling it in buys you.
 * The Learner is a stated intended user, and this is the panel that holds the
 * one idea in the tool that is about a run rather than a lineup.
 */
.plan-empty {
  list-style: none;
  padding: var(--s3);
  border: 1px dashed var(--line);
  border-radius: var(--r-md);
  background: var(--surface-2);
}
.plan-empty p {
  margin: 0;
  max-width: 54ch;
  color: var(--text-mute);
  font-size: 12.5px; line-height: 1.55;
}
.plan-empty p + p { margin-top: var(--s2); }
.plan-empty b { color: var(--text); font-weight: 650; }

/* ------------------------------------- dragging chips into the dock
 *
 * A tap keeps a chip; a drag says where it goes. The second exists because the
 * order of the three is the thing the dock is for, and a tap can only append.
 */

/*
 * The sprite is the handle, and only the sprite.
 *
 * touch-action: none tells the browser "this gesture is mine", which is what
 * makes a touch drag possible at all -- and is exactly what you must not put on
 * a row in a list people scroll with a thumb. So it goes on the icon, the way
 * the level-up plan puts it on the rank number.
 */
/*
 * The whole chip says it can be picked up; the sprite is the instant grip.
 *
 * touch-action: none means "this gesture is mine" and must not go on a card in
 * a list people scroll with a thumb -- so it stays on the sprite, where it buys
 * a drag that starts with no hold at all. The rest of the card is draggable
 * too, by press-and-hold, which is what a Tatari card has always done.
 */
.card--chip { cursor: grab; }
.card--chip:active { cursor: grabbing; }
.card--chip .chiprow__art,
.chipbench__chip img { touch-action: none; cursor: grab; }
/*
 * Except in the grid, where the sprite is the whole tile.
 *
 * The rule above is right about the list and wrong here, by its own reasoning:
 * "must not go on a card in a list people scroll with a thumb". In the list the
 * sprite is a 40px square on a full-width row, so there is plenty of card left
 * to scroll from. In the grid .chiprow__art is width:100% on a square tile --
 * the instant grip covers almost the entire thing, so a thumb flicking down the
 * list picks up a chip instead of scrolling. Reported exactly that way.
 *
 * Handing the gesture back to the browser puts the tile on the same footing as
 * a Tatari card: flick to scroll, press and hold to drag. Nothing is lost --
 * dnd.js only skips its 180ms hold for surfaces that claim the gesture, and the
 * whole card has always been draggable by holding it.
 *
 * Touch only in effect: a mouse drag starts on 5px of movement and never
 * consults touch-action at all.
 */
.roster[data-chip-view="grid"] .card--chip .chiprow__art { touch-action: auto; }
.card--chip.is-dragging,
.chipbench__chip.is-dragging { opacity: .4; }

/* The ghost under the finger: the face, so you can see what you are carrying,
   and the name, because at 26px several chip faces are hard to tell apart. */
.chipghost {
  display: inline-flex; align-items: center; gap: 6px;
  padding: 4px 9px 4px 4px;
  border-radius: var(--r-sm);
  background: var(--surface-2);
  box-shadow: 0 0 0 1px var(--accent), var(--shadow-2);
  font-size: 12px; font-weight: 700; color: var(--text);
  white-space: nowrap;
}
.chipghost img { width: 24px; height: 24px; border-radius: 4px; display: block; }

/*
 * An outline for each pick not yet made.
 *
 * Three boxes say "three of these" in a way the text "0/3" does not, and each
 * one is a drop target, so the empty dock teaches the gesture instead of just
 * announcing a rule.
 */
.chipbench__slot {
  display: inline-flex; align-items: center; justify-content: center;
  width: 26px; height: 26px;
  border-radius: var(--r-sm);
  border: 1px dashed var(--line-2);
  color: var(--text-mute); font-size: 12px; font-weight: 700;
  flex: none;
}
.chipbench__slot--extra { border-style: dotted; }

/* Where it would land. The chip being pushed aside leans out of the way, which
   is the only motion here that carries information. */

.chipbench__strip.is-over { box-shadow: inset 0 0 0 2px var(--accent); border-radius: var(--r-sm); }
.chipbench__chip, .chipbench__slot {
  position: relative;
  transition: transform .12s ease, box-shadow .12s ease, background-color .12s ease;
}
@media (prefers-reduced-motion: reduce) {
  .chipbench__chip, .chipbench__slot, .chiptray__zone { transition: none; }
  /* The bar still moves, because it is not decoration: without the gap it
     draws on top of the chip it is meant to sit in front of. */
}

/*
 * Somewhere to drop, even when the dock had no reason to be on screen.
 *
 * Scroll the roster far enough and the dock can be off the top of a phone, or
 * hidden entirely because nothing is kept yet. For the length of a drag it is
 * there regardless: the first drag is the one that most needs a target, and it
 * is the one that had none.
 */
body.is-dragging-active #chipbench[hidden] { display: block; }
body.is-dragging-active .chipbench__slot { border-color: var(--accent); }

:root[data-contrast="more"] .chipbench__slot { border-color: var(--text-dim); border-style: solid; }

@media (max-width: 760px) {
  /*
   * The dock comes up over the sheet for the length of a drag.
   *
   * On a phone the roster is a sheet at z-index 70 and the dock sits at 62,
   * which means the dock is behind the only surface you would ever drag a chip
   * from -- you could pick one up and there was nowhere on screen to put it
   * down. Raised above the sheet while a drag is live, and only then, so the
   * target rises to meet the thing you are carrying and drops back the moment
   * you let go. The app bar is 75 and stays clear of it.
   */
  body.is-dragging-active #dock {
    z-index: 78;
    box-shadow: 0 -10px 26px -10px rgb(0 0 0 / .6);
  }
  body.is-dragging-active #chipbench { box-shadow: inset 0 0 0 1px var(--accent); border-radius: var(--r-sm); }
}

/* The dock's chips are focusable now that arrows reorder them, so they need to
   say when they have focus. Same ring the rest of the app uses. */
.chipbench__chip:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

/* ------------------------------------------ the chips' bench, in the tab
 *
 * Two trays with a border round each, because they are two different promises:
 * the three you are taking, in order, and a shortlist of as many as you like.
 * The border is doing real work -- it says where a chip can be put down before
 * you have picked one up, which a bare row of icons never did.
 */
.chiptray {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: var(--s2);
  margin: 0 0 var(--s2);
}
.chiptray[hidden] { display: none; }

.chiptray__zone {
  border: 1px solid var(--line);
  border-radius: var(--r-md);
  background: var(--surface-2);
  padding: var(--s1) var(--s2) var(--s2);
  min-width: 0;
}

.chiptray__label {
  margin: 0 0 5px;
  display: block;
  font-size: 10.5px; font-weight: 800;
  letter-spacing: .09em; text-transform: uppercase;
  color: var(--text-dim);
}
.chiptray__row {
  display: flex; flex-wrap: wrap; align-items: center;
  gap: var(--s1);
  min-height: 30px;
}
.chiptray__hint { font-size: 11px; color: var(--text-mute); }

/* Numbered outlines rather than blank ones: the empty tray already says the
   answer is three, and which position you are about to fill. */
.chiptray .chipbench__slot { width: 30px; height: 30px; }
.chiptray .chipbench__chip { width: 30px; height: 30px; }

/*
 * The whole square lights up, not a line inside it.
 *
 * The highlight was drawn inside the tray's own border, so at a glance you saw
 * two rectangles a couple of pixels apart and had to work out which one meant
 * something. The tray is the thing you are dropping into; the tray is what
 * should say so. The label is inside the target now too, which it should always
 * have been -- aiming at a box and being told to hit the row inside the box is
 * a distinction only the code cared about.
 */
.chiptray__zone { transition: border-color .12s ease, box-shadow .12s ease, background-color .12s ease; }
.chiptray__zone.is-over {
  border-color: var(--accent);
  box-shadow: 0 0 0 2px var(--accent);
  background: color-mix(in srgb, var(--accent) 12%, var(--surface-2));
}

/*
 * Order is a different question, and gets a different answer.
 *
 * Dropping into a tray asks "in or out". Dropping onto one of the three asks
 * "before which one", and a ring round a chip cannot say that -- a ring means
 * "this one", and the thing you are about to do is land in front of it. So it
 * is a bar in the gap, the chip steps aside to open that gap, and the number
 * the chip is wearing is the number you are about to take. That last part is
 * why the badge inverts rather than just glowing: it is the answer, not
 * decoration.
 */
.chiptray .chipbench__chip.is-over,
.chipbench__strip .chipbench__chip.is-over {
  transform: translateX(8px);
  box-shadow: none;
  border-color: var(--line);
}
.chiptray .chipbench__chip.is-over::before,
.chipbench__strip .chipbench__chip.is-over::before {
  content: "";
  position: absolute;
  left: -8px; top: -3px; bottom: -3px;
  width: 4px;
  border-radius: 3px;
  background: var(--accent);
}
.chiptray .chipbench__chip.is-over .chipbench__n,
.chipbench__strip .chipbench__chip.is-over .chipbench__n {
  background: var(--text);
  color: var(--surface);
  min-width: 14px; height: 14px;
  font-size: 9px;
}
/* An empty position is a position too, so it fills rather than outlines. */
.chipbench__slot.is-over {
  border-style: solid;
  border-color: var(--accent);
  background: var(--accent);
  color: var(--accent-ink);
}

/* Both trays go full width once there is not room for two, which on a phone is
   immediately -- three 30px chips and a label do not share 358px with a
   shortlist that can hold any number of them. */
@media (max-width: 760px) {
  .chiptray { grid-template-columns: 1fr; gap: var(--s1); }
  .chiptray__zone { padding: 5px var(--s1) var(--s1); }
  .chiptray__label { margin-bottom: 3px; }
}

:root[data-contrast="more"] .chiptray__zone { border-color: var(--text-dim); }
:root[data-contrast="more"] .chiptray__label { color: var(--text); }

/* The list saying it will take a chip back. Same language the roster already
   speaks when you drag a Tatari onto it to un-bench one. */
.roster.is-dropping {
  box-shadow: inset 0 0 0 2px var(--accent);
  border-radius: var(--r-sm);
}

/*
 * A press on a chip is a press, not the start of a text selection.
 *
 * body.is-dragging-active turns selection off, but only once the drag has
 * started -- and the thing that stops a touch drag starting is the browser
 * deciding the press was a selection and cancelling the pointer. So the
 * surfaces you drag from say it up front, before there is a drag to protect.
 */
.card--chip,
.chipbench__chip,
.chiptray__zone { user-select: none; -webkit-user-select: none; }


/* ============================================================ swap groups
 *
 * A square you filled but did not settle. Two surfaces, and they are drawn to
 * different rules on purpose: the legend lives inside the frame and is read from
 * a screenshot by somebody with no legend to check, so it names its Tatari in
 * words as well as art; the editor lives outside the frame and is read by you,
 * with the board in front of you, so it is sprites and nothing else.
 */

/* The badge on the square. Bottom-left, the one corner a token leaves free --
   tier, role, owner and planned level have the other three. */
.cell[data-swap] { border-style: dashed; border-color: var(--accent); }
.cell[data-swap]::after {
  content: attr(data-swap);
  position: absolute; left: 2px; bottom: 2px; z-index: 2;
  min-width: 11px; padding: 0 2px;
  border-radius: 3px;
  background: var(--accent);
  color: var(--accent-ink);
  font-size: 8.5px; font-weight: 800; line-height: 12px; text-align: center;
  pointer-events: none; user-select: none;
}

/* Both kinds of square are a target while marking; see the pair of rules by
   the .is-flexing block above, which is where the empty half lives. */

/* ---- the legend, inside the frame ---- */
.field-frame__swaps {
  display: flex; flex-wrap: wrap; align-items: center; gap: var(--s2);
  margin: 0;
  padding: 5px var(--s3);
  border-top: 1px solid var(--line-soft);
  background: var(--surface-2);
  font-size: 13px; font-weight: 700; letter-spacing: .01em;
  overflow-wrap: anywhere;
}
.field-swap { display: inline-flex; align-items: center; gap: 4px; }
.field-swap__tag {
  min-width: 15px; padding: 0 3px;
  border-radius: 3px;
  background: var(--accent); color: var(--accent-ink);
  font-size: 11px; line-height: 15px; text-align: center; letter-spacing: .06em;
}
.field-swap__or { color: var(--text-mute); font-weight: 600; }
.field-swap__want { display: inline-flex; align-items: center; gap: 3px; }
.field-swap__want img { width: 20px; height: 20px; }

/* ---- the editor, outside it ---- */
.swapbar {
  display: flex; flex-direction: column; gap: var(--s2);
  margin: var(--s2) 0 0;
  padding: var(--s2) var(--s3);
  border: 1px dashed var(--accent);
  border-radius: var(--r-md);
  background: var(--surface-2);
}
.swapbar__hint { margin: 0; color: var(--text-mute); font-size: 12.5px; line-height: 1.4; }
.swapbar__none { margin: 0; color: var(--text-mute); font-size: 12.5px; }
.swapbar__picker { display: flex; flex-wrap: wrap; gap: 4px; }
.swapbar__acts { display: flex; gap: var(--s2); }
.swap-pick {
  padding: 2px;
  border: 1px solid var(--line-soft); border-radius: 6px;
  background: var(--surface-1);
  cursor: pointer;
  /* Only the border moves. A sprite that changes size on press makes a row of
     them jump, and the row is what you are reading. */
  transition: border-color .12s ease, background-color .12s ease;
}
.swap-pick img { display: block; width: 34px; height: 34px; }
.swap-pick[aria-pressed="true"] {
  border-color: var(--accent);
  background: color-mix(in srgb, var(--accent) 14%, transparent);
}

/*
 * The alternates lens: runners-up drawn on the square they are runners-up for.
 *
 * A field tile is 49.2px on a 390px phone and a sprite fills 47 of it. That is
 * the whole budget, and it is what decides which of the two layouts below is
 * the default: an equal four-way split leaves about 22px each, a shape and a
 * colour rather than an identification, and it takes with it the one thing the
 * square has to say -- which of them is actually standing there.
 *
 * Whichever is chosen, the board and the card draw the same one. A board that
 * looked different from a picture of itself would be the worse failure.
 */
/*
 * The two layouts, as percentages of the tile. Same numbers as swapLayout() in
 * card.js -- that function is the source, this is the same set written in CSS,
 * and the two are meant to be read side by side whenever either changes.
 *
 * Default: the one that is down keeps 78% and the runners-up run down the right
 * edge at 30%. The first question anybody asks a square is which Tatari is
 * actually standing on it, and only a size difference answers that without
 * words. At ~15px the runners-up are a count and a hint of colour, which is
 * honest about what they are -- the band under the board is where they are
 * named.
 */
.token--alted > img { position: absolute; }
.token__alts { position: absolute; inset: 0; z-index: 1; pointer-events: none; }
.token__alts img { position: absolute; object-fit: contain; }

/* The placed one dominant, runners-up down the edge. */
.token--alted > img { left: 0; top: 11%; width: 78%; height: 78%; }
.token__alts img { right: 0; width: 30%; height: 30%; opacity: .85; }
.token[data-alts="1"] .token__alts img:nth-child(1) { top: 35%; }
.token[data-alts="2"] .token__alts img:nth-child(1) { top: 20%; }
.token[data-alts="2"] .token__alts img:nth-child(2) { top: 50%; }
.token[data-alts="3"] .token__alts img:nth-child(1) { top: 5%; }
.token[data-alts="3"] .token__alts img:nth-child(2) { top: 35%; }
.token[data-alts="3"] .token__alts img:nth-child(3) { top: 65%; }

/*
 * Two badges give way on a square with runners-up.
 *
 * The runners-up run down the right edge, so the role icon top-right and the
 * planned level bottom-right would sit on top of them. Tier stays: it is
 * top-left, over the one that is actually down, which is whose tier it is.
 */
.token--alted .token__role,
.token--alted .token__rank,
.token--alted .token__add { display: none; }

/* ============================================================ pinball */

/*
 * The pinball calculator. One column, because every panel on it is a short
 * question with a short answer and a second column would only put half of them
 * below the fold on a phone anyway.
 *
 * Nothing here invents a colour or a border: .panel, .field and .eyebrow carry
 * the page, and the only new shapes are the number tiles, which need a size the
 * rest of the app has no use for.
 */
.pinball {
  display: grid;
  gap: var(--s4);
  max-width: 720px;
  padding: var(--s5) var(--s5) var(--s6);
  margin: 0 auto;
}

/* Fields wrap to their own row before they shrink: a number input narrower than
   about ten characters starts hiding the digits it exists to show. */
.pinball__inputs { display: flex; flex-wrap: wrap; align-items: flex-end; gap: var(--s3); }
.pinball__f { display: grid; gap: 5px; flex: 0 1 150px; min-width: 120px; }
.pinball__f .field { width: 100%; }

/* Reads as a sentence beside the inputs, and drops under them when the row
   runs out of width. */
.pinball__push {
  flex: 1 1 200px; min-width: 160px;
  margin: 0 0 2px;
  display: grid; gap: 1px;
  font-size: 13.5px;
}
.pinball__push b { font-weight: 700; }
.pinball__push span { font-size: 12.5px; }

/*
 * A number and what it counts. The number is the whole point, so it gets the
 * display face and enough size to be read from across a desk, and the label
 * underneath is deliberately small -- you already know you asked for candy.
 */
.pbstat {
  display: grid; gap: 1px;
  padding: var(--s3);
  border: 1px solid var(--line-soft);
  border-radius: var(--r-md);
  background: var(--surface-2);
}
.pbstat__n {
  font-family: var(--font-display);
  font-size: 22px; font-weight: 700; letter-spacing: -.02em;
  line-height: 1.05;
}
.pbstat__k { font-size: 12.5px; font-weight: 600; }
.pbstat__per { font-family: var(--font-mono); font-size: 11px; }

/* Candy is the headline: two thirds of what a stack pays, and the only figure
   here that runs to seven digits. */
.pbstat--lead {
  margin-bottom: var(--s3);
  border-color: color-mix(in srgb, var(--accent) 38%, var(--line));
  background: color-mix(in srgb, var(--accent) 7%, var(--surface-2));
}
.pbstat--lead .pbstat__n { font-size: 34px; color: var(--accent-ink-on); }

/* 175px rather than 150: at 150 the five tiles fit four across and leave the
   fifth alone on a row of its own, which reads as a straggler rather than a
   set. Three and two is a shape. */
.pbstat-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(175px, 1fr));
  gap: var(--s2);
}

/*
 * The two outcomes this page counts but cannot value. Dashed, because a dashed
 * edge is the app's existing shorthand for "this figure is not the whole story"
 * -- see .card__fx[data-base="false"] -- and these are hits, not payouts.
 */
.pbstat--roll {
  border-style: dashed;
  background: none;
}

/* The one sentence per panel that answers the question the panel asked. */
.pinball__verdict {
  margin: var(--s3) 0 0;
  padding: var(--s3);
  border-radius: var(--r-md);
  border: 1px solid var(--line-soft);
  background: var(--surface-2);
  font-size: 13.5px;
}
.pinball__verdict[data-state="blocked"] {
  border-color: color-mix(in srgb, var(--danger) 40%, var(--line));
  background: color-mix(in srgb, var(--danger) 7%, var(--surface-2));
}
.pinball__verdict[data-state="ok"] {
  border-color: color-mix(in srgb, var(--ok) 40%, var(--line));
  background: color-mix(in srgb, var(--ok) 7%, var(--surface-2));
}

/* Only ever on screen when an edit has left the table not summing to 100. */
.pinball__warn {
  margin: var(--s3) 0 0;
  color: var(--danger);
  font-size: 12.5px; font-weight: 600;
}

.pinball__variance { margin-top: var(--s3); margin-bottom: 0; }

/* Five number columns do not fit a 360px phone at any font size worth reading,
   so the table scrolls inside its own box rather than the page scrolling. */
.pinball__tablewrap { overflow-x: auto; margin-bottom: var(--s3); }
.pinball__table { border-collapse: collapse; width: 100%; font-size: 13px; }
.pinball__table th, .pinball__table td {
  padding: var(--s2) var(--s2) var(--s2) 0;
  text-align: left;
  border-bottom: 1px solid var(--line-soft);
}
.pinball__table thead th {
  font-family: var(--font-mono);
  font-size: 10.5px; font-weight: 500;
  letter-spacing: .1em; text-transform: uppercase;
  color: var(--text-mute);
  white-space: nowrap;
}
.pinball__table tbody th { font-weight: 600; white-space: nowrap; }
.pinball__table tbody tr:last-child th,
.pinball__table tbody tr:last-child td { border-bottom: 0; }
.pinball__table .field--tiny { width: 5.5em; }

.pinballpage .hint:last-child { margin-bottom: 0; }

/*
 * The "give or take" under each figure. Mono and dim on purpose: it is a
 * qualifier on the number above it, and the moment it competes with that number
 * the tile stops answering the question it was asked. Empty for anything whose
 * spread rounds to nothing, and :empty keeps the tile from reserving the line.
 */
.pbstat__sd {
  font-family: var(--font-mono);
  font-size: 11px;
  color: var(--text-mute);
}
.pbstat__sd:empty { display: none; }
/* The dashed tiles are the two the spread actually matters for -- at x40 a
   Camp-raid count swings by two thirds of itself -- so there it is not a
   footnote and does not read as one. */
.pbstat--roll .pbstat__sd { color: var(--text-dim); }


/* ============================================================ masthead (B)
 *
 * Three bands, top to bottom: who and where (.topbar), every page as a tab
 * (.sitetabs, in site-nav.css), and this formation (.formbar). See the note
 * above the header in index.html. Last in the file on purpose: it replaces
 * the one-row header's rules, and the few older ones that still match
 * (.field--name, #mode-switch, .saves-handle) are settled here.
 */

/* The brand row scrolls away; the formation bar is what stays. */
.topbar { position: static; border-bottom: 0; padding-block: var(--s2); }
.topbar__help { margin-left: auto; }

.formbar {
  position: sticky; top: 0; z-index: var(--z-sticky);
  display: flex; align-items: center; gap: var(--s3);
  padding: var(--s2) var(--s5);
  background: color-mix(in srgb, var(--surface-2) 94%, transparent);
  backdrop-filter: blur(10px);
  border-bottom: 1px solid var(--line);
}
.formbar .field--name {
  order: 0; flex: 0 1 320px; min-width: 140px;
  padding: 4px var(--s2); margin-left: calc(var(--s2) * -1);
  border-color: transparent; background: transparent;
  font-family: var(--font-display); font-size: 19px; font-weight: 700; letter-spacing: -.02em;
}
.formbar .field--name:hover { border-color: var(--line); }
.formbar .field--name:focus { background: var(--surface); border-color: var(--line); }
.formbar #formation-status { order: 0; flex: 0 1 auto; margin: 0; }
.formbar #mode-switch { order: 0; margin-left: 0; }

/* Was a sideways tab fixed to the right edge; now an ordinary button here. */
.formbar .saves-handle {
  position: static; transform: none; writing-mode: horizontal-tb;
  flex-direction: row; gap: 6px;
  margin-left: auto;
  padding: 7px var(--s3);
  border: 1px solid var(--line); border-radius: var(--r-sm);
  background: var(--surface); color: var(--text);
  font-size: 13px; font-weight: 550; letter-spacing: 0;
}
.formbar .saves-handle[aria-expanded="true"] { border-color: var(--accent); }

/* A running Live session is inside the menu now, so the menu says so. */
body.is-live .formmenu > summary::before {
  content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--accent);
}
body.is-live-down .formmenu > summary::before { background: transparent; box-shadow: inset 0 0 0 1px var(--accent); }

@media (max-width: 760px) {
  .topbar { padding: var(--s2) var(--s3); gap: var(--s2); flex-wrap: nowrap; }
  .topbar__help { min-width: 36px; min-height: 36px; padding: 0; }

  /* Name on its own line, the count under it, then the switch and the menu. */
  .formbar {
    position: static; flex-wrap: wrap;
    gap: var(--s2); padding: var(--s2) var(--s3) var(--s3);
    backdrop-filter: none; background: var(--surface-2);
  }
  .formbar .field--name { flex: 1 1 100%; min-width: 0; margin-left: 0; padding: 4px 0; }
  .formbar .field--name:focus { padding-left: var(--s2); }
  .formbar #formation-status { flex: 1 1 100%; margin-top: -6px; }
  .formbar .saves-handle { display: none; }
  .formbar .formmenu { margin-left: auto; }
  .formbar .formmenu > summary { min-height: 44px; }
}

/* ============================================================ toolbars inside the drafter
 *
 * Same rules as the masthead: one filled thing per row, toggles that look
 * like toggles, and nothing that reads as a different kind of control from
 * its neighbour for no reason.
 */

/* Flex slots is an on/off setting, so it is drawn as a switch rather than a
   bare checkbox sitting between three buttons. */
.panel--field .panel__tools > .check {
  gap: 8px; padding: 4px 10px 4px 6px;
  border: 1px solid var(--line); border-radius: 999px; background: var(--surface-2);
  font-size: 12.5px; font-weight: 550; cursor: pointer;
}
.panel--field .panel__tools > .check input {
  appearance: none; margin: 0; flex: none;
  width: 28px; height: 16px; border-radius: 999px;
  background: var(--surface-3); box-shadow: inset 0 0 0 1px var(--line);
  position: relative; cursor: pointer;
  transition: background-color 140ms ease;
}
.panel--field .panel__tools > .check input::after {
  content: ""; position: absolute; top: 2px; left: 2px;
  width: 12px; height: 12px; border-radius: 50%;
  background: var(--text-mute);
  transition: transform 140ms var(--ease-out), background-color 140ms ease;
}
.panel--field .panel__tools > .check input:checked { background: var(--accent); box-shadow: none; }
.panel--field .panel__tools > .check input:checked::after { transform: translateX(12px); background: var(--accent-ink); }
.panel--field .panel__tools > .check input:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* Advanced reads as a button that opens something, like its neighbours. */
.panel--field .pill--advanced { border-style: solid; border-radius: var(--r-sm); color: var(--text-dim); }
/* Clear field is the one that throws work away; it goes to the far end. */
.panel--field .panel__tools #btn-clear-field { margin-left: auto; }

/* The hint: one line, "How it works" for the rest. */
/* A row of text is 19px tall; a thing you press needs 24. */
details.hint > summary { list-style: none; cursor: pointer; padding: 4px 0; min-height: 24px; }
/* A thumb's 44px where there is a thumb: the summary is the only thing to press. */
@media (pointer: coarse) { details.hint > summary { min-height: 44px; padding-block: 12px; box-sizing: border-box; } } /* not flex: it drops the spaces around a <b> */
details.hint > summary::-webkit-details-marker { display: none; }
details.hint { padding: 7px 10px; border-radius: var(--r-sm); background: var(--surface-2); }
details.hint[open] { line-height: 1.55; }
details.hint > summary b { color: var(--text); font-weight: 600; }
.hint__more { margin-left: var(--s2); color: var(--accent-ink-on); font-weight: 600; white-space: nowrap; }
details.hint[open] .hint__more { display: none; }
details.hint[open] > summary { margin-bottom: var(--s1); }

/* Tatari / Zobos / Chips are what the panel is showing, so they are tabs:
   underlined, not filled. The yellow fill is kept for one thing per row. */
#roster-tabs { padding: 0; border: 0; background: transparent; gap: var(--s1); }
#roster-tabs .segmented__btn {
  padding: 6px var(--s2); border-radius: 0; background: transparent;
  border-bottom: 2px solid transparent; box-shadow: none; color: var(--text-dim);
}
#roster-tabs .segmented__btn:hover { color: var(--text); }
#roster-tabs .segmented__btn[aria-selected="true"],
#roster-tabs .segmented__btn[aria-selected="true"]:hover {
  background: transparent; color: var(--text); box-shadow: none;
  border-bottom-color: var(--accent);
}
/* Sized as the panel's main choice: it swaps the whole list, so it has to read
   before the Roster/Waves and Cards/List switches beside it, which only change
   how that list is shown. Those step down to match. */
#roster-tabs { gap: var(--s2); }
#roster-tabs .segmented__btn { font-size: 15px; font-weight: 700; padding: 6px 2px 7px; border-bottom-width: 3px; color: var(--text-mute); }
@media (max-width: 760px) { #roster-tabs .segmented__btn { font-size: 14px; } }
#sec-roster .panel__views .segmented__btn { font-size: 11.5px; padding: 3px var(--s2); font-weight: 600; }
/* Cards / List is a view setting, so it gets the quiet selected state. */
.panel__views .segmented__btn[aria-selected="true"],
.panel__views .segmented__btn[aria-selected="true"]:hover {
  background: var(--surface-3); color: var(--text); box-shadow: inset 0 0 0 1px var(--line);
}

/* Type and role chips carry their word, not just an icon: on a phone there is
   no hover, so an unlabelled circle was a guess. */
.chip__label { text-transform: capitalize; font-size: 12px; font-weight: 550; }
.chip--type, .chip--role { display: inline-flex; width: auto; gap: 5px; padding-inline: 6px 10px; }

/* ============================================================ share sheet on one screen
 *
 * The preview used to take the sheet's full width, which made the picture
 * taller than the window and pushed Download and Copy below the fold. Now the
 * picture is sized to the screen and the controls sit beside it (computer) or
 * under it (phone), so everything is visible without scrolling. The sheet still
 * scrolls as a last resort on a very short window.
 */
.share__preview img { width: auto; max-width: 100%; margin-inline: auto; object-fit: contain; }

@media (min-width: 761px) {
  .modal--share {
    width: min(1120px, calc(100vw - 2 * var(--s4)));
    grid-template-columns: minmax(0, 1fr) 300px;
    grid-auto-rows: min-content;
    gap: var(--s2) var(--s5);
    align-content: start;
  }
  .modal--share[open] { display: grid; }
  .modal--share > * { grid-column: 2; }
  .modal--share > #share-scope { justify-self: start; }
  .modal--share > .share__preview { grid-column: 1; grid-row: 1 / span 20; align-self: start; margin: 0; }
  /* Window minus the sheet's margin and padding and the preview's own padding. */
  .share__preview img { max-height: calc(100svh - 4 * var(--s5) - 2 * var(--s2) - 4px); }
  .modal--share .modal__actions { margin-top: var(--s2); }
  .modal--share .modal__actions .btn { flex: 1 1 auto; }
}

@media (max-width: 760px) {
  /* About 580px of controls under the picture (measured on a 390 x 844 phone,
     Post to Community included); it gets the rest, never less than 160. */
  .share__preview img { max-height: max(160px, calc(100svh - 580px)); cursor: zoom-in; }
  .share__preview.is-big img { max-height: none; width: 100%; cursor: zoom-out; }
  .modal--share { padding: var(--s4); }
  .modal--share .share__preview { margin: var(--s2) 0; }
}

/* iOS zooms the whole page in when a text box under 16px is tapped, and never
   zooms back out: every field on a touch screen is 16px, whatever it looks like
   on a desktop. Checkboxes, radios and ranges have no text to zoom. */
@media (pointer: coarse) {
  /* :root in front of each, so this outranks the class rules that set 13px --
     .btn on the sort dropdown was the one that got through. */
  :root input:not([type="checkbox"]):not([type="radio"]):not([type="range"]),
  :root select,
  :root textarea,
  :root .field {
    font-size: 16px;
  }
}

/* Apple's guideline is a 44px target, and a finger is not a mouse pointer: on a
   touch screen the small controls grow to it. Only where it costs nothing --
   the grid's squares stay 40px on a 375px screen because six of them plus the
   margins is the whole width, and that is the board, not a button. */
@media (pointer: coarse) {
  :root .btn, :root .chip, :root .segmented__btn, :root summary.btn,
  :root .topbar__help, :root select,
  #advanced-toggle, #filters-toggle, #frame-name {
    min-height: 44px;
  }
  .chip, .segmented__btn { min-width: 44px; }
  .topbar__help { min-width: 44px; }
  .patchnote__x, .chipcard__keep { width: 44px; height: 44px; }
  /* nowrap: the taller target squeezed the width and broke "Say thanks" over
     two lines, which made the header taller than the button saved. */
  .byline__tip { display: inline-flex; align-items: center; min-height: 44px; white-space: nowrap; }
}

/*
 * The filter chips wrap on a phone.
 *
 * The type and role chips carry their names now, and a row of five labelled
 * chips is wider than any iPhone: the drawer grew to fit them, ran off the
 * right edge, and Rock, Support and T3 sat off screen where no tap could
 * reach them. A second line of chips costs some height; a chip nobody can
 * press costs the filter. min-width: 0 keeps the drawer to the sheet's width
 * so the wrap has an edge to wrap at. The opened effect row keeps its own
 * sideways scroll (.chips--sub), which was a deliberate trade.
 *
 * .filters rather than the drafter's drawer alone: the range recorder uses the
 * same filters with no drawer around them, and clipped the same chips.
 */
@media (max-width: 760px) {
  .filters, .filters__body { min-width: 0; max-width: 100%; }
  .filters .chips:not(.chips--sub) { flex-wrap: wrap; }
}

/* The header's light/dark switch, on every page (site-nav.js). Square, a
   finger wide, beside the credit. */
.topbar .byline { margin-left: auto; }
.themetoggle { flex: none; width: 44px; height: 44px; min-height: 44px; padding: 0; display: inline-grid; place-items: center; color: var(--text-dim); }
.themetoggle:hover { color: var(--text); }
/* The drafter's "?" joins the right-hand group rather than floating mid-bar. */
.topbar__help { margin-left: auto; }
.topbar__help ~ .byline { margin-left: 0; }
/* A narrow phone: the cup alone says "tip jar", and the switch keeps its room. */
@media (max-width: 420px) {
  /* Not on the drafter, whose header was measured to keep it (mobiletest). */
  .topbar:not(:has(.topbar__help)) .byline__tip-text { display: none; }
  .topbar .byline { flex: 0 1 auto; min-width: 0; }
  .topbar__title { min-width: 0; }
  .topbar__title h1 { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
}

/* Patch notes: new Tatari, as sprites linking to their numbers. */
.chnew { margin-bottom: var(--s5); }
.chnew[hidden] { display: none; }
.chnew__when { margin: var(--s3) 0 var(--s2); font-size: 14px; color: var(--text-dim); font-weight: 600; }
.chnew__list { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); gap: var(--s2); }
/* No sprite on the wiki yet: a quiet placeholder the size of one, not a broken image. */
.chnew__noart {
  display: grid; place-items: center; width: 64px; height: 64px; margin: 0 auto;
  border: 1px dashed var(--line); border-radius: var(--r-sm, 4px);
  color: var(--text-mute); font-size: 22px; font-weight: 700;
}
.chnew__item { display: grid; justify-items: center; gap: 2px; padding: var(--s2); min-height: 44px; text-align: center;
  border: 1px solid var(--line); border-radius: var(--r-md, 10px); background: var(--surface); color: var(--text); text-decoration: none; }
.chnew__item:hover { border-color: var(--accent); }
.chnew__item img { width: 64px; height: 64px; }
.chnew__name { font-weight: 700; font-size: 14px; }
.chnew__meta { font-size: 12px; color: var(--text-mute); }
