/* ═══════════════════════════════════════════════════════════════
   FlexRank Editor — Column Depth
   ═══════════════════════════════════════════════════════════════ */

/* Ambient editor backdrop — a restrained layered gradient over the flat
   surface so the workspace reads with depth and the frosted comparison panel
   has something to refract. Layers (top → bottom): a soft accent glow in the
   top-right, a cool counter-tint in the lower-left, and a faint tonal diagonal.
   All low-alpha and built on theme rgb tokens, so it adapts light/dark and
   stays subtle rather than loud. Sits on top of the element's bg-surface color
   (background-image only — the Tailwind bg-surface color shows through). */
.fr-editor-shell {
  background-image:
    radial-gradient(115% 80% at 100% 0%, rgb(var(--accent-rgb) / .07), transparent 55%),
    radial-gradient(100% 75% at 0% 100%, rgb(var(--surface-3-rgb) / .5), transparent 55%),
    linear-gradient(155deg, rgb(var(--surface-2-rgb) / .45) 0%, transparent 42%);
  background-attachment: local;
}

/* Light mode: subtle lift shadow on column cards for depth */
.fr-editor-column {
  box-shadow: 0 1px 3px rgba(60, 40, 20, 0.06),
              0 1px 2px rgba(60, 40, 20, 0.04);
  /* Containment: column is its own layout/paint/style boundary so a
     reorder in one column can't dirty layout/style in another. Drives
     the drag-time UpdateLayoutTree elementCount way down (was 85k+). */
  contain: layout paint style;
}
html[data-theme="dark"] .fr-editor-column {
  box-shadow: none;
}

/* Skip rendering work for columns scrolled off-screen horizontally.
   Active drag column stays in viewport so SortableJS measurements
   are unaffected. Intrinsic size matches typical column width so
   placeholder doesn't collapse the layout. */
.fr-editor-column {
  content-visibility: auto;
  contain-intrinsic-size: auto 800px;
}

/* ═══════════════════════════════════════════════════════════════
   FlexRank Editor — List Row Width Tiers
   ═══════════════════════════════════════════════════════════════

   The row's layout used to branch on `lg:` — a VIEWPORT breakpoint —
   but a column's width has almost nothing to do with the viewport:
   multi-column mode pins every column to --fr-col-w (340px), while
   single-list focus removes the cap and hands one column the whole
   canvas. Both happen at the same viewport, so `lg:` was true for a
   340px column and a 1200px one alike. The 340px case truncated names
   to ~9 characters; the 1200px case left the name box flex-growing
   into a dead gutter with the metadata pinned far right.

   The column is now a query container and the row sizes off its OWN
   width. The thresholds below are CONTENT-box widths — container-type:
   inline-size queries the content box, and .fr-editor-column carries a
   1px border, so a column laid out at exactly 560px measures 558 here. Note this is the size the column is GIVEN (an explicit
   --fr-col-w, or the focus-mode block width) — never a size derived
   from the rows inside it — so inline-size containment introduces no
   circularity. It composes with the existing `contain` and
   content-visibility above rather than replacing them. */
.fr-editor-column {
  container-type: inline-size;
  container-name: frcol;
}

/* Every tier rule below is scoped to .fr-list-row, NOT .fr-row-content.
   BoardCardComponent renders its own .fr-row-content — a flex-column
   card, not a table row — and would be deformed by row-tier layout. */
.fr-list-row {
  display: flex;
  align-items: center;
  height: 1.75rem;                      /* 28px, the dense default */
}

/* .fr-row-meta is a grouping element only: at every width except the
   narrow tier its children behave as ordinary sibling cells of the row,
   which is what keeps Tm/Pos/Bye aligned to their header labels. */
.fr-list-row .fr-row-meta,
.fr-row-header .fr-row-meta { display: contents; }

/* Column-name labels ("BYE 6") are for the stacked caption only; the
   aligned layout has real header labels above the values instead. */
.fr-mlbl { display: none; }

/* Action cluster: width declared here (plus the coarse-pointer override
   right below) for both the row and the header's reserve. Previously the
   header hand-copied the buttons' widths as four spacer spans, and
   drifted from them. */
.fr-cell-acts { width: 5.25rem; }       /* 84px: compare + pencil + kebab + chevron, sized for desktop's 16-24px icons */

/* Under a coarse pointer the pencil/kebab/chevron each grow to a 28px touch
   target (see Editor::RowInsertMenuComponent#trigger_classes and the mobile
   branches in ranked_node_component.html.erb) -- 7rem is that same
   compare(20px) + pencil(32px) + kebab(30px) + chevron(30px) sum. Without
   this the cluster overflowed the 84px desktop reserve by ~28px and spilled
   left into the ECR/snapshot delta badge next to it. */
@media (pointer: coarse) {
  .fr-cell-acts { width: 7rem; }
}

/* Wide-only data columns are absent below their tier entirely.

   The class is DOUBLED, and the rule sits above the @container blocks rather
   than below them, for reasons that are both load-bearing:

   Doubled, because a single `.fr-cell-wide` is an exact specificity tie with
   Tailwind's `.inline-flex` -- which every one of these cells carries (see the
   metadata_columns loop in ranked_node_component.html.erb and its twin in
   ranking_list_column_component.html.erb). A tie is settled by source order,
   and editor.css does NOT come last: the application layout's
   `stylesheet_link_tag :app` re-links every stylesheet in the directory
   alphabetically, which serves tailwind.css a second time AFTER this file. The
   utility won, so the wide and extra-wide columns rendered at every container
   width -- including the phone's narrow tier, where the row is a grid and the
   caption line is a bounded `minmax(0, 1fr)` cell. A 151px fRank-OVR chain
   that should never have been there overflowed it and ran underneath the
   comparison-delta pill and the action icons.

   Above the container blocks, because doubling makes this 0-2-0 -- the same
   weight as the `.fr-list-row .fr-cell-wide` reveals below. Same weight is
   settled by order again, and here the LATER rule has to win, or the columns
   would never appear at any width. */
.fr-cell-wide.fr-cell-wide { display: none; }
.fr-cell-xwide.fr-cell-xwide { display: none; }

/* ── Narrow tier: the stacked row ──────────────────────────────
   Below this width a single line cannot hold a full player name —
   the fixed chrome (handle, delta, Tm/Pos/Bye, actions) leaves it
   under ~70px, about nine characters. Rather than truncate the one
   piece of information the row exists to convey, the row becomes two
   explicit lines. Named grid areas, not flex-wrap: wrapping let the
   browser choose break points, which produced a different ragged
   shape per row depending on name length. */
@container frcol (max-width: 420px) {
  /* A single row of column labels describes nothing once rows stack. */
  .fr-row-header { display: none; }

  .fr-list-row {
    display: grid;
    /* Five tracks. The caption line needs its own end cell for the comparison
       delta — sharing one `meta` cell between the metadata and an end-justified
       delta let the two overlap once the metadata ran long. `base` is the
       variant editor's baseline-delta gutter: it sits with the rank, spanning
       both lines, so the delta and its reset read as part of the rank rather
       than as trailing decoration on the name. The track collapses to zero
       wherever no row renders that cell, which is everywhere but the variant
       editor. */
    grid-template-columns: auto auto minmax(0, 1fr) auto auto;
    grid-template-areas:
      "handle base name name  acts"
      "handle base meta delta acts";
    align-items: center;
    row-gap: 1px;
    height: auto;
    min-height: 2.75rem;                /* 44px */
    padding-top: .3rem;
    padding-bottom: .3rem;
  }

  .fr-list-row .fr-cell-handle { grid-area: handle; align-self: center; }
  .fr-list-row .fr-cell-name   { grid-area: name; margin-inline-start: .375rem; }
  .fr-list-row .fr-cell-acts   { grid-area: acts; align-self: center; }

  /* The name is the headline: a full size step AND a weight step above
     the caption, and it never truncates here. */
  .fr-list-row .fr-name {
    white-space: normal;
    overflow: visible;
    text-overflow: clip;
    font-size: .8125rem;                /* 13px */
    font-weight: 600;
    line-height: 1.2;
  }

  /* Second line: one caption built from the row's only copy of the
     metadata — there is no lg:hidden twin any more. */
  .fr-list-row .fr-row-meta {
    grid-area: meta;
    display: flex;
    align-items: center;
    gap: .5rem;
    margin-inline-start: .375rem;
    min-width: 0;
    font-size: .625rem;                 /* 10px */
    letter-spacing: .03em;
  }
  .fr-list-row .fr-row-meta > .fr-cell {
    width: auto;
    margin: 0;
    flex: 0 0 auto;
    justify-content: flex-start;
    color: var(--text-dim);
  }
  /* Cells with nothing in them (the reserved rookie slot on a non-rookie, a
     free agent's empty team) collapse rather than leaving gaps in the caption.
     Note this is display:none, not removal — an empty cell is still a sibling
     for `+`, so a middot separator drawn with `.fr-cell + .fr-cell::before`
     rendered BEFORE the first visible item on every non-rookie row. Spacing
     alone carries the separation here; there is no way to express "first
     visible sibling" in CSS, and a leading stray dot on most rows is a worse
     outcome than no dots at all. */
  .fr-list-row .fr-row-meta > .fr-cell:empty { display: none; }
  /* Team keeps its brand colour and position its own; both are cues you
     scan for. Everything else on the caption recedes.

     The position badge drops its filled pill here and keeps only its
     colour: a saturated block was the loudest object on a row whose
     subject is the player, and it beat the name for attention. Position
     still reads two ways without it — the coloured label itself, and
     the row's existing [data-nfl-position] tint. */
  .fr-list-row .fr-row-meta .fr-cell-tm { font-weight: 700; }
  .fr-list-row .fr-pos-badge {
    width: auto;
    padding: 0;
    background: none;
    font-weight: 700;
  }
  .fr-list-row .fr-mlbl {
    display: inline;
    margin-inline-end: .2rem;
    text-transform: uppercase;
    letter-spacing: .06em;
    opacity: .85;
  }

  /* Comparison deltas belong with the metadata, not wedged between the
     rank and the name where a coloured pill outshouted both. The two can
     co-occur (the isolated variant editor renders a baseline delta as
     well as the ECR/snapshot pair), so they get separate cells. */
  .fr-list-row .fr-cell-delta {
    grid-area: delta;
    justify-self: end;
    align-self: center;
    width: auto;
    margin: 0 0 0 .4rem;
  }
  .fr-list-row .fr-cell-basedelta {
    grid-area: base;
    justify-self: start;
    align-self: center;
    width: auto;
    margin: 0 .15rem 0 .25rem;
  }
}

/* ── Wide tier: spend the surplus, don't part the sea ──────────
   With the name box flex-growing, a wide column put the name hard left
   and the metadata hard right with nothing between them. The name now
   stops at a readable measure and the freed space goes to data columns
   the profile opts into — each one a real column with a header label
   and a rule, so a scan down the list compares like against like. */
@container frcol (min-width: 560px) {
  /* The name column is sized to what names actually MEASURE, not to a
     round number. Across the 295-player overall list the widest name is
     123px and the median is 75px; the longest name anywhere in the player
     table is 159px ("Christian Scotland-Williamson"). 12rem = 192px clears
     all of them with headroom, so nothing truncates here either.

     This was 22rem, which is ~3x what any name needs: the cell stayed a
     fixed 352px while the text inside it was 61-89px, so every row carried
     ~250px of empty name column between the player and his team — the same
     chasm the tier work was meant to remove, merely bounded. The column has
     to stay a FIXED width (not shrink-to-fit) or the metadata beside it
     would land at a different x on every row.

     12rem is only the CEILING. name_column_controller measures the widest
     name in each list and publishes it as --fr-name-w, so a column of ordinary
     names sits at its own width rather than at the width of the longest name
     in the whole player table. The cap still applies when a list genuinely
     holds one — min() takes whichever is smaller.

     Whatever is left over after this collects in front of the action
     cluster, which pins to the row's right edge. That gutter separates data
     from controls and breaks no association — unlike a gap between a player
     and his own team. It cannot be designed away at these column widths:
     the row's content simply needs less room than two open lists are given
     (--fr-col-w is a floor, and columns grow to share the scroller). */
  .fr-list-row .fr-cell-name,
  .fr-row-header .fr-cell-name { max-width: min(var(--fr-name-w, 12rem), 12rem); }
  .fr-list-row .fr-cell-acts,
  .fr-row-header .fr-cell-acts { margin-inline-start: auto; }

  .fr-list-row .fr-cell-wide,
  .fr-row-header .fr-cell-wide { display: inline-flex; }

  /* Column rules start where the identity block ends. */
  .fr-list-row .fr-cell-tm,
  .fr-row-header .fr-cell-tm,
  .fr-list-row .fr-cell-wide,
  .fr-row-header .fr-cell-wide,
  .fr-list-row .fr-cell-acts,
  .fr-row-header .fr-cell-acts {
    border-inline-start: 1px solid var(--border-subtle);
  }
  /* Tm opens the data block, so it carries the leading rule. The padding has
     to be SYMMETRIC: a one-sided padding-inline-start shifted the content off
     the cell's centre (Tm sat ~4px right of centre while Pos and Bye were
     dead-centre), and it shifted the header and the row by different amounts
     because one centres with flex and the other with text-align. The cell
     widens to keep the same 1.5rem of content box it had before the padding. */
  .fr-list-row .fr-cell-tm,
  .fr-row-header .fr-cell-tm {
    width: 2.25rem;
    /* The trailing pad carries the extra 1px because the leading rule is a
       border, and a border-box counts it on the leading side only — even
       padding would leave the content sitting half a pixel right of centre. */
    padding-inline: .375rem calc(.375rem + 1px);
    margin-inline-start: .375rem;
  }
}

/* ── Extra-wide tier: the columns the wide tier cannot afford ──
   The fRank OVR history cell prints three labelled ranks and two
   arrows ("OVR8←OVR2←OVR24"), ~140px. At the wide tier's 560px the
   row would have to take that width out of the player's name — the
   one thing this product never truncates — so the column waits until
   the ranking column has room for it outright. Single-list focus on
   any real desktop clears this; a 340px multi-column does not, and
   should not. */
@container frcol (min-width: 720px) {
  .fr-list-row .fr-cell-xwide,
  .fr-row-header .fr-cell-xwide {
    display: inline-flex;
    /* --border, not the --border-subtle the data-block rules use. This is the
       one boundary between the ranking columns (Rk, and the heuristics before
       it) and a block of ranks from somewhere else entirely -- the fRank Open
       archive -- so it has to actually read as a boundary. At --border-subtle
       (#241f2e) it is invisible against surface-2 (#1f1a28), which is every
       other row: the rule was there and nobody could see it. --border is the
       weight the row's own bottom rule already carries, so the column reads
       set off without reading heavy. */
    border-inline-start: 1px solid var(--border);
  }
}


/* ═══════════════════════════════════════════════════════════════
   FlexRank Editor — Row Containment
   ═══════════════════════════════════════════════════════════════ */

/* Each player row is its own layout/paint/style boundary. Without
   this, a class change on body or a parent forces every row's
   subtree through style recalc/layout. With it, invalidation stops
   at the row root. Trace pre-fix showed 34k dirty layout objects
   on a single drag move; containment scopes that to the rows that
   actually changed. Style containment is safe here because rows
   don't use counters/quotes that need to escape. */
.fr-row,
.fr-tier-break-row {
  contain: layout paint style;
}

/* Skip rendering work for rows scrolled off-screen vertically.
   Each column scrolls independently; rows the user can't see don't
   need style/layout/paint. Browser auto-promotes to full rendering
   when a row enters the viewport, so SortableJS sees real rects on
   anything it could practically swap with (drag candidate area is
   always in-viewport). Intrinsic sizes match the rendered heights:
   28 px desktop / 48 px mobile for player rows; 20 px / 28 px for
   tier breaks. Off by a couple px on either side is fine —
   content-visibility's main job is to drop the per-row recalc cost,
   not to preserve scroll position to the pixel. */
/* Per-row content-visibility was deliberately removed: it collapses
   off-screen rows' contribution to the column body's scrollHeight, so the
   .overflow-y-auto body never overflows — overflow-y-auto stays inactive
   and spotlight autoscroll has nothing to scroll. The horizontal
   .fr-editor-column content-visibility (above) still skips off-screen
   columns; per-row containment (contain: layout paint style on .fr-row)
   still scopes style/layout invalidation. The drag perf win from #15
   came mostly from `contain`, not from content-visibility on each row. */

/* SortableJS drag list: paint+layout containment so reorder animations
   don't trigger paint invalidation on neighbors. Children (rows) already
   carry style containment, so it isn't needed at this level. */
[data-controller~="drag"] {
  contain: layout paint;
}

/* (Hover-kill rule deliberately not present here. Rationale: a rule
   like `.fr-editor-shell.fr-dragging .fr-row > *` cascades on every
   class toggle of `.fr-dragging`, forcing the style engine to
   re-evaluate `.fr-row > *` matches across the whole editor (~30k+
   elements). The trace after Step 3 showed a 380 ms style recalc
   spike at pointerdown that traced back to this exact toggle pattern.
   The hover cost it suppressed (~85 ms cumulative dragenter/mouseenter)
   was an order of magnitude smaller than the cascade it caused.
   Any future hover-suppression mechanism MUST avoid descendant
   selectors anchored at the toggled class.) */

/* ═══════════════════════════════════════════════════════════════
   FlexRank Editor — Drag & Drop States
   ═══════════════════════════════════════════════════════════════ */

/* ── Ghost: the invisible slot left in the list ────────────────
   Content is hidden. The animated gap from neighboring items
   sliding apart is the drop indicator — no decoration needed.
   No pseudo-elements or position: relative to avoid layout
   recalculation jitter as SortableJS moves the ghost in the DOM. */
.fr-sortable-ghost {
  opacity: 0 !important;
}

/* Force-render the dragged row's subtree. Rows carry
   `content-visibility: auto` for offscreen-skip perf, but Chromium's
   native HTML5 drag-image snapshot generator skips content-visibility
   subtrees, producing an empty drag image. Override on the states
   active during drag so the snapshot includes the row's content. */
.fr-sortable-chosen,
.fr-sortable-fallback {
  content-visibility: visible;
}

/* ── Chosen: the instant lift when you grab a row ──────────────
   Provides immediate tactile feedback before dragging begins. */
.fr-sortable-chosen {
  z-index: 10;
}
.fr-sortable-chosen > .fr-row-content {
  background-color: var(--surface-2);
  box-shadow: inset 0 0 0 2px var(--accent),
              0 4px 12px -2px rgba(0, 0, 0, 0.15);
  border-radius: 0.25rem;
}

/* ── Fallback: the card following your cursor ──────────────────
   Elevated card with subtle scale for depth. Clean silhouette
   with hidden expand/tier-break content. */
.fr-sortable-fallback {
  box-shadow: 0 16px 32px -6px rgba(0, 0, 0, 0.30),
              0 8px 16px -4px rgba(0, 0, 0, 0.15),
              0 0 0 1px rgba(255, 255, 255, 0.06);
  border-radius: 0.375rem;
  opacity: 0.95;
  background-color: var(--surface);
}
.fr-sortable-fallback .drag-exclude {
  display: none !important;
}

/* (Expanded-row collapse during drag is now handled in
   drag_controller.js#onChoose by setting inline style directly on the
   handful of `.fr-row-expanded` elements, then restoring on drag-end.
   The previous descendant rule `.fr-editor-shell.fr-dragging .fr-row-expanded`
   triggered a full-tree style invalidation on every `.fr-dragging`
   toggle even though only 0-1 elements actually matched. Same root
   cause as the hover-kill cascade above — descendant selectors
   anchored at a toggled class are paid in full on toggle.) */

/* ── Drop landing: brief highlight when item settles ───────────
   Applied by JS after drop, removed after animation completes. */
.fr-drop-landed > .fr-row-content {
  animation: drop-land 400ms ease-out;
}
@keyframes drop-land {
  0%   { background-color: var(--accent-bg); box-shadow: inset 0 0 0 2px var(--accent); }
  100% { background-color: transparent; box-shadow: none; }
}

/* ── Composite list syncing lock: applied while async propagation is
   in flight. Removed implicitly when the container is replaced by the
   server's turbo_stream response (synchronous path) or by an
   ActionCable broadcast (async path). The watchdog refetch in
   composite_lock_controller is the recovery path for dropped streams.

   Visual treatment: dim the list + lay a slow diagonal stripe shimmer
   over it to communicate "working, don't touch yet". pointer-events
   are blocked on the inner content so accidental clicks/drags can't
   interleave with the in-flight server work. */
.fr-list-syncing {
  position: relative;
  opacity: 0.65;
  transition: opacity 120ms ease-out;
}
.fr-list-syncing > [data-controller~="drag"] {
  pointer-events: none;
}
.fr-list-syncing::after {
  content: '';
  position: absolute;
  inset: 0;
  pointer-events: none;
  background: repeating-linear-gradient(
    45deg,
    transparent 0 10px,
    var(--accent-bg) 10px 20px
  );
  background-size: 200% 200%;
  animation: fr-list-syncing-shimmer 1.4s linear infinite;
  border-radius: inherit;
  mix-blend-mode: multiply;
}
html[data-theme="dark"] .fr-list-syncing::after {
  mix-blend-mode: screen;
}
@keyframes fr-list-syncing-shimmer {
  from { background-position: 0 0; }
  to   { background-position: 28px 0; }
}
@media (prefers-reduced-motion: reduce) {
  .fr-list-syncing::after {
    animation: none;
    background: var(--accent-bg);
    opacity: 0.35;
  }
}

/* ── Synced move: flash on composite list nodes that moved as a
   result of a positional list reorder. Applied server-side via
   the Turbo Stream replace so the animation plays on render. */
.fr-synced-move {
  animation: synced-flash 600ms ease-out;
}
@keyframes synced-flash {
  0%   { background-color: var(--accent-bg); box-shadow: inset 3px 0 0 0 var(--accent); }
  100% { background-color: transparent; box-shadow: none; }
}

/* ── Cross-list highlight: same player in other lists ─────────
   When a player row is grabbed, matching rows in other columns
   get an accent tint so the ranker can see where that player
   sits across lists. */
.fr-cross-highlight > .fr-row-content {
  background-color: var(--accent-bg);
  box-shadow: inset 3px 0 0 0 var(--accent);
}

/* ── Composite row dividers: stronger contrast against surface-3 ─
   Default --border is near-invisible on composite column backgrounds,
   so bump to --border-hover for legible row separation. */
.bg-surface-3 .border-b {
  border-color: var(--border-hover);
}

/* ── Position row tints: faint background color by position ────
   Uses the same CSS variables as position badges so badge color
   and row tint always match across themes. */
[data-nfl-position="QB"]  > .fr-row-content { background-color: var(--pos-qb-tint); }
[data-nfl-position="RB"]  > .fr-row-content { background-color: var(--pos-rb-tint); }
[data-nfl-position="WR"]  > .fr-row-content { background-color: var(--pos-wr-tint); }
[data-nfl-position="TE"]  > .fr-row-content { background-color: var(--pos-te-tint); }
[data-nfl-position="K"]   > .fr-row-content { background-color: var(--pos-k-tint); }
[data-nfl-position="DEF"] > .fr-row-content { background-color: var(--pos-def-tint); }

/* ── Position number: smooth transition on renumber ────────── */
[data-position-number] {
  transition: opacity 100ms ease;
}
.fr-pos-updating {
  opacity: 0.4;
}

/* ── Prevent text selection during drag ─────────────────────── */
.drag-handle {
  user-select: none;
  -webkit-user-select: none;
}
.fr-sortable-fallback {
  user-select: none;
  -webkit-user-select: none;
}

/* ── Drag handle affordance ──────────────────────────────────── */
.drag-handle {
  transition: color 120ms ease;
}
.drag-handle:hover svg {
  color: var(--accent) !important;
  transform: scale(1.15);
  transition: transform 120ms ease, color 120ms ease;
}
.drag-handle:active svg {
  transform: scale(0.95);
}

/* ── Player spotlight: hover and pinned states ────────────────
   Hover gives ephemeral highlight; pinned is stronger for
   sustained cross-list tracking. Applied to row wrapper, so
   use > .fr-row-content to match existing fr-cross-highlight
   pattern and layer over position tints. */
.fr-spotlight-hover > .fr-row-content {
  background-color: var(--accent-bg);
  box-shadow: inset 3px 0 0 0 var(--accent);
}

.fr-spotlight-pinned > .fr-row-content {
  background-color: var(--accent-bg);
  box-shadow: inset 3px 0 0 0 var(--accent), 0 0 0 1px var(--accent);
}

/* Jump landing marker. Spotlight's pinned state says "this player, everywhere";
   this says "here, just now", and on a phone it is the only feedback a jump
   produces at all — spotlight is disabled there (see player_jump_controller). */
.fr-jump-flash > .fr-row-content {
  animation: fr-jump-flash 1.4s ease-out;
}
@keyframes fr-jump-flash {
  0%   { background: rgb(var(--accent-rgb) / .28); }
  100% { background: transparent; }
}
@media (prefers-reduced-motion: reduce) {
  .fr-jump-flash > .fr-row-content { animation: none; box-shadow: inset 0 0 0 2px rgb(var(--accent-rgb) / .5); }
}

/* ── Row-insert menu: anchor + destructive-preview tints ────────
   Two distinct, non-overlapping cues driven by row_insert_menu_controller:
     .fr-row-insert-anchor  — "the open menu belongs to this row." Added on
       open, removed on close. Neutral (not danger-coded): most menu items
       are non-destructive (Insert player/tier), so a red cue here would
       misdescribe them. Roughly the weight of the row's own hover state.
     .fr-row-insert-preview — "this row is inside the destructive item's
       reach." Added on hover/focus of Remove-player / Remove-and-below,
       from the exact element set _syncTrimItem's count is built from (see
       row_insert_menu_controller#_previewSet) — the highlight and the
       number cannot disagree because they read the same query. Danger-
       coded via rgb(var(--red-rgb) / alpha), the established local pattern
       (see .cmp-chip-dot--red below, and Tailwind's own .text-red) --
       NOT var(--red)/var(--red-bg): those DO resolve (fyf_theme, the
       vendored brand gem, defines them, loaded app-wide via
       stylesheet_link_tag "fyf_theme"), but they exist nowhere in this
       app's OWN stylesheets, so a rename/removal upstream would silently
       drop this rule to transparent with no local signal -- var() does not
       fall back to the previous cascade winner when its token is
       unresolvable, the property just computes to initial/inherited.
       --red-rgb is mirrored locally (design_tokens.css, both themes), so
       it can't go stale that way. Same order of magnitude as the
       --accent-bg tints above rather than a shout across 280 rows.
   Both key off > .fr-row-content, the child rows and board cards share
   (see the cross-highlight/spotlight tints above) — one rule reaches both
   surfaces. A tier-break row has no .fr-row-content wrapper of its own
   (see tier_break_node_component.html.erb), hence the direct fallback.
   Board cards additionally get a full ring (not just this left edge) —
   see the .fr-board-card overrides near .fr-board-card:hover below, which
   have to win a specificity TIE against that hover rule's own accent ring,
   so they live right after it rather than here. */
.fr-row-insert-anchor > .fr-row-content,
.fr-tier-break-row.fr-row-insert-anchor {
  background-color: var(--surface-3);
  box-shadow: inset 3px 0 0 0 var(--border-hover);
}

.fr-row-insert-preview > .fr-row-content {
  background-color: rgb(var(--red-rgb) / .16);
  box-shadow: inset 3px 0 0 0 rgb(var(--red-rgb));
}

/* .fr-row-insert-tier-preview — "a tier break will land HERE", shown while
   the menu's Tier break item is hovered or focused.

   A boundary marker, not a row treatment: the break is inserted ABOVE this
   row, so the line sits on the row's top edge and reads as the seam between
   two rows rather than as a property of either one. Full row width and
   solid, deliberately unlike the dashed, name-column-indented line a real
   tier break renders as -- a preview's job is to be unmissable mid-scan,
   and matching the final render exactly would make it easy to miss and easy
   to mistake for a break that already exists.

   Accent rather than red: this is the one constructive item in a menu whose
   other two previews are destructive, and the colour is what separates them
   at a glance.

   .fr-row already sets `position: relative` and `contain: layout paint
   style`; the marker is inset within the row's own box so containment does
   not clip it. */
.fr-row-insert-tier-preview::before {
  content: "";
  position: absolute;
  inset-inline: 0;
  top: 0;
  height: 2px;
  background: rgb(var(--accent-rgb));
  z-index: 2;
  pointer-events: none;
}

/* .fr-row-insert-pending — "the request for this row's removal is in
   flight." Applied at turbo:submit-start (row_insert_menu_controller
   #_applyPending) to the same element set the destructive-preview tint
   above targets, and left in place ALONGSIDE that tint (not swapped for
   it) until the Turbo Stream response removes the row or a failed submit
   clears it back off -- see #_onDestructiveSubmitEnd. The row keeps
   reading as "this is what's about to go" through the wait; it doesn't
   switch stories mid-flight.
   Deliberately NOT an animation. The Turbo Stream swap that lands when the
   request finishes is itself a large, synchronous DOM replace (a 160-
   player trim measures ~4.8MB) that blocks the main thread on its own --
   a CSS animation running on these rows would freeze mid-cycle for
   however long that swap takes and read as a hang, exactly the "it just
   sits there" complaint this feature exists to fix. transition/animation
   are explicitly none, belt-and-braces, in case a future rule elsewhere
   ever starts transitioning opacity on .fr-row / .fr-board-card.
   Reduced opacity (not a filter or a different background) survives the
   swap cleanly: it's cheap, needs no keyframe, and applies to the whole
   element rather than just a background layer -- which is why no separate
   .fr-board-card override was needed the way .fr-row-insert-anchor/
   -preview each need one above: opacity dims a board card's own ring
   (box-shadow) right along with its background and text, verified against
   a rendered card in both themes, so the same bare rule reads as legibly
   "inert" on a card as it does on a list row. */
.fr-row-insert-pending {
  opacity: 0.55;
  pointer-events: none;
  transition: none;
  animation: none;
}

/* ── ECR delta indicators ────────────────────────────────────
   Hidden by default. Visible when fr-show-ecr is active on an
   ancestor. Content and color are set by JS after toggle/drag. */
.fr-ecr-delta {
  display: none;
}
.fr-show-ecr .fr-ecr-delta {
  display: inline;
}

/* ── Snapshot delta indicators ───────────────────────────────
   Same pattern as ECR. Visible when fr-show-snapshot is active. */
.fr-snapshot-delta {
  display: none;
}
.fr-show-snapshot .fr-snapshot-delta {
  display: inline;
}

/* Board cards are narrow grid cells — let the vs-ECR / vs-snapshot pills size
   to their content instead of the fixed w-8 the toolbar applies for the roomier
   list row (two stacked pills would otherwise overrun the cell). */
.fr-board-card .fr-ecr-delta,
.fr-board-card .fr-snapshot-delta {
  width: auto;
  min-width: 0;
}

/* The per-row "snap this player to the consensus rank" button that sits
   immediately right of the vs-ECR pill. It reveals and hides with the pill —
   it is meaningless when the comparator is off — and when there is nothing to
   reset (no consensus rank, or the row already sits on it) it goes
   `invisible` rather than `hidden`, so the reserve stays a constant width down
   the column and the header's matching spacer keeps its labels over their
   values. */
.fr-ecr-reset {
  display: none;
}
.fr-show-ecr .fr-ecr-reset {
  display: inline-flex;
}

/* Tiny single-letter source label inside delta pills (E = ECR, S = Snapshot).
   Same color as delta value via currentColor, dimmed so number stays primary. */
.fr-delta-label {
  font-size: 7px;
  font-weight: 600;
  opacity: 0.55;
  margin-right: 1px;
  letter-spacing: 0.02em;
}

/* ── Scrollbars: thin, translucent, non-intrusive ──────────── */
::-webkit-scrollbar {
  width: 6px;
  height: 6px;
}
::-webkit-scrollbar-track {
  background: transparent;
}
::-webkit-scrollbar-thumb {
  background-color: rgba(0, 0, 0, 0.12);
  border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
  background-color: rgba(0, 0, 0, 0.2);
}
::-webkit-scrollbar-corner {
  background: transparent;
}
/* Firefox */
* {
  scrollbar-width: thin;
  scrollbar-color: rgba(0, 0, 0, 0.12) transparent;
}
html[data-theme="dark"] ::-webkit-scrollbar-thumb {
  background-color: rgba(255, 255, 255, 0.1);
}
html[data-theme="dark"] ::-webkit-scrollbar-thumb:hover {
  background-color: rgba(255, 255, 255, 0.2);
}
html[data-theme="dark"] * {
  scrollbar-color: rgba(255, 255, 255, 0.1) transparent;
}

/* ── Number input spinners: hide until hover ──────────────── */
input[type="number"]::-webkit-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button {
  opacity: 0;
  transition: opacity 150ms ease;
}
input[type="number"]:hover::-webkit-inner-spin-button,
input[type="number"]:hover::-webkit-outer-spin-button,
input[type="number"]:focus::-webkit-inner-spin-button,
input[type="number"]:focus::-webkit-outer-spin-button {
  opacity: 1;
}
/* Firefox: hide spinners entirely — value is editable via typing */
input[type="number"] {
  -moz-appearance: textfield;
}

/* ── Reduced motion ────────────────────────────────────────── */
@media (prefers-reduced-motion: reduce) {
  .fr-sortable-ghost::after,
  .fr-sortable-chosen,
  .fr-sortable-fallback,
  .fr-drop-landed > .fr-row-content,
  .fr-synced-move,
  .fr-cross-highlight > .fr-row-content,
  .fr-spotlight-hover > .fr-row-content,
  .fr-spotlight-pinned > .fr-row-content,
  .fr-row-insert-anchor > .fr-row-content,
  .fr-tier-break-row.fr-row-insert-anchor,
  .fr-board-card.fr-row-insert-anchor,
  .fr-row-insert-preview > .fr-row-content,
  .fr-board-card.fr-row-insert-preview,
  [data-position-number],
  .drag-handle,
  .drag-handle svg {
    transition: none !important;
    animation: none !important;
    transform: none !important;
  }
}

/* ═══════════════════════════════════════════════════════════════
   FlexRank Editor — Tag Dots + Popover
   ═══════════════════════════════════════════════════════════════ */

/* Cursor hint: the cluster carries a native title tooltip listing the tags.
   A styled CSS popover can't be used here — .fr-row sets `contain: layout
   paint style` (load-bearing for drag perf) which clips any absolute/fixed
   descendant painting outside the 28px row. The native title renders in the
   browser's top layer, immune to containment. */
.fr-tag-dots { cursor: help; }

/* ═══════════════════════════════════════════════════════════════
   FlexRank Editor — Preset Tab Switching
   ═══════════════════════════════════════════════════════════════ */

/* Columns revealed by a preset change get a brief enter animation
   so the swap reads as a deliberate transition, not a reload. */
@keyframes fr-column-enter {
  from { opacity: 0; transform: translateY(4px); }
  to   { opacity: 1; transform: translateY(0); }
}

.fr-column-enter {
  animation: fr-column-enter 160ms ease-out;
}

@media (prefers-reduced-motion: reduce) {
  .fr-column-enter {
    animation: none;
  }
}

/* ═══════════════════════════════════════════════════════════════
   FlexRank Editor — Row Insert Menu
   ═══════════════════════════════════════════════════════════════ */

/* Popover shell — layered shadow gives lift without overpowering the
   surrounding editor. */
.fr-row-insert-menu {
  box-shadow:
    0 1px 2px rgba(0, 0, 0, 0.25),
    0 8px 24px rgba(0, 0, 0, 0.45);
}

/* Icon column — fixed width so labels line up across items even when
   glyph shapes differ (plus vs dashed-line). Lights up to the accent
   color on parent menuitem hover/focus. */
.fr-row-insert-glyph {
  flex: none;
  width: 15px;
  height: 15px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  color: var(--color-text-muted, #8a92a0);
  opacity: 0.85;
  transition: color 120ms ease, opacity 120ms ease;
}

.fr-row-insert-item:hover .fr-row-insert-glyph,
.fr-row-insert-item:focus .fr-row-insert-glyph {
  color: rgb(var(--accent-rgb));
  opacity: 1;
}

/* Keyboard shortcut chip. Top inset highlight + slight bottom shadow +
   thicker bottom border give a faux-3D "physical key" affordance, so P / T
   read as keystrokes, not as labels. */
.fr-row-insert-kbd {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 17px;
  height: 17px;
  padding: 0 4px;
  background:
    linear-gradient(to bottom, rgba(255, 255, 255, 0.05), rgba(0, 0, 0, 0.12)),
    var(--color-surface-2, #1f232c);
  border: 1px solid var(--color-border, #353a45);
  border-bottom-width: 2px;
  border-radius: 5px;
  font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
  font-size: 11px;
  font-weight: 600;
  color: var(--color-text-muted, #c0c6d1);
  line-height: 1;
  flex: none;
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.04),
    0 1px 0 rgba(0, 0, 0, 0.25);
  transition: color 120ms ease, border-color 120ms ease;
}

.fr-row-insert-item:hover .fr-row-insert-kbd,
.fr-row-insert-item:focus .fr-row-insert-kbd {
  border-color: rgb(var(--accent-rgb));
  color: var(--text);
}

/* Remove-player / trim items — destructive, so they get the danger color
   instead of the menu's default text color. */
.fr-row-insert-item--danger { color: rgb(var(--red-rgb)); }
.fr-row-insert-count { font-size: 11px; font-variant-numeric: tabular-nums; }

/* ── Comparison tray ─────────────────────────────────────────────── */
/* Right-docked side panel at lg+ (a flex-row sibling of the rankings scroller
   in .fr-editor-main, so rankings keep their own width + horizontal scroll).
   Below lg it detaches to a full-screen overlay. Hidden entirely when empty. */
.cmp-tray-wrap {
  /* --cmp-tray-w is set by cmp_resize_controller as a drag/keyboard-nudged
     width. It's a custom property, not a direct flex/flex-basis write, so
     every state override below (min/float/max/mobile-docked) -- none of
     which reference the var -- still wins outright via ordinary cascade
     specificity. See cmp_resize_controller.js for why that distinction
     matters (an inline flex-basis would otherwise leak into `min`). */
  flex: 0 0 var(--cmp-tray-w, clamp(360px, 32vw, 520px));
  display: flex;
  position: relative; /* containing block for .cmp-grip (position:absolute) */
  min-height: 0;
  border-left: 1px solid var(--border);
  /* Frosted matte slab: a translucent elevated surface lifts the panel off the
     rankings beside it (docked) and frosts the columns behind it (max overlay).
     backdrop-filter only resolves over content that's actually behind the box,
     so it bites in the overlay state; docked still reads as a distinct matte
     tint over the shell. */
  background: rgb(var(--surface-2-rgb) / .62);
  backdrop-filter: blur(16px) saturate(1.2);
  -webkit-backdrop-filter: blur(16px) saturate(1.2);
  box-shadow: -10px 0 28px rgb(var(--bg-rgb) / .35);
  z-index: 45;
}
[data-compare-state="empty"] .cmp-tray-wrap { display: none; }

/* Popped out to a separate window: hide the in-editor panel and surface a pill
   to bring it back. The frame stays loaded (just hidden) so bring-back is
   instant. */
[data-compare-state="popped"] .cmp-tray-wrap { display: none; }
.cmp-popped-pill { display: none; }
[data-compare-state="popped"] .cmp-popped-pill {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  position: absolute;
  bottom: 16px;
  right: 16px;
  z-index: 46;
  padding: 8px 14px;
  font-size: 12px;
  font-weight: 600;
  color: var(--accent);
  background: rgb(var(--surface-2-rgb) / .92);
  border: 1px solid var(--border);
  border-radius: 999px;
  box-shadow: 0 6px 18px rgb(var(--bg-rgb) / .4);
  cursor: pointer;
}
/* Below lg, `docked` already fills the screen identically to `max` (both
   position:absolute; inset:0 -- see the mobile override further down), so
   the head-bar full-screen toggle would be a visible no-op there. Float is a
   fixed 380x520 box with `resize:both`, impractical to grab with a finger;
   New window needs pointer precision too. Slim to rail and Dock stay --
   minimize is the only way to collapse the full-screen panel without
   clearing the comparison, and there is no Esc on a phone. (A prior pass
   deleted this block believing it only relabeled the now-removed "slim »"
   text button; that relabel sub-rule really was dead, but this hiding logic
   was not -- restored.) */
/* Floating messenger-style box (in-page). Fixed, draggable by its header,
   resizable, collapsible. Default bottom-right; position persisted by
   cmp_float_controller (inline style overrides these defaults once moved). */
[data-compare-state="float"] .cmp-tray-wrap {
  position: fixed;
  bottom: 16px;
  right: 16px;
  top: auto;
  left: auto;
  flex: 0 0 auto;
  width: 380px;
  height: 520px;
  min-width: 300px;
  min-height: 120px;
  border: 1px solid var(--border);
  border-radius: 12px;
  overflow: hidden;
  resize: both;
  box-shadow: 0 18px 48px rgb(var(--bg-rgb) / .55);
  z-index: 60;
}
[data-compare-state="float"] .cmp-tray-head { cursor: move; }
/* Collapsed: only the header bar shows. */
[data-compare-state="float"].cmp-float-collapsed .cmp-tray-wrap {
  height: auto !important;
  resize: none;
}
[data-compare-state="float"].cmp-float-collapsed .cmp-tray-scroll,
[data-compare-state="float"].cmp-float-collapsed .cmp-take { display: none; }
/* Dock only makes sense once you have left the dock. */
.cmp-menu button[data-action="compare-tray#dock"] { display: none; }
[data-compare-state="float"] .cmp-menu button[data-action="compare-tray#dock"],
[data-compare-state="min"] .cmp-menu button[data-action="compare-tray#dock"] { display: flex; }

/* In the popped-out window the panel is a focused compare surface — the editor
   shell affordances (dock/float/window/maximize) don't apply, so hide the
   whole header action cluster there. Per-player ✕, season/scoring pills, and
   click-to-sort remain (they live in the table body, not .cmp-acts). */
.cmp-popout-body .cmp-acts { display: none; }
/* Clear lives in .cmp-idstrip (identity strip), not .cmp-acts, so the rule
   above no longer catches it. It needs its own hide here: the popout page
   only mounts compare_popout_controller, which has no `clear` action, so
   Clear's single data-action="compare-tray#clear" (unlike the per-player ✕,
   which dual-binds compare-tray#remove + compare-popout#remove) would be a
   silent no-op button if left visible there. */
.cmp-popout-body .cmp-clear { display: none; }
/* The popped-out window had no bounded height, so .cmp-tray-scroll never became
   a scroll container and the whole document scrolled instead — which also made
   the sticky identity strip a no-op there. Bounding the root hands scrolling
   back to the panel, matching the dock. Height goes on the root rather than
   turning <body> into a flex column, which would relayout the toast container. */
.cmp-popout-body { margin: 0; }
.cmp-popout-root { display: flex; flex-direction: column; min-height: 0;
  height: 100vh; height: 100dvh; }

/* Minimized: slim the docked panel to a thin right-edge rail that stays out of
   the way; the rail click restores it (dock()). A desktop docked affordance —
   hidden < lg where the panel is already a full-screen overlay (nothing to
   slim). The wrap keeps its border + frosted bg, so the rail reads as a
   deliberate strip rather than a stub. */
[data-compare-state="min"] .cmp-tray-wrap { flex: 0 0 40px; }
[data-compare-state="min"] .cmp-tray-head,
[data-compare-state="min"] .cmp-tray-scroll,
[data-compare-state="min"] .cmp-take { display: none; }

.cmp-rail { display: none; }
[data-compare-state="min"] .cmp-rail {
  display: flex;
  flex: 1 1 auto;
  flex-direction: column;
  align-items: center;
  gap: 10px;
  width: 100%;
  padding: 12px 0;
  background: none;
  border: 0;
  cursor: pointer;
}
.cmp-rail-ico { font-size: 16px; line-height: 1; color: var(--accent); }
.cmp-rail-ttl {
  writing-mode: vertical-rl;
  transform: rotate(180deg);
  font-size: 11px;
  font-weight: 700;
  letter-spacing: .08em;
  text-transform: uppercase;
  color: var(--text-muted);
  transition: color 120ms ease;
}
.cmp-rail:hover .cmp-rail-ttl,
.cmp-rail:focus-visible .cmp-rail-ttl { color: var(--accent); }

/* The slim/minimize trigger has no meaning in the float box (it is not docked
   to an edge to slim toward) or popped out (that is a separate window), so it
   is hidden there. In `min` the whole head is hidden anyway; in `empty` the
   whole wrap is.

   `max` used to be on this list and no longer is. Full screen is exactly where
   "get this out of my way" is worth one click: the only other way out was the
   ⤢ toggle, which returns the panel to whatever size it came from rather than
   putting it away. Minimize takes it straight to the rail. This also retires a
   mobile-only rule that existed solely to undo this one in `max`. */
[data-compare-state="float"] .cmp-acts button[data-action="compare-tray#minimize"],
[data-compare-state="popped"] .cmp-acts button[data-action="compare-tray#minimize"] { display: none; }

/* Full-screen overlay deep-dive (desktop "maximize", and the only open mode on
   mobile). Covers the editor shell; sits above app chrome inside the shell. */
[data-compare-state="max"] .cmp-tray-wrap {
  position: absolute;
  inset: 0;
  flex-basis: auto;
  border-left: 0;
  z-index: 60;
  box-shadow: 0 0 0 100vmax rgba(0,0,0,.25);
}

/* The dock wrapper nests its content under a <turbo-frame id="comparison-tray">.
   A turbo-frame is an inline custom element with no layout box, so without this
   it can't carry the wrapper's bounded height down to .cmp-tray — .cmp-tray then
   grows to content height and .cmp-tray-scroll never scrolls (content spills
   past the viewport). Make the frame a height-filling flex column so the bounded
   height propagates and the inner scroll region works. */
turbo-frame#comparison-tray { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; min-width: 0; }
.cmp-tray { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; width: 100%; }
.cmp-tray-scroll { flex: 1 1 auto; min-height: 0; overflow: auto; }
.cmp-tray-head { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; border-bottom: 1px solid var(--border); flex: none; }
.cmp-ttl { font-size: 11px; letter-spacing: .08em; text-transform: uppercase; color: var(--text-dim); font-weight: 700; }
.cmp-acts { display:flex; gap:2px; position:relative; }
.cmp-acts button { background:none; border:0; color:var(--text-dim); cursor:pointer;
  width:26px; height:26px; border-radius:6px; display:grid; place-items:center;
  font-size:13px; line-height:1; margin:0;
  transition:background-color 120ms ease, color 120ms ease; }
.cmp-acts button:hover { background:rgb(var(--text-rgb) / .07); color:var(--text); }
.cmp-acts button:focus-visible { outline:2px solid var(--accent); outline-offset:1px; }

.cmp-menu { position:absolute; top:30px; right:0; z-index:20; min-width:158px; padding:4px;
  background:var(--surface-2); border:1px solid var(--border-hover); border-radius:8px;
  box-shadow:0 12px 30px rgb(var(--bg-rgb) / .7); }
.cmp-menu[hidden] { display:none; }
.cmp-menu button { display:flex; align-items:center; gap:9px; width:100%; height:auto;
  padding:7px 9px; font-size:11.5px; color:var(--text-muted); border-radius:5px;
  justify-content:flex-start; font-family:inherit; }
.cmp-menu i { font-style:normal; width:14px; text-align:center; opacity:.75; }
/* Breakpoint-swapped menu copy. display:contents rather than inline so the
   glyph and the label stay flex items of the button itself and keep its gap —
   wrapping them in a real box would collapse the two into one item and lose it. */
.cmp-lg-only { display:contents; }
.cmp-sm-only { display:none; }

/* 26px under a mouse, 44px under a finger — the chrome-wide touch floor, same
   mechanism as .fr-strip-menu-btn and .fr-puck-btn. `pointer: coarse`, not a
   width breakpoint: an iPad in landscape is exactly 1024px and would otherwise
   keep the pointer-precise sizes it has no precision for.
   Four things move together or the header breaks:
   - the buttons themselves;
   - .cmp-tray-head's vertical padding, which would push a 44px cluster into a
     60px bar — on mobile this head sits atop a full-screen panel, and 60px of
     it is chrome the comparison isn't using. 4px lands the bar at 52px, in the
     range a touch nav bar normally occupies, and the title stays centred
     because the head is align-items:center, not padding-positioned;
   - .cmp-menu's `top`, which is measured off the button row it hangs under and
     would otherwise open 18px INSIDE the row that spawned it;
   - the menu's own rows, which are ~30px — a 44px button opening a menu you
     still can't hit is half a fix. .fr-strip-menu-btn/.fr-strip-menu-row set
     the same precedent in the editor strip. */
@media (pointer: coarse) {
  .cmp-tray-head { padding: 4px 12px; }
  .cmp-acts button { width: 44px; height: 44px; font-size: 15px; }
  .cmp-menu { top: 48px; }
  .cmp-menu button { min-height: 44px; }
}

/* The panel's left border, as a drag handle. Docked only -- float resizes via
   the CSS `resize` property, and min/max/popped have no user-set width.
   Desktop (lg+) only: below 1024px `docked` IS the mobile full-screen
   overlay (see the @media max-width:1023px docked override further down),
   where there's no board beside the panel to reflow and a touch on this 5px
   edge strip would only preventDefault() a touch that's likely an iOS
   back-swipe gesture and persist a bogus width for no visible effect. */
.cmp-grip { display:none; }
@media (min-width: 1024px) {
  /* 11px of hit area for a 2px visual. A drag only needs the edge, but the
     grip also takes a click to slim, and a click wants a target you can hit
     without aiming. The bar stays 2px so the panel edge reads as an edge. */
  [data-compare-state="docked"] .cmp-grip {
    display:flex; align-items:center; justify-content:center;
    position:absolute; left:0; top:0; bottom:0; width:11px; z-index:5;
    cursor:col-resize; }
}
.cmp-grip::before { content:""; width:2px; height:26px; border-radius:2px;
  background:rgb(var(--text-rgb) / .16); transition:background-color 120ms ease, height 120ms ease; }
/* Grows on approach: the taller bar is the affordance that says this edge is
   grabbable, without spending any resting-state ink to say it. */
.cmp-grip:hover::before, .cmp-grip:focus-visible::before {
  background:rgb(var(--text-rgb) / .38); height:40px; }
.cmp-grip:focus-visible { outline:2px solid var(--accent); outline-offset:-2px; }

/* Clear rides at the end of the identity strip -- a roster action beside the
   per-player ✕, not a window control. */
.cmp-clear { margin-left:auto; background:none; border:0; color:var(--text-dim);
  font:inherit; font-size:10.5px; cursor:pointer; padding:3px 7px; border-radius:5px;
  transition:background-color 120ms ease, color 120ms ease; }
.cmp-clear:hover { background:rgb(var(--text-rgb) / .07); color:var(--text); }
.cmp-clear:focus-visible { outline:2px solid var(--accent); outline-offset:1px; }
@media (prefers-reduced-motion: reduce) {
  .cmp-acts button, .cmp-grip::before, .cmp-clear { transition:none; }
}

/* Below lg there's no room for a side dock. `docked` fills the editor (and
   `max` already does at all widths). `min` is the exception: it must NOT cover
   the editor — it collapses to a slim full-width bottom bar so the ranking list
   stays visible and tappable above it. `float`/`popped` keep their own
   positioning. Scoping by state matters: a blanket `.cmp-tray-wrap { inset:0 }`
   also caught `min`, leaving the minimized rail floating over a blanked editor. */
/* ── Comparison tray — below lg ────────────────────────────────────────────
   ONE mobile block, deliberately. This file used to carry two separate
   max-width:1023px blocks with ~200 lines of base rules between them, and
   three bugs came from writing an override into the earlier one, where it
   lost on source order to a base rule declared in between. A media query adds
   no specificity, so position is the only thing deciding. Every below-lg rule
   belongs here, after the base rules it overrides. */
@media (max-width: 1023px) {
  /* ── controls ─────────────────────────────────────────────────────────── */
  /* Pop-outs are desktop affordances — float and new-window are impractical on
     a phone. The full-screen toggle goes too: `docked` already fills the screen
     here, so it would be a visible no-op. */
  .cmp-acts button[data-action="compare-tray#expand"] { display: none; }
  .cmp-menu button[data-action="compare-tray#popFloat"],
  .cmp-menu button[data-action="compare-tray#popWindow"] { display: none; }

  /* The `max` escape hatch that used to live here is gone, and so is the reason
     for it: minimize is no longer stripped from `max` at any width, so there is
     nothing left to undo. It matters most on a phone — the full-screen toggle
     is hidden just above, there is no Esc key, and `max` is still reachable by
     rotating a tablet out of landscape — but it is the same control now, not a
     mobile special case. */

  /* The panel collapses DOWN to a footer bar here, not sideways to a rail, so
     the control reads "— Minimize" rather than "» Slim to rail". */
  .cmp-lg-only { display: none; }
  .cmp-sm-only { display: contents; }

  /* ── layout ───────────────────────────────────────────────────────────── */
  [data-compare-state="docked"] .cmp-tray-wrap {
    position: absolute;
    inset: 0;
    flex-basis: auto;
    border-left: 0;
    z-index: 60;
  }

  /* Minimized → slim bottom bar, not a full-screen overlay. */
  [data-compare-state="min"] .cmp-tray-wrap {
    position: absolute;
    inset: auto 0 0 0;
    height: auto;
    flex: none;
    border-left: 0;
    border-top: 1px solid var(--border);
    z-index: 60;
  }
  [data-compare-state="min"] .cmp-rail {
    flex-direction: row;
    justify-content: center;
    gap: 8px;
    width: 100%;
    padding: 12px 16px;
  }
  [data-compare-state="min"] .cmp-rail-ttl {
    writing-mode: horizontal-tb;
    transform: none;
  }
  [data-compare-state="min"] .cmp-rail-ico {
    transform: rotate(90deg); /* ‹ → up chevron: tap to expand upward */
  }
}
/* Full-width, fixed layout so every stat/rank row's dot-plot track spans the
   whole tray. --cmp-w is no longer set (identity moved out of these tables
   into the .cmp-idstrip above), so max-width falls back to 100%; the var stays
   as a hook in case a future width constraint is reintroduced. */
table.cmp-t { width: 100%; max-width: var(--cmp-w, 100%); margin-inline: auto; table-layout: fixed; border-collapse: collapse; font-size: 11px; }
/* The lazy stats frame spans the full scroll width so its inner table centres
   on the same axis as the ranks sibling table below it. It must keep a layout
   BOX (display:block, NOT contents): a display:contents frame has no box, so
   Turbo's loading="lazy" IntersectionObserver never fires and the stats
   never load. The ranks frame needs display:block for a different reason:
   <turbo-frame> is an inline custom element by default, so without it the
   ranks table renders inline and escapes the fixed-layout grid. */
turbo-frame#comparison-stats,
turbo-frame#comparison-ranks { display: block; width: 100%; }
/* While a frame is fetching its update, Turbo sets [busy]; dim it briefly so a
   targeted reload reads as "updating" instead of a hard content pop. */
turbo-frame#comparison-stats,
turbo-frame#comparison-ranks { transition: opacity 120ms ease-out; }
turbo-frame#comparison-stats[busy],
turbo-frame#comparison-ranks[busy] { opacity: .55; }
@media (prefers-reduced-motion: reduce) {
  turbo-frame#comparison-stats,
  turbo-frame#comparison-ranks { transition: none; }
}
/* Tile look: a vertical rule off the label gutter so the matrix reads as a
   side-by-side control panel rather than an airy table. The label gutter
   (first col) stays flat. */
.cmp-t td:not(.cmp-rowlbl):not([colspan]) { border-left: 1px solid var(--border); }
.cmp-t th, .cmp-t td { padding: 4px 6px; text-align: center; border-bottom: 1px solid var(--border); white-space: nowrap; }
.cmp-t thead th { text-align: center; background: var(--surface-2); position: sticky; top: 0; }
/* Recedes until the row is hovered/focused — on a compact single line the ✕ was
   competing with the name it sits beside. */
.cmp-pl-x { background: none; border: 0; color: var(--text-dim); cursor: pointer;
  font-size: 9px; line-height: 1; padding: 2px; opacity: .45; transition: opacity .12s ease; }
.cmp-id:hover .cmp-pl-x, .cmp-pl-x:focus-visible { opacity: 1; }
@media (prefers-reduced-motion: reduce) { .cmp-pl-x { transition: none; } }
/* Group headers are separators, not signals -- neutral ink, no accent. */
.cmp-grp td { background:var(--surface-2); padding:0; border-bottom:1px solid var(--border); }
.cmp-grp-btn { display:flex; align-items:center; gap:7px; width:100%;
  background:none; border:0; cursor:pointer; font:inherit; text-align:left;
  padding:7px 14px; font-size:10px; letter-spacing:.07em; text-transform:uppercase;
  font-weight:700; color:var(--text-dim); transition:color 120ms ease; }
.cmp-grp-btn:hover { color:var(--text-muted); }
.cmp-grp-btn:focus-visible { outline:2px solid var(--accent); outline-offset:-2px; }
.cmp-grp-chev { font-size:8px; display:inline-block; transition:transform 120ms ease; }
[data-cmp-group][aria-expanded="true"] .cmp-grp-chev { transform:rotate(90deg); }
/* Hide the group's data rows, never its own header row. */
[data-cmp-group][aria-expanded="false"] > tr:not(.cmp-grp) { display:none; }

/* The player profile drawer reuses the group header above, but its groups are
   <section>s rather than <tbody>s, so the collapse targets a body wrapper
   instead of rows. Same button, same chevron, same persisted state -- only the
   thing being hidden differs. */
[data-cmp-group][aria-expanded="false"] > .fr-sec-body { display:none; }
/* The drawer's own 20px gutter, so the caret lines up with the content under
   it. Everything else is inherited from .cmp-grp-btn. */
.fr-sec-grp-btn { padding:8px 20px; background:var(--surface-2);
  border-bottom:1px solid var(--border-subtle); }
.fr-sec-body { padding:12px 20px 16px; }
@media (prefers-reduced-motion: reduce) {
  .cmp-grp-btn, .cmp-grp-chev { transition:none; }
}
/* Cross-column tracking: tint the whole row on hover so the eye can follow a
   single stat/rank across all player columns. Group-header rows are excluded. */
.cmp-t tbody tr:not(.cmp-grp):hover { background: rgb(var(--text-rgb) / .045); }
.cmp-lazy { padding: 0; }
.cmp-spinner { padding: 10px; text-align: center; color: var(--text-dim); font-size: 11px; }
.cmp-row-btn.is-comparing { opacity: 1; color: var(--accent); }
.cmp-row-btn:focus-visible { outline: 1px solid var(--accent); outline-offset: 1px; }

/* ── Comparison tray v2 — typed cells ── */
.cmp-legend-row td { padding: 0; background: transparent; }
.cmp-legend { display: flex; align-items: center; gap: 7px; padding: 5px 12px;
  color: var(--text-dim, #6f6889); font-size: 10px; line-height: 1.3; }
.cmp-legend-tick { flex: none; }
.cmp-legend-tick { width: 26px; height: 7px; border-radius: 4px; background: var(--surface-2, #352e4d);
  position: relative; }
.cmp-legend-tick::after { content: ""; position: absolute; left: 50%; top: -1px; bottom: -1px;
  width: 2px; background: var(--cmp-tick, #cfc7e6); opacity: .7; }
.cmp-delta { font-size: 9px; font-weight: 700; padding: 1px 6px; border-radius: 9px; background: var(--surface-2); color: var(--text-dim); }
.cmp-delta--up { background: rgba(43,163,69,.14); color: #8fc7a3; }
.cmp-delta--down { background: rgba(239,83,80,.14); color: #d89a98; }
.cmp-pips { display: inline-flex; gap: 2px; }
.cmp-pips i { width: 7px; height: 10px; border-radius: 1px; background: var(--surface-2); display: inline-block; }
.cmp-pips i.on { background: rgb(var(--text-muted-rgb) / .45); }
.cmp-pips.is-best i.on { background: rgb(var(--green-rgb) / .6); }
.cmp-tier { font-size: 10px; font-weight: 700; padding: 2px 8px; border-radius: 4px; background: var(--surface-2); color: var(--text-muted); }
.cmp-tier--0 { background: rgba(167,139,250,.18); color: #c4b5fd; }
.cmp-tier--1 { background: rgba(96,165,250,.16); color: #93c5fd; }
.cmp-num { font-variant-numeric: tabular-nums; }
.cmp-num.is-best { color: #8fc7a3; font-weight: 700; }
/* Tag chips in the compact strip. The td is nowrap + centered, so the chip row
   is its own flex container to let a multi-tag cell wrap instead of running
   under its neighbour. Colour lives on the dot only; the label stays neutral. */
.cmp-chips { display: flex; flex-wrap: wrap; justify-content: center; gap: 3px; }
.cmp-chip { display: inline-flex; align-items: center; gap: 4px; max-width: 100%;
  background: var(--surface-2); border: 1px solid rgb(var(--text-rgb) / .12); border-radius: 4px;
  padding: 1px 6px; font-size: 9px; font-weight: 600; color: var(--text-muted); }
.cmp-chip-dot { flex: none; width: 5px; height: 5px; border-radius: 50%; }
.cmp-chip-dot--red { background: rgb(var(--red-rgb)); }
.cmp-chip-dot--orange { background: rgb(var(--orange-rgb)); }
.cmp-chip-dot--yellow { background: rgb(var(--yellow-rgb)); }
.cmp-chip-dot--green { background: rgb(var(--green-rgb)); }
.cmp-chip-dot--blue { background: rgb(var(--blue-rgb)); }
.cmp-chip-dot--purple { background: rgb(var(--purple-rgb)); }
.cmp-chip-dot--pink { background: rgb(var(--pink-rgb)); }
.cmp-chip-dot--gray { background: rgb(var(--text-rgb) / .35); }
.cmp-take { padding: 14px 16px 18px; border-top: 1px solid var(--border); }
.cmp-take-hd { font-size: 10px; letter-spacing: .12em; text-transform: uppercase; color: var(--text-dim); font-weight: 700; margin-bottom: 12px;
  cursor: pointer; list-style: none; display: flex; align-items: center; gap: 6px; }
.cmp-take-hd::-webkit-details-marker { display: none; }
.cmp-take-hd::before { content: "\25B8"; font-size: 9px; color: var(--text-dim); transition: transform .12s ease; }
details.cmp-take[open] > .cmp-take-hd::before { transform: rotate(90deg); }
details.cmp-take:not([open]) > .cmp-take-hd { margin-bottom: 0; }
.cmp-take-cols { display: grid; gap: 18px; justify-content: center; }
.cmp-take-gutter { /* aligns blurb columns under the table's player columns */ }
.cmp-take-col { border-left: 2px solid var(--border); padding-left: 12px; }
.cmp-take-col.is-leader { border-left-color: rgb(var(--green-rgb) / .65); }
.cmp-take-col h4 { margin: 0 0 1px; font-size: 13px; color: var(--text); font-weight: 700; }
.cmp-take-meta { font-size: 9px; color: var(--text-dim); margin-bottom: 8px; }

.cmp-rail, .cmp-rail.is-empty { flex: none; }
.cmp-take-col p { margin: 0; font-family: Georgia, 'Times New Roman', serif; font-size: 13.5px; line-height: 1.62; color: var(--text-muted); }

/* ── Comparison tray — season/scoring controls header ── */
.cmp-controls { display: inline-flex; gap: 8px; flex-wrap: nowrap; justify-content: flex-end; }
/* Segmented radio-pill group — replaces the native <select>s. Each set is a
   bordered track; the checked option fills with the accent. The native radio
   is visually hidden but stays the focus/keyboard target. */
.cmp-pillset { display: inline-flex; gap: 2px; background: var(--surface); border: 1px solid var(--border); border-radius: 6px; padding: 2px; }
.cmp-pill { position: relative; display: inline-flex; margin: 0; }
.cmp-pill input { position: absolute; inset: 0; margin: 0; opacity: 0; cursor: pointer; }
.cmp-pill span { display: block; padding: 3px 7px; border-radius: 4px; font-size: 10px; line-height: 1; color: var(--text-dim); white-space: nowrap; cursor: pointer; transition: color 100ms ease, background 100ms ease; }
.cmp-pill:hover span { color: var(--text-muted); }
.cmp-pill input:checked + span { background: var(--surface-3); color: var(--text); font-weight: 600;
  box-shadow: inset 0 0 0 1px rgb(var(--text-rgb) / .14); }
.cmp-pill input:focus-visible + span { outline: 2px solid var(--accent); outline-offset: 1px; }

.cmp-undo-btn { margin-left: 10px; background: none; border: 0; color: var(--accent); font-weight: 700; cursor: pointer; }

/* ── Comparison tray — mobile layout ── */


/* ── Comparison tray — identity strip ── */
.cmp-controls-bar { display:flex; align-items:center; gap:10px; flex-wrap:wrap; padding:11px 16px; border-bottom:1px solid var(--border); }
/* Who is being compared has to stay answerable at any scroll depth — the rows
   below are unlabelled by design (identity lives on the marks), so scrolling
   past this strip left the panel anonymous. Sticks to the top of
   .cmp-tray-scroll, which is the scroll container; needs an opaque ground of its
   own or the rows read through it. */
/* The strip is sticky, so every line it spends is a line the rows below never
   get back. Chips pack several per row rather than one player per line: at four
   players that was ~115px of header before any data. `align-items:center` (not
   baseline) lets chips on the same row sit level even when one name wraps. */
.cmp-idstrip { position:sticky; top:0; z-index:4;
  display:flex; align-items:center; gap:3px 8px; flex-wrap:wrap; padding:5px 12px;
  background:var(--surface); border-bottom:1px solid var(--border);
  /* Rows pass underneath, so the strip needs to read as the nearer layer. */
  box-shadow:0 6px 10px -8px rgb(0 0 0 / .6); }
/* flex:0 1 auto — chips size to their name and wrap as a unit. Never a
   percentage basis: that would force a long name to wrap mid-chip, and a
   player's name is the one thing in this panel that must stay whole. */
.cmp-id { display:flex; align-items:baseline; gap:5px; border-radius:6px;
  flex:0 1 auto; padding:2px 5px; margin:0 -1px; transition:background .12s ease; }
/* The name is the spotlight toggle. Styled as the surrounding text rather than
   as a control — it only needs to look pressable on hover/focus. */
.cmp-id-pin { font:inherit; font-size:12px; font-weight:700; color:var(--text);
  background:none; border:0; padding:0; cursor:pointer; border-radius:4px;
  white-space:nowrap; }
.cmp-id:hover { background:rgb(var(--text-rgb) / .05); }
/* The strip is a paint target too (it carries data-cmp-player), so it has to
   answer the spotlight. Held well above the marks' .28 — this is the reference
   for who is in the comparison, and it stays readable at any dim. */
.cmp-id.is-dim { opacity:.5; }
.cmp-id-pin:focus-visible { outline:2px solid var(--accent); outline-offset:3px; }
/* Pinned reads as held-down, so it's clear the spotlight will survive the
   pointer leaving — otherwise a pinned panel looks like a stuck hover. */
.cmp-id:has(.cmp-id-pin[aria-pressed="true"]) { background:rgb(var(--text-rgb) / .10); }
.cmp-id-pin[aria-pressed="true"] { color:var(--accent); }
@media (prefers-reduced-motion: reduce) { .cmp-id { transition:none; } }
/* Position only. Team and age live in this element's title — they are reference
   detail, and inline they roughly doubled each chip's width, which is what
   forced one player per line. */
.cmp-id-sub { font-size:9.5px; color:var(--text-dim); white-space:nowrap;
  letter-spacing:.04em; cursor:help; }



/* ═══════════════════════════════════════════════════════════════
   FlexRank Editor — Draft Board View
   ═══════════════════════════════════════════════════════════════ */

/* .fr-board-wrap is deliberately NOT a scroll container and so has no rule of
   its own. Both axes belong to the shared scroller in board.html.erb
   (`overflow-y-auto overflow-x-auto`), which is also the one the shelves layout
   scrolls in — so the two card layouts have ONE scroll model instead of two.

   This element used to carry `overflow-y:auto; overflow-x:clip`, and the clip
   was the bug: .fr-board-grid's tracks floor at minmax(--fr-board-min-card,1fr),
   so the grid's min width is cols*120px + gaps — 1550px at the default 12 cols,
   which needs a ~1750px viewport once the sidebar, list rail and padding are
   paid for. No standard laptop clears that (13" 1440 / 14" 1512 / 16" 1728), so
   on every one of them the rightmost columns overflowed this box and the clip
   DESTROYED them: no scrollbar, no pan, cards sliced mid-render. Shelves never
   had a wrapper in the way and scrolled correctly the whole time — same grid,
   same width, same parent — which is what made the divergence obvious.

   The clip was guarding something real: a tier rail nudged into the outer
   gutter is transformed up to `50% of 14px + gap/2` = 12px past the grid's
   edge, and that would inflate scroll width when the board otherwise fits. It
   doesn't need clipping to solve. A LEFT overhang is negative overflow, which
   is never scrollable in LTR, and a RIGHT one lands inside the scroller's own
   px-3 (12px) padding. Measured at 4/6/8/12 cols with left-in-first-column and
   right-in-last-column rails injected: zero spurious scroll in every case.

   Drag edge-autoscroll is unaffected: board_drag's _scrollParent walks for the
   nearest ancestor that actually scrolls rather than naming this element, so it
   simply resolves one level further up to the shared scroller.

   The class itself still matters — it is the turbo_stream replace target
   (dom_id(list, :board)) and board_drag resolves the board id through
   closest(".fr-board-wrap") — it just carries no styling. */

/* Serpentine flow spine. board_drag traces the pick order through card centres
   into the <path>; the SVG is the grid's first child with no z-index, so tree
   order paints it beneath the position:relative cards. The line is hidden under
   each card and only surfaces in the gutter slivers between them, so it reads as
   subtle lanes threading the board — the flow cue that replaced the per-card
   direction glyphs. overflow:visible lets the rounded U-turn caps bleed into the
   gap without clipping. */
.fr-board-lanes {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  overflow: visible;
  pointer-events: none;
}
/* Quiet "#" prefix on the corner rank tag. Rendered via ::before so the
   [data-position-number] span text stays a bare integer — board_drag and the
   vs-ECR/snapshot pass both parseInt that textContent, and a "#" in it would
   NaN the commit + delta math. Dimmer than the number so the digit anchors. */
.fr-board-rank::before {
  content: "#";
  opacity: 0.5;
  font-weight: 500;
  margin-right: 0.5px;
}

/* Draft (round.pick) labels are meaningful only as a draft board — show them
   in Snake, hide in Linear (a plain grid). Snake state lives on the shell. */
.fr-editor-shell:not([data-board-snake="true"]) [data-round-pick] { display: none; }

.fr-board-lanes path {
  fill: none;
  stroke: var(--accent);
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
  /* Subtle: a faint colored thread, not a highlight. Tuned to read in the
     gutters without competing with card content in either theme. */
  opacity: 0.14;
}

/* Configurable-width draft grid (1-16 cols, default 12 — see --fr-board-cols,
   set on the shell and stepped live by editor-view-mode#stepCols). Server
   emits cards + dividers in flat rank order; board_drag_controller assigns
   each child a grid-row / grid-column-start (snake vs straight, and now
   width, are pure client concerns). position:relative anchors the
   absolutely-positioned drop caret. */
.fr-board-grid {
  position: relative;
  display: grid;
  grid-template-columns: repeat(var(--fr-board-cols, 12), minmax(var(--fr-board-min-card, 120px), 1fr));
  /* Uniform rows: every card the same height regardless of how many lines its
     name wraps to. A physical draft board is a grid of identical stickers —
     ragged row heights broke that read (and made the meta row land at a
     different height on every card). 84px seats a 2-line name comfortably;
     the rare 3-line name bleeds a few px into the card's own padding rather
     than truncating (names never truncate). Uniform rows also make the
     content-visibility placeholder height exact, so lazily-revealed rows can
     no longer shift layout under a long autoscroll drag. */
  grid-auto-rows: var(--fr-board-row, 84px);
  column-gap: var(--fr-board-gap, 10px);
  row-gap: var(--fr-board-gap, 10px);
  align-items: stretch;
}

/* A card = the list row expanded into a compact vertical tile: rank+round.pick
   row, wrapping name, team/pos/bye row. content-visibility skips offscreen
   cards so a 300-card board stays cheap; contain-intrinsic-size reserves the
   collapsed height so scroll geometry holds. Deliberately NO `contain: paint`
   — a card's kebab menu portals to <body>, but an explicit paint box would
   also clip the fixed-position picker popover if it ever anchored here. The
   hover ring is an INSET shadow so it can't be clipped by content-visibility's
   implicit containment. */
.fr-board-card {
  position: relative;
  height: 100%;
  border-radius: 8px;
  border: 1px solid var(--border);
  background-color: var(--surface);
  content-visibility: auto;
  contain-intrinsic-size: auto var(--fr-board-row, 84px);
  /* The whole card is grabbable — say so before the user has to guess. */
  cursor: grab;
  /* `transform` here animates the make-room preview shifts board_drag writes
     inline (cards sliding along the winding path), plus the hover lift. */
  /* Make-room slide: snappy, near-instant start then a quick settle so cards
     feel like they're getting out of the way in real time rather than easing
     lazily behind the pointer. 130ms reads as responsive without teleporting. */
  transition:
    border-color 120ms ease,
    box-shadow 120ms ease,
    transform 130ms cubic-bezier(0.22, 1, 0.36, 1);
  /* Pointer-driven drag (board_drag_controller) needs the browser to hand us
     pointermove instead of scroll-panning when a touch/pen starts on a card —
     tablet drag support. Phones never render the board (server-gated < lg). */
  touch-action: none;
  /* Card text is labeling, not prose — and a mousedown-to-drag would otherwise
     anchor a text selection that then sweeps the page as the pointer moves. */
  user-select: none;
  -webkit-user-select: none;
}
.fr-board-card:active {
  cursor: grabbing;
}
.fr-board-card > .fr-row-content {
  border-radius: 7px;
}
/* Hover = the card rises slightly to meet the hand: physical "pick me up" cue.
   Suppressed while any drag is live so parked cards sit still as the ghost
   passes over them. Outer drop shadow (not just the inset ring) sells the lift. */
.fr-board-card:hover,
.fr-board-card:focus-within {
  border-color: var(--accent);
  box-shadow:
    inset 0 0 0 1px var(--accent),
    0 3px 10px rgba(0, 0, 0, 0.25);
  z-index: 1;
}

/* row_insert_menu's anchor/preview rings (see the shared > .fr-row-content
   wash near the cross-highlight/spotlight tints above). Placed here, right
   after :hover/:focus-within, purely to win that identical-specificity tie
   via source order — the kebab that opens the menu lives inside the card's
   hover-revealed action cluster, so the card is almost always :hover or
   :focus-within for as long as the menu (or a destructive-item preview) is
   showing, and the anchor/preview state is the more informative one to see
   in that moment. */
.fr-board-card.fr-row-insert-anchor {
  box-shadow: inset 0 0 0 1px var(--border-hover);
}
.fr-board-card.fr-row-insert-preview {
  box-shadow: inset 0 0 0 1px rgb(var(--red-rgb));
}

/* Drag-live styling hooks anchor at .fr-drag-live on the GRID (a few hundred
   descendants), never at the shell's .fr-dragging (tens of thousands —
   toggling broad descendant rules there costs a ~200ms style recalc; see the
   hover-kill cascade note at the top of this file). The shell class remains
   purely a JS state contract (turbo_sync_guard deferral, toggleSnake guard);
   the page-wide grabbing cursor is an inline style on <body> set by the
   controller — inline on one element invalidates nothing. */
.fr-board-grid:not(.fr-drag-live) .fr-board-card:hover {
  transform: translateY(-1px);
}
.fr-drag-live .fr-board-card:hover {
  border-color: var(--border);
  box-shadow: none;
}
.fr-drag-live .fr-board-card {
  cursor: grabbing;
  user-select: none !important;
  -webkit-user-select: none !important;
  /* NOTE: no blanket will-change here. A board typically fits its scroller
     (~all cards on-screen), so content-visibility does NOT bound the set —
     promoting every drag-live card spawned ~70 compositor layers and made the
     drag WORSE. Promotion is now applied per-card, only to the handful actually
     sliding, in board_drag_controller._paintPreview (cleared on drag end). */
}

/* Hover-intent action cluster: reveal only after a deliberate pause so a
   grab-and-drag (press + move well under the delay) never collides with the
   buttons. Focus-within reveals instantly (keyboard). Hidden outright while a
   drag is live — parked cards shouldn't sprout toolbars as the ghost passes.
   The grip glyph mirrors the cluster inversely (same delay), yielding its
   corner exactly when the cluster arrives. */
.fr-board-actions {
  opacity: 0;
  pointer-events: none;
  transition: opacity 100ms ease;
}
.fr-board-card:hover .fr-board-actions {
  opacity: 1;
  pointer-events: auto;
  transition-delay: 350ms;
}
.fr-board-card:focus-within .fr-board-actions {
  opacity: 1;
  pointer-events: auto;
  transition-delay: 0ms;
}
.fr-board-grip {
  transition: opacity 100ms ease;
}
.fr-board-card:hover .fr-board-grip {
  opacity: 0;
  transition-delay: 350ms;
}
.fr-board-card:focus-within .fr-board-grip {
  opacity: 0;
  transition-delay: 0ms;
}
.fr-drag-live .fr-board-actions {
  opacity: 0 !important;
  pointer-events: none !important;
}
.fr-drag-live .fr-board-grip {
  opacity: 1 !important;
}

/* One-frame transition suppression while board_drag restores preview
   transforms in the same paint as a grid re-place (drop/cancel teardown). */
.fr-board-no-transition .fr-board-card {
  transition: none !important;
}

/* Composite (surface-3) boards: bump the card border so tiles stay legible on
   the darker composite backdrop, mirroring the list's composite treatment. */
.bg-surface-3 .fr-board-card {
  border-color: var(--border-hover);
}

/* Tier Shelves layout — one grid section ("shelf") per tier. Header/chip/
   add-tier chrome is styled by Tailwind utilities in the component; these
   rules own the inter-shelf spacing and the per-shelf grid. The grid mirrors
   .fr-board-grid exactly (same shared --fr-board-cols width, uniform 84px
   rows, gaps) so cards read identically to the flat board and the layout is
   correct even before shelf_drag hydrates (graceful degradation); shelf_drag
   sets each card's explicit grid-row/column-start on top of this for snake. */
/* Positioning context for the hover insert widget (parked absolutely by
   shelf_drag against this root, spanning across the section grids). */
.fr-shelves {
  position: relative;
  /* Single source for the shelves subtree: the grid track floor, the .fr-shelf
     definite-width calc, and shelf_drag's inline gap all read these off here
     (custom props cascade down), so the floor and the tracks can never drift.
     Keep --fr-board-min-card in sync with MIN_CARD_WIDTH in compare_tray_controller.js. */
  --fr-board-min-card: 120px;
  --fr-board-gap: 10px;
}
.fr-shelf {
  margin-bottom: 1.25rem;
}
.fr-shelf:last-child {
  margin-bottom: 0;
}
.fr-shelf-header {
  margin-bottom: 0.5rem;
}
.fr-shelf-grid {
  position: relative;
  display: grid;
  grid-template-columns: repeat(var(--fr-board-cols, 12), minmax(var(--fr-board-min-card, 120px), 1fr));
  grid-auto-rows: var(--fr-board-row, 84px);
  column-gap: var(--fr-board-gap, 10px);
  row-gap: var(--fr-board-gap, 10px);
  align-items: stretch;
}

/* Give each section a DEFINITE floor width = cols * min-card + gaps, so when the
   available width is below it the section (header + grid) overflows uniformly to
   the right and the shared container (board.html.erb) scrolls — headers span the
   scrolled width, every shelf's grid stays column-aligned. Deliberately NOT
   `max-content`: that would put the minmax(120px,1fr) grid into intrinsic sizing,
   where 1fr tracks size to the longest (never-truncated) player name instead of
   flooring at 120px — inflating the surface and scrolling even undocked. A
   definite calc floors at exactly the same width the grid tracks do, so when the
   container is wider the section fills it (1fr distributes the slack, no scroll). */
.fr-shelf {
  min-width: calc(
    var(--fr-board-cols, 12) * var(--fr-board-min-card, 120px) +
    (var(--fr-board-cols, 12) - 1) * var(--fr-board-gap, 10px)
  );
}

/* Hover "insert tier break here" widget — one per shelves surface, parked by
   shelf_drag in the gutter between two cards of the hovered shelf (strictly
   BETWEEN players; the shelf-end is the component's "+ Add tier below"). Mirror
   of .fr-board-insert; hidden during an active drag. */
.fr-shelf-insert {
  position: absolute;
  z-index: 4;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 20px;
  opacity: 0;
  pointer-events: none;
  transition: opacity 100ms ease;
}
.fr-shelf-insert--visible {
  opacity: 1;
  pointer-events: auto;
}
.fr-shelf-insert-btn {
  flex: none;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 18px;
  height: 18px;
  border-radius: 999px;
  border: 1px solid var(--border);
  background-color: var(--surface-2);
  color: var(--text-dim);
  cursor: pointer;
  transition:
    color 100ms ease,
    border-color 100ms ease,
    background-color 100ms ease,
    transform 100ms ease;
}
.fr-shelf-insert-btn:hover,
.fr-shelf-insert-btn:focus-visible {
  color: var(--accent);
  border-color: var(--accent);
  transform: scale(1.08);
}
.fr-drag-live .fr-shelf-insert {
  display: none;
}

/* Drop-target placeholder — a dashed outline shelf_drag parks in the grid cell
   the dragged card would land in (the gap the make-room preview opens). Placed
   as a grid item at the insertion cell during a drag, hidden otherwise. Mirror
   of .fr-board-slot. */
.fr-shelf-slot {
  display: none;
  border: 1.5px dashed color-mix(in srgb, var(--accent) 45%, transparent);
  border-radius: 8px;
  background-color: color-mix(in srgb, var(--accent) 6%, transparent);
  pointer-events: none;
  z-index: 0;
}

/* Gutter divider — a thin colored rail carrying a narrow vertical tier label.
   align-self: stretch lets it span the height of the row board_drag places it
   in. The label reads vertically so the rail can stay slim in the gutter. */
.fr-board-divider {
  align-self: stretch;
  justify-self: start;
  min-width: 14px;
  touch-action: none;
  /* Sits above the cards it overlaps so the tier rail always reads. */
  z-index: 2;
}
.fr-board-divider-label {
  writing-mode: vertical-rl;
  transform: rotate(180deg);
}
/* board_drag stacks a divider into the SAME grid cell as an adjacent card and
   tags which gutter to nudge toward (data-board-gutter). The card sizes the
   cell; these transforms slide the thin rail into the between-columns gutter
   (half the item + half the gap centers it on the gap), or hug the row's inner
   edge when the divider lands at a row's reading start/end (no gutter there).
   justify-self:start keeps the item from stretching to fill the 1fr column. */
.fr-board-divider[data-board-gutter="left"] {
  transform: translateX(calc(-50% - var(--fr-board-gap, 10px) / 2));
}
.fr-board-divider[data-board-gutter="right"] {
  justify-self: end;
  transform: translateX(calc(50% + var(--fr-board-gap, 10px) / 2));
}
.fr-board-divider[data-board-gutter="edge-start"] {
  justify-self: start;
  transform: none;
}
.fr-board-divider[data-board-gutter="edge-end"] {
  justify-self: end;
  transform: none;
}

/* Edge tier rails (edge-start / edge-end) have no gutter to sit in, so they
   stack ON their neighbour card. board_drag tags that neighbour and we inset
   its content so the vertical tier label never overlaps the rank/name — the
   fix for the crammed "TIER 1" on the board's first card. Only the tier-
   adjacent card pays the ~rail-width of space. */
.fr-board-card--tier-lead .fr-row-content {
  padding-left: 20px;
}
.fr-board-card--tier-trail .fr-row-content {
  padding-right: 20px;
}

/* Implied Tier 1 now renders as a real .fr-board-divider rail (data-implied-tier)
   that board_drag places edge-start before card 0 — see board_component.html.erb.
   It reuses all the divider rail/gutter/tier-lead CSS above, so no dedicated
   styles are needed here anymore. */

/* Slim gutter caret — DIVIDER drags only (a tier rail moves between gutters,
   so a gutter marker is the honest preview). Hidden until board_drag positions
   + reveals it. */
.fr-board-caret {
  display: none;
  position: absolute;
  width: 3px;
  border-radius: 2px;
  background-color: var(--accent);
  pointer-events: none;
  z-index: 5;
}

/* Open drop slot — CARD drags. board_drag grid-places this into the cell the
   surrounding cards just vacated (the make-room preview), so the empty
   outlined cell literally is where the card will land. */
.fr-board-slot {
  display: none;
  border: 1.5px dashed color-mix(in srgb, var(--accent) 45%, transparent);
  border-radius: 8px;
  background-color: color-mix(in srgb, var(--accent) 6%, transparent);
  pointer-events: none;
  z-index: 0;
}

/* Single gutter insert widget (Task 7) — replaces the old per-card kebab
   insert menu (~300 fewer nodes/controllers: one widget for the whole board,
   not one per card). Absolutely positioned like the caret/slot above;
   board_drag's idle-hover placement (_placeInsertWidget) sets left/top/height
   to park it in the gutter nearest the pointer and toggles --visible to
   reveal it. Two stacked minimal icon buttons — add player / add tier — so a
   single hover-and-click inserts at that gutter's rank. Opacity + pointer-
   events (not display:none) so the reveal can fade rather than pop. */
.fr-board-insert {
  position: absolute;
  z-index: 4;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 3px;
  width: 20px;
  opacity: 0;
  pointer-events: none;
  transition: opacity 100ms ease;
}
.fr-board-insert--visible {
  opacity: 1;
  pointer-events: auto;
}
.fr-board-insert-btn {
  flex: none;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 18px;
  height: 18px;
  border-radius: 999px;
  border: 1px solid var(--border);
  background-color: var(--surface-2);
  color: var(--text-dim);
  cursor: pointer;
  transition:
    color 100ms ease,
    border-color 100ms ease,
    background-color 100ms ease,
    transform 100ms ease;
}
.fr-board-insert-btn:hover,
.fr-board-insert-btn:focus-visible {
  color: var(--accent);
  border-color: var(--accent);
  transform: scale(1.08);
}
/* Tier-insert is forbidden at rank 1 (the implied Tier 1 boundary — Task 5's
   min-2 validation rejects start_position 1) — board_drag hides this button
   in the gutter above card 1 rather than rendering a control that would
   always fail. */
.fr-board-insert-btn--hidden {
  display: none;
}
/* Mid-drag: the widget is an idle-hover affordance only. Hide it outright the
   instant a real drag begins (same .fr-drag-live hook the actions cluster and
   grip use — see above) so it never overlaps the ghost, caret, or slot. */
.fr-drag-live .fr-board-insert {
  display: none;
}

/* Drag states. Source = the real card, hidden for the drag (display:none, but
   kept in the DOM for a clean Escape/cancel revert). Its grid cell stays
   RESERVED (empty) the whole drag — the survivors do NOT reflow closed at
   pickup, so nothing shifts when a drag begins; board_drag's homeIndex bridges
   the resulting open-hole off-by-one for the hit-test + make-room preview.
   Ghost = a fixed-position clone appended to <body> that tracks the pointer;
   the controller sets its position/size inline, this only styles the lift. */
.fr-board-drag-source {
  display: none;
}
/* Ghost = a fixed wrapper the controller moves with a compositor-only
   translate3d (left/top tracking forced layout on every pointermove), wrapping
   the visual clone whose lift/settle transitions animate scale/rotate/shadow
   independently of the tracking transform. */
.fr-board-ghost-wrap {
  position: fixed;
  left: 0;
  top: 0;
  z-index: 60;
  pointer-events: none;
  will-change: transform;
}
/* Settle: the wrapper glides (transform transition) into the open slot. */
.fr-board-ghost-wrap.fr-board-ghost-settling {
  transition: transform 160ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.fr-board-drag-ghost {
  opacity: 0.95;
  cursor: grabbing;
  /* Two-stage physicality: the clone starts flat exactly over the source card,
     then .fr-board-ghost-lifted (added one frame later) scales/tilts it up —
     the card visibly leaves the board. The settle removes the lift mid-glide. */
  transform: scale(1) rotate(0deg);
  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
  transition:
    transform 150ms cubic-bezier(0.2, 0.8, 0.2, 1),
    box-shadow 150ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.fr-board-drag-ghost.fr-board-ghost-lifted {
  transform: scale(1.04) rotate(1.5deg);
  box-shadow: 0 14px 36px rgba(0, 0, 0, 0.5);
}

@media (prefers-reduced-motion: reduce) {
  .fr-board-card,
  .fr-board-drag-ghost,
  .fr-board-ghost-settling {
    transition: none;
  }
}

/* Task 10: minimal mobile grid. Coarse pointers (touch, no hover — phones/
   tablets) never trigger :hover, so a card's action cluster reveals off a
   `--selected` class board_drag toggles on tap (pointerup that never crossed
   the drag slop — see _toggleTapSelect) instead of hover.
   board_drag mirrors this same `(hover: none)` query in JS (_coarsePointer)
   to clamp the effective width to <=4 and force snake off.

   Round.pick draft labels are hidden device-wide here (not just when Snake
   is off): _snake() forces linear math on coarse pointers, but the base
   label-visibility rule above is keyed on the raw data-board-snake
   attribute, which defaults to true — without this, a default user on a
   phone would see labels computed for a grid the JS never actually rendered
   (stale server math, since _refreshDraftLabels no-ops when forced-false).
   !important beats the snake-keyed `display: none` toggle above when Snake
   is on. */
@media (hover: none) {
  [data-round-pick] {
    display: none !important;
  }

  .fr-board-card--selected .fr-board-actions {
    opacity: 1;
    pointer-events: auto;
    transition-delay: 0ms;
  }

  /* The `+` insert widget stays hidden on coarse pointers (default non-hover
     state, no reveal rule here). Its on-screen position is only recomputed
     from idle `pointermove`, which a plain tap doesn't fire, so revealing it
     would show a tappable but permanently mispositioned/no-op affordance.
     Deferred: needs tap-based widget positioning before it can be surfaced
     on touch. */
}

/* ── Comparison dot-plot ─────────────────────────────────────────────── */
:root { --cmp-gold:#e6be4a; --cmp-silver:#c2c6d2; --cmp-bronze:#cd8a52; --cmp-tick:#cfc7e6;
  /* Vertical step per card tier — one card's height plus breathing room. */
  --mk-tier-step:30px; }

/* 56px, right-aligned. Was 104px + 16px padding, plus a 30px plot margin for
   the ceiling number and 30/24px track insets -- roughly 180px of chrome
   around a ~170px track when docked at 380px. Right-aligning puts every
   label's last character on a fixed column against the plot, so the eye
   enters each track at the same x. Labels are 2-4 chars (TGT/REC/YDS/FPPG);
   a longer one wraps rather than widening the column for every row. */
.cmp-dp-lbl { width:56px; padding:4px 8px 4px 10px; text-align:right;
  color:var(--text-muted); font-weight:600; font-size:11.5px; line-height:1.15; }
/* `.cmp-t th, .cmp-t td { white-space:nowrap }` matches this same <td>, and at
   (0,1,1) it outweighs a bare `.cmp-dp-lbl` (0,1,0) -- source order never gets
   a say, so the plain reset that used to live in the rule above silently did
   nothing. Qualifying with the table class matches the competing specificity
   and adds the type selector to win outright.
   The wrap matters because this column carries more than the 2-4 char stat
   labels: the ranks group labels rows with a user-authored list name and
   heuristics with a user-authored title ("Superflex Dynasty", "Breakout Age"),
   none of which fit 56px on one line. Without this they overflow into the plot
   column rather than wrapping. */
.cmp-t td.cmp-dp-lbl { white-space:normal; }
.cmp-dp-lbl small { display:block; color:var(--text-dim); font-weight:500; font-size:9px;
  font-variant-numeric:tabular-nums; margin-top:2px; }
.cmp-dp-note { position:relative; cursor:help; opacity:.55; }
.cmp-dp-note:hover, .cmp-dp-note:focus-visible { opacity:1; color:var(--text-muted); }
/* Same card as .cmp-dp-tick-pop (size/colour unchanged) below, but left- not
   centre-anchored: the glyph sits at the right edge of a 56px gutter, so
   centring would run the card off the panel's left edge. Anchoring its left
   edge to the glyph instead lets it open rightward into the track, which has
   the room. Content here is a full sentence rather than the tick's short
   stat, so it wraps in a fixed width instead of the tick's nowrap. */
.cmp-dp-note-pop { position:absolute; bottom:100%; left:0; margin-bottom:4px; display:none;
  width:150px; white-space:normal; text-align:left; line-height:1.35;
  font-size:9px; padding:3px 6px; border-radius:4px; background:var(--surface-2); border:1px solid var(--border); color:var(--text-muted); z-index:3; }
.cmp-dp-note:hover .cmp-dp-note-pop, .cmp-dp-note:focus .cmp-dp-note-pop { display:block; }
/* The row is 56px with the axis low, not centred: every label sits above the
   line in one card, and the band under the axis carries the neighbour-gap
   annotation. Still shorter than the 60px this layout replaced, and the 8px it
   spends over the label-only version buys stated differences rather than
   decoration.

   Everything inside the plot is anchored to the BOTTOM. When cards can't fit
   side by side (narrow panel, phone) spreadMarks tiers them and raises --mk-tiers,
   and the row grows upward from a fixed axis — anchoring from the top would drag
   the axis down the row instead. */
.cmp-dp-plot { position:relative;
  height:calc(56px + var(--mk-tiers, 0) * var(--mk-tier-step)); }
.cmp-dp-track { position:absolute; left:14px; right:12px; top:0; bottom:0; }
.cmp-dp-base { position:absolute; left:0; right:0; bottom:15px; height:2px; border-radius:2px;
  background:linear-gradient(90deg, rgb(var(--text-rgb) / .06), rgb(var(--text-rgb) / .15)); }
.cmp-dp-gap { position:absolute; bottom:14.5px; height:3px; border-radius:3px; background:rgb(var(--text-rgb) / .08); }
.cmp-dp-bound { position:absolute; bottom:12px; height:9px; width:1px; background:rgb(var(--text-rgb) / .28); border-radius:1px; }
.cmp-dp-bound--lo { left:0; }
.cmp-dp-bound--hi { right:0; }
.cmp-dp-tick { position:absolute; bottom:11px; height:12px; width:10px; margin-left:-5px; display:flex; justify-content:center; }
.cmp-dp-tick::before { content:""; width:2px; height:100%; background:var(--cmp-tick); opacity:.5; border-radius:2px; }
.cmp-dp-tick:hover::before, .cmp-dp-tick:focus::before { opacity:.9; }
.cmp-dp-tick-pop { position:absolute; bottom:16px; left:50%; transform:translateX(-50%); display:none; white-space:nowrap;
  font-size:9px; padding:2px 6px; border-radius:4px; background:var(--surface-2); border:1px solid var(--border); color:var(--text-muted); z-index:3; }
.cmp-dp-tick:hover .cmp-dp-tick-pop, .cmp-dp-tick:focus .cmp-dp-tick-pop { display:block; }
.cmp-dp-band { position:absolute; bottom:11px; height:12px; border-radius:3px; background:rgb(var(--green-rgb) / .10); border:1px solid rgb(var(--green-rgb) / .22); }
/* Neighbour-gap annotation, drawn as a dimension bracket: a dotted line drops
   from each of the two ticks, turns, and runs inward to the distance between
   them. Anchoring it to the ticks ties the number to the marks it measures
   instead of leaving a rule floating under the axis.
   The rules are faint solids rather than dashes: a dash pattern is texture, and
   texture is what the eye catches first, so the annotation kept announcing
   itself. Solid at roughly half the alpha carries the same visual weight with
   nothing to catch on, sitting in the same ink range as the axis line it hangs
   from (.06–.15) so it reads as chart furniture until looked for. */
.cmp-dp-gapmark { position:absolute; bottom:1px; height:12px;
  --gap-ink:rgb(var(--text-rgb) / .10); --gap-ink-drop:rgb(var(--text-rgb) / .14); }
/* The drops carry slightly more ink than the long horizontal run: they are only
   7px, and they are the part that ties the number to its two ticks, so they have
   to survive where the horizontal can stay fainter. */
.cmp-dp-gapmark::before, .cmp-dp-gapmark::after { content:""; position:absolute; top:0; width:1px; height:7px;
  background:var(--gap-ink-drop); }
.cmp-dp-gapmark::before { left:0; }
.cmp-dp-gapmark::after { right:0; }
.cmp-dp-gaprow { position:absolute; left:0; right:0; top:2px; height:10px;
  display:flex; align-items:center; gap:4px; }
.cmp-dp-gaprow i { flex:1; height:1px; background:var(--gap-ink); }
/* Held a step below the values it derives from, so the annotation is there to
   tune into and easy to look past when reading the marks themselves. */
.cmp-dp-gaplabel { font-size:8px; font-weight:600; font-style:normal; line-height:10px;
  color:rgb(var(--text-rgb) / .34); font-variant-numeric:tabular-nums; }
/* Too narrow to seat its own label — spreadMarks measures and flags these.
   The annotation is NOT dropped: a bracket vanishes exactly when its two marks
   nearly touch, and "these two are 0.5 apart" is usually the most decision-
   relevant fact on the row — the difference between a real edge and a coin
   flip. Hiding it left that as arithmetic the reader has to do from the cards,
   which is the work these marks exist to remove.
   Instead the rules collapse to two end ticks and the number moves just
   outside, to the right of the bracket. */
.cmp-dp-gapmark.is-tight .cmp-dp-gaprow { gap:0; }
/* The connecting runs are what needs the width; drop them and keep the drops
   (::before/::after), so the pair still reads as one measured span. */
.cmp-dp-gapmark.is-tight .cmp-dp-gaprow i { display:none; }
.cmp-dp-gapmark.is-tight .cmp-dp-gaplabel {
  position:absolute; left:100%; margin-left:3px; white-space:nowrap;
  /* A hair brighter than the inline label: out here it has no bracket around
     it, so it needs to hold together as a unit on its own. */
  color:rgb(var(--text-rgb) / .42); }
/* Last gap on the row would push its label past the track's right edge —
   flip it to the left of the bracket instead. spreadMarks measures and flags. */
.cmp-dp-gapmark.is-tight.is-flipped .cmp-dp-gaplabel {
  left:auto; right:100%; margin-left:0; margin-right:3px; }

/* Mark layout inside the bottom 56px of the row: card 2–29 · stem 29–39 ·
   tick 39. The box is bottom-anchored so a tiered row grows above it. */
.cmp-mk { position:absolute; bottom:0; height:56px; width:0; }
/* One stem, anchored to the dot and running up to the card. When the card is
   nudged aside, JS leans it (--stem-h/--stem-rot) so it runs from the true dot
   x out to the displaced card and the two still read as one object. */
/* --tier lifts a card clear of its neighbours when they can't sit side by side;
   the stem grows to keep reaching its tick, so the pairing survives the lift. */
.cmp-mk-stem { position:absolute; left:0; bottom:17px; width:1px;
  height:calc(10px + var(--tier, 0) * var(--mk-tier-step));
  transform-origin:bottom center; background:rgb(var(--text-rgb) / .15); }
.cmp-mk.is-nudged .cmp-mk-stem { height:var(--stem-h,10px); transform:rotate(var(--stem-rot,0)); }
/* Name over value in one container. Two containers straddling the axis made
   each player two objects to track and put their identity and their number on
   opposite sides of the line; stacked, the pair reads as one thing. */
/* Rows stretch rather than centre, so the card's contents land on its edges: the
   name flush left, the value and its rank pushed to opposite corners. Centring
   every element gave three ragged axes to track per card; edge-aligned, the eye
   reads a fixed column position for each part. */
.cmp-mk-card { position:absolute; top:calc(2px - var(--tier, 0) * var(--mk-tier-step)); left:0; transform:translateX(-50%); white-space:nowrap;
  display:flex; flex-direction:column; align-items:stretch; gap:1px;
  padding:2px 6px; border-radius:6px;
  background:var(--surface-2); border:1px solid rgb(var(--text-rgb) / .16); }
/* The name is identity, not the quantity under comparison, so it sits a step
   back in muted ink and lets the value carry the emphasis. Medal-tinting it was
   the other option, but the medal is already on the card border, the dot and the
   value — a fourth statement, and two shouting elements instead of a hierarchy. */
.cmp-mk-nm { font-size:9px; font-weight:600; line-height:10px; color:var(--text-muted); text-align:left; }
/* --tick-shift staggers ticks that share an x so both stay legible; x stays
   exact, so the tick still encodes the true value. */
/* A tick across the axis, not a disc. Only the mark's *position* is the datum,
   and a 2px-wide tick states that position with an exact edge where a circle
   smeared it over its diameter. (Distinct from .cmp-dp-tick, which is the
   positional-median marker on the axis itself.) The medal is the tick's colour:
   a ring around a mark this small would be mostly ring, and it restated what
   x-position already says — see the medal block below. */
.cmp-mk-tick { position:absolute; bottom:13px; left:0; transform:translate(-50%,calc(-50% + var(--tick-shift,0px)));
  width:2px; height:8px; border-radius:1px; background:rgb(var(--text-rgb) / .5); border:0; }
/* Value tracks the name's 9px — a larger number read as the louder of the two,
   which inverted the intended hierarchy (the name should anchor the mark). */
/* Bottom row is two panels: the value on the left edge, its rank/delta on the
   right, the divider riding with the right panel however wide the card gets. */
.cmp-mk-val { display:flex; align-items:center; justify-content:space-between; gap:8px;
  font-size:9px; font-weight:700; line-height:10px;
  font-variant-numeric:tabular-nums; color:var(--text); }
.cmp-mk-meta { display:inline-flex; align-items:center; gap:3px; line-height:10px;
  padding-left:5px; border-left:1px solid rgb(var(--text-rgb) / .18); }
.cmp-mk-rk { font-size:8px; font-weight:600; color:var(--text-dim); }
.cmp-mk-delta { font-size:8px; font-weight:600; }
.cmp-mk-delta--up { color:rgb(var(--green-rgb)); } .cmp-mk-delta--down { color:var(--cmp-bronze); } .cmp-mk-delta--zero { color:var(--text-dim); }

/* Medal = a thin flat ring on the dot + a matching thin pill border. Kept
   minimal so dense rows stay legible — the per-row placement medal is the
   only medal in the tray now (no header coin disc). */
.cmp-mk.is-gold   .cmp-mk-card { border-color:var(--cmp-gold); }
.cmp-mk.is-silver .cmp-mk-card { border-color:var(--cmp-silver); }
.cmp-mk.is-bronze .cmp-mk-card { border-color:var(--cmp-bronze); }
.cmp-mk.is-gold   .cmp-mk-val { color:var(--cmp-gold-text); }
.cmp-mk.is-bronze .cmp-mk-val { color:var(--cmp-bronze-text); }
/* The medal is the tick's own colour rather than a ring around it. A ring drew a
   second contour to say what the fill already says, and at this size there is no
   room for both — the mark stays a mark and still carries the medal. */
.cmp-mk.is-gold   .cmp-mk-tick { background:var(--cmp-gold); }
.cmp-mk.is-silver .cmp-mk-tick { background:var(--cmp-silver); }
.cmp-mk.is-bronze .cmp-mk-tick { background:var(--cmp-bronze); }

/* Crowded-row de-clutter — spreadMarks (lib/compare_sync) adds .is-nudged and a
   --nudge x-offset that fans the card off the true dot x. */
.cmp-mk.is-nudged .cmp-mk-card { transform:translateX(calc(-50% + var(--nudge, 0px))); }

/* Cross-row spotlight (bindSpotlight in lib/compare_sync). Hovering one player's
   mark drops the others back so a single player can be read straight down the
   table. The dimming does the work — the hovered mark keeps its own colours
   rather than gaining a highlight, so nothing shifts or reflows on hover. */
.cmp-mk, .cmp-cp-cell { transition:opacity .12s ease; }
.cmp-mk.is-dim, .cmp-cp-cell.is-dim { opacity:.28; }
.cmp-mk.is-lit { z-index:2; }
.cmp-mk.is-lit .cmp-mk-card { border-color:rgb(var(--text-rgb) / .45); }
.cmp-mk.is-lit.is-gold .cmp-mk-card { border-color:var(--cmp-gold); }
.cmp-mk.is-lit.is-silver .cmp-mk-card { border-color:var(--cmp-silver); }
.cmp-mk.is-lit.is-bronze .cmp-mk-card { border-color:var(--cmp-bronze); }
/* The gap brackets measure pairs, not players, so they recede as a group rather
   than trying to half-belong to the lifted mark. */
.cmp-dp-track:has(.cmp-mk.is-dim) .cmp-dp-gapmark { opacity:.4; }
@media (prefers-reduced-motion: reduce) {
  .cmp-mk, .cmp-cp-cell { transition:none; }
}

/* ── Comparison compact rows (Games/progress, tier, rating, number) ──── */
.cmp-cp-strip { display:flex; gap:22px; padding:8px 24px 8px 0; }
.cmp-cp-cell { flex:1; max-width:230px; }
/* ── Comparison week strip (Games) ───────────────────────────────────────
   One cell per week of the season on a shared column grid: every player's
   week N sits at the same x, so a missed stretch reads straight down. The
   grid is fractional (not a fixed cell px) so 18 columns survive the panel
   narrowing to the float box without overflowing. */
/* One grid for the whole strip, with the tick row and every player row as
   `subgrid` children rather than as independent grids that happen to share a
   hard-coded template. The name track is then free to size to the longest
   name in *this* comparison and still line up across rows -- a fixed 52px
   wrapped "R. Stevenson" onto two lines while the wide tray sat on an empty
   gutter to its left. Rows stay real elements (not display:contents) because
   .cmp-wk-row.is-dim animates opacity, which a contents box cannot carry. */
.cmp-wk { display:grid; grid-template-columns:minmax(52px, max-content) 1fr minmax(26px, max-content);
  gap:4px 8px; align-items:center; padding:9px 14px 9px 0; }
.cmp-wk-scale, .cmp-wk-row { display:grid; grid-column:1 / -1;
  grid-template-columns:subgrid; align-items:center; }
.cmp-wk-row { transition:opacity .12s ease; }
.cmp-wk-scale { font-size:7px; color:var(--text-dim); font-variant-numeric:tabular-nums; }
/* The tick row mirrors the cell grid exactly -- same column count, same gap --
   so week N's number sits over week N's cell. */
.cmp-wk-ticks { display:grid; grid-template-columns:repeat(var(--wk-cols, 18), 1fr);
  gap:2px; text-align:center; }
/* Wraps, never clips: a truncated surname reads as a different, shorter
   player. align-items:center on the row above keeps the cells/count
   columns vertically centered against a name that wraps to two lines.
   max-width is the cap on the auto-sized name track above: a max-content
   contribution is clamped by max-width, so ordinary names get one clean line
   and only a genuinely long one falls back to wrapping instead of stealing
   the week strip's width. */
.cmp-wk-nm { max-width:112px;
  font-size:9px; font-weight:600; color:var(--text-muted); text-align:right;
  /* .cmp-t th, .cmp-t td sets white-space:nowrap for the data cells; reset it
     back to wrapping here or the comment above ("Wraps, never clips") is a lie
     -- the name overflows its 52px (40px mobile) track and gets clipped by
     .cmp-tray-scroll's overflow:auto instead. overflow-wrap so a hyphenated
     surname can still break mid-word in that width. */
  white-space:normal; overflow-wrap:anywhere; }
.cmp-wk-cells { display:grid; grid-template-columns:repeat(var(--wk-cols, 18), 1fr); gap:2px; }
.cmp-wk-cells i { height:11px; border-radius:2px; display:block; position:relative; }
/* Absent weeks carry a ring and a small ×, not a fill: bye, injury, and healthy
   scratch are indistinguishable without schedule data, so the cell states "no
   game" and claims nothing about why. The × is what makes an empty cell read as
   deliberately empty rather than as a rendering gap — an outline alone is easy
   to skim past in a run of filled weeks.
   Drawn as two rotated rules rather than a "×" glyph: at 9px the glyph's
   metrics differ per font and it sat off-centre, where two 1px bars are
   pixel-exact at every cell width the strip reaches (≈16px docked, ≈9px in the
   float box, ≈6px on a phone). Neutral ink, never a tier hue — it marks the
   absence of data, not a value. */
.cmp-wk-cells i.none { background:transparent; box-shadow:inset 0 0 0 1px rgb(var(--text-rgb) / .13); }
.cmp-wk-cells i.none::before,
.cmp-wk-cells i.none::after {
  content:""; position:absolute; top:50%; left:50%; width:5px; height:1px;
  background:rgb(var(--text-rgb) / .34); border-radius:1px;
}
.cmp-wk-cells i.none::before { transform:translate(-50%,-50%) rotate(45deg); }
.cmp-wk-cells i.none::after  { transform:translate(-50%,-50%) rotate(-45deg); }
.cmp-wk-cells i.t1 { background:var(--wk-t1); }
.cmp-wk-cells i.t2 { background:var(--wk-t2); }
.cmp-wk-cells i.t3 { background:var(--wk-t3); }
.cmp-wk-val { font-size:10px; font-weight:700; font-variant-numeric:tabular-nums;
  color:var(--text); text-align:right; }
/* The key is load-bearing, not decoration: the palette's contrast profile and
   the deliberately-overridden lightness-band check both assume it is present. */
.cmp-wk-key { display:flex; align-items:center; gap:10px; flex-wrap:wrap;
  /* Starts on the cells column by living in the grid rather than by a
     padding that guessed at the old fixed name track (52px + 8px gap) --
     that guess goes wrong the moment the name column sizes to content. */
  grid-column:2 / -1; margin-top:2px; font-size:8.5px; color:var(--text-dim); }
.cmp-wk-key b { width:9px; height:9px; border-radius:2px; display:inline-block; position:relative; }
/* The key's swatch carries the × too — a legend that doesn't look like the
   thing it labels sends the reader back to the strip to work out the mapping. */
.cmp-wk-key b.none { box-shadow:inset 0 0 0 1px rgb(var(--text-rgb) / .13); }
.cmp-wk-key b.none::before,
.cmp-wk-key b.none::after {
  content:""; position:absolute; top:50%; left:50%; width:5px; height:1px;
  background:rgb(var(--text-rgb) / .34); border-radius:1px;
}
.cmp-wk-key b.none::before { transform:translate(-50%,-50%) rotate(45deg); }
.cmp-wk-key b.none::after  { transform:translate(-50%,-50%) rotate(-45deg); }
.cmp-wk-key b.t1 { background:var(--wk-t1); }
.cmp-wk-key b.t2 { background:var(--wk-t2); }
.cmp-wk-key b.t3 { background:var(--wk-t3); }
.cmp-wk-row.is-dim { opacity:.28; }
@media (prefers-reduced-motion: reduce) { .cmp-wk-row { transition:none; } }
.cmp-cp-nm { display:block; font-size:9.5px; color:var(--text-dim); margin-top:3px; }

/* ── Comparison tray — phone layout ──────────────────────────────────
   Placed after the dot-plot rules on purpose: these override properties the
   base rules also set (--mk-tier-step, .cmp-mk-card flex-direction), and at
   equal specificity the later declaration wins. Sitting above them, half of
   this block silently lost. */
@media (max-width: 640px) {
  .cmp-t { max-width: none; }
  /* No horizontal scrolling: the marks tier instead of running off the track,
     so the row fits the phone and reading a comparison never means dragging
     the table sideways to find the fourth player. */
  .cmp-tray-scroll { overflow-x: hidden; }

  /* The card collapses to a single line — name beside value rather than over
     it. Wider, but less than half the height, which is the better trade once
     rows are tiering: each tier now costs 18px instead of 30px. */
  .cmp-mk-card { flex-direction: row; align-items: baseline; gap: 5px; padding: 1px 5px; }
  .cmp-mk-val { justify-content: flex-start; gap: 5px; }
  :root { --mk-tier-step: 18px; }

  /* The 56px gutter and the margin-free plot are already the width-back move;
     the track just gets a couple more pixels at this size. */
  .cmp-dp-track { left: 10px; right: 12px; }

  /* Identity strip: just tighter here. The chips pack on their own now that
     team and age moved to the tooltip, so this no longer needs the 42% basis
     that used to force two per line — a basis would wrap a long name mid-chip,
     and the name has to stay whole. The position stays visible: it is 2-3
     characters and it is what tells you a row is scaled per-position. */
  .cmp-idstrip { gap: 2px 7px; padding: 5px 10px; }

  .cmp-controls-bar { padding: 8px 10px; gap: 6px; }
  /* The template moves to the container now that the rows are subgrid; the
     name cap comes down with it so the strip keeps the width on a phone. */
  .cmp-wk { grid-template-columns: minmax(40px, max-content) 1fr minmax(22px, max-content);
    gap: 4px 6px; padding: 8px 10px 8px 0; }
  .cmp-wk-nm { max-width: 62px; }
  /* Cells drop to ~6px wide here; a 5px × would run into the ring on both
     sides and read as a filled blob rather than a mark. */
  .cmp-wk-cells i.none::before,
  .cmp-wk-cells i.none::after { width: 3px; }
}

/* Baseline picker — "compare these rankings against nothing / ECR / your last
   snapshot". Same segmented language as the board puck so the two read as the
   same family of control, at strip scale rather than canvas scale. */
.fr-vs {
  display: inline-flex;
  align-items: center;
  gap: 1px;
  padding: 2px;
  border-radius: 9px;
  border: 1px solid rgb(var(--border-rgb) / 0.85);
  background: rgb(var(--surface-2-rgb) / 0.6);
}
/* Reads as part of the control, not as a heading over it: same size and
   weight as the segments, dimmer, and lowercase — the spec names this the
   `vs.` control, and uppercasing it to match the segments would make it look
   like a fourth option. */
.fr-vs-label {
  flex: none;
  font-size: 10px;
  font-weight: 600;
  letter-spacing: 0.06em;
  color: var(--text-dim);
}
.fr-vs-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  height: 22px;
  padding: 0 8px;
  border: 0;
  border-radius: 7px;
  background: transparent;
  color: var(--text-muted);
  font-size: 10px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  transition: color 120ms ease, background-color 120ms ease, box-shadow 120ms ease;
}
.fr-vs-btn:hover { color: var(--text); background: rgb(var(--text-rgb) / 0.05); }
.fr-vs-btn:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 1px;
}
.fr-vs-btn[aria-checked="true"] {
  color: var(--accent);
  background: rgb(var(--accent-rgb) / 0.12);
  box-shadow: inset 0 0 0 1px rgb(var(--accent-rgb) / 0.32);
}
/* The resting state is not a selection to celebrate — Off reads as neutral when
   chosen, so the eye can tell "no comparison" from "comparing" at a glance. */
.fr-vs-btn[data-baseline="off"][aria-checked="true"] {
  color: var(--text);
  background: rgb(var(--text-rgb) / 0.08);
  box-shadow: none;
}
@media (pointer: coarse) {
  .fr-vs-btn { height: 32px; padding: 0 12px; font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
  .fr-vs-btn { transition: none; }
}

/* Editor strip touch sizing -- row, ⋯ button, ⋯ menu rows. These three
   MUST agree: a 44px touch target inside a 40px row overflows it. They used
   to size off `lg:` (Tailwind's 1024px breakpoint), which reads as "not
   touch" only by coincidence -- an iPad in landscape is exactly 1024px wide
   and was getting the pointer-precise sizes it doesn't have the precision
   for. `pointer: coarse` is the actual touch signal, same mechanism as
   .fr-vs-btn above. */
.fr-strip-row { height: 40px; }
@media (pointer: coarse) {
  .fr-strip-row { height: 44px; }
}

.fr-strip-menu-btn {
  width: 32px;
  height: 32px;
}
@media (pointer: coarse) {
  .fr-strip-menu-btn { width: 44px; height: 44px; }
}

.fr-strip-menu-row { min-height: 32px; }
@media (pointer: coarse) {
  .fr-strip-menu-row { min-height: 44px; }
}

/* Settings drawer backdrop top offset — MUST equal .fr-strip-row's height at
   lg+ (see the WHY comment above .fr-strip-row). The drawer backdrop sits on
   top of the editor; if its top offset is even a few px short of the strip's
   real height, it dims the bottom of the strip -- including the ⋯ button,
   which fills the row exactly. Below lg the app topbar (h-16, see
   shared/_app_topbar.html.erb) is the top-most chrome instead, so this class
   is inert there and the markup's own `top-16` utility handles that case. */
@media (min-width: 1024px) {
  .fr-strip-backdrop-top { top: 40px; }
}
@media (min-width: 1024px) and (pointer: coarse) {
  .fr-strip-backdrop-top { top: 44px; }
}

/* Board puck — the board's controls, floated over the canvas rather than
   parked in the toolbar. Bottom-right so it sits under the hand that is
   already dragging cards, and clear of the list rail on the left.
   Anchored to [data-board-region] rather than .fr-editor-main (see
   Editor::BoardPuckComponent's render site) -- that region is already
   shrunk to exclude the comparison tray's column while the tray is IN the
   flex flow (docked, min), so the puck clears it there for free, with no
   tray-state-specific CSS. float/popped take the tray OUT of the flow --
   see the corner-swap rule below for those. */
/* Instrument panel, not a button bar: it rests on the board rather than
   interrupting it. Glass over an opaque slab because the cards it covers
   should still read through it, and a hairline top highlight so the edge
   catches light the way a physical control would. */
.fr-puck {
  position: absolute;
  right: 16px;
  bottom: 16px;
  z-index: 30;
  align-items: center;
  gap: 2px;
  padding: 3px;
  border-radius: 11px;
  border: 1px solid rgb(var(--border-rgb) / 0.85);
  background: rgb(var(--surface-2-rgb) / 0.72);
  backdrop-filter: blur(14px) saturate(140%);
  -webkit-backdrop-filter: blur(14px) saturate(140%);
  box-shadow:
    inset 0 1px 0 rgb(255 255 255 / 0.05),
    0 1px 2px rgb(0 0 0 / 0.16),
    0 14px 34px -18px rgb(0 0 0 / 0.6);
  transition: opacity 140ms ease, transform 140ms ease, box-shadow 140ms ease;
}
/* Lifts under the hand — the one motion this thing gets. */
.fr-puck:hover {
  transform: translateY(-1px);
  box-shadow:
    inset 0 1px 0 rgb(255 255 255 / 0.07),
    0 2px 4px rgb(0 0 0 / 0.18),
    0 18px 40px -18px rgb(0 0 0 / 0.65);
}

/* Float and popped take the tray out of the flex flow, so the board region no
   longer shrinks around it and the region's bottom-right corner is the tray's
   own default resting place (float: fixed 380x520 at z-60; popped: the pill at
   z-46). Anchoring alone cannot help there — the puck swaps corners instead.
   A floated tray is user-draggable, so this avoids its default position, not
   every position it can be moved to. */
.fr-editor-shell[data-compare-state="float"] .fr-puck,
.fr-editor-shell[data-compare-state="popped"] .fr-puck { right: auto; left: 16px; }

/* Dimmed, not hidden: the puck stays where the eye last saw it, so it does not
   reappear somewhere new when the drag ends. */
.fr-puck.is-dimmed { opacity: 0.15; pointer-events: none; }

.fr-puck-seg { display: flex; align-items: center; gap: 1px; }


/* 28px with a mouse, 44px under a finger. The chrome-wide 44px floor exists
   for touch; holding a pointer-precise control to it just pads the panel out
   until it competes with the board. See the pointer: coarse block below. */
.fr-puck-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  height: 28px;
  min-width: 28px;
  padding: 0 9px;
  border-radius: 8px;
  border: 0;
  background: transparent;
  /* --text-muted, not --text-dim: at 10px uppercase the dim token measures
     about 3.4:1 on this surface, under the 4.5 floor. */
  color: var(--text-muted);
  font-size: 10px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  transition: color 120ms ease, background-color 120ms ease, box-shadow 120ms ease;
}
.fr-puck-btn:hover { color: var(--text); background: rgb(var(--text-rgb) / 0.05); }

/* Width and snake are board furniture; the layout picker beside them is how you
   reach a board in the first place, so it never hides. Declared AFTER
   .fr-puck-seg and .fr-puck-btn deliberately — those set display at the same
   specificity, so source order is what decides, and declared earlier this rule
   lost to .fr-puck-btn and left the snake toggle showing in list mode. The
   shell's reveal rule for board/shelves outranks it on specificity. */
.fr-puck-board-only { display: none; }

/* Selected state reads as a lit key, not a filled button: the accent tints and
   rings it instead of flooding it. Solid accent-on-white put the loudest
   colour on screen next to the players, which are the thing being judged. */
.fr-puck-btn[aria-pressed="true"] {
  color: var(--accent);
  background: rgb(var(--accent-rgb) / 0.12);
  box-shadow: inset 0 0 0 1px rgb(var(--accent-rgb) / 0.32);
}
.fr-puck-btn[aria-pressed="true"]:hover { background: rgb(var(--accent-rgb) / 0.16); }

.fr-puck-value {
  min-width: 22px;
  text-align: center;
  font-size: 11px;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  color: var(--text);
  letter-spacing: 0.01em;
}
.fr-puck-rule { width: 1px; height: 16px; margin: 0 3px; background: rgb(var(--border-rgb) / 0.9); }

@media (pointer: coarse) {
  .fr-puck { gap: 3px; padding: 4px; border-radius: 14px; }
  .fr-puck-btn { height: 44px; min-width: 44px; padding: 0 12px; font-size: 11px; }
  .fr-puck-value { min-width: 30px; font-size: 12px; }
  .fr-puck-rule { height: 22px; }
}

@media (prefers-reduced-motion: reduce) {
  .fr-puck,
  .fr-puck-btn { transition: none; }
  .fr-puck:hover { transform: none; }
}

/* ── ⌘K player palette ─────────────────────────────────────────────────────
   A centred dialog floated near the top of the viewport, where a command
   palette is expected and where it does not cover the row you are about to
   land on. See the pointer: coarse block below for the touch shape. */
.fr-palette-backdrop {
  position: fixed;
  inset: 0;
  z-index: 80;
  display: flex;
  justify-content: center;
  align-items: flex-start;
  padding-top: 12vh;
  background: rgb(var(--bg-rgb) / .55);
}
.fr-palette-backdrop[hidden] { display: none; }
.fr-palette {
  width: min(560px, calc(100vw - 32px));
  max-height: 70vh;
  display: flex;
  flex-direction: column;
  background: var(--surface-2);
  border: 1px solid var(--border-hover);
  border-radius: 14px;
  box-shadow: 0 24px 60px rgb(var(--bg-rgb) / .6);
  overflow: hidden;
}
.fr-palette-input {
  flex: none;
  height: 48px;
  padding: 0 16px;
  background: none;
  border: 0;
  border-bottom: 1px solid var(--border);
  color: var(--text);
  font-size: 14px;
}
.fr-palette-input:focus { outline: none; }
.fr-palette-results { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 6px; }
.fr-palette-empty { padding: 14px 16px; font-size: 12px; color: var(--text-dim); }
.fr-palette-empty[hidden] { display: none; }

.fr-palette-row {
  display: flex;
  flex-direction: column;
  gap: 2px;
  width: 100%;
  min-height: 40px;
  padding: 7px 10px;
  border: 0;
  border-radius: 8px;
  background: none;
  text-align: left;
  cursor: pointer;
}
.fr-palette-row:hover,
.fr-palette-row[aria-selected="true"] { background: var(--surface-3); }
.fr-palette-row-main { display: flex; align-items: baseline; gap: 6px; }
/* Never truncates: the name is the identity, the metadata beside it is not. */
.fr-palette-row-name { font-size: 13px; font-weight: 600; color: var(--text); overflow-wrap: anywhere; }
.fr-palette-row-pos { flex: none; font-size: 10px; font-weight: 700; text-transform: uppercase; color: var(--text-dim); }
.fr-palette-row-detail { font-size: 10.5px; line-height: 1.3; color: var(--text-dim); }

.fr-palette-group {
  padding: 8px 10px 4px;
  font-size: 10px;
  font-weight: 700;
  letter-spacing: .08em;
  text-transform: uppercase;
  color: var(--text-dim);
}

/* An add row is a write, so its detail line carries the accent the ranked
   rows' list tags don't — the one visual difference between "go here" and
   "change something". */
.fr-palette-row--add .fr-palette-row-detail { color: var(--accent); }

/* Touch: the palette is the screen, not a box floating on it. A centred dialog
   with 12vh above it loses most of its result list to the on-screen keyboard,
   and the phone is the surface this whole phase exists for.
   `pointer: coarse`, not a width breakpoint — an iPad in landscape is exactly
   1024px, the lesson phase 4 learned twice. */
@media (pointer: coarse) {
  .fr-palette-backdrop { padding-top: 0; align-items: stretch; }
  .fr-palette {
    width: 100%;
    max-width: none;
    max-height: 100%;
    border-radius: 0;
    border-left: 0;
    border-right: 0;
    border-top: 0;
  }
  /* 16px, not 14px: iOS Safari zooms the viewport on focus for any input font
     below 16px, which would strand the ranker zoomed into a dialog they are
     about to close. */
  .fr-palette-input { height: 56px; font-size: 16px; }
  .fr-palette-row { min-height: 44px; padding: 9px 12px; }
  .fr-palette-row-name { font-size: 14px; }
  .fr-palette-row-detail { font-size: 11.5px; }
}

/* ── Contest pool rail + slot placeholders ─────────────────────────────── */

/* Desktop: a docked rail beside the column, sibling of the scroller region
   (same flex-sibling trick the comparison tray uses). The turbo-frame is the
   rail element itself so data-open survives frame reloads. */
.contest-pool {
  display: flex;
  flex-direction: column;
  min-height: 0;
  background-color: var(--surface);
  /* Query container so the card's second line can shed its OVR badge as the
     pool narrows (rule below). Safe for the same reason .fr-editor-column
     is: the pool's width is always GIVEN (--pool-w from pool_resize_controller,
     the 40px minimized strip, or the phone sheet's full width), never derived
     from its rows, so inline-size containment introduces no circularity. */
  container-type: inline-size;
  container-name: pool;
}
/* Below ~270px of content width (the resize floor is 240px) the card's second
   line -- "#90 · RB31 · DEN · B10" plus "RB16 OVR30 332" -- overruns the
   reserved action column; measured with the shipped fonts, 280px still fits.
   The OVR badge is the one thing added since that line was sized, so it is
   the one thing that goes. The stats tables keep it at every width. */
@container pool (max-width: 270px) {
  .contest-pool__ovr { display: none; }
}
@media (min-width: 1024px) {
  .contest-pool {
    /* --pool-w is written by pool_resize_controller (drag/keyboard). A custom
       property, not an inline flex-basis, so the collapsed rule below still
       wins by ordinary cascade — same trick as .cmp-tray-wrap. */
    flex: 0 0 var(--pool-w, 320px);
    border-left: 1px solid var(--border);
    position: relative; /* containing block for .contest-pool__grip */
  }
  .contest-pool__head { cursor: default; }

  /* On a 320px rail the header already carries a title, a second line, the
     import link and three dock buttons; the fill button inline would leave
     the text block ~120px and wrap the import link to three lines. So on
     desktop only, the header wraps and the button takes its own full-width
     row underneath -- where it also reads as the primary action it is.
     Phones keep it inline: there the header IS the peek row, and a button
     on a second line would sit below the fold of the shut sheet. */
  .contest-pool__head { flex-wrap: wrap; }
  .contest-pool .contest-pool__fill-form { order: 3; flex: 0 0 100%; margin-top: 6px; }
  .contest-pool .contest-pool__fill { width: 100%; min-height: 30px; }

  /* Minimized: a 40px strip with one expand button. In the stacked dock the
     strip is horizontal (40px tall), so the label stays horizontal there. */
  .contest-pool[data-collapsed="true"] { flex: 0 0 40px; }
  .contest-pool[data-collapsed="true"] .contest-pool__head,
  .contest-pool[data-collapsed="true"] .contest-pool__body { display: none; }
  .contest-pool[data-collapsed="true"] .contest-pool__rail {
    display: flex;
    flex: 1 1 auto;
    align-items: center;
    justify-content: center;
    background: none;
    border: 0;
    cursor: pointer;
    padding: 12px 0;
  }
  .contest-pool__rail-txt {
    writing-mode: vertical-rl;
    transform: rotate(180deg);
    font-size: 11px;
    font-weight: 600;
    white-space: nowrap;
    color: var(--text-dim);
  }
  .fr-side-dock[data-dock-mode="stacked"] .contest-pool__rail-txt {
    writing-mode: horizontal-tb;
    transform: none;
  }
  .contest-pool__rail:hover .contest-pool__rail-txt,
  .contest-pool__rail:focus-visible .contest-pool__rail-txt { color: var(--text); }
}
.contest-pool__rail { display: none; }

/* Resize grip on the rail's left border — .cmp-grip's twin (11px hit area,
   2px bar). Only the expanded side-by-side desktop rail resizes: collapsed
   is 40px by rule, and the stacked dock is governed by the tray width var. */
.contest-pool__grip { display: none; }
@media (min-width: 1024px) {
  .contest-pool:not([data-collapsed="true"]) .contest-pool__grip {
    display: flex; align-items: center; justify-content: center;
    position: absolute; left: 0; top: 0; bottom: 0; width: 11px; z-index: 5;
    cursor: col-resize; }
  .fr-side-dock[data-dock-mode="stacked"] .contest-pool__grip { display: none; }
}
.contest-pool__grip::before { content: ""; width: 2px; height: 26px; border-radius: 2px;
  background: rgb(var(--text-rgb) / .16); transition: background-color 120ms ease, height 120ms ease; }
.contest-pool__grip:hover::before, .contest-pool__grip:focus-visible::before {
  background: rgb(var(--text-rgb) / .38); height: 40px; }
.contest-pool__grip:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }

/* ── Side dock: pool rail + comparison tray share one flex parent ──────────
   Below lg the wrapper dissolves entirely so the phone layouts (pool bottom
   sheet, tray overlay) still see the same ancestors they were written for. */
.fr-side-dock { display: contents; }
@media (min-width: 1024px) {
  .fr-side-dock { display: flex; min-height: 0; min-width: 0; position: relative; }

  /* Layout controls act on the pool+tray pair: hidden while the tray is out
     of the flow (empty / float / popped / max), where stacking or swapping
     against nothing looks like a dead button. */
  .contest-pool__dockbtns { display: none; }
  [data-compare-state="docked"] .contest-pool__dockbtns,
  [data-compare-state="min"] .contest-pool__dockbtns { display: flex; }

  /* Swap: order works in both row (side) and column (stacked) direction.
     Explicit orders on all three children (panel / hgrip / tray) keep the
     stacked divider between the panels in either arrangement. */
  .fr-side-dock .contest-pool { order: 1; }
  .fr-side-dock .fr-side-dock__hgrip { order: 2; }
  .fr-side-dock .cmp-tray-wrap { order: 3; }
  .fr-side-dock[data-dock-order="tray-first"] .contest-pool { order: 3; }
  .fr-side-dock[data-dock-order="tray-first"] .cmp-tray-wrap { order: 1; }

  /* Stacked: the dock becomes the column and takes the tray's width var;
     the two panels split its height. Scoped to the docked tray state —
     float/max/popped take the tray out of the flow and the pool keeps the
     full height by being the only in-flow child. */
  .fr-side-dock[data-dock-mode="stacked"] {
    flex-direction: column;
    /* --dock-w is side_dock_controller's own var, written on THIS element:
       the tray grip's --cmp-tray-w lives on .cmp-tray-wrap (a child) and
       custom properties never inherit upward. */
    flex: 0 0 var(--dock-w, clamp(360px, 32vw, 520px));
  }

  /* The stacked layout's single resize handle — the dock's left border,
     .cmp-grip's geometry. Hidden in side-by-side, where each panel keeps its
     own grip. */
  .fr-side-dock__grip { display: none; }
  .fr-side-dock[data-dock-mode="stacked"] .fr-side-dock__grip {
    display: flex; align-items: center; justify-content: center;
    position: absolute; left: 0; top: 0; bottom: 0; width: 11px; z-index: 46;
    cursor: col-resize; }
  .fr-side-dock__grip::before { content: ""; width: 2px; height: 26px; border-radius: 2px;
    background: rgb(var(--text-rgb) / .16); transition: background-color 120ms ease, height 120ms ease; }
  .fr-side-dock__grip:hover::before, .fr-side-dock__grip:focus-visible::before {
    background: rgb(var(--text-rgb) / .38); height: 40px; }
  .fr-side-dock__grip:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
  /* --dock-split is the POOL's height share (side_dock_controller). Both
     bases stay flexible (1 1) so a hidden/min tray still lets the pool grow. */
  .fr-side-dock[data-dock-mode="stacked"] .contest-pool { flex: 1 1 var(--dock-split, 50%); }
  .fr-side-dock[data-dock-mode="stacked"] .contest-pool[data-collapsed="true"] { flex: 0 0 40px; }
  [data-compare-state="docked"] .fr-side-dock[data-dock-mode="stacked"] .cmp-tray-wrap {
    flex: 1 1 calc(100% - var(--dock-split, 50%));
    border-top: 0; /* the hgrip draws the boundary */
    border-left: 0;
  }

  /* The stacked height split: a horizontal divider between the panels. Shown
     only while there are two in-flow panels to split (stacked + docked tray,
     pool not collapsed). */
  .fr-side-dock__hgrip { display: none; }
  [data-compare-state="docked"] .fr-side-dock[data-dock-mode="stacked"]:not(:has(.contest-pool[data-collapsed="true"])) .fr-side-dock__hgrip {
    display: flex; align-items: center; justify-content: center;
    flex: 0 0 11px;
    cursor: row-resize;
    border-top: 1px solid var(--border);
  }
  .fr-side-dock__hgrip::before { content: ""; height: 2px; width: 26px; border-radius: 2px;
    background: rgb(var(--text-rgb) / .16); transition: background-color 120ms ease, width 120ms ease; }
  .fr-side-dock__hgrip:hover::before, .fr-side-dock__hgrip:focus-visible::before {
    background: rgb(var(--text-rgb) / .38); width: 40px; }
  .fr-side-dock__hgrip:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
  /* The tray's min rail is built vertical; lying in a 40px-tall strip its
     label must go horizontal, same as the pool's. */
  .fr-side-dock[data-dock-mode="stacked"] .cmp-rail { flex-direction: row; }
  .fr-side-dock[data-dock-mode="stacked"] .cmp-rail-ttl {
    writing-mode: horizontal-tb;
    transform: none;
  }
}

/* Containing block for the hover action cluster below. Rows carry the same
   position tint wash as the board cards (data-nfl-position), so the pool
   reads like the board it feeds; hover adds a flat wash on top rather than
   replacing the tint. */
.contest-pool__row { position: relative; }
.contest-pool__row[data-nfl-position="QB"]  { background-color: var(--pos-qb-tint); }
.contest-pool__row[data-nfl-position="RB"]  { background-color: var(--pos-rb-tint); }
.contest-pool__row[data-nfl-position="WR"]  { background-color: var(--pos-wr-tint); }
.contest-pool__row[data-nfl-position="TE"]  { background-color: var(--pos-te-tint); }
.contest-pool__row[data-nfl-position="K"]   { background-color: var(--pos-k-tint); }
.contest-pool__row[data-nfl-position="DEF"] { background-color: var(--pos-def-tint); }
.contest-pool__row:hover {
  background-image: linear-gradient(rgb(var(--text-rgb) / .05), rgb(var(--text-rgb) / .05));
}

/* Position chips: the pressed one reads as the selected filter. */
.contest-pool__chip[aria-pressed="true"] {
  border-color: rgb(var(--accent-rgb) / .6);
  background-color: var(--accent-bg);
  color: var(--accent);
}

/* View + insert-at-rank fade in on hover. Keyed on hover capability, NOT lg:
   an iPad is lg-wide with no hover, and opacity:0 there would make the
   buttons unreachable. Opacity (not display) keeps them focusable, and
   focus-within reveals them for keyboard users.

   They also leave the flow: three 24px buttons reserved inline cost the name
   ~72px of a 320px rail, which wrapped even two-word names onto two lines
   while the row looked half empty. Absolutely positioned, the name owns that
   width and the cluster slides over the row's tail (team + position badge)
   only while the row is hovered or keyboard-focused. */
@media (hover: hover) and (pointer: fine) {
  .contest-pool__acts {
    position: absolute;
    top: 1px;          /* clear the row's bottom border */
    bottom: 1px;
    right: 32px;       /* clear the always-visible add (+) button */
    padding-left: 20px;
    opacity: 0;
    pointer-events: none;
    transition: opacity 120ms ease;
    /* Frosted backdrop: near-opaque neutral plus a blur, so it hides the
       covered badge/pill on ANY position tint (the tints are alpha washes,
       so inheriting them would let the text bleed through). The mask gives
       a short fade-in edge so the covered meta line does not hard-clip. */
    background-color: rgb(var(--surface-2-rgb) / .92);
    -webkit-backdrop-filter: blur(4px);
    backdrop-filter: blur(4px);
    -webkit-mask-image: linear-gradient(to right, transparent 0, #000 20px);
    mask-image: linear-gradient(to right, transparent 0, #000 20px);
  }
  .contest-pool__row:hover .contest-pool__acts,
  .contest-pool__acts:focus-within { opacity: 1; pointer-events: auto; }
}

/* Phone/tablet-portrait: a bottom sheet. Collapsed shows the header only;
   data-open="true" (contest_pool_controller#toggle) slides the rest up.

   --pool-peek is the height of the shut sheet: the 48px handle plus the
   device's own bottom inset. Without the inset the handle sits UNDER an
   iPhone's home indicator, which both dims it and makes the tap a
   system gesture rather than a toggle. It is one variable because three
   rules have to agree on it -- the sheet's own resting transform, the
   column's clearance, and the open sheet's height budget. */
@media (max-width: 1023.98px) {
  .contest-pool {
    --pool-peek: calc(48px + env(safe-area-inset-bottom, 0px));
    position: fixed;
    left: 0;
    right: 0;
    bottom: 0;
    z-index: 40;
    /* 55vh left ~5 rows visible once the header, the board-full note, the
       search field and the chips were subtracted -- on the one surface where
       the pool IS the way players get added. 78vh is the most that still
       leaves a usable strip of board above it. */
    max-height: 78vh;
    border-top: 1px solid var(--border);
    box-shadow: 0 -10px 28px rgb(0 0 0 / 0.18);
    padding-bottom: env(safe-area-inset-bottom, 0px);
    transform: translateY(calc(100% - var(--pool-peek)));
    transition: transform 180ms ease;
  }
  .contest-pool[data-open="true"] { transform: none; }
  .contest-pool__head { cursor: pointer; min-height: 48px; }
  .contest-pool[data-open="true"] .contest-pool__chev { transform: rotate(180deg); }

  /* The second line and the import link only exist once the sheet is open:
     shut, the peek row is 48px shared between the title, the fill button and
     the chevron, and three stacked lines of text overflow it. */
  .contest-pool:not([data-open="true"]) .contest-pool__subhead { display: none; }

  /* Leave room under the column so the last rows clear the SHUT sheet.
     Keyed off the rail's presence (.contest-pool), not the slots controller:
     a full board with a still-nonempty pool shows the rail with no slots
     stamped, and the sheet still needs clearance in that case. */
  .fr-editor-shell:has(.contest-pool) .fr-editor-column {
    padding-bottom: calc(var(--pool-peek, 48px) + 8px);
  }
  /* And room to clear the OPEN sheet, so the row an entrant just added is
     still scrollable into view instead of sitting permanently underneath it.
     Without this the column's scroll range ends where the sheet begins and
     the bottom ~78vh of the board is simply unreachable while it is open. */
  .fr-editor-shell:has(.contest-pool[data-open="true"]) .fr-editor-column {
    padding-bottom: calc(78vh + 8px);
  }

  /* One horizontally scrolling row rather than two wrapped ones.
     Doubled selector, not a bare class: the element also carries Tailwind's
     own `flex-wrap` utility, which is a single class and therefore ties on
     specificity and wins on source order (the Tailwind build is linked after
     this sheet). Verified in a browser -- the bare `.contest-pool__chips`
     rule computed to flex-wrap: wrap. */
  .contest-pool .contest-pool__chips {
    flex-wrap: nowrap;
    overflow-x: auto;
    /* No companion ::-webkit-scrollbar rule: declaring scrollbar-width at all
       switches the element to the standard scrollbar model and silently voids
       every ::-webkit-scrollbar rule on it, so the pair would be one live
       declaration and one dead one. The affordance is the chip clipped at the
       edge -- and at 390px the chips measure narrower than the row anyway, so
       this only ever engages on a contest with more scored positions. */
    scrollbar-width: none;
    -webkit-overflow-scrolling: touch;
  }
  .contest-pool__chip { flex: 0 0 auto; min-height: 28px; }
}

/* Touch rows: real targets, and only the two actions that matter here.
   Keyed on pointer capability, NOT a width breakpoint -- an iPad is lg-wide
   with a coarse pointer, and the hover rule these mirror
   (.contest-pool__acts, above) is keyed the same way for the same reason.

   On a hover-capable pointer the four buttons cost nothing: they are absolutely
   positioned, hidden at 0 opacity, and slide over the row's tail only while it
   is hovered. On touch that media query never matches, so all four sit INLINE
   at 24px each -- ~96px of tap targets, every one of them below the 44px
   minimum, packed against a player name that (project rule) must never
   truncate. Mis-taps between "compare" and "add" were the predictable result.

   So on touch the row keeps the two that carry the surface:
     - insert-at-consensus-rank, the correct default on a contest board (the
       server clamps a rank past the tail, so on a part-built board it behaves
       exactly like the append it replaces), and
     - view profile, the one research affordance with no other route in from
       a phone.
   Compare and the plain append are hidden: the comparison tray is an overlay
   that fights the sheet for the same screen, and append duplicates insert on
   every path a phone entrant actually takes. Both stay in the DOM and stay
   available to every hover-capable pointer.

   Every selector below is DOUBLED (.contest-pool .contest-pool__act--x).
   These buttons carry Tailwind's own `flex`, `h-6` and `w-6` utilities, which
   are single classes -- a bare class here ties on specificity and loses on
   source order, because the Tailwind build is linked after this sheet. The
   same trap ate the chips rule above; it was caught in a browser, not
   reasoned about, so do not "simplify" these back to single classes. */
@media (hover: none), (pointer: coarse) {
  .contest-pool .contest-pool__act--compare,
  .contest-pool .contest-pool__act--add { display: none; }
  .contest-pool .contest-pool__act--insert,
  .contest-pool .contest-pool__act--profile { height: 44px; width: 44px; }
  .contest-pool .contest-pool__row { padding-top: 2px; padding-bottom: 2px; }
}

/* The fill button. 28px is a header control on a desktop rail; on touch it is
   a primary action in the one row of the sheet that is always on screen, so
   it takes the same 44px floor as the row buttons above. Doubled for the same
   reason as everything else here -- the button carries `px-2`. */
.contest-pool__fill { min-height: 28px; }
.contest-pool__fill-form { margin: 0; }
@media (hover: none), (pointer: coarse) {
  .contest-pool .contest-pool__fill { min-height: 44px; padding-left: 10px; padding-right: 10px; }
}

.fr-slot {
  display: flex;
  align-items: center;
  gap: .5rem;
  min-height: 2.25rem;
  margin: 0 .25rem;
  padding: 0 .5rem;
  border: 1px dashed var(--border);
  border-radius: .375rem;
  color: var(--text-dim);
  font-size: .75rem;
}
.fr-slot + .fr-slot { margin-top: 2px; }
.fr-slot__num {
  width: 1.75rem;
  text-align: right;
  font-variant-numeric: tabular-nums;
  opacity: .7;
}
.fr-slot__hint { font-size: .6875rem; }

/* Out shelf (start/sit). Sits under the last numbered row inside the list's
   scroll region: muted, unnumbered, no drag handle. Rows are plain flex, not
   .fr-list-row, so nothing about drag, spotlight, or the row menu attaches.
   Named .fr-out-shelf (not .fr-shelf) -- .fr-shelf is already the Tier
   Shelves grid section class above, with its own margin/min-width rules that
   would otherwise bleed onto this mount by classname collision. */
.fr-out-shelf-head { border-top: 1px dashed rgb(var(--border-rgb) / 0.8); margin-top: 4px; }
.fr-out-shelf-row { opacity: 0.85; }
.fr-out-shelf-row:hover { opacity: 1; background: rgb(var(--surface-2-rgb) / 0.5); }

/* Week chip (start/sit). Same height and type scale as the lens pill it sits
   beside so the title cluster reads as one line of chips, not a pill and a
   button. */
.fr-week-chip { height: 22px; }
