:root {
  --bg: #0b0d10;
  --panel: #14171b;
  --panel-raised: #191d22;
  --border: #262b31;
  --border-strong: #383f47;
  --text: #e6e8eb;
  --text-muted: #7d8590;

  --accent: #22d3ee;
  --accent-scale: #facc15;
  --accent-poa: #ef4444;
  --accent-holes: #3b82f6;

  --font-sans: system-ui, -apple-system, "Segoe UI", sans-serif;
  --font-mono: ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
}

* {
  box-sizing: border-box;
}

body {
  font-family: var(--font-sans);
  background: var(--bg);
  color: var(--text);
  margin: 0;
  padding: 1.5rem 2rem 3rem;
}

#app-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: 1rem;
  margin-bottom: 1.5rem;
  padding-bottom: 1rem;
  border-bottom: 1px solid var(--border);
}

#app-title h1 {
  margin: 0;
  font-size: 1.15rem;
  font-weight: 600;
  letter-spacing: 0.04em;
}

#app-nav {
  display: flex;
  gap: 0.25rem;
}

#app-nav a {
  padding: 0.4rem 0.75rem;
  border-radius: 6px;
  color: var(--text-muted);
  text-decoration: none;
  font-size: 0.78rem;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.06em;
}

#app-nav a:hover {
  color: var(--text);
  background: var(--panel-raised);
}

#app-nav a.active {
  color: var(--accent);
  background: rgba(34, 211, 238, 0.12);
}

/* The "Gear" group (Firearms/Ammo Types/Target Types) -- a plain
   <details>/<summary> disclosure, same pattern as compare.html's own
   #dashboard-more-filters, not a hand-rolled click-to-open dropdown, so
   this needs no JavaScript at all. */
#app-nav-gear {
  position: relative;
}

#app-nav-gear > summary {
  padding: 0.4rem 0.75rem;
  border-radius: 6px;
  color: var(--text-muted);
  font-size: 0.78rem;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  cursor: pointer;
  list-style: none;
  display: inline-flex;
  align-items: center;
  gap: 0.3rem;
}

#app-nav-gear > summary::-webkit-details-marker {
  display: none;
}

#app-nav-gear > summary::after {
  content: "▾";
  font-size: 0.6rem;
}

#app-nav-gear > summary:hover {
  color: var(--text);
  background: var(--panel-raised);
}

/* Reflects active_page, server-rendered -- deliberately NOT keyed off
   [open] too: opening the group to look inside it shouldn't itself
   claim "you are here," only actually being on one of its three pages
   should (same distinction #app-nav a.active already draws between
   hover and active). */
#app-nav-gear > summary.active {
  color: var(--accent);
  background: rgba(34, 211, 238, 0.12);
}

#app-nav-gear-menu {
  position: absolute;
  top: calc(100% + 4px);
  left: 0;
  z-index: 31;
  display: flex;
  flex-direction: column;
  gap: 0.1rem;
  min-width: 160px;
  padding: 0.4rem;
  background: var(--panel-raised);
  border: 1px solid var(--border-strong);
  border-radius: 8px;
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
}

#app-nav-gear-menu a {
  padding: 0.45rem 0.6rem;
  border-radius: 5px;
  color: var(--text);
  text-decoration: none;
  font-size: 0.8rem;
  font-weight: 500;
}

#app-nav-gear-menu a:hover {
  background: var(--panel);
  color: var(--accent);
}

#app-nav-gear-menu a.active {
  color: var(--accent);
}

#page-heading h2 {
  margin: 0 0 1.25rem;
  font-size: 0.8rem;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.12em;
  color: var(--text-muted);
}

/* Dashboard's three main section titles (Activity, Ranking, Point of Aim
   and Group Accuracy) only — not the page's own "Dashboard" title, the
   activity picker dialog's "Sessions" heading, or "Insufficient Data"
   (a sub-section of Ranking, not one of the three). #page-heading h2 is
   shared by nearly every template's page headings, so this overrides
   just color/size on the one class added to these three specific
   elements, rather than changing the shared rule itself; needs the
   #page-heading ancestor in the selector too, since a bare class alone
   is lower specificity than the id-qualified rule above and wouldn't
   win. */
#page-heading h2.dashboard-main-section-heading {
  color: #ffffff;
  font-size: 0.95rem;
}

main a {
  color: var(--accent);
}

main a:hover {
  text-decoration: none;
}

#toolbar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.85rem;
  margin-bottom: 1.25rem;
  padding: 0.75rem;
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: 8px;
}

#file-btn {
  display: inline-flex;
  align-items: center;
  padding: 0.5rem 0.9rem;
  background: var(--panel-raised);
  border: 1px solid var(--border-strong);
  border-radius: 6px;
  color: var(--text);
  font-size: 0.8rem;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  cursor: pointer;
}

#file-btn:hover {
  border-color: var(--accent);
  color: var(--accent);
}

.mode-group {
  display: flex;
  border: 1px solid var(--border-strong);
  border-radius: 6px;
  overflow: hidden;
}

.mode-btn {
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
  padding: 0.5rem 0.85rem;
  background: var(--panel-raised);
  border: none;
  border-right: 1px solid var(--border-strong);
  color: var(--text-muted);
  font-family: inherit;
  font-size: 0.8rem;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  cursor: pointer;
}

.mode-btn:last-child {
  border-right: none;
}

.mode-index {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 1.1rem;
  height: 1.1rem;
  border-radius: 50%;
  border: 1px solid currentColor;
  font-size: 0.65rem;
}

.mode-btn:not(:disabled):hover {
  color: var(--text);
}

.mode-btn:disabled {
  opacity: 0.35;
  cursor: not-allowed;
}

.mode-btn.mode-scale.active {
  background: rgba(250, 204, 21, 0.14);
  color: var(--accent-scale);
  box-shadow: inset 0 -2px 0 var(--accent-scale);
}

.mode-btn.mode-poa.active {
  background: rgba(239, 68, 68, 0.14);
  color: var(--accent-poa);
  box-shadow: inset 0 -2px 0 var(--accent-poa);
}

.mode-btn.mode-holes.active {
  background: rgba(59, 130, 246, 0.14);
  color: var(--accent-holes);
  box-shadow: inset 0 -2px 0 var(--accent-holes);
}

#mode-label {
  padding: 0.4rem 0.75rem;
  border: 1px solid var(--border-strong);
  border-radius: 999px;
  color: var(--text-muted);
  font-size: 0.78rem;
  white-space: nowrap;
}

#mode-label[data-mode="scale"] {
  color: var(--accent-scale);
  border-color: var(--accent-scale);
  background: rgba(250, 204, 21, 0.1);
}

#mode-label[data-mode="poa"] {
  color: var(--accent-poa);
  border-color: var(--accent-poa);
  background: rgba(239, 68, 68, 0.1);
}

#mode-label[data-mode="holes"] {
  color: var(--accent-holes);
  border-color: var(--accent-holes);
  background: rgba(59, 130, 246, 0.1);
}

#drop-zone {
  position: relative;
  min-height: 320px;
  padding: 1.25rem;
  background: var(--panel);
  border: 1px dashed var(--border-strong);
  border-radius: 10px;
  display: flex;
  align-items: center;
  justify-content: center;
}

#drop-zone.dragover {
  border-color: var(--accent);
  background: rgba(34, 211, 238, 0.06);
}

#drop-zone.has-image {
  display: block;
  min-height: 0;
  padding: 1.25rem;
  border-style: solid;
  border-color: var(--border);
}

#drop-hint {
  color: var(--text-muted);
  font-size: 0.85rem;
}

#drop-zone.has-image #drop-hint {
  display: none;
}

#canvas-toolbar {
  display: none;
  align-items: center;
  gap: 0.6rem;
  margin-bottom: 0.75rem;
}

#drop-zone.has-image #canvas-toolbar {
  display: flex;
}

#canvas-toolbar button {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 1.9rem;
  padding: 0.3rem 0.6rem;
  background: var(--panel-raised);
  border: 1px solid var(--border-strong);
  border-radius: 6px;
  color: var(--text);
  font-family: var(--font-mono);
  font-size: 0.85rem;
  cursor: pointer;
}

#canvas-toolbar button:hover {
  border-color: var(--accent);
  color: var(--accent);
}

#canvas-toolbar button:disabled {
  opacity: 0.35;
  cursor: not-allowed;
}

#canvas-toolbar button:disabled:hover {
  border-color: var(--border-strong);
  color: var(--text);
}

#undo-hole {
  margin-left: 0.5rem;
}

#zoom-level {
  min-width: 3.4rem;
  text-align: center;
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
  color: var(--text-muted);
  font-size: 0.82rem;
}

#canvas-viewport {
  display: none;
  max-height: 70vh;
  overflow: auto;
}

#drop-zone.has-image #canvas-viewport {
  display: block;
}

#target-canvas {
  display: block;
  background: #000;
  cursor: crosshair;
}

#target-canvas.pan-ready {
  cursor: grab;
}

#target-canvas.panning {
  cursor: grabbing;
}

#pan-hint {
  margin-left: 0.5rem;
  color: var(--text-muted);
  font-size: 0.75rem;
}

/* No max-width -- same "a fixed cap wastes a wide monitor" fix already
   applied to #hub-cards and /accuracy-by-distance's own page width. The
   700px cap here left the two .results-group boxes stuck at about a
   third of the window on a wide screen, next to a photo canvas
   (#drop-zone) that already had no cap of its own and used the full
   width. auto-fit (not auto-fill) is what makes this safe with only two
   real .results-group children: unused column tracks collapse to zero
   rather than sitting as visible empty gaps, so the two groups simply
   grow via their own 1fr share to fill whatever width is available,
   never leaving a phantom third column. */
#results {
  margin-top: 1.5rem;
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
  gap: 1rem;
}

.results-group {
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 0.9rem 1rem;
}

#session-summary {
  max-width: 500px;
  margin-bottom: 1.5rem;
}

.results-group h2 {
  margin: 0 0 0.6rem;
  font-size: 0.7rem;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.1em;
  color: var(--text-muted);
  border-bottom: 1px solid var(--border);
  padding-bottom: 0.5rem;
}

.result-row {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 0.75rem;
  padding: 0.3rem 0;
}

.result-row[hidden] {
  display: none;
}

.result-label {
  color: var(--text-muted);
  font-size: 0.82rem;
}

/* Custom tooltip, not the native title attribute — title's show delay and
   behavior are too inconsistent across browsers to rely on. */
.has-definition {
  position: relative;
  text-decoration: underline dotted;
  text-underline-offset: 0.15rem;
  cursor: help;
}

.has-definition::after {
  content: attr(data-definition);
  position: absolute;
  bottom: 100%;
  left: 0;
  z-index: 20;
  width: max-content;
  max-width: 260px;
  margin-bottom: 0.4rem;
  padding: 0.5rem 0.65rem;
  border: 1px solid var(--border-strong);
  border-radius: 6px;
  background: var(--panel-raised);
  color: var(--text);
  font-size: 0.75rem;
  font-weight: normal;
  line-height: 1.4;
  white-space: normal;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35);
  opacity: 0;
  visibility: hidden;
  transition: opacity 0.1s ease;
  pointer-events: none;
}

.has-definition:hover::after,
.has-definition:focus-visible::after {
  opacity: 1;
  visibility: visible;
}

/* Table headers sit inside .table-scroll, which clips anything positioned
   above it (overflow-x: auto forces overflow-y: auto too, per spec) — so
   these open downward and right-aligned instead of up and left. */
.data-table th.has-definition::after {
  top: 100%;
  bottom: auto;
  left: auto;
  right: 0;
  margin-top: 0.4rem;
  margin-bottom: 0;
  text-transform: none;
  letter-spacing: normal;
}

/* The dashboard's ranking/insufficient tables can still be too SHORT
   (few ranked firearms, or the ammo breakdown collapsed) for the
   downward tooltip above to fit before hitting .table-scroll's own
   bottom edge, which clips it there too — a table-height problem, not a
   direction problem, so flipping direction again can't fix it. These
   two tables' header tooltips are shown via JS instead
   (#dashboard-th-tooltip, position: fixed — immune to any ancestor's
   overflow, see dashboard.js), so the CSS-generated one is suppressed
   here specifically; every other .data-table (e.g. the firearm detail
   page's session history table, which isn't inside a clipping
   container) keeps the rule above untouched. */
#dashboard-ranking-table th.has-definition::after,
#dashboard-insufficient-table th.has-definition::after {
  content: none;
}

.result-value {
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
  font-size: 0.95rem;
  color: var(--text);
}

/* WHY A WRAPPED VALUE NEEDS THIS AND A SINGLE-LINE ONE DOES NOT.
   .result-row is flex with justify-content: space-between, which pushes
   the value's BOX to the right edge -- but the text INSIDE that box is
   left-aligned by default. On one line the box is exactly as wide as the
   text, so the two coincide and it looks flush. As soon as the value
   wraps, the box is as wide as its longest line and every shorter line
   sits at the box's left edge, so the right edge goes ragged. Measured on
   the session page's target card: "S&W 686 Single Action" ended 134px
   short, "Front Sight Focused" 81px, the session line 142px, and Group
   Metrics' three-line "Mean Point of Impact" 88px.

   Scoped to .result-row deliberately, NOT to .result-value generally.
   The same value class is used by the speed/accuracy card's figure
   cells, which are flex-direction: column -- label above value -- where
   right-aligning would push every figure to the far side of its column.
   Two different shapes share the class; only the label-left/value-right
   one wants this. */
.result-row .result-value {
  text-align: right;
}

/* Every firearm-page panel carrying .result-rows. Without a cap a panel
   stretches to the full content width, and .result-row is
   justify-content: space-between -- so the value is pushed to the far
   right edge, up to 1130px from the label it belongs to at a 1280px
   viewport, reading as an orphan rather than as that label's own figure.

   CAP THEM TOGETHER, NOT ONE AT A TIME. Two panels carried the cap and
   four did not, and fixing only the one that was reported is what
   produced a page alternating between 500px and 1216px -- Summary then
   sat full width between two narrow neighbours, its values stranded
   right, looking worse than before it was touched. Nothing compares a
   panel's width against its siblings', so a new panel omitted from this
   list does the same thing again, silently.

   #firearm-trend-inclusion is deliberately NOT here: it holds no
   .result-row, so it has no value to strand, and its checkbox label
   measures 466px against the 468px a capped panel leaves -- two pixels
   of slack, close enough that a font change wraps it onto a second
   line. It stays full width until it has a reason not to. */
#firearm-identity-panel,
#firearm-summary,
#firearm-trigger-weight-panel,
#firearm-notes-panel,
#firearm-offline-estimate,
#firearm-best-target-estimate {
  max-width: 500px;
  margin-bottom: 1.5rem;
}

#firearm-notes-panel .form-actions {
  margin-top: 0.6rem;
}

.notes-textarea {
  width: 100%;
  background: var(--panel-raised);
  border: 1px solid var(--border-strong);
  border-radius: 6px;
  color: var(--text);
  font-family: var(--font-sans);
  font-size: 0.85rem;
  padding: 0.5rem 0.6rem;
  resize: vertical;
}

.notes-textarea:focus {
  outline: none;
  border-color: var(--accent);
}

.panel-hint {
  margin: -0.2rem 0 0.5rem;
  color: var(--text-muted);
  font-size: 0.78rem;
}

/* Dashboard-only: a description sitting below the section it describes
   (activity chart, ranking block, point-of-aim cards — see
   dashboard.html) rather than under a heading above it. .panel-hint's
   and .pooled-note's own negative top margin was tuned for the
   under-a-heading position (pulls the text up close to it); this
   overrides that with a little breathing room from the content above
   instead, without changing either shared class for the other pages
   that still use them in the original, above-content position. */
.dashboard-section-caption {
  margin-top: 0.75rem;
}

#firearm-offline-estimate .form-actions {
  margin-top: 0.6rem;
}

.inline-input {
  width: 110px;
  background: var(--panel-raised);
  border: 1px solid var(--border-strong);
  border-radius: 6px;
  color: var(--text);
  font-family: var(--font-mono);
  font-size: 0.85rem;
  padding: 0.3rem 0.5rem;
  text-align: right;
}

.inline-input:focus {
  outline: none;
  border-color: var(--accent);
}

/* .inline-input's own 110px/right-aligned defaults suit a short number
   (trigger weight, offline rounds, ...) -- firearm_detail.html's own
   Firearm Identity panel (Name/Make/Model/Serial Number) needs the same
   look but sized and aligned for a real string instead. */
.inline-input--text {
  width: 220px;
  text-align: left;
}

/* Profile pages: firearms, ammo types, target types */

.profile-form {
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 1rem 1.25rem;
  margin-bottom: 1.25rem;
  max-width: 900px;
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
  gap: 0.85rem;
  /* start, not end -- a real report of the marking page's Save Target
     form fields not lining up. end bottom-aligns every .field within
     its own grid row to the row's tallest item; several of this form's
     own fields (Target Type's "View reference photo" button, Grip's
     and Optic's "Carried over from your last target" hints -- see
     index.html) only sometimes add extra height below their own
     label+input, and whenever one does, every OTHER field sharing that
     row got pushed down to match its bottom, while fields in the row
     before and after stayed put -- the labels and inputs across a row
     no longer shared one top edge. start anchors every field's own
     label+input to the top of its row instead, which is unaffected by
     what any sibling field happens to be showing beneath itself.
     Checked the other five pages sharing this class (firearms, ammo
     types, target types, sessions, session detail) for the same
     pattern -- a field with conditional trailing content sharing a row
     with plain fields -- and none of them have it; every conditional
     block on those pages is either its own .field.wide (spanning the
     full row alone, nothing to misalign against) or otherwise doesn't
     share a row with a shorter field the way this page's own grip/optic
     fields do. */
  align-items: start;
}

/* Companion rule -- see constraint the derived test enforces:
   .profile-form sets display, so without this the plain `hidden`
   attribute does nothing. sessions.html's #session-form is edit-only and
   starts hidden; it was rendering on every page load. */
.profile-form[hidden] {
  display: none;
}

/* .profile-form's own 900px cap left the marking page's Save Target
   form -- ~10 fields, the widest use of this shared class -- stuck at
   about a third of the window, next to a photo canvas (#drop-zone) that
   already has no cap and used the full width; same "a fixed cap wastes
   a wide monitor" fix as #hub-cards and #results just above. auto-fit
   with only 180px as its minimum means a wide window fits genuinely
   more field columns per row here, not just wider ones -- unlike
   #results' two boxes, this form has enough fields (session, firearm,
   ammo type, target type, distance, rounds fired, grip, optic, notes,
   include photo) to actually use that extra width rather than just
   stretching a couple of boxes thin. Scoped to this one form by ID,
   not the shared .profile-form class itself: firearms.html,
   ammo_types.html, target_types.html, sessions.html, and
   session_detail.html all reuse that class too, several with far fewer
   fields, and widening it for all of them without checking each one
   first risked stretching a short form (e.g. ammo type's six fields)
   uncomfortably thin across a wide screen. Same class of fix, just not
   applied everywhere it could be yet. */
#save-form {
  max-width: none;
}

/* Same "a fixed cap wastes a wide monitor" fix, scoped the same way --
   the sessions page's own form has only 3 fields normally visible
   (Date, Location, Venue; 4 more inside .conditions-group when Venue is
   Outdoor), far fewer than #save-form's ~10, so this is the one place
   in this pass where I'm less sure removing the cap outright is
   actually an improvement rather than just stretching a date input and
   a short text field uncomfortably wide on a very wide monitor -- flag
   this one for a look, unlike the others in this file. */
#session-form {
  max-width: none;
}

/* The sessions table itself had the same shared table.data-table cap
   (900px) every other page using that class also has -- scoped to this
   page's own table by id rather than widened on the shared rule, since
   .data-table is also used by firearms/ammo-types/target-types (see
   #firearms-table just below for the first of those) and by Compare's
   own ranking table, which already has its own horizontal-scroll
   handling (.table-scroll) and column-specific rules that a blanket
   width change risked interacting with badly. */
#sessions-table {
  max-width: none;
}

/* Same fix as #session-form -- 5 fields (Name, Make, Model, Caliber,
   Serial Number), more than the sessions page's 3-7 but still well
   short of #save-form's ~10. Removing the cap outright is a more
   moderate bet than the sessions page's own 3-field case, but check it
   too. */
#firearm-form {
  max-width: none;
}

#firearms-table {
  max-width: none;
}

/* Same fix again -- 6 fields (Name, Caliber, Bullet Type, Grains, Box
   Price, Rounds per Box), the same moderate case as #firearm-form. */
#ammo-type-form {
  max-width: none;
}

#ammo-types-table {
  max-width: none;
}

.profile-form h3 {
  grid-column: 1 / -1;
  margin: 0;
  font-size: 0.75rem;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.1em;
  color: var(--text-muted);
}

.field {
  display: flex;
  flex-direction: column;
  gap: 0.3rem;
}

.field[hidden] {
  display: none;
}

.conditions-group {
  grid-column: 1 / -1;
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
  gap: 0.85rem;
}

.conditions-group[hidden] {
  display: none;
}

.field.wide {
  grid-column: 1 / -1;
}

.field label {
  font-size: 0.72rem;
  color: var(--text-muted);
  text-transform: uppercase;
  letter-spacing: 0.06em;
}

/* A field's value was carried over as a default (see marking.js's
   updateGripOpticDefaults), not chosen — the amber accent already used
   for calibration draws the eye without implying an error, since a
   carried-over default isn't wrong until the user leaves it wrong. Any
   manual change to the field hides this immediately. */
/* Small muted guidance under a form field. The class already existed and
   was used once (index.html's mark-setup note) with no rule at all, so it
   rendered at full body size; giving it one styles that usage too. Muted
   rather than accent-coloured, deliberately: .carried-over-hint below is
   amber because it reports a value the app filled in and the user did not
   choose (never-overstate-what-is-known), which is a different and louder
   job than explaining what a field is for. */
/* .empty-state centres its text, which is right for the one-line "No X
   yet" every other list page uses it for and wrong for the four
   paragraphs the Drills page puts there. Centred prose at this width ran
   about 110 characters a line, which is roughly double a comfortable
   measure and hard to track back to the start of the next line. Scoped
   to this one block rather than changed on .empty-state itself, because
   every other user of that class genuinely is a single centred line.
   The heading stays centred; only the explanation is set left. */
#drill-empty p:not(:first-child) {
  max-width: 66ch;
  margin-left: auto;
  margin-right: auto;
  text-align: left;
}

/* The shot-times format choice. A fieldset rather than a select, and
   styled to sit quietly under its label: both options have to stay
   visible, because the whole point is that the reader can see which of
   the two things their timer is showing them. See index.html's own note. */
.radio-group {
  margin: 0.25rem 0 0.4rem;
  padding: 0.5rem 0.65rem;
  border: 1px solid var(--border);
  border-radius: 6px;
}

.radio-group legend {
  padding: 0 0.35rem;
  font-size: 0.72rem;
  color: var(--text-muted);
}

.radio-group label {
  display: flex;
  align-items: baseline;
  gap: 0.45rem;
  margin: 0.15rem 0;
  font-size: 0.78rem;
  text-transform: none;
  letter-spacing: normal;
  color: var(--text);
}

/* The example numbers beside each option, monospaced so they read as
   data rather than prose. */
.radio-group label .field-hint {
  margin: 0;
  font-family: var(--font-mono);
}

/* The interpretation shown back before saving. Not a warning and not an
   error: it is the app stating what it understood, which is the only
   thing standing between a misread timer column and silently wrong
   figures. */
#save-shot-times-preview {
  margin-top: 0.4rem;
  padding: 0.5rem 0.65rem;
  border: 1px solid var(--border);
  border-radius: 6px;
  background: var(--panel-raised);
  font-size: 0.75rem;
  color: var(--text-muted);
}

#save-shot-times-preview dl {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: 0.15rem 0.75rem;
  margin: 0 0 0.4rem;
}

#save-shot-times-preview dd {
  margin: 0;
  font-family: var(--font-mono);
  color: var(--text);
}

/* The three counts, which are independent and often differ
   (rounds-holes-independent). Shown together so the disagreement is
   visible, never reconciled. */
#save-shot-times-preview .count-row {
  font-family: var(--font-mono);
  color: var(--text);
}

/* The Timing panel on a target card.

   IT SPANS BOTH COLUMNS, and that is a deliberate choice between four that
   were rendered and compared on screen rather than reasoned about.
   .target-card-panels is `repeat(auto-fit, minmax(220px, 1fr))` across the
   578px the panels area gets inside a 900px card, which is exactly two
   281px columns -- three would need 3x220 plus gaps, or 692px, and cannot
   fit. So a third panel lands alone in row two with an empty cell beside
   it, reading as something that fell off the bottom rather than a peer of
   the other two.

   The alternatives and what they cost, so this is not relitigated from
   scratch: three columns needs the card widened to 1080px, which breaks
   "Mean Point of Impact" across four lines and widens every card on the
   page by 20%; moving Details into the photo column puts the two
   measurement panels side by side but opens a larger empty region
   bottom-right and makes a card with no run 245px TALLER. Spanning is the
   only one of the four that removes the empty cell in both states, costs
   nothing typographically, and shortens the card (1014px to 827px). What
   it gives up is adjacency between Timing and Group Metrics, the two
   measurement panels -- judged worth less than clean typography. */
.target-card-panels .timing-panel {
  grid-column: 1 / -1;
}

/* Two-across inside the band, so a full-width panel is short rather than a
   tall column stretched sideways. auto-fit rather than a fixed two, so it
   collapses to one column if the panels grid itself ever drops to one -- a
   hardcoded two would be unreadable in a 281px panel. */
.timing-figures {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
  column-gap: 1.25rem;
  align-content: start;
}

/* The pass/fail verdict, and the caveat that always accompanies it. */
/* Spans the whole figures grid. .timing-figures is two-across, so
   without this the verdict lands in the right-hand column beside Drill
   rather than beneath it -- squeezing a full sentence and its caveat into
   half the panel, and putting the run's headline result somewhere the eye
   reaches second. */
.timing-verdict-block {
  grid-column: 1 / -1;
  margin: 0.25rem 0 0.35rem;
}

/* Green and red are this app's own existing pair -- #22c55e is the same
   literal Personal bests uses for "improved", and --accent-poa is the
   same red the marking screen uses for point of aim. The verdict wraps,
   unlike every other .result-value, because it carries a sentence rather
   than a figure -- which is why white-space is set here and text-align
   is not. This was the one place someone had already hit the ragged-wrap
   problem and fixed it locally; .result-row .result-value now covers it
   for every value in that shape, and keeping a redundant declaration
   here would suggest this value is special in a way it is not. */
.timing-verdict {
  font-weight: 700;
  white-space: normal;
}

.timing-verdict--pass {
  color: #22c55e;
}

.timing-verdict--fail {
  color: var(--accent-poa);
}

/* Shown on every drill target that has a verdict, and never collapsed --
   see buildTimingVerdict's own note on why hiding it behind a click
   would defeat it. */
.timing-verdict-caveat {
  margin: 0.3rem 0 0;
  font-size: 0.7rem;
  line-height: 1.4;
  color: var(--text-muted);
}

/* A row action's failure, in a full-width row directly under the row it
   belongs to. The .form-error inside carries the colour; this only has
   to stop the cell adding its own table padding above the message. */
.row-error-row td {
  padding-top: 0;
}

/* Card 6's control row. Follows card 3's own .option-comparison-*
   controls exactly rather than inventing a layout: five selectors is
   past what a hub-card header holds, and card 3 is the existing answer
   for a card with more controls than fit beside its heading.

   The [hidden] companions are required, not decorative: a rule setting
   display beats the user-agent stylesheet's [hidden] { display: none }
   regardless of specificity, so without them the plain `hidden`
   attribute silently does nothing. See
   tests/templates/test_hidden_attribute_is_not_overridden.py, which
   derives its subjects and would fail on their absence. */
.speed-accuracy-controls {
  display: block;
}

.speed-accuracy-controls[hidden] {
  display: none;
}

.speed-accuracy-selector-row {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  margin-top: 0.6rem;
}

/* flex: 1 1 150px, card 3's own basis: the five share the width evenly
   while there is room, and wrap to a second and third row rather than
   squeezing below legibility. That is what makes the narrow fallbacks
   work without a media query of their own. */
.speed-accuracy-field {
  flex: 1 1 150px;
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: 0.2rem;
}

.speed-accuracy-field[hidden] {
  display: none;
}

.speed-accuracy-field label {
  font-size: 0.7rem;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--text-muted);
}

/* The same six appearance properties .option-comparison-select and
   .hub-card-distance-select both carry. Written out rather than
   inherited from either: this card's selects are neither card 3's nor a
   header select, and the first version of this rule set only width and
   min-width, which left five white default browser dropdowns on a dark
   card -- caught by looking at a screenshot, since every geometry check
   passed on it. */
.speed-accuracy-select {
  width: 100%;
  min-width: 0;
  background: var(--panel-raised);
  border: 1px solid var(--border-strong);
  border-radius: 6px;
  color: var(--text);
  font-family: var(--font-sans);
  font-size: 0.8rem;
  padding: 0.3rem 0.5rem;
}

/* Card 6's verdict line in its tooltip. The detail row's own verdict
   needs no rule of its own -- it is an ordinary .speed-accuracy-figure
   cell whose value carries .timing-verdict--pass/--fail for the colour,
   so green and red are defined once and the two cards cannot drift to
   different shades of the same meaning. */
.speed-accuracy-tooltip-verdict {
  margin-top: 0.25rem;
  font-weight: 700;
  white-space: normal;
}

/* The three-counts note. Muted and wrapped, because it is an explanation
   of why the numbers above disagree, not one of the numbers. */
.timing-count-note {
  grid-column: 1 / -1;
  margin: 0.5rem 0 0;
  font-size: 0.72rem;
  line-height: 1.4;
  color: var(--text-muted);
}

/* The interpretation shown back before a correction is saved -- the same
   thing the Mark tab shows, in a panel about a third the width. One
   .result-row per figure rather than a run-on line: the dot that
   separates the figures is also the dot that separates the splits within
   one of them, so on a single line there is no way to see where the
   splits end. */
.shot-times-preview {
  margin-top: 0.4rem;
  padding: 0.5rem 0.65rem;
  border: 1px solid var(--border);
  border-radius: 6px;
  background: var(--panel-raised);
  font-size: 0.75rem;
}

.shot-times-preview-heading {
  margin: 0 0 0.3rem;
  color: var(--text-muted);
}

/* Two-across, matching .timing-figures directly above it. These rows are
   .result-row, which is space-between, so across the full 544px band a
   label and its value ended up at opposite ends of a very long gap. The
   same auto-fit rule keeps them paired and collapses to one column when
   the band is narrow. */
.shot-times-preview-body {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
  column-gap: 1.25rem;
  align-content: start;
  margin: 0;
  font-family: var(--font-mono);
  line-height: 1.5;
  color: var(--text);
}

.field-hint {
  margin: 0.15rem 0 0;
  font-size: 0.72rem;
  color: var(--text-muted);
}

.carried-over-hint {
  margin: 0;
  font-size: 0.72rem;
  color: var(--accent-scale);
}

.checkbox-field label {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  font-size: 0.85rem;
  color: var(--text);
  text-transform: none;
  letter-spacing: normal;
  cursor: pointer;
}

#session-show-archived-field {
  margin-bottom: 0.75rem;
}

tr.archived-row td {
  color: var(--text-muted);
}

.archived-tag {
  margin-left: 0.4rem;
  font-size: 0.7rem;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--text-muted);
}

.checkbox-field input[type="checkbox"] {
  width: 16px;
  height: 16px;
  accent-color: var(--accent);
}

.field input,
.field select,
.field textarea {
  background: var(--panel-raised);
  border: 1px solid var(--border-strong);
  border-radius: 6px;
  color: var(--text);
  font-family: var(--font-sans);
  font-size: 0.85rem;
  padding: 0.45rem 0.6rem;
}

.field textarea {
  resize: vertical;
}

.field input:focus,
.field select:focus,
.field textarea:focus {
  outline: none;
  border-color: var(--accent);
}

.form-error {
  grid-column: 1 / -1;
  color: var(--accent-poa);
  font-size: 0.8rem;
  /* readableErrorMessage (error_format.js) joins multiple field errors
     with \n — a plain <p>'s default white-space collapses that into
     one run-on line, which would defeat the point of listing them
     separately. pre-line renders each \n as a real line break while
     still collapsing ordinary whitespace/wrapping normally, so this
     never affects the single-line, single-error case. */
  white-space: pre-line;
}

.form-error.success {
  color: var(--accent);
}

.form-actions {
  grid-column: 1 / -1;
  display: flex;
  gap: 0.5rem;
}

.form-actions button {
  padding: 0.5rem 1rem;
  border-radius: 6px;
  font-size: 0.8rem;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  cursor: pointer;
}

.form-actions .primary {
  background: var(--accent);
  border: 1px solid var(--accent);
  color: #04252b;
}

.form-actions .secondary {
  background: var(--panel-raised);
  border: 1px solid var(--border-strong);
  color: var(--text);
}

table.data-table {
  width: 100%;
  max-width: 900px;
  border-collapse: collapse;
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: 8px;
  overflow: hidden;
}

table.data-table th,
table.data-table td {
  padding: 0.6rem 0.85rem;
  text-align: left;
  border-bottom: 1px solid var(--border);
  font-size: 0.85rem;
}

table.data-table th {
  color: var(--text-muted);
  font-size: 0.68rem;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  font-weight: 600;
  background: var(--panel-raised);
}

#dashboard-ranking-table th {
  font-size: 0.78rem;
  font-weight: 700;
  color: var(--text);
  text-align: center;
  line-height: 1.2;
}

/* Sortable headers: clicking sorts the table (see wireRankingSort in
   dashboard.js). Direction toggles on the already-active column;
   clicking a new column starts it at ascending. */
#dashboard-ranking-table th.sortable {
  cursor: pointer;
}

#dashboard-ranking-table th.sortable:hover {
  color: var(--accent);
}

#dashboard-ranking-table th.sortable.sort-active {
  color: var(--accent);
}

/* Its own line below the header label, not inline trailing after it —
   inline placement used to pull the label's apparent center left,
   since text-align centers the label-plus-arrow as one unit rather
   than the label alone. Block placement keeps the label centered on
   its own and lets the arrow center independently beneath it. Height
   (not just width) is reserved even when empty/inactive, so a header's
   total content height — and therefore where every header bottom-aligns
   to (see the shared height below) — never depends on whether that
   particular column happens to be the active sort. Reused as-is by
   personal_bests.js's own sortable distance-column headers
   (.pb-col-label--sortable, below) -- not redeclared there. */
.sort-indicator {
  display: block;
  width: fit-content;
  height: 0.75rem;
  line-height: 0.75rem;
  font-size: 0.7rem;
  margin: 0.15rem auto 0;
}

#dashboard-ranking-table td,
#dashboard-insufficient-table th,
#dashboard-insufficient-table td {
  text-align: center;
}

/* Rank split into its own fixed-width column, separate from the toggle
   chevron — a wide rank number no longer competes with the chevron for
   space in one cell, so neither wraps. */
#dashboard-ranking-table th.col-chevron,
#dashboard-ranking-table td.col-chevron {
  width: 1.8rem;
  white-space: nowrap;
}

#dashboard-ranking-table th.col-rank,
#dashboard-ranking-table td.col-rank {
  width: 2rem;
  white-space: nowrap;
}

/* No longer sharing a cell with adjacent rank text (see .col-chevron
   above), so the spacing that used to separate them is dead weight —
   the base .row-toggle rule (shared with firearm_detail.js's session
   history table, where its button still shares a cell with text) stays
   untouched. */
#dashboard-ranking-table .col-chevron .row-toggle {
  margin-right: 0;
}

/* Header labels bottom-align to a common baseline, so a two-line label
   (e.g. "Avg Extreme Spread") and a one-line label (e.g. "Shots") sit
   flush together instead of each centering independently within a row
   whose height is set by the tallest label. The explicit height is a
   fixed anchor comfortably fitting a two-line label plus the sort
   indicator's own reserved line — box-sizing: border-box (set
   globally) includes the cell's padding in it. Table cells treat
   height as a minimum, never a clip: if a particular browser's font
   metrics need more room than this, the row simply grows, and every
   header still bottom-aligns to that shared (now-taller) row exactly
   the same way. */
#dashboard-ranking-table th,
#dashboard-insufficient-table th {
  height: 3.6rem;
  vertical-align: bottom;
}

/* Values where a mid-content line break would obscure meaning — the
   column widens to fit instead of wrapping. */
.nowrap-cell {
  white-space: nowrap;
}

table.data-table td.numeric {
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
}

table.data-table tr:last-child td {
  border-bottom: none;
}

table.data-table tr:hover td {
  background: rgba(255, 255, 255, 0.03);
}

table.data-table td a.row-link {
  display: block;
  margin: -0.6rem -0.85rem;
  padding: 0.6rem 0.85rem;
  font-weight: 600;
}

.row-actions {
  display: flex;
  gap: 0.4rem;
}

.row-actions[hidden] {
  display: none;
}

.row-actions button {
  padding: 0.25rem 0.6rem;
  background: var(--panel-raised);
  border: 1px solid var(--border-strong);
  border-radius: 5px;
  color: var(--text);
  font-family: var(--font-sans);
  font-size: 0.75rem;
  cursor: pointer;
}

.row-actions button:hover {
  border-color: var(--accent);
  color: var(--accent);
}

.row-actions button.danger:hover {
  border-color: var(--accent-poa);
  color: var(--accent-poa);
}

.delete-confirm-row {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 0.75rem;
  margin: 0.75rem 0;
  padding: 0.75rem 1rem;
  background: rgba(239, 68, 68, 0.08);
  border: 1px solid var(--accent-poa);
  border-radius: 6px;
}

.delete-confirm-row[hidden] {
  display: none;
}

.delete-confirm-text {
  flex: 1 1 280px;
  font-size: 0.82rem;
  color: var(--text);
}

.delete-confirm-row .row-actions {
  flex: 0 0 auto;
}

.delete-confirm-row button.danger {
  background: var(--accent-poa);
  border-color: var(--accent-poa);
  color: #fff;
}

.delete-confirm-row button.danger:hover {
  opacity: 0.9;
}

.empty-state {
  max-width: 900px;
  padding: 2rem;
  text-align: center;
  color: var(--text-muted);
  font-size: 0.85rem;
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: 8px;
  margin-top: -1px;
}

.target-card {
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 1rem 1.25rem;
  margin-bottom: 1rem;
  max-width: 900px;
  transition: border-color 0.2s, box-shadow 0.2s;
}

.target-card.highlighted {
  border-color: var(--accent);
  box-shadow: 0 0 0 2px var(--accent);
}

.table-scroll {
  overflow-x: auto;
  max-width: 100%;
}

.table-scroll table.data-table {
  max-width: none;
}

.target-card-header {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  align-items: center;
  gap: 0.75rem;
  padding-bottom: 0.75rem;
  margin-bottom: 0.9rem;
  border-bottom: 1px solid var(--border);
}

.target-card-title {
  font-size: 0.92rem;
}

.target-card-title .muted {
  color: var(--text-muted);
}

.target-card-number {
  display: inline-block;
  font-family: monospace;
  font-weight: 700;
  font-size: 0.85em;
  color: var(--accent);
  border: 1px solid var(--accent);
  border-radius: 4px;
  padding: 0.05rem 0.4rem;
  margin-right: 0.3rem;
}

.target-card-body {
  display: flex;
  flex-wrap: wrap;
  gap: 1.25rem;
}

.target-photo-wrap {
  flex: 0 0 260px;
  max-width: 260px;
}

.target-photo-wrap canvas {
  display: block;
  width: 100%;
  height: auto;
  border-radius: 6px;
  border: 1px solid var(--border);
  background: var(--panel-raised);
}

.target-photo-placeholder {
  display: flex;
  align-items: center;
  justify-content: center;
  height: 180px;
  border-radius: 6px;
  border: 1px dashed var(--border-strong);
  color: var(--text-muted);
  font-size: 0.8rem;
  text-align: center;
  padding: 0.5rem;
}

.target-photo-link {
  display: inline-block;
  margin-top: 0.5rem;
  font-size: 0.75rem;
}

.target-card-panels {
  flex: 1 1 420px;
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
  gap: 1rem;
  align-content: start;
}

.target-card-panels .results-group {
  margin: 0;
}

.metrics-empty {
  color: var(--text-muted);
  font-size: 0.82rem;
}

.pooled-note {
  color: var(--text-muted);
  font-size: 0.82rem;
  max-width: 60ch;
}

/* Dashboard-only: this section's pooled note sits below the whole
   ranking block rather than above the table (see dashboard.html), so
   the shared .pooled-note's 60ch cap — sized for a note sitting next to
   a narrower table column — no longer applies. Scoped to this one
   element by id rather than changing .pooled-note itself, which
   firearm_detail.html and session_detail.html also use and weren't
   asked to change. */
#dashboard-ranking-note {
  max-width: none;
}

#pooled-metrics-groups {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
  gap: 1rem;
  margin-bottom: 1.5rem;
}

#pooled-metrics-groups .results-group {
  margin: 0;
}

#performance-controls {
  display: flex;
  flex-wrap: wrap;
  gap: 0.85rem;
  margin-bottom: 1rem;
}

#performance-controls .field {
  max-width: 220px;
}

#performance-groups {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
  gap: 1rem;
  margin-bottom: 1.5rem;
}

#performance-groups .results-group {
  margin: 0;
}

#trend-groups,
#session-history-groups {
  display: flex;
  flex-direction: column;
  gap: 1rem;
  margin-bottom: 1.5rem;
}

#trend-groups .results-group,
#session-history-groups .results-group {
  margin: 0;
}

.trend-canvas-wrap {
  height: 260px;
}

.reference-canvas-wrap {
  margin-top: 0.6rem;
}

.reference-canvas-wrap canvas {
  max-width: 100%;
  height: auto;
  cursor: crosshair;
  border: 1px solid var(--border);
}

dialog {
  background: var(--panel);
  color: var(--text);
  border: 1px solid var(--border-strong);
  border-radius: 8px;
  padding: 1.25rem 1.5rem;
  max-width: min(90vw, 700px);
}

dialog::backdrop {
  background: rgba(0, 0, 0, 0.6);
}

#target-type-reference-view-canvas {
  cursor: default;
}

#activity-session-picker-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 0.4rem;
}

#activity-session-picker-list a {
  font-size: 0.95rem;
}

.chart-panel {
  margin-top: 1.25rem;
}

.chart-panel-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.75rem;
  margin-bottom: 0.6rem;
  border-bottom: 1px solid var(--border);
  padding-bottom: 0.5rem;
}

.chart-panel-header h2 {
  margin: 0;
  border-bottom: none;
  padding-bottom: 0;
}

.chart-panel-label {
  font-size: 0.7rem;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.1em;
  color: var(--text-muted);
}

.chart-view-toggle {
  font-size: 0.8rem;
  padding: 0.25rem 0.5rem;
  width: auto;
}

/* Hub card 4's activity strip -- see activity.js. Its body content is
   rebuilt on every load (loading/populated/failed), same convention as
   firearm-range-body's own populated flex-direction override below. */
#activity-body.activity-body--populated {
  flex-direction: column;
  align-items: stretch;
  min-width: 0;
  gap: 0.75rem;
}

.activity-bars-wrap {
  position: relative;
}

.activity-canvas {
  display: block;
  width: 100%;
  height: 100px;
  cursor: default;
}

.activity-tooltip {
  position: absolute;
  z-index: 20;
  padding: 0.5rem 0.65rem;
  border: 1px solid var(--border-strong);
  border-radius: 6px;
  background: var(--panel-raised);
  color: var(--text);
  font-size: 0.75rem;
  line-height: 1.5;
  white-space: nowrap;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35);
  pointer-events: none;
  transform: translate(-50%, calc(-100% - 8px));
}

.activity-tooltip[hidden] {
  display: none;
}

.activity-tooltip-firearm {
  display: flex;
  align-items: center;
  gap: 0.35rem;
  margin-top: 0.3rem;
  padding-top: 0.3rem;
  border-top: 1px solid var(--border);
  font-weight: 600;
}

.activity-tooltip-swatch {
  flex: 0 0 auto;
  width: 0.6rem;
  height: 0.6rem;
  border-radius: 2px;
}

.activity-caption {
  margin-top: 0.75rem;
}

/* The rounds-by-gun summary -- appended after .activity-caption (see
   activity.js's own renderActivity), so it's always the last thing in
   the card regardless of how much vertical room the chart itself
   leaves above it. */
.activity-rounds-summary {
  margin-top: 0.5rem;
  font-size: 0.78rem;
}

.activity-rounds-summary-label {
  margin: 0 0 0.4rem;
  color: var(--text-muted);
}

/* Two columns, not a wrapping row -- a longer firearm list stays a
   tidy, scannable list instead of wrapping unpredictably across lines. */
.activity-rounds-summary-grid {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: 0.35rem 1.5rem;
}

.activity-rounds-summary-item {
  display: inline-flex;
  align-items: center;
  gap: 0.35rem;
  min-width: 0;
  color: var(--text);
  font-variant-numeric: tabular-nums;
}

/* Personal bests -- a firearm x yardage grid, each
   populated cell that firearm's own single best target at that
   distance (see [personal-bests-single-target-grid]). */
#personal-bests-body.personal-bests-body--populated {
  flex-direction: column;
  align-items: stretch;
  gap: 0.75rem;
}

.pb-wrap {
  overflow-x: auto;
}

.pb-grid {
  display: grid;
  gap: 5px;
}

.pb-corner {
}

.pb-col-label,
.pb-row-label {
  display: flex;
  align-items: center;
  font-family: var(--font-mono);
  font-size: 0.72rem;
  font-weight: 600;
  color: var(--text);
}

.pb-col-label {
  flex-direction: column;
  justify-content: center;
  padding-bottom: 0.3rem;
}

/* Clicking a distance column header sorts firearm rows by that one
   column's value under the active metric (see renderPersonalBests in
   personal_bests.js) -- same click/hover/active-color convention as
   Compare's own sortable ranking-table headers
   (#dashboard-ranking-table th.sortable, above), and reuses that same
   .sort-indicator class for the arrow. */
.pb-col-label--sortable {
  cursor: pointer;
}

.pb-col-label--sortable:hover {
  color: var(--accent);
}

.pb-col-label--sortable.sort-active {
  color: var(--accent);
}

.pb-col-label--sortable:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
  border-radius: 4px;
}

.pb-row-label {
  justify-content: flex-start;
  padding-right: 0.5rem;
}

.pb-cell {
  background: var(--panel-raised);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 0.5rem 0.6rem;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.3rem;
  text-decoration: none;
  cursor: pointer;
  transition: border-color 0.15s ease, transform 0.15s ease;
}

/* Whatever cell the mouse is over gets the same blue outline+number
   treatment regardless of whether it's also a column's own best (green,
   see .pb-cell.column-best below) -- hovering always wins while the
   mouse is actually there; see .pb-cell.column-best:hover for the
   explicit override that guarantees this rather than relying on
   .pb-cell:hover and .pb-cell.column-best happening to have equal
   specificity and hoping source order alone decides the winner. */
.pb-cell:hover {
  border-color: var(--accent);
  box-shadow: 0 0 0 1px var(--accent);
  transform: translateY(-1px);
}

.pb-cell:hover .pb-cell-value {
  color: var(--accent);
}

.pb-cell:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 1px;
}

/* Target left, value alone right -- the pair is centered together
   within the cell (inherited from .pb-cell's own align-items: center,
   since this wrapper shrinks to fit its own content rather than
   stretching full width). Date and grip technique live in their own row
   below this one (see .pb-cell-footer below), not stacked in here --
   a cell's total height is the mini target's own height plus padding
   plus that one footer row, nothing more. */
.pb-cell-main {
  display: flex;
  flex-direction: row;
  align-items: center;
  gap: 0.5rem;
}

/* The measurement is this card's headline content -- the one number a
   reader is scanning for -- so it carries the largest type in the cell.
   Raised from 0.95rem on 7 September 2026 by request. It costs no row
   height: the mini target canvas beside it is a fixed 40px (cellPx,
   personal_bests.js) and drives .pb-cell-main's height on its own, so
   the value has headroom well past this before it could grow a row.
   Row height is the constraint this card's layout is most careful
   about -- firearm count, and therefore row count, is unbounded -- so
   check that in a real browser, not a fake DOM, before raising it
   further. */
.pb-cell-value {
  font-family: var(--font-mono);
  font-size: 1.15rem;
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  color: var(--text);
}

/* Date (left corner) and grip technique (right corner, when the
   winning target recorded one -- see buildPersonalBestsGripLabel,
   personal_bests.js) share one row below the mini target/value row.
   align-self: stretch so this spans the cell's full width despite
   .pb-cell's own align-items: center (which would otherwise shrink it,
   and everything in it, to hug its own content) -- only a full-width
   row lets space-between actually push the two into opposite corners
   rather than just sitting side by side in the middle. A lone date (no
   grip to show) still lands flush left under space-between, not
   centered, so the "opposite corners" layout degrades gracefully with
   only one thing to show. */
/* Three fixed columns -- date left, aiming technique center, grip
   right -- rather than flex with space-between. The two technique
   labels are each optional (omitted, never a placeholder dash, when the
   winning target didn't record one), and under space-between a missing
   aim would have re-centered the grip and a missing grip would have
   re-centered the aim: the same cell's labels would sit in different
   places from row to row. Explicit grid columns pin each label to its
   own slot, so an absent one simply leaves a gap. The outer 1fr columns
   are equal, which is what actually keeps the middle label centered on
   the cell rather than merely between its neighbours. */
/* Three EQUAL columns, each minmax(0, 1fr), and every label stretched to
   fill its own column with plain text-align rather than justify-self.
   Both halves of that matter and both were got wrong first time:

   - Three EQUAL columns. This is the only sizing under which the middle
     label is centered on the CELL rather than merely somewhere between
     its neighbours, and being centered on the cell is the whole point of
     the middle slot. `auto minmax(0,1fr) minmax(0,1fr)` was tried, to
     give the fixed-width date exactly its natural width, and it put the
     aiming technique 23.8px left of center in every single cell -- the
     date column's own width, pushed onto everything after it. Measured,
     not guessed, and the reason the date now ellipsises instead of
     claiming its own width: keeping the date whole is worth less than
     keeping the middle label actually in the middle.
   - minmax(0, 1fr) plus text-align, not justify-self. A grid item with
     justify-self: center/end is sized to its CONTENT, not to its track,
     so min-width/overflow never engage and a long label simply overflows
     sideways over its neighbour. That is not theoretical: "Front Sight
     Focused" and "Modern Thumbs-Forward" rendered on top of each other
     in a real browser, while every unit test passed and even a
     scrollWidth>clientWidth check reported no clipping -- nothing is
     clipped when the element is free to grow. Stretched items are sized
     by the track, so min-width: 0 lets them shrink and the ellipsis
     below does its job. */
.pb-cell-footer {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  align-items: center;
  align-self: stretch;
  gap: 0.25rem;
}

/* var(--text), not the dimmer --text-muted this used before -- a real
   report that the date was hard to read. Same bright color
   .pb-cell-value already uses, so date and value share one legible
   tier of text, with only the grip label (below) carrying a color of
   its own. */
.pb-cell-date {
  grid-column: 1;
  text-align: left;
  /* nowrap + ellipsis, never wrap. Its column is an equal third now (see
     .pb-cell-footer), so at the narrowest grid this card can produce --
     six distance columns -- there is a few pixels less than the date
     wants. Ellipsising costs a character; wrapping would add a line to
     every row in the grid, and row count here is unbounded. */
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  min-width: 0;
  font-family: var(--font-mono);
  font-size: 0.62rem;
  font-variant-numeric: tabular-nums;
  color: var(--text);
}

/* The middle of the three, same font family, size and weight as
   .pb-cell-grip opposite it -- they are peers, so neither should read
   as more important than the other. Color is never set here: each
   cell's own label carries its technique's color inline, from
   aimColorMap (aim_colors.js), the same convention .pb-cell-grip and
   drawPersonalBestsMiniTarget already follow.

   min-width: 0 with an ellipsis because this is the one label whose
   text can be long ("Front Sight Focused") in the narrowest column of a
   grid whose width is driven by six distance columns. It truncates
   rather than widening the cell and pushing the grid into a horizontal
   scroll; the full name is always in the cell's own hover title. */
.pb-cell-aim {
  grid-column: 2;
  text-align: center;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-family: var(--font-mono);
  font-size: 0.62rem;
  font-weight: 700;
}

/* Same font size as .pb-cell-date right beside it (0.62rem, the
   smallest existing text on this card -- "small, but readable" per the
   request this exists for), bold so its per-technique color (set
   inline per cell, from gripColorMap -- grip_colors.js) still reads
   clearly at this size. Color itself is never set here -- each cell's
   own label carries its technique's color as an inline style, the same
   convention drawPersonalBestsMiniTarget already uses for its
   per-firearm shot color. */
.pb-cell-grip {
  grid-column: 3;
  text-align: right;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-family: var(--font-mono);
  font-size: 0.62rem;
  font-weight: 700;
}

.pb-cell-empty {
  background: transparent;
  border: 1px dashed var(--border);
  border-radius: 8px;
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--border-strong);
  font-size: 0.75rem;
}

/* The lowest value WITHIN each column (one highlighted cell per
   distance, not one across the whole grid -- see
   personalBestsBestKeysByColumn's own comment in personal_bests.js and
   [personal-bests-single-target-grid]). Green (#22c55e), the same
   literal this app already uses for "improved" elsewhere
   (.firearm-trend-card-change-pill.firearm-trend-summary-better,
   .firearm-progress-change-box--better), not a new color invented for
   this. */
.pb-cell.column-best {
  border-color: #22c55e;
  box-shadow: 0 0 0 1px #22c55e;
}

.pb-cell.column-best .pb-cell-value {
  color: #22c55e;
}

.pb-cell.column-best:hover {
  border-color: var(--accent);
  box-shadow: 0 0 0 1px var(--accent);
}

.pb-cell.column-best:hover .pb-cell-value {
  color: var(--accent);
}

.pb-caption {
  margin: 0;
  font-size: 0.75rem;
  color: var(--text-muted);
}

/* The marker on a cell whose firearm/distance had targets held out by the
   qualification gate, and the footnote explaining it. Both muted: this is
   a caveat on a figure, not a warning about one -- the number itself is
   correct, it is simply the best of what qualified. Amber or red would
   read as "something is wrong here", which would be its own kind of
   overstatement.

   No max-width on the note. It sits under a full-width grid, the same
   reasoning that lifted the cap on card 3's own pooled note. */
.pb-cell-excluded-marker {
  color: var(--text-muted);
  font-size: 0.7em;
  margin-left: 0.1em;
  /* The value is the cell's headline number and is vertically centred
     against the mini target beside it; a raised marker must not add to
     the line's height and push that alignment around. */
  line-height: 0;
  vertical-align: super;
}

.pb-excluded-note {
  margin: 0 0 0.4rem;
  font-size: 0.75rem;
  color: var(--text-muted);
  max-width: none;
}

/* position: fixed (viewport-relative, not document-flow-relative) so
   this can never be clipped by .table-scroll's overflow, regardless of
   how short the ranking/insufficient table is — see dashboard.js's
   showThTooltip/hideThTooltip and the CSS note on
   #dashboard-ranking-table th.has-definition::after above. */
#dashboard-th-tooltip {
  position: fixed;
  z-index: 50;
  width: max-content;
  max-width: 260px;
  padding: 0.5rem 0.65rem;
  border: 1px solid var(--border-strong);
  border-radius: 6px;
  background: var(--panel-raised);
  color: var(--text);
  font-size: 0.75rem;
  line-height: 1.4;
  white-space: normal;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35);
  pointer-events: none;
}

#dashboard-th-tooltip[hidden] {
  display: none;
}

.row-toggle {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  background: var(--panel-raised);
  border: 1px solid var(--border-strong);
  border-radius: 5px;
  color: var(--accent);
  cursor: pointer;
  font-size: 0.8rem;
  line-height: 1;
  width: 1.6rem;
  height: 1.6rem;
  margin: -0.3rem 0.85rem -0.3rem 0;
  padding: 0;
  vertical-align: middle;
}

.row-toggle:hover {
  border-color: var(--accent);
}

.row-toggle:disabled {
  visibility: hidden;
}

.session-history-row-expandable {
  cursor: pointer;
}

.session-history-row-expandable:hover {
  background: var(--panel-raised);
}

.session-target-row {
  color: var(--text-muted);
  font-size: 0.85em;
}

.session-target-row td:first-child {
  padding-left: 1.6rem;
}

/* Dashboard */

#dashboard-controls {
  position: sticky;
  top: 0;
  z-index: 30;
  display: flex;
  flex-wrap: wrap;
  align-items: flex-end;
  gap: 1.25rem;
  background: var(--bg);
  padding: 0.85rem 0;
  margin-bottom: 1rem;
  border-bottom: 1px solid var(--border);
}

#dashboard-controls .field {
  min-width: 140px;
}

#dashboard-more-filters {
  position: relative;
}

/* A native <select> carries its own OS/engine-specific intrinsic sizing
   that identical padding/border/font alone won't override consistently
   across browsers. Turning that off for the row's selects and giving
   the "More filters" trigger the exact same box model (plus a matching
   drawn-on arrow) is the only way to guarantee they render at one
   consistent size rather than merely looking close in one engine. */
#dashboard-controls .field select,
#dashboard-more-filters-summary {
  appearance: none;
  -webkit-appearance: none;
  -moz-appearance: none;
  display: block;
  height: 2.15rem;
  line-height: 1.2;
  background-color: var(--panel-raised);
  background-repeat: no-repeat;
  background-position: right 0.65rem center;
  background-size: 10px 6px;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%237d8590'/%3E%3C/svg%3E");
  border: 1px solid var(--border-strong);
  border-radius: 6px;
  color: var(--text);
  font-family: var(--font-sans);
  font-size: 0.85rem;
  font-weight: normal;
  padding: 0.45rem 1.6rem 0.45rem 0.6rem;
  white-space: nowrap;
  cursor: pointer;
}

#dashboard-more-filters-summary {
  list-style: none;
}

#dashboard-more-filters-summary::-webkit-details-marker {
  display: none;
}

#dashboard-more-filters[open] #dashboard-more-filters-summary {
  border-color: var(--accent);
}

#dashboard-more-filters-fields {
  position: absolute;
  top: 100%;
  left: 0;
  z-index: 31;
  display: flex;
  flex-wrap: wrap;
  gap: 1rem;
  margin-top: 0.4rem;
  padding: 0.85rem;
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: 6px;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}

#dashboard-summary {
  margin: 0 0 0.35rem;
  color: var(--text-muted);
  font-size: 0.82rem;
  white-space: nowrap;
}

.tabular-figures td.numeric,
.tabular-figures th {
  font-variant-numeric: tabular-nums;
}

.nested-row {
  color: var(--text-muted);
  font-size: 0.85em;
}

.nested-row td:nth-child(3) {
  padding-left: 1.6rem;
}

.firearm-sub {
  color: var(--text-muted);
  font-size: 0.78em;
}

.link-button {
  background: none;
  border: none;
  color: var(--accent);
  cursor: pointer;
  font-size: 0.85rem;
  padding: 0.2rem 0.6rem 0.2rem 0;
  text-decoration: underline;
}

#dashboard-empty-distance-links {
  display: flex;
  flex-wrap: wrap;
  gap: 0.25rem;
}

#dashboard-insufficient-section {
  margin-top: 1.5rem;
  padding-top: 1rem;
  border-top: 1px dashed var(--border-strong);
}

#dashboard-firearm-trend-panel {
  margin-top: 2.5rem;
}

.firearm-trend-axis-label {
  margin: -0.2rem 0 0.5rem;
  color: var(--text-muted);
  font-size: 0.7rem;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.08em;
}

#dashboard-firearm-trend-cards {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
  gap: 1rem;
}

/* Fixed, identical height on every card regardless of firearm name or
   summary length — the header is reserved space above the plot, never
   part of it, so no card's plot area can be squeezed relative to another
   (see renderFirearmTrendCards in dashboard.js). overflow: hidden plus
   the child rules below mean this height can never grow. */
.firearm-trend-card-header {
  height: 1.6rem;
  flex-wrap: nowrap;
  overflow: hidden;
}

.firearm-trend-card-header h2 {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  min-width: 0; /* lets the name truncate instead of forcing the row wider */
}

/* A colored top edge identifying the card's firearm at a glance — same
   color as its scatter/trend line and its activity-strip segments (see
   the module docstring in dashboard.js). Only the width/fallback color
   live here; the actual per-firearm color is set inline in JS, since
   it's assigned at runtime from a palette, not known to static CSS. */
.firearm-trend-card {
  border-top-width: 3px;
  border-top-color: var(--border);
}

.firearm-trend-card-badges {
  display: flex;
  align-items: center;
  gap: 0.35rem;
  flex-shrink: 0;
}

.firearm-trend-card-value-pill,
.firearm-trend-card-change-pill {
  display: inline-flex;
  align-items: center;
  padding: 0.1rem 0.5rem;
  border-radius: 999px;
  font-size: 0.72rem;
  font-weight: 600;
  white-space: nowrap;
}

.firearm-trend-card-value-pill {
  background: var(--panel-raised);
  border: 1px solid var(--border);
  color: var(--text);
}

/* Neutral by default (no change yet, or no prior session to compare) —
   same muted treatment the plain-text summary used before this became a
   pill. One deliberate, scoped exception to the Dashboard's "no color
   implies good/bad" rule (see Claude.md) — green when the latest
   session's mean radius improved (lower) on the one before it, red when
   it worsened (higher). */
.firearm-trend-card-change-pill {
  background: rgba(125, 133, 144, 0.16);
  color: var(--text-muted);
}

.firearm-trend-card-change-pill.firearm-trend-summary-better {
  background: rgba(34, 197, 94, 0.16);
  color: #22c55e;
}

.firearm-trend-card-change-pill.firearm-trend-summary-worse {
  background: rgba(239, 68, 68, 0.16);
  color: #ef4444;
}

.firearm-trend-card-canvas-wrap {
  height: 180px;
}

/* The scatter canvas has a fixed width/height attribute (see
   FIREARM_SCATTER_CANVAS_SIZE in dashboard.js) so its drawing is a true
   square with equal aspect ratio; max-width/height here only ever
   shrinks it responsively on a narrow card, scaling both dimensions
   together so the square never distorts. */
#dashboard-scatter-view-field {
  max-width: 220px;
  margin-bottom: 0.75rem;
}

.firearm-scatter-canvas-wrap {
  position: relative;
  display: flex;
  justify-content: center;
  margin-bottom: 0.5rem;
}

.firearm-scatter-canvas-wrap canvas {
  max-width: 100%;
  height: auto;
}

/* Only shown in the "per session" scatter view (see
   attachScatterSessionInteractivity in dashboard.js) — the "all shots"
   cloud has no per-dot tooltip, since individual shots aren't clickable. */
/* Brought into line with the other four tooltip classes on 8 September
   2026. This one alone had font-size 0.72rem, padding 0.35rem 0.5rem and
   no line-height, against 0.75rem / 0.5rem 0.65rem / 1.5 everywhere
   else. Nothing about a scatter's tooltip needs to be smaller than the
   activity strip's or the range card's, and no comment ever claimed a
   reason -- it read as drift, and the values were measured in a browser
   before and after rather than assumed. */
.firearm-scatter-tooltip {
  position: absolute;
  z-index: 20;
  padding: 0.5rem 0.65rem;
  border: 1px solid var(--border-strong);
  border-radius: 6px;
  background: var(--panel-raised);
  color: var(--text);
  font-size: 0.75rem;
  line-height: 1.5;
  white-space: nowrap;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35);
  pointer-events: none;
  transform: translate(-50%, calc(-100% - 8px));
}

.firearm-scatter-tooltip[hidden] {
  display: none;
}

/* Login page — standalone, doesn't extend base.html (no app nav to show
   before you're authenticated), so it needs its own minimal layout. */
#login-page {
  min-height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 1.5rem;
}

#login-card {
  width: 100%;
  max-width: 340px;
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 1.75rem;
}

#login-card h1 {
  margin: 0 0 1.25rem;
  font-size: 1.1rem;
  font-weight: 600;
  text-align: center;
}

#login-form .field {
  margin-bottom: 0.9rem;
}

#login-form .form-actions {
  margin-top: 1.1rem;
}

#login-form .form-actions .primary {
  width: 100%;
}

/* App header: current user + logout, shown on every authenticated page. */
#app-user {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  font-size: 0.8rem;
  color: var(--text-muted);
}

#app-user-logout {
  background: none;
  border: 1px solid var(--border-strong);
  border-radius: 5px;
  color: var(--text-muted);
  font-size: 0.75rem;
  font-family: var(--font-sans);
  padding: 0.3rem 0.6rem;
  cursor: pointer;
}

#app-user-logout:hover {
  color: var(--text);
  border-color: var(--accent);
}

#instructions-toc {
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 0.9rem 1.2rem;
  margin-bottom: 1.5rem;
  max-width: 500px;
}

#instructions-toc ol {
  margin: 0;
  padding-left: 1.2rem;
}

#instructions-toc li {
  margin: 0.2rem 0;
}

.instructions-section {
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 1.2rem 1.5rem;
  margin-bottom: 1.5rem;
  max-width: 70ch;
  line-height: 1.55;
}

.instructions-section h1 {
  margin-top: 0;
  font-size: 1.4rem;
}

.instructions-section h2 {
  font-size: 1.05rem;
  margin-top: 1.5rem;
  border-bottom: 1px solid var(--border);
  padding-bottom: 0.4rem;
}

.instructions-section h3 {
  font-size: 0.95rem;
  margin-top: 1.2rem;
}

.instructions-section p,
.instructions-section ul,
.instructions-section ol {
  margin: 0.7rem 0;
}

.instructions-section li {
  margin: 0.3rem 0;
}

.instructions-section code {
  font-family: var(--font-mono);
  background: var(--panel-raised);
  padding: 0.1rem 0.3rem;
  border-radius: 4px;
  font-size: 0.9em;
}

/* Small, unobtrusive contextual link to a specific Instructions
   section — used sparingly next to a form field, panel heading, or
   control bar. Never a button, never a full sentence of its own. */
.help-link {
  font-size: 0.72rem;
  color: var(--text-muted);
  text-decoration: none;
  border-bottom: 1px dotted var(--text-muted);
  white-space: nowrap;
}

.help-link:hover {
  color: var(--accent);
  border-bottom-color: var(--accent);
}

/* --- Hub (Claude.md Phase 3): six cards, each fetching independently --- */

/* Three columns at desktop width, fewer as the window narrows, one on a
   phone -- the standard repeat(auto-fit, minmax(...)) idiom already used
   elsewhere on this page (e.g. #dashboard-firearm-trend-cards). No
   max-width here (a real report: on a fully extended Safari window,
   this grid was capped at 1400px and used only 3/4 to 7/8 of the
   available width -- the same "a fixed cap wastes a wide monitor"
   problem /accuracy-by-distance's own page-width cap already went
   through) -- the grid now uses the full width body's own padding
   leaves it.
   The old fix for "never more than 3 columns" was max-width itself: at
   1400px, exactly 3 columns of ~420-460px fit and a 4th never had room.
   That trick stops working once the cap is gone -- auto-fit would
   happily grow to 4, 5, 6 columns on a wide-enough monitor, since
   nothing else limits the column COUNT, only each column's own minimum
   width. The media query below is the real fix: past 1400px viewport
   width (comfortably past the point 3 real columns need -- body's own
   2rem+2rem padding plus 3 * 420px plus 2 * 1.25rem gaps), the layout
   switches from responsive auto-fit to a FIXED 3-column grid that
   simply fills whatever width is available, however wide that grows --
   cards get wider, never more numerous. Below 1400px, auto-fit's own
   ordinary responsive behavior (3, then 2, then 1 column as the window
   narrows) is unchanged. */
#hub-cards {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(420px, 1fr));
  gap: 1.25rem;
}

@media (min-width: 1400px) {
  #hub-cards {
    grid-template-columns: repeat(3, 1fr);
  }

  /* Card 5 ("How far can I still shoot well?") used to span two rows
     here, filling row 2's otherwise-empty third cell. It no longer needs
     to: collapsing its two background paragraphs behind a disclosure took
     roughly 195px off it, and the speed/accuracy card now occupies that
     cell as an ordinary 1x1 item, so the grid reads as two rows of three
     with Personal bests full width beneath.

     Shrinking prose shrinks that card because the chart never drove its
     height: .firearm-range-chart-wrap is flex: 1 with a 150px minimum, so
     the canvas absorbs whatever vertical space is left rather than
     setting it. See firearm_range_chart.js for which paragraph may NOT be
     collapsed, and why that is a licensing requirement. */
}

/* Personal bests grows with the shooter's own firearm count
   (see [personal-bests-single-target-grid]), so it's the one card
   whose natural height is genuinely unpredictable -- left as an
   ordinary grid item, its growth would stretch whatever row it lands
   in (today, cards 3 and 4) taller to match, the same row-height
   coupling every other card in this grid is exempt from. `1 / -1`
   spans from the grid's first column line to its last regardless of
   how many columns #hub-cards currently has (3 fixed above 1400px,
   otherwise auto-fit's own responsive count) -- always its own full
   row, never sharing height with a sibling, at every viewport width.
   hub.html's own DOM order (1, 2, 5, then 3, 4, then 7) is what keeps
   this card last in reading order too, not just widest. */
/* The populated body stacks more than one block-level child (chart,
   caption, exclusion notes), so it needs its own flex-direction: column
   -- .hub-card-body defaults to row, which would put them side by side.
   Required by tests/templates/test_populated_body_flex_direction.py,
   which caught this card missing it. */
.hub-card-body.speed-accuracy-body--populated,
.hub-card-body.speed-accuracy-body--empty {
  flex-direction: column;
  align-items: stretch;
}

/* flex: 1 so this fills the card body, which is itself flex: 1 inside the
   card. Without it the inner block is only as tall as its content and the
   chart's own flex: 1 has nothing to grow into -- the chart sat at its
   150px minimum with the rest of the card empty beneath it. */
.speed-accuracy-inner {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
  width: 100%;
  flex: 1;
  min-height: 0;
}

/* flex: 1 so the chart absorbs whatever vertical space the card has,
   rather than a fixed height leaving the rest of the card empty -- the
   same shape .firearm-range-chart-wrap uses, and for the same reason. A
   grid row is as tall as its tallest card, so a short card is stretched
   whether it wants to be or not; this at least spends the space on the
   chart. position: relative + an absolutely-filled canvas so the canvas
   contributes nothing to its parent's own height, which would otherwise
   feed back into the flex sizing. */
.speed-accuracy-chart-wrap {
  flex: 1;
  min-height: 150px;
  width: 100%;
  position: relative;
}

.speed-accuracy-chart-wrap canvas {
  position: absolute;
  inset: 0;
  display: block;
  width: 100%;
  height: 100%;
}

/* Two selectors in one header. .hub-card-distance-select carries
   margin-left: auto to push a single selector to the right edge; with two
   of them the second gets pushed away from the first, leaving a gap
   across the middle of the header. Only the first needs the auto margin
   -- the header's own flex gap then holds them together. */
.hub-card-distance-select + .hub-card-distance-select {
  margin-left: 0;
}

/* Same shape as .firearm-range-tooltip and .activity-tooltip: absolutely
   positioned inside the (position: relative) chart wrap, anchored above
   the cursor by the transform. Positioned from JS with individual CSSOM
   properties only. */
.speed-accuracy-tooltip {
  position: absolute;
  z-index: 20;
  padding: 0.5rem 0.65rem;
  border: 1px solid var(--border-strong);
  border-radius: 6px;
  background: var(--panel-raised);
  color: var(--text);
  font-size: 0.75rem;
  line-height: 1.5;
  white-space: nowrap;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35);
  pointer-events: none;
  transform: translate(-50%, calc(-100% - 8px));
}

/* Flipped below the cursor when there is no room above -- the transform
   re-anchors from the tooltip's bottom centre to its top centre. */
.speed-accuracy-tooltip--below {
  transform: translate(-50%, 8px);
}

.speed-accuracy-tooltip-headline {
  font-weight: 700;
}

/* Names which run the figures below belong to. It is what makes showing
   a run the reader did not choose honest -- see
   speedAccuracyDefaultDetailPoint. Spans the whole figure grid. */
.speed-accuracy-figures-heading {
  grid-column: 1 / -1;
  margin: 0;
  font-size: 0.72rem;
  color: var(--text-muted);
}

/* The one-run state: the figures, not a scatter. Laid out across the
   card's width rather than as a narrow left-hand column -- this card is
   full width, and five stacked rows in a 32rem block left most of it
   empty, which reads as something missing rather than as a deliberate
   summary. auto-fit so it collapses to fewer columns on a narrow
   viewport instead of squeezing five. */
.speed-accuracy-single {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
  gap: 0.75rem 1.5rem;
  align-content: start;
}

.speed-accuracy-figure {
  display: flex;
  flex-direction: column;
  gap: 0.1rem;
}

.speed-accuracy-caption,
.speed-accuracy-exclusion {
  margin: 0;
  font-size: 0.75rem;
  line-height: 1.45;
  color: var(--text-muted);
  max-width: 80ch;
}

#hub-cards .hub-card[data-card-id="7"],
#hub-cards .hub-card[data-card-id="8"] {
  grid-column: 1 / -1;
}

.hub-card {
  display: flex;
  flex-direction: column;
  gap: 0.6rem;
  /* A grid item's default min-width is auto, meaning it refuses to
     shrink below its content's own min-content size -- if that content
     (e.g. an unwrapping name, several fixed-size canvases side by side)
     is wider than #hub-cards' 420px column minimum, the whole column
     (and every card sharing it) stretches to fit, which is what made
     card 1 render far wider than cards 2/3 even with no explicit
     full-width span. This is the standard fix. */
  min-width: 0;
  /* .results-group's own shared border (--border, #262b31) sits close
     enough to --panel's own background (#14171b) to read as barely
     there -- a deliberate, brighter override scoped to hub cards
     specifically (not every .results-group panel elsewhere in the
     app), so each card's own edge reads clearly against the six-card
     grid it sits in. Brighter than --border-strong (#383f47, tried
     first) -- a literal, not a shared token, since nothing else in the
     app needs this exact value yet. */
  border-color: #4d5560;
}

/* min-height reserves room for the longest title (card 3's "Grip
   Technique, Ammunition, and Sights") wrapped across two lines --
   1.5rem * 1.2 line-height * 2 lines, matching .hub-card-question's own
   explicit line-height below -- so every header ends up the SAME total
   height whether its own title wraps or not, and whether it carries a
   trailing <select> (cards 1, 2, 4, 5, 6) or not (card 3). Without this,
   a card whose title happens to wrap sizes its own header taller than a
   card whose title fits on one line, and align-items: center then
   centers the badge and select within two DIFFERENT header heights --
   same absolute vertical start (the grid row's own top), but different
   centers, so badges/selects drift out of alignment with each other
   card to card. */
.hub-card-header {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 0.7rem;
  min-height: 3.6rem;
}

/* Solid filled circle, per design/hub-mockup-dark.png -- shown the same
   way on all six cards, since the mockup uses one consistent badge
   style throughout, not something specific to card 1. --accent-holes
   is reused here (not a new color): it's the app's existing blue,
   already used for marking holes on the marking screen, and it's the
   closest existing token to the mockup's badge blue. Enlarged (was
   1.6rem/0.75rem, then 2.1rem/1rem) alongside .hub-card-question below,
   both deliberately sized to read as a page-level section break rather
   than routine body text -- .hub-card-header switched from
   align-items: baseline to center for this same change, since a badge
   this size no longer reads as text sharing a baseline with the title
   next to it. */
.hub-card-number {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
  width: 2.7rem;
  height: 2.7rem;
  border-radius: 999px;
  background: var(--accent-holes);
  font-size: 1.2rem;
  font-weight: 700;
  color: #fff;
}

.hub-card-question {
  margin: 0;
  font-size: 1.5rem;
  font-weight: 700;
  line-height: 1.2;
  color: var(--text);
}

.hub-card-subtitle {
  margin: 0;
  font-size: 0.78rem;
  color: var(--text-muted);
}

/* flex: 1 is the grid-level fix, not a per-card one: #hub-cards is a
   grid row whose track height is set by its tallest card (card 1's
   three scatters, in the top row), and grid items stretch to that
   height by default -- so every shorter card's own .hub-card (a column
   flexbox: header, subtitle, this body) ends up taller than its natural
   content, with nothing to claim the leftover space. Before this rule,
   that space collected below .hub-card-body, inside the card but below
   its own footer content, since a flex column with no growable child
   leaves extra space at the end (flex-start's default). Making the body
   itself the one growable child means it absorbs that space instead --
   for the plain single-message cards (3-6, and 2/1 in their loading/
   empty/failed states) the message just centers in the taller area,
   already this rule's own align-items: center; for card 2's own
   populated state, this is what gives .firearm-progress-chart-wrap
   (style.css, below) real extra height to grow into rather than fixed
   space to sit in front of. Card 1 itself is unaffected: it's the one
   defining the row height, so there's never leftover space here to
   absorb. */
.hub-card-body {
  margin-top: 0.4rem;
  min-height: 4.5rem;
  display: flex;
  align-items: center;
  border-top: 1px solid var(--border);
  padding-top: 0.6rem;
  flex: 1;
}

.hub-card-message {
  margin: 0;
  font-size: 0.82rem;
}

/* The four states (see hub.js's renderState) are visually distinct by
   both wording and color, the same "colored text, not a colored panel"
   treatment .form-error already uses elsewhere -- never a color-only
   signal. Loading and "no data yet" share the same muted, neutral
   color deliberately: neither is bad news, and Claude.md's dashboard
   convention against implying good/bad without cause applies here too.
   "No data yet" is additionally italic so it reads as a description of
   what's missing, not a status report. */
.hub-card-body--loading .hub-card-message {
  color: var(--text-muted);
}

.hub-card-body--populated .hub-card-message {
  color: var(--text);
}

.hub-card-body--empty .hub-card-message {
  color: var(--text-muted);
  font-style: italic;
}

.hub-card-body--failed .hub-card-message {
  color: var(--accent-poa);
}

/* --- Card 1, "Which gun do I shoot best?" (Claude.md Phase 3 part 2b,
   matched against design/hub-mockup-dark.png's approved layout): a
   distance selector in the header, three scatters distributed evenly
   across the card's own single grid column, and a summary strip along
   the bottom. --- */

/* The real bug behind "the summary strip renders beside the panels
   instead of below them": .hub-card-body (shared by all six cards) is
   display:flex with no flex-direction, so its default is row --
   correct for the OTHER five cards' single centered message line, but
   wrong the moment this card's populated state puts two block-level
   children (the panels grid, then the summary row) inside it. The ID
   selector scopes this to card 1's body specifically, and only its
   populated state -- the loading/empty/failed single-message states
   still want the original centered row behavior every other card's
   body uses, so only .firearm-comparison-body--populated is
   overridden here. align-items: stretch (flex's own default once
   align-items isn't set to center) is what makes each child, in
   particular the summary row, take the container's full width rather
   than shrinking to its own content -- which is also what was letting
   the date range spill toward the next card: an unstretched flex
   child sizes to its content, not its container, so nothing was ever
   constraining it to this card's own width. min-width: 0 throughout
   this chain is the same defensive fix as .hub-card's own (see
   above) -- any of these could otherwise refuse to shrink below an
   unwrapped child's content size and force the row wider than the
   card.
*/
#firearm-comparison-body.firearm-comparison-body--populated {
  flex-direction: column;
  align-items: stretch;
  min-width: 0;
  /* The panels box below now draws its own top border (see
     .firearm-comparison-panels) -- keeping this shared border too
     would double it up with a second, redundant line right above it.
     margin-top/padding-top are left alone, so the gap before the box
     stays the same size, just without a line of its own. */
  border-top: none;
  padding-top: 0;
  /* When this card's own content is shorter than a taller sibling in
     the same hub grid row, .hub-card-body's flex: 1 grows this element
     beyond its natural content height (see that rule's own comment) --
     with no growable child of its own to absorb that (unlike card 2,
     which grows its chart via .firearm-progress-chart-wrap; this
     card's three scatter panels are fixed-size and shouldn't stretch),
     that leftover space used to collect below .firearm-comparison-summary-row,
     leaving Dig Deeper stranded above the card's actual bottom edge
     instead of landing in the same corner it does on every other card.
     space-between sends any genuinely leftover space to the gap
     between the panels and the summary row instead -- the summary
     row's own margin-top: 0.75rem stays intact as a floor for the
     normal (unstretched) case, since space-between only adds to gaps,
     never removes from them. */
  justify-content: space-between;
}

.hub-card-distance-select {
  margin-left: auto;
  background: var(--panel-raised);
  border: 1px solid var(--border-strong);
  border-radius: 6px;
  color: var(--text);
  font-family: var(--font-sans);
  font-size: 0.8rem;
  padding: 0.3rem 0.5rem;
}

/* A row-major CSS grid -- children are appended in field order (all
   names, then all canvases, then all radius values, ...), not grouped
   per firearm, so a CSS grid row's own height (set by its tallest
   cell) keeps every column's name/scatter/radius/label/shot-count/
   badge at the same height as its neighbors regardless of one
   firearm's name wrapping to two lines (see renderComparison,
   app/static/js/firearm_comparison.js). grid-template-columns is set
   inline by renderComparison, one column per entry (1, 2, or 3, never
   hardcoded) -- a fixed repeat(3, 1fr) here misaligned every row
   whenever the comparison had fewer than three entries, since CSS
   Grid's auto-placement fills a fixed column count in document order
   with no idea where one field-row's cells were meant to end and the
   next begin; it has no way to know "row" is a JS-side authoring
   convention rather than something the grid itself enforces. Row-gap
   is deliberately small and column-gap is zero -- the per-cell
   border-right below (see .firearm-comparison-panel-divider) needs
   adjoining cells to form one visually continuous divider, the same
   effect as the mockup's column rules. */
/* Its own rounded border, per design/hub-mockup-dark.png -- inset from
   the outer card border by the card's own existing padding
   (.results-group), not by extra margin here. */
.firearm-comparison-panels {
  display: grid;
  row-gap: 0.2rem;
  margin-top: 0.4rem;
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 0.75rem 0.25rem;
}

.firearm-comparison-panels > * {
  min-width: 0;
  padding: 0 0.35rem;
  text-align: center;
}

/* A thin vertical rule between columns, like the mockup's column
   dividers. Applied by renderComparison to every cell except the ones
   in the comparison's actual last column -- never by a fixed
   :nth-child(3n)-style selector, which is exactly what broke alignment
   with fewer than three entries (see the module comment above): it
   can't know the real column count is 1, 2, or 3 without being told. */
.firearm-comparison-panel-divider {
  border-right: 1px solid var(--border);
}

/* Off-white text, not colored -- the swatch beside it carries the
   firearm's color instead (deliberate: colored text at this size on a
   dark background is harder to read than off-white, and this keeps
   identity and legibility separate). Larger and bolder than the old
   0.78rem/inherited weight so the name reads with real prominence,
   matching the mockup, without needing to color it to get there. */
.firearm-comparison-panel-name {
  margin: 0;
  font-size: 0.95rem;
  font-weight: 700;
  color: var(--text);
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 0.35rem;
}

/* Reinforces the firearm's own color (set inline in JS -- assigned at
   runtime from firearm_colors.js's palette, not known to static CSS,
   the same convention the Dashboard's per-firearm coloring already
   uses). The name text itself, not this swatch, is what identifies the
   firearm -- color is never relied on alone. */
.firearm-comparison-swatch {
  display: inline-block;
  width: 0.55rem;
  height: 0.55rem;
  border-radius: 999px;
  flex-shrink: 0;
}

/* Every scatter is the same pixel dimensions (SCATTER_CANVAS_SIZE,
   app/static/js/firearm_comparison.js) and centered in its own grid
   cell -- only the dot positions differ between panels, never the
   circle's own size. This wrap (not the canvas itself) is now the grid
   cell: position: relative so its own two children -- the canvas and
   its DOM label layer, .firearm-comparison-scatter-labels below -- can
   both be absolutely positioned to fill it exactly, the same pattern
   firearm_range_chart.js's own chart-wrap/canvas pairing already uses.
   padding: 0 overrides .firearm-comparison-panels > *'s own cell
   padding -- that rule was written for the text cells (name, radius,
   shot count, ...), and letting it apply here too would inset the
   canvas and its labels from what their own coordinate math (computed
   in JS against this exact width/height) assumes. */
.firearm-comparison-scatter-wrap {
  position: relative;
  width: 104px;
  height: 104px;
  padding: 0;
  justify-self: center;
}

/* One firearm, nothing to compare yet: renderComparison adds this
   class to that one scatter wrap so it fills the card's width instead
   of sitting at the three-panel size with empty space beside it.
   Matches SCATTER_CANVAS_SIZE_SINGLE (app/static/js/firearm_comparison.js)
   -- drawFirearmComparisonScatter sets the canvas element's width/height
   ATTRIBUTES to this size times devicePixelRatio (the drawing buffer's
   real resolution, for a crisp result on a Retina display); this CSS
   rule sets the DISPLAYED size, deliberately not the same number
   anymore -- see that function's own comment for why the two are no
   longer meant to match. */
.firearm-comparison-scatter-wrap--large {
  width: 260px;
  height: 260px;
}

.firearm-comparison-scatter {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
}

/* The three vertical-axis tick labels, real DOM text now instead of
   canvas fillText (see buildScatterLabels, firearm_comparison.js, and
   that file's own module comment for why). pointer-events: none here,
   once, covers every label inside -- this scatter has no interaction
   today, but the convention is established regardless, since a chart
   that DOES need hover/click on its own canvas (through this layer)
   must never have a label silently eat the event first. */
.firearm-comparison-scatter-labels {
  position: absolute;
  inset: 0;
  pointer-events: none;
}

/* Same small/muted treatment as this card's own other secondary labels
   (.firearm-comparison-metric-label) -- real DOM text gets the
   browser's own hinting/antialiasing, so it no longer needs
   chart_colors.js's brighter CHART_AXIS_LABEL_COLOR, which existed
   specifically to compensate for canvas text's own lack of it. */
.firearm-comparison-scatter-label {
  position: absolute;
  font-size: 0.68rem;
  color: var(--text-muted);
  white-space: nowrap;
}

/* Mean radius is the dominant element on each panel -- large and bold,
   per the approved mockup -- with its label and shot count beneath it,
   smaller and quieter. */
/* The dominant figure on the panel, per the approved mockup -- larger
   than .hub-card-question (1.5rem), the biggest other text on the
   card. */
.firearm-comparison-metric-value {
  margin: 0.3rem 0 0;
  font-size: 1.6rem;
  font-weight: 700;
  color: var(--text);
  line-height: 1.1;
}

.firearm-comparison-metric-label {
  margin: 0;
  font-size: 0.68rem;
  color: var(--text-muted);
}

/* Colored to match the firearm's own scatter/swatch color (set inline
   in JS), the same way shot count is already tied to a firearm's
   identity everywhere else in this app. */
.firearm-comparison-shot-count {
  margin: 0;
  font-size: 0.72rem;
  font-weight: 600;
}

/* Always present for every column (see renderComparison), even when
   its entry isn't flagged -- an empty cell still participates in the
   row's own height, which is what keeps an unflagged column's baseline
   level with a flagged neighbor's, rather than riding up to fill the
   space the badge would have taken. */
.firearm-comparison-badge-cell {
  display: flex;
  justify-content: center;
}

/* Rule 1 (below the ranking threshold) or rule 2 (well under the
   comparison's own highest sample) -- see is_thin_sample,
   app/services/firearm_comparison.py. Shown on the flagged firearm's
   own panel specifically, not just folded into the summary line, so a
   flagged non-leader (e.g. ranked #2 or #3) is never invisible on
   screen even though the underlying data already flags it correctly.
   Amber, the same --accent-scale token already used for calibration
   attention elsewhere in this app -- caution, not an error. */
/* The warning-triangle icon is an inline SVG data URI, the same
   mechanism (and the only one) already used in this app -- see the
   custom dropdown-arrow background-image on #dashboard-controls .field
   select above. No emoji or dingbat character, and no new icon
   mechanism introduced. */
.firearm-comparison-flag-badge {
  display: inline-flex;
  align-items: center;
  margin-top: 0.2rem;
  padding: 0.12rem 0.5rem 0.12rem 1.15rem;
  border: 1px solid var(--accent-scale);
  border-radius: 999px;
  background-color: rgba(250, 204, 21, 0.12);
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='11' height='10'%3E%3Cpath d='M5.5 0.5 10.5 9.3 0.5 9.3z' fill='none' stroke='%23facc15' stroke-width='1' stroke-linejoin='round'/%3E%3Crect x='4.9' y='3.2' width='1.2' height='3.2' fill='%23facc15'/%3E%3Crect x='4.9' y='7.2' width='1.2' height='1.2' fill='%23facc15'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: 0.4rem center;
  background-size: 9px 8px;
  color: var(--accent-scale);
  font-size: 0.62rem;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  white-space: nowrap;
}

/* The card's bottom strip: the summary line on the left, one date
   range for the whole card on the right -- never a color implying
   good/bad, since "smaller is better" here describes the comparison
   itself, not a change from a prior value (Claude.md's dashboard
   convention on when color is and isn't earned). */
.firearm-comparison-summary-row {
  display: flex;
  /* flex-end (bottom), not baseline: firearmComparisonSummaryLine can
     wrap to two or three lines in a narrow card, and baseline aligns
     to only the FIRST of those lines -- which left Dig Deeper
     stranded near the top of the row, with empty space below it down
     to the row's own (taller, wrapped-text-driven) bottom, instead of
     landing flush with it. flex-end keeps the summary and the meta
     (date range + Dig Deeper) sharing one visual bottom edge
     regardless of how many lines the summary text takes. */
  align-items: flex-end;
  justify-content: space-between;
  gap: 1rem;
  width: 100%;
  min-width: 0;
  margin-top: 0.75rem;
  /* No border-top here -- the panels box below now has its own bottom
     border (see .firearm-comparison-panels), which is what separates
     this strip from it in the approved mockup. A second divider line
     right above this one would double up with that border. */
}

/* The bar-chart icon, like the badge's warning triangle above, is an
   inline SVG data URI -- the one icon mechanism already established in
   this app, not a new one. */
.firearm-comparison-summary {
  margin: 0;
  font-size: 0.8rem;
  color: var(--text);
  min-width: 0;
  padding-left: 1.1rem;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12'%3E%3Crect x='0' y='7' width='3' height='5' fill='%237d8590'/%3E%3Crect x='4.5' y='4' width='3' height='8' fill='%237d8590'/%3E%3Crect x='9' y='1' width='3' height='11' fill='%237d8590'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: 0 0.15rem;
  background-size: 11px 11px;
}

.firearm-comparison-summary-meta {
  display: flex;
  align-items: baseline;
  gap: 0.75rem;
  flex-shrink: 0;
}

.firearm-comparison-date-range {
  margin: 0;
  font-size: 0.78rem;
  color: var(--text-muted);
  white-space: nowrap;
  flex-shrink: 0;
}

.firearm-comparison-view-details {
  font-size: 0.78rem;
  color: var(--accent);
  white-space: nowrap;
  flex-shrink: 0;
  text-decoration: none;
}

.firearm-comparison-view-details:hover {
  text-decoration: underline;
}

/* Card 2 ("Am I getting better with this gun?", see
   app/static/js/firearm_progress.js). Two selectors share the header
   with card 1's own .hub-card-distance-select class -- .hub-card-header's
   existing gap and the first select's margin-left: auto push both to
   the right edge as a pair, with no extra CSS needed for that layout.

   The same bug card 1 had (see #firearm-comparison-body.firearm-comparison-body--populated
   above): .hub-card-body is display:flex with no flex-direction, so
   its default is row -- correct for the other, still-stub cards'
   single centered message, but wrong the moment this card's populated
   state puts several block-level children (the chart, then the change
   box or single-session note, then the summary row) inside it. Without
   this override the chart was squeezed into roughly one flex "column"
   alongside the summary text's own column,
   vertically centered by the row's align-items -- which is also
   exactly why there was empty space above the chart: it was being
   centered against a taller sibling, not sized by its own content.
   align-items: stretch (flex's own default once align-items isn't set
   to center) is what makes each child take the container's full width
   rather than shrinking to its own content. Scoped to this card's
   body specifically, and only its populated state, the same way card
   1's fix is -- the loading/empty/failed single-message states still
   want the shared centered-row behavior every other card's body uses. */
#firearm-progress-body.firearm-progress-body--populated {
  flex-direction: column;
  align-items: stretch;
  min-width: 0;
}

/* The one block in this card's body that flexes -- see .hub-card-body's
   own comment on why the body itself grows to fill the hub grid row's
   leftover height, and firearm_progress.js's sizeAndDrawProgressChart
   for how the canvas's actual drawing resolution follows this box's
   real size rather than staying fixed while the extra height sits
   unused. The change box and summary row below stay at their own
   content size (no flex set), per the same instruction that ruled out
   stretching them too -- a taller card should make the chart more
   readable, not inflate a box that's already sized to fit its text.
   min-height keeps the original fixed size as a floor, never smaller.
   position: relative is what lets the canvas below be positioned
   against this box specifically (see .firearm-progress-canvas's own
   comment on why that has to be absolute, not a plain block child). */
.firearm-progress-chart-wrap {
  margin-top: 0.6rem;
  flex: 1;
  min-height: 140px;
  position: relative;
}

/* position: absolute (filling chart-wrap via inset: 0), not a plain
   block child sized with width/height: 100% -- confirmed live that the
   plain-child version grows without settling: chart-wrap's own height
   comes from flex-grow, resolved *after* the grid has sized this row,
   but grid/flex also need chart-wrap's own max-content size *before*
   that to size the row in the first place, and a plain-flow canvas's
   size (its intrinsic default, or whatever firearm_progress.js last set
   canvas.width/height to) counts toward that max-content size -- so
   each layout's output became the next layout's input, and the chart
   grew a little more on every resize instead of settling. A reload at a
   given viewport size measurably produced a different chart height than
   resizing down to that exact same viewport from a wider one, and
   remained unstable across repeated resizes either way. An absolutely
   positioned element is removed from normal flow -- it plays no part in
   its containing block's own content-based sizing at all -- which
   breaks the cycle outright rather than just changing its shape: this
   box's size now depends only on flex-grow and its min-height floor,
   never on what the canvas happens to already contain. */
.firearm-progress-canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
}

/* Hover tooltip -- the plotted session's own date (see
   attachProgressChartInteractivity, firearm_progress.js). Same look and
   positioning approach as .firearm-range-tooltip/.activity-tooltip:
   absolutely positioned within the (position: relative) wrap above,
   anchored above the cursor via the transform below. */
.firearm-progress-tooltip {
  position: absolute;
  z-index: 20;
  padding: 0.5rem 0.65rem;
  border: 1px solid var(--border-strong);
  border-radius: 6px;
  background: var(--panel-raised);
  color: var(--text);
  font-size: 0.75rem;
  line-height: 1.5;
  white-space: nowrap;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35);
  pointer-events: none;
  transform: translate(-50%, calc(-100% - 8px));
}

.firearm-progress-tooltip[hidden] {
  display: none;
}

/* Block two (2+ sessions): a contained box, matching the approved
   mockup -- the percentage headline large, the absolute-inches
   explanation smaller beneath it, in its own bordered/tinted area
   rather than plain text competing visually with the chart above it.
   Neutral (no prior session to compare, or no change) by default; one
   deliberate, scoped exception to "no color implies good/bad"
   (Claude.md), the same exception Compare's own firearm trend cards
   already carry, applied here to a first-vs-last range instead of a
   session-vs-previous change. */
.firearm-progress-change-box {
  margin-top: 0.75rem;
  padding: 0.6rem 0.85rem;
  border-radius: 8px;
  border: 1px solid var(--border);
  background: var(--panel-raised);
}

.firearm-progress-change-box--better {
  border-color: rgba(34, 197, 94, 0.35);
  background: rgba(34, 197, 94, 0.08);
}

.firearm-progress-change-box--worse {
  border-color: rgba(239, 68, 68, 0.35);
  background: rgba(239, 68, 68, 0.08);
}

.firearm-progress-change-headline {
  margin: 0;
  font-size: 1.3rem;
  font-weight: 700;
  color: var(--text-muted); /* neutral default; overridden below for better/worse */
}

.firearm-progress-change-box--better .firearm-progress-change-headline {
  color: #22c55e;
}

.firearm-progress-change-box--worse .firearm-progress-change-headline {
  color: #ef4444;
}

.firearm-progress-change-supporting {
  margin: 0.2rem 0 0;
  font-size: 0.78rem;
  color: var(--text-muted);
}

/* The blank-aim exclusion note, shown only when a technique is selected
   and something is genuinely left out. Same quiet treatment as card 3's
   own .option-comparison-unrecorded-note, which it mirrors. */
.firearm-progress-unrecorded-note {
  margin: 0.6rem 0 0;
  font-size: 0.78rem;
  color: var(--text-muted);
}

.firearm-progress-single-session-note {
  margin: 0.75rem 0 0;
  font-size: 0.8rem;
  color: var(--text-muted);
}

/* Block three: sample size, date range, and Dig Deeper, together on
   one line -- its own block below the change box (or the single-session
   note), matching the approved mockup, which shows this as a separate
   line rather than sharing the box's own row. flex-wrap is still a
   deliberate safety net for a card at #hub-cards' own 420px column
   minimum, where all three together can run close to the available
   width; margin-left: auto on the link itself (not the row's
   justify-content) is what keeps Dig Deeper hugging the right edge
   whether it's sharing a line with the other two or, if the row
   wraps, sitting alone on its own. */
.firearm-progress-summary-row {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  column-gap: 0.75rem;
  row-gap: 0.2rem;
  width: 100%;
  min-width: 0;
  margin-top: 0.75rem;
}

.firearm-progress-sample {
  margin: 0;
  font-size: 0.78rem;
  color: var(--text-muted);
  white-space: nowrap;
}

.firearm-progress-date-range {
  margin: 0;
  font-size: 0.78rem;
  color: var(--text-muted);
  white-space: nowrap;
}

.firearm-progress-view-details {
  margin-left: auto;
  font-size: 0.78rem;
  color: var(--accent);
  white-space: nowrap;
  text-decoration: none;
}

.firearm-progress-view-details:hover {
  text-decoration: underline;
}

/* Card 3 ("Grip, Ammunition, Sights, and Aiming Technique", see
   app/static/js/option_comparison.js). Everything -- tabs, the fixed-
   variable selector row, the note, the ranked table, and Dig Deeper --
   lives inside the body, unlike cards 1/2's compact header selectors:
   there's simply too much control surface here (3 tabs plus up to 4
   selects) to fit in a header row without it fighting the card's own
   title for space. Same bug card 1 and card 2 both had (see their own
   identical comments above): .hub-card-body is display:flex with no
   flex-direction, correct for the other cards' single centered message
   but wrong the moment this card's populated state stacks several
   block-level children. */
#option-comparison-body.option-comparison-body--populated {
  flex-direction: column;
  align-items: stretch;
  min-width: 0;
}

.option-comparison-tabs {
  display: flex;
  gap: 0.4rem;
  margin-top: 0.6rem;
}

.option-comparison-tab {
  flex: 1 1 0;
  min-width: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.15rem;
  padding: 0.4rem 0.3rem;
  border: 1px solid var(--border);
  border-radius: 6px;
  background: var(--panel-raised);
  color: var(--text-muted);
  font-family: var(--font-sans);
  font-size: 0.78rem;
  cursor: pointer;
}

.option-comparison-tab:hover {
  border-color: var(--border-strong);
  color: var(--text);
}

.option-comparison-tab--active {
  border-color: var(--accent);
  color: var(--text);
  background: rgba(34, 211, 238, 0.08);
}

.option-comparison-tab-marker {
  font-size: 0.62rem;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--text-muted);
}

/* NARROW-WIDTH DECISION (selector row): Firearm, Distance, and up to
   two fixed-dimension selects wrap from four across down to two or one
   per row, rather than compressing each <select> to an unreadable
   width. flex-wrap does this for free with no media query -- each
   field's own 150px flex-basis is about the narrowest a distance/ammo-
   name select stays legible at, so wrapping starts naturally right
   where cramming would otherwise begin. */
.option-comparison-selector-row {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  margin-top: 0.6rem;
}

.option-comparison-fixed-field {
  flex: 1 1 150px;
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: 0.2rem;
}

.option-comparison-fixed-field[hidden] {
  display: none;
}

.option-comparison-fixed-field label {
  font-size: 0.68rem;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--text-muted);
}

.option-comparison-select {
  width: 100%;
  min-width: 0;
  background: var(--panel-raised);
  border: 1px solid var(--border-strong);
  border-radius: 6px;
  color: var(--text);
  font-family: var(--font-sans);
  font-size: 0.8rem;
  padding: 0.3rem 0.5rem;
}

/* "Grip is not held constant here" -- amber, the same --accent-scale
   token used for calibration attention and card 1's own thin-sample
   badge elsewhere in this app: a caution, not an error, and not a
   result (never --accent-poa, which is reserved for failure states). */
.option-comparison-note {
  margin: 0.5rem 0 0;
  font-size: 0.75rem;
  color: var(--accent-scale);
}

.option-comparison-note[hidden] {
  display: none;
}

.option-comparison-results-failed {
  color: var(--accent-poa);
}

.option-comparison-empty-message {
  color: var(--text-muted);
  font-style: italic;
}

/* NARROW-WIDTH DECISION (table): no column is ever hidden or dropped at
   a narrow card width. Earlier this table also carried a min-width
   forcing it to 480px regardless of content, on the theory that six
   columns needed that much room -- wrong in practice: it fought the
   browser's own content-based auto layout, forcing every column wider
   than its actual value needed (a mean radius is "0.36 in", six
   characters) and forcing every row taller in the process, since a
   table row's height already comes from its content, not from a fixed
   rule anywhere in this file (confirmed live -- no flex or grid rule
   anywhere in this card touches a <tr> or <td> at all; row height was
   purely the min-width pushing cell content to wrap). No min-width
   here now -- table-layout stays the browser default (auto), sized to
   the short content real rows actually have (an option name, two
   inches figures, two small integers, a date range), and two-line
   headers (see option_comparison.js's buildHeaderCell) so a long header
   label never has to force its column wider than the values under it.
   .table-scroll (reused from the app's own ranking table) stays as the
   fallback for whatever doesn't fit -- an unusually long option name --
   rather than a first resort. */
.option-comparison-name-cell {
  white-space: normal;
}

/* Inline after the name, not on a line of its own -- flows onto the
   same line when there's room (the common case: most option names are
   well under a card's own width) and wraps normally, like any other
   inline content, when there isn't -- rather than always claiming an
   extra line of row height regardless of how short the name is. */
.option-comparison-inline-badge {
  margin-top: 0;
}

/* max-width: none overrides the shared .pooled-note's 60ch cap, the same
   override #dashboard-ranking-note already makes and for the same reason:
   that cap is sized for a note sitting beside a narrow table column, and
   this one spans the card above a full-width table. It was wrapping to
   three short lines with the right-hand third of the card empty beside
   it. Scoped here rather than changed on .pooled-note itself, which
   firearm_detail.html and session_detail.html also use and which were not
   asked to change. */
.option-comparison-pooled-note {
  margin: 0 0 0.6rem;
  max-width: none;
}

.option-comparison-second-option-note {
  margin: 0.6rem 0 0;
  font-size: 0.78rem;
  color: var(--text-muted);
  font-style: italic;
}

/* Not italic, unlike the empty/second-option notes above -- this one
   names real excluded data and links to where it's fixable, so it reads
   as actionable information rather than a description of absence. */
.option-comparison-unrecorded-note {
  margin: 0.6rem 0 0;
  font-size: 0.78rem;
  color: var(--text-muted);
}

/* Right-aligned at the bottom of the card, same corner card 1's own
   .firearm-comparison-view-details and card 2's own
   .firearm-progress-view-details land in -- this card has no
   accompanying sample-size/date-range text to share a flex row with
   (see buildViewDetailsLink, option_comparison.js), so a full-width
   block with text-align: right reaches the same position by a simpler
   route rather than fabricating a meta line this card doesn't have. */
.option-comparison-view-details {
  display: block;
  text-align: right;
  margin-top: 0.75rem;
  font-size: 0.78rem;
  color: var(--accent);
  text-decoration: none;
}

.option-comparison-view-details:hover {
  text-decoration: underline;
}

/* Card 5 ("How far can I still shoot well?", see
   app/static/js/firearm_range.js) -- one firearm selector in the
   header, sharing card 1 and card 2's own .hub-card-distance-select
   class. This is the one hub view charts-never-mix-distances's own
   exception covers (Claude.md); no other card plots more than one
   distance.

   Same bug cards 1-3 all had (see their own identical comments above):
   .hub-card-body is display:flex with no flex-direction, correct for
   the other still-stub cards' single centered message but wrong the
   moment this card's populated state stacks several block-level
   children (chart, geometry note, optional third-distance/flagged
   notes, summary row). */
#firearm-range-body.firearm-range-body--populated {
  flex-direction: column;
  align-items: stretch;
  min-width: 0;
  /* One consistent gap between every stacked block (legend, chart,
     delta chart, geometry note, third-distance/flagged notes, summary
     row) instead of each block declaring its own ad hoc margin-top
     (previously 0.4rem-0.75rem depending on which block, with no
     pattern to it) -- a flex container's own gap never collapses with
     a child's margin, so this is the one place spacing is set now; see
     each of those classes' own comments for the margin they no longer
     carry. */
  gap: 0.75rem;
}

/* The card's entire meaning rests on comparing the data line against
   the reference line -- nothing on the chart itself says which is
   which, so this sits ABOVE the chart, not below it (see
   buildRangeLegend, firearm_range.js). Two short line-segment swatches
   (border-top, not a filled block) so each one actually looks like the
   line it stands for -- solid firearm color for the data line, dashed
   for the reference. */
.firearm-range-legend {
  display: flex;
  flex-wrap: wrap;
  gap: 1rem;
  font-size: 0.76rem;
  color: var(--text-muted);
}

.firearm-range-legend-entry {
  display: flex;
  align-items: center;
  gap: 0.4rem;
}

.firearm-range-legend-swatch {
  display: inline-block;
  width: 1.3rem;
  height: 0;
  border-top-width: 2px;
}

/* border-top-color set inline per firearm (see buildRangeLegend) --
   never known to static CSS, same convention as every other per-firearm
   swatch in this app (e.g. .firearm-comparison-swatch). */
.firearm-range-legend-swatch--data {
  border-top-style: solid;
}

/* Fixed, not per-firearm -- the reference line is always this one
   color (RANGE_REFERENCE_LINE_COLOR, firearm_range.js) regardless of
   which firearm is selected. */
.firearm-range-legend-swatch--reference {
  border-top-style: dashed;
  border-top-color: #b0b7c0;
}

/* The fitted line's own swatch, deliberately quieter than the reference
   above -- matches RANGE_FIT_LINE_COLOR and its thinner stroke, so the
   legend reads in the same visual order as the chart itself. */
.firearm-range-legend-swatch--fit {
  border-top-style: dashed;
  border-top-width: 1.5px;
  border-top-color: #6b7280;
}

/* Same flex/absolute-canvas approach as .firearm-progress-chart-wrap/
   .firearm-progress-canvas -- see those rules' own comments for why the
   canvas is absolutely positioned rather than a plain block child. */
.firearm-range-chart-wrap {
  flex: 1;
  min-height: 150px;
  position: relative;
}

.firearm-range-canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
}

/* Hover tooltip -- which distance's shots/targets went into that point's
   pooled mean radius (see attachRangeChartTooltip, firearm_range_chart.js).
   Same look and positioning approach as .activity-tooltip (activity.js's
   own bar tooltip): absolutely positioned within the (position: relative)
   wrap above, anchored above the cursor via the transform below. */
.firearm-range-tooltip {
  position: absolute;
  z-index: 20;
  padding: 0.5rem 0.65rem;
  border: 1px solid var(--border-strong);
  border-radius: 6px;
  background: var(--panel-raised);
  color: var(--text);
  font-size: 0.75rem;
  line-height: 1.5;
  white-space: nowrap;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35);
  pointer-events: none;
  transform: translate(-50%, calc(-100% - 8px));
}

.firearm-range-tooltip[hidden] {
  display: none;
}

.firearm-range-tooltip-headline {
  font-weight: 600;
}

/* The delta bar chart (/accuracy-by-distance only -- see
   renderRangeChartInto's own options.showDeltaChart,
   firearm_range_chart.js). Same flex/absolute-canvas approach as
   .firearm-range-chart-wrap directly above it, but flex: 0 0 auto with
   a fixed height rather than flex: 1 -- this row never competes with
   the line chart above it for the card's available vertical space,
   which is what keeps its own plot width locked to the line chart's
   (both are full-width children of the same column flex container,
   stretched identically by align-items: stretch) without needing a
   shared parent wrapper. No horizontal margin/padding here beyond what
   .firearm-range-chart-wrap also carries -- any difference between the
   two would throw off "the bar chart uses that same [xFor] function
   with the same inputs" (see drawRangeDeltaChart's own module comment):
   a bar's own x has to land exactly under its point above, and that
   only holds if both canvases report the same CSS width. */
.firearm-range-delta-chart-wrap {
  flex: 0 0 auto;
  height: 200px;
  position: relative;
}

.firearm-range-delta-canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
}

.firearm-range-delta-caption {
  margin: 0;
  font-size: 0.72rem;
  color: var(--text-muted);
}

/* Condition 3 of the rule exception, as real legible text -- always
   present alongside the chart, never left to the canvas's own small
   axis labels to carry alone. */
.firearm-range-geometry-note {
  margin: 0;
  font-size: 0.76rem;
  color: var(--text-muted);
}

.firearm-range-third-distance-note {
  margin: 0;
  font-size: 0.76rem;
  color: var(--text-muted);
  font-style: italic;
}

/* The card's background explanation, collapsed. Paragraph 1 (the
   geometry statement) is deliberately NOT in here -- see
   buildRangeGeometryNote's own comment: it discharges one of
   charts-never-mix-distances Exception 1's four conditions, and a
   condition satisfied only after a click is not satisfied on first look.
   Plain <details>, so this needs no JavaScript. */
.firearm-range-notes > summary {
  cursor: pointer;
  font-size: 0.75rem;
  color: var(--text-muted);
  padding: 0.1rem 0;
}

.firearm-range-notes > summary:hover {
  color: var(--text);
}

.firearm-range-notes[open] > summary {
  margin-bottom: 0.5rem;
}

.firearm-range-notes .firearm-range-geometry-note + .firearm-range-geometry-note {
  margin-top: 0.5rem;
}

.firearm-range-flagged {
  display: flex;
  flex-wrap: wrap;
  gap: 0.35rem;
}

/* Same treatment as card 1's own .firearm-comparison-flag-badge --
   amber, --accent-scale, a caution rather than an error. Reused here
   per-distance instead of per-firearm, since this card has no side
   panel of its own for a flagged point to live on. */
.firearm-range-flag-badge {
  display: inline-flex;
  align-items: center;
  padding: 0.12rem 0.5rem 0.12rem 1.15rem;
  border: 1px solid var(--accent-scale);
  border-radius: 999px;
  background-color: rgba(250, 204, 21, 0.12);
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='11' height='10'%3E%3Cpath d='M5.5 0.5 10.5 9.3 0.5 9.3z' fill='none' stroke='%23facc15' stroke-width='1' stroke-linejoin='round'/%3E%3Crect x='4.9' y='3.2' width='1.2' height='3.2' fill='%23facc15'/%3E%3Crect x='4.9' y='7.2' width='1.2' height='1.2' fill='%23facc15'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: 0.4rem center;
  background-size: 9px 8px;
  color: var(--accent-scale);
  font-size: 0.62rem;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  white-space: nowrap;
}

/* Exactly one distance: no curve is possible -- a plain figure, styled
   like card 2's own single-session state rather than a chart. */
.firearm-range-single {
  text-align: center;
  width: 100%;
}

.firearm-range-single-value {
  margin: 0;
  font-size: 1.8rem;
  font-weight: 700;
  color: var(--text);
  line-height: 1.1;
}

.firearm-range-single-label {
  margin: 0.2rem 0 0;
  font-size: 0.72rem;
  color: var(--text-muted);
}

.firearm-range-single-note {
  margin: 0.6rem 0 0;
  font-size: 0.8rem;
  color: var(--text-muted);
}

.firearm-range-summary-row {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  column-gap: 0.75rem;
  row-gap: 0.2rem;
  width: 100%;
  min-width: 0;
}

.firearm-range-sample {
  margin: 0;
  font-size: 0.78rem;
  color: var(--text-muted);
  white-space: nowrap;
}

.firearm-range-date-range {
  margin: 0;
  font-size: 0.78rem;
  color: var(--text-muted);
  white-space: nowrap;
}

.firearm-range-view-details {
  margin-left: auto;
  font-size: 0.78rem;
  color: var(--accent);
  white-space: nowrap;
  text-decoration: none;
}

.firearm-range-view-details:hover {
  text-decoration: underline;
}

/* /combinations page ("Ammo, Grip, and Sight Findings", see
   app/static/js/combinations.js) -- the first dedicated card-linked
   page, reached from hub card 3's own Dig Deeper link. Dark, matches
   the hub and every other page; no new color tokens. */

.page-subtitle {
  margin: -0.75rem 0 1.25rem;
  font-size: 0.85rem;
  color: var(--text-muted);
}

.combinations-controls {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-end;
  gap: 1rem;
  margin-bottom: 1.25rem;
}

.combinations-controls[hidden] {
  display: none;
}

/* .option-comparison-tabs/.option-comparison-tab (reused from card 3)
   are normally the sole, full-width content of their own container
   there, so each tab's flex-basis: 0 plus min-width: 0 (see
   .option-comparison-tab) dividing space evenly never has to compete
   with anything else, and the tallest label ("Ammunition") still gets
   an equal, generous third of the whole card's width. Here the tab bar
   shares a row with the distance field instead. Confirmed live in two
   steps: giving just the outer .option-comparison-tabs flex: 0 0 auto
   was not enough on its own -- a flex container's own auto-width
   contribution is still computed from its children's flex-basis (0
   here), not their actual label width, so the group as a whole still
   measured itself too narrow and "Ammunition" clipped mid-word even
   though the container itself was no longer being asked to shrink.
   Overriding the individual tabs to flex: 0 0 auto too (each sized to
   its own real label width, no forced equal division) fixed it --
   confirmed by reading the tab's own getBoundingClientRect() before and
   after, not just by eye. Both overrides are scoped to this page only,
   so card 3's own equal-thirds tab layout is untouched. */
.combinations-controls .option-comparison-tabs {
  flex: 0 0 auto;
}

.combinations-controls .option-comparison-tab {
  flex: 0 0 auto;
  white-space: nowrap;
}

.combinations-distance-field {
  max-width: 200px;
}

/* The one thing this page most needs to say correctly, so it's styled
   to be read, not skimmed past -- a bordered, tinted callout (the same
   amber --accent-scale token used for card 1's own thin-sample caution
   and card 3's "not held constant" note elsewhere in this app), never
   fine print. The headline is its own bold line, not buried inside a
   paragraph, so it reads even if a user reads only the first line of
   this page's body. */
.combinations-caveat {
  margin-bottom: 1.5rem;
  padding: 0.85rem 1rem;
  border: 1px solid var(--accent-scale);
  border-radius: 8px;
  background: rgba(250, 204, 21, 0.08);
  max-width: 640px;
}

.combinations-caveat[hidden] {
  display: none;
}

.combinations-caveat p {
  margin: 0;
  font-size: 0.85rem;
  color: var(--text);
}

.combinations-caveat p + p {
  margin-top: 0.5rem;
  color: var(--text-muted);
}

.combinations-caveat-headline {
  font-weight: 700;
  color: var(--accent-scale);
}

.combinations-excluded-note {
  margin: 0 0 1.5rem;
  font-size: 0.8rem;
  color: var(--text-muted);
  font-style: italic;
}

.combinations-excluded-note[hidden] {
  display: none;
}

/* A quick-reference key, static markup (see combinations.html's own
   comment) -- four short definitions with icons, in a row that wraps
   to fewer columns on a narrow viewport rather than scrolling. Sits
   directly above .combinations-methodology, the fuller prose it's a
   summary of, never a replacement for it. */
.combinations-legend {
  display: flex;
  flex-wrap: wrap;
  gap: 1rem 1.5rem;
  margin-top: 2rem;
  padding-top: 1.25rem;
  border-top: 1px solid var(--border);
}

.combinations-legend-item {
  display: flex;
  align-items: flex-start;
  gap: 0.5rem;
  flex: 1 1 220px;
  min-width: 200px;
  max-width: 320px;
}

.combinations-legend-icon {
  width: 1.1rem;
  height: 1.1rem;
  margin-top: 0.15rem;
  flex-shrink: 0;
  color: var(--text-muted);
}

.combinations-legend-item p {
  margin: 0;
  font-size: 0.8rem;
  line-height: 1.4;
  color: var(--text-muted);
}

.combinations-legend-item strong {
  color: var(--text);
}

/* Plain explanatory prose, deliberately not styled as a warning (unlike
   .combinations-caveat above it) -- this is background reading, not a
   caution about the current selection. max-width still caps line
   length for readability, but wide enough that the paragraphs use a
   meaningful share of the page rather than sitting in a narrow column
   with empty space beside it on anything but a small viewport. No
   border-top of its own -- the legend directly above it already
   carries that divider, and a second one right beneath a 1.5rem gap
   would read as two separate sections rather than one (key, then
   fuller explanation). */
.combinations-methodology {
  margin-top: 1.5rem;
  max-width: 100ch;
}

.combinations-methodology h3 {
  margin: 0 0 0.75rem;
  font-size: 1rem;
  color: var(--text);
}

.combinations-methodology p {
  margin: 0 0 0.85rem;
  font-size: 0.85rem;
  line-height: 1.5;
  color: var(--text-muted);
}

.combinations-methodology p:last-child {
  margin-bottom: 0;
}

.combinations-firearm-section {
  margin-bottom: 1.25rem;
  padding-bottom: 1.25rem;
  border-bottom: 1px solid var(--border);
}

.combinations-firearm-section:last-child {
  border-bottom: none;
}

/* <summary> gets its own focus/hover treatment since it's the
   interactive, keyboard-reachable part of a <details> element --
   browsers already give it a disclosure triangle and click/Enter/Space
   toggle behavior for free, none of which this reimplements. */
.combinations-firearm-summary {
  cursor: pointer;
  padding: 0.4rem 0;
  list-style-position: outside;
}

.combinations-firearm-summary:hover .combinations-firearm-name {
  text-decoration: underline;
}

/* Colored per-firearm via inline style (firearmColorMap), the same
   color that firearm gets on the hub -- identity only, never a signal
   of which option is best (no card below is colored, bordered, or
   otherwise set apart from its siblings because of its rank). */
.combinations-firearm-name {
  font-size: 1rem;
  font-weight: 700;
}

.combinations-firearm-body {
  padding: 0.75rem 0 0.25rem;
}

.combinations-dimension-empty {
  color: var(--text-muted);
  font-style: italic;
}

/* Not italic, unlike .combinations-dimension-empty above -- this one
   names real excluded data and links to where it's fixable, so it reads
   as actionable information rather than a description of absence. */
.combinations-unrecorded-note {
  color: var(--text-muted);
  font-size: 0.85rem;
  margin: 0.5rem 0 0;
}

/* One card per option, wrapping to as many rows as needed -- every
   option the same size and treatment, in ranked order (smallest pooled
   mean radius first) but with nothing marking the first card as a
   winner: no border color, background, or badge tied to rank. The
   single reused exception, the THIN SAMPLE badge, is a caution that can
   land on any card, not an endorsement of whichever one lacks it. */
.combinations-option-cards {
  display: flex;
  flex-wrap: wrap;
  gap: 1rem;
}

.combinations-option-card {
  width: 210px;
  padding: 0.75rem;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--panel-raised);
  /* Top edge carries the firearm's color -- same treatment as the
     Dashboard's own .firearm-trend-card. Only the width/fallback color
     live here; the real per-firearm value is set inline in JS (see
     buildOptionCard, combinations.js), since it's assigned at runtime
     from a palette, not known to static CSS. */
  border-top-width: 3px;
  border-top-color: var(--border);
}

.combinations-option-name {
  margin: 0 0 0.5rem;
  font-size: 0.85rem;
  font-weight: 600;
  line-height: 1.2;
  /* Reserved for up to two lines so a long option name never grows one
     card taller than a short-named sibling in the same wrapped row of
     cards (see combinations.js's own note on row alignment). */
  min-height: 2.1rem;
  color: var(--text);
}

/* Always present, occupied or empty -- see combinations.js's own note
   on why this exists (the same fix as .firearm-comparison-badge-cell).
   Sized to the flag badge's own rendered height (padding + border +
   line height) plus a small margin of safety, so an unflagged card's
   empty row still takes up exactly as much space as a flagged
   neighbour's, and every card's scatter starts at the same vertical
   offset regardless of flag state. */
.combinations-option-badge-row {
  min-height: 1.3rem;
  margin: 0 0 0.5rem;
}

.combinations-option-scatter {
  display: block;
  width: 140px;
  height: 140px;
  margin: 0 auto 0.5rem;
}

/* Each of the three headline metrics (mean radius, best group, mean POA
   distance) gets its own bordered row: icon, uppercase label, and the
   figure right-aligned -- see design/combination-option-card.png, the
   reference this treatment was built from. Rows stack vertically with a
   small gap; the border is the neutral --border color on every row
   (never the firearm's own color -- that would compete with the row's
   own icon and make the boundary itself look like part of the data). */
.combinations-metric-rows {
  display: flex;
  flex-direction: column;
  gap: 0.4rem;
  margin: 0 0 0.5rem;
}

.combinations-metric-row {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  border: 1px solid var(--border);
  border-radius: 6px;
  padding: 0.4rem 0.55rem;
}

.combinations-metric-label {
  font-size: 0.6rem;
  font-weight: 700;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--text);
}

/* The figures stay legible regardless of the firearm's own color -- an
   icon can be distinguishable-but-dim without hurting anything, but a
   dim or low-contrast NUMBER would (see combinations.js's own note).
   Monospaced, the same convention this app already uses for other
   prominent numeric figures. Right-aligned via margin-left: auto so the
   label and figure anchor to opposite ends of the row regardless of
   label length. */
.combinations-metric-value {
  margin-left: auto;
  font-family: var(--font-mono);
  font-size: 1.05rem;
  font-weight: 700;
  color: var(--text);
}

/* The one figure that's sometimes a link (see buildOptionCard's own
   note on the best-group link) -- colored with the firearm's color
   instead of var(--text) specifically because it's now interactive:
   every other colored element on this card (the border, the scatter
   dots, every row's own icon) already carries that same color, so a
   clickable figure in a second, unrelated color (e.g. the app's
   general --accent) would read as a departure from the card's own
   color story rather than as "this card's own data, and you can check
   it." Underlined permanently, not just on hover, so it reads as a
   link at a glance the way design/combination-option-card.png shows. */
.combinations-metric-value--link {
  text-decoration: underline;
}

/* Shared by every icon on this card (the three metric rows above and
   the shots/date lines below) -- shape lives in the per-kind mask
   below, color is set inline in JS at runtime from the firearm's own
   palette entry, the same convention as the card's own top border.
   mask-image (not a plain <img> or background-image) is what lets one
   fixed monochrome SVG shape be recolored per firearm without keeping
   a separate copy of each icon per color. */
.combinations-metric-icon {
  display: inline-block;
  flex-shrink: 0;
  width: 1.05rem;
  height: 1.05rem;
  -webkit-mask-repeat: no-repeat;
  mask-repeat: no-repeat;
  -webkit-mask-position: center;
  mask-position: center;
  -webkit-mask-size: contain;
  mask-size: contain;
}

/* Circle + radius line + center dot -- "radius" drawn literally. */
.combinations-metric-icon--radius {
  -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Ccircle cx='10' cy='10' r='7' fill='none' stroke='black' stroke-width='1.6'/%3E%3Cline x1='10' y1='10' x2='10' y2='3.2' stroke='black' stroke-width='1.6'/%3E%3Ccircle cx='10' cy='10' r='1.4' fill='black'/%3E%3C/svg%3E");
  mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Ccircle cx='10' cy='10' r='7' fill='none' stroke='black' stroke-width='1.6'/%3E%3Cline x1='10' y1='10' x2='10' y2='3.2' stroke='black' stroke-width='1.6'/%3E%3Ccircle cx='10' cy='10' r='1.4' fill='black'/%3E%3C/svg%3E");
}

/* Bullseye: two rings + center dot -- deliberately distinct from the
   radius icon above (two rings, no radius line) and from mean POA
   distance below (no crosshair, no offset dot). */
.combinations-metric-icon--bestgroup {
  -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Ccircle cx='10' cy='10' r='7.5' fill='none' stroke='black' stroke-width='1.4'/%3E%3Ccircle cx='10' cy='10' r='4.2' fill='none' stroke='black' stroke-width='1.4'/%3E%3Ccircle cx='10' cy='10' r='1.4' fill='black'/%3E%3C/svg%3E");
  mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Ccircle cx='10' cy='10' r='7.5' fill='none' stroke='black' stroke-width='1.4'/%3E%3Ccircle cx='10' cy='10' r='4.2' fill='none' stroke='black' stroke-width='1.4'/%3E%3Ccircle cx='10' cy='10' r='1.4' fill='black'/%3E%3C/svg%3E");
}

/* Crosshair (point of aim) with a single dot offset from center and a
   dashed line connecting them -- "a shot's distance from point of
   aim," and deliberately its own icon rather than reusing the radius
   icon's circle-and-line (an earlier reference mockup shared one icon
   between the two; this file gives every metric its own). */
.combinations-metric-icon--poa {
  -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cline x1='10' y1='2' x2='10' y2='18' stroke='black' stroke-width='1.4'/%3E%3Cline x1='2' y1='10' x2='18' y2='10' stroke='black' stroke-width='1.4'/%3E%3Ccircle cx='14.2' cy='6.2' r='1.6' fill='black'/%3E%3Cline x1='10' y1='10' x2='14.2' y2='6.2' stroke='black' stroke-width='1.1' stroke-dasharray='1.6,1.4'/%3E%3C/svg%3E");
  mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cline x1='10' y1='2' x2='10' y2='18' stroke='black' stroke-width='1.4'/%3E%3Cline x1='2' y1='10' x2='18' y2='10' stroke='black' stroke-width='1.4'/%3E%3Ccircle cx='14.2' cy='6.2' r='1.6' fill='black'/%3E%3Cline x1='10' y1='10' x2='14.2' y2='6.2' stroke='black' stroke-width='1.1' stroke-dasharray='1.6,1.4'/%3E%3C/svg%3E");
}

.combinations-metric-divider {
  border: none;
  border-top: 1px solid var(--border);
  margin: 0.5rem 0;
}

.combinations-sample-line {
  display: flex;
  align-items: center;
  gap: 0.4rem;
  margin: 0.2rem 0 0;
  font-size: 0.75rem;
  color: var(--text);
}

.combinations-sample-line .combinations-metric-icon {
  width: 0.85rem;
  height: 0.85rem;
}

/* Single ring + center dot -- simpler than the best-group icon's two
   rings, so a shot/target count doesn't visually compete with the
   headline metrics above it. */
.combinations-metric-icon--shots {
  -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Ccircle cx='10' cy='10' r='6' fill='none' stroke='black' stroke-width='1.5'/%3E%3Ccircle cx='10' cy='10' r='1.5' fill='black'/%3E%3C/svg%3E");
  mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Ccircle cx='10' cy='10' r='6' fill='none' stroke='black' stroke-width='1.5'/%3E%3Ccircle cx='10' cy='10' r='1.5' fill='black'/%3E%3C/svg%3E");
}

.combinations-metric-icon--date {
  -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Crect x='3' y='4.5' width='14' height='12.5' rx='1.5' fill='none' stroke='black' stroke-width='1.4'/%3E%3Cline x1='3' y1='8.3' x2='17' y2='8.3' stroke='black' stroke-width='1.4'/%3E%3Cline x1='6.8' y1='2' x2='6.8' y2='5.5' stroke='black' stroke-width='1.4'/%3E%3Cline x1='13.2' y1='2' x2='13.2' y2='5.5' stroke='black' stroke-width='1.4'/%3E%3C/svg%3E");
  mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Crect x='3' y='4.5' width='14' height='12.5' rx='1.5' fill='none' stroke='black' stroke-width='1.4'/%3E%3Cline x1='3' y1='8.3' x2='17' y2='8.3' stroke='black' stroke-width='1.4'/%3E%3Cline x1='6.8' y1='2' x2='6.8' y2='5.5' stroke='black' stroke-width='1.4'/%3E%3Cline x1='13.2' y1='2' x2='13.2' y2='5.5' stroke='black' stroke-width='1.4'/%3E%3C/svg%3E");
}

/* /accuracy-by-distance page (see app/static/js/accuracy_by_distance.js)
   -- hub card 5's own "How far can I still shoot well?" chart, once per
   firearm, so a visitor never has to toggle that card's single-firearm
   selector one gun at a time. Every .firearm-range-* class inside each
   section (chart, legend, geometry note, flagged badges, summary row)
   is already fully styled -- reused unchanged, since this page calls
   the exact same DOM-building functions (firearm_range_chart.js) the
   hub card does. Only the page-level shell and the per-firearm section
   wrapper below are new.

   Unlike a hub card (bounded to a single grid column, at most a few
   hundred px wide -- see #hub-cards), this page has no grid at all and
   nothing else constrains its width: body itself carries no max-width,
   so without this rule a section runs the full browser window.

   History, because both earlier numbers here were wrong for a
   measurable reason rather than just "too small," confirmed live rather
   than guessed a third time: 800px (matching .profile-form) left
   roughly half a full-screen window empty -- a profile form's own
   reasonable width has nothing to do with how much of a wide monitor a
   page of charts should use. 1400px (matching #hub-cards' own cap, the
   one existing max-width convention in this app meant to answer "how
   wide should this app let itself get") was STILL too small -- checked
   directly in a browser (window.innerWidth 2277, the page measured
   exactly 1400, confirming the rule itself was being applied correctly
   and the number was simply the problem, not a second, hidden
   constraint). 2400px covers that 2277px window with real headroom
   (main's own content width there is ~2213px, well under the cap, so
   the page reaches its full natural width rather than hitting this
   ceiling at all) while still capping a genuinely ultrawide display
   (3000px+) short of stretching a single chart's lines uncomfortably
   far. Applied to the whole page, not just the charts, so the header,
   legend, both charts, and every note stay in one consistent column
   rather than text spanning wider than the charts sitting inside it.
   See .firearm-range-chart-wrap's own page-scoped override just below
   for the chart's own height. */
#accuracy-by-distance-page {
  max-width: 2400px;
}

/* Holds the one page-level copy of the geometry statement and the delta
   chart's own caption (see accuracy_by_distance.js's own buildPageIntro)
   -- both reuse .firearm-range-geometry-note/.firearm-range-delta-caption
   unchanged, which now carry margin: 0 and rely on a gap from their own
   container instead (same convention as #firearm-range-body's own
   populated rule -- see that rule's own comment). */
#accuracy-by-distance-intro {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
  margin-bottom: 1.25rem;
}

/* Plain <section>+<h3>, never <details>+<summary> -- unlike
   .combinations-firearm-section, nothing here collapses. The whole
   point of this page is seeing every firearm at once without an extra
   click, so a disclosure triangle would reintroduce exactly the
   toggling it exists to avoid. */
.accuracy-by-distance-section {
  margin-bottom: 1.5rem;
  padding-bottom: 1.5rem;
  border-bottom: 1px solid var(--border);
}

.accuracy-by-distance-section:last-child {
  border-bottom: none;
  margin-bottom: 0;
  padding-bottom: 0;
}

.accuracy-by-distance-section-header {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  margin: 0;
  font-size: 1rem;
  font-weight: 700;
  color: var(--text);
}

/* Colored per-firearm via inline style (firearmColorMap), the same
   color that firearm gets everywhere else in this app -- identity only,
   never a signal of rank (sections are ordered by distinct-distance
   count, not by any accuracy figure). Same swatch shape as card 1's own
   .firearm-comparison-swatch. */
.accuracy-by-distance-swatch {
  display: inline-block;
  width: 0.65rem;
  height: 0.65rem;
  border-radius: 999px;
  flex-shrink: 0;
}

/* This element also always carries the shared hub-card-body/
   hub-card-body--${state} classes (see accuracy_by_distance.js), the
   same state-coloring convention every hub card's own body uses -- a
   section's own loading/failed message gets the identical treatment for
   free, not redeclared here. */
.accuracy-by-distance-section-body {
  padding-top: 0.75rem;
}

/* The same bug cards 1-4 all had, and all needed this identical fix for
   (see e.g. #firearm-range-body.firearm-range-body--populated above):
   .hub-card-body is display:flex with no flex-direction, correct for a
   single centered loading/empty/failed message but wrong the moment
   this element's populated state stacks several block-level children
   (legend, chart, geometry note, optional third-distance/flagged
   notes, summary row) -- without this override they sit side by side
   in a row instead, and the chart's own canvas (position: absolute,
   contributing nothing to its flex-item parent's own width) collapses
   toward zero width. A class selector, not an ID -- unlike the hub
   cards, this page has one such body per firearm section, not one
   fixed element. */
.accuracy-by-distance-section-body.hub-card-body--populated {
  flex-direction: column;
  align-items: stretch;
  min-width: 0;
  /* Same consistent-gap treatment as #firearm-range-body's own
     populated rule -- see that rule's own comment. */
  gap: 0.75rem;
}

/* .firearm-range-chart-wrap's own base rule (min-height: 150px) is
   shared with card 5 on the hub, which stays a compact grid card and
   must not grow -- this page-scoped override raises the line chart's
   real height only here. 260px isn't a new number: it matches
   .trend-canvas-wrap, the firearm detail page's own full-width session
   trend chart -- that page ALSO has no width cap of its own, so 260px
   is already this app's own proven answer for "how tall should a
   single line chart be when it can render at any real window width,"
   not just at whatever number #accuracy-by-distance-page happens to be
   capped at. That's also why this number didn't need to change again
   when #accuracy-by-distance-page's own cap grew from 1400px to
   2400px (see that rule's own comment) -- unlike RANGE_DELTA_CANVAS_HEIGHT
   below, which raised for a different, unrelated reason (bigger fonts
   needing more label room -- see firearm_range_chart.js). */
.accuracy-by-distance-section-body .firearm-range-chart-wrap {
  min-height: 260px;
}

/* --- Mark setup panel: the three-field session/firearm/ammo gate ---------
   Presentation only. The mode <select> under each field is the state the
   buttons write to and every test reads (see marking.js's
   wireSetupModeButtons and Claude.md's never-overstate-what-is-known);
   it is hidden here rather than removed. Neither mode button carries
   .is-active on arrival — the blank-first gate has to be visible as well
   as true. */

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

#mark-setup-picker .mark-setup-field {
  display: grid;
  grid-template-columns: 72px minmax(190px, 230px) minmax(0, 1fr);
  gap: 1.25rem;
  align-items: start;
  padding: 1.5rem 0;
  border-bottom: 1px solid var(--border);
}

#mark-setup-picker .mark-setup-field:last-of-type {
  border-bottom: 0;
}

.mark-setup-icon {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 72px;
  height: 72px;
  border: 1px solid var(--border-strong);
  border-radius: 14px;
  background: var(--panel-raised);
  color: var(--accent);
}

.mark-setup-icon svg {
  width: 30px;
  height: 30px;
}

.mark-setup-name {
  margin: 0 0 0.4rem;
  font-size: 0.82rem;
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text);
}

/* The step number belongs to the field, not to the controls inside it:
   numbering each field's mode choice "1" and its picker "2" would print
   three number ones down the panel. See this restyle's own notes. */
.mark-setup-step {
  margin-right: 0.5rem;
  color: var(--text-muted);
  font-variant-numeric: tabular-nums;
}

.mark-setup-step::after {
  content: ".";
}

.required-marker {
  color: var(--accent-poa);
}

.mark-setup-description {
  margin: 0;
  font-size: 0.85rem;
  line-height: 1.45;
  color: var(--text-muted);
}

.mark-setup-controls {
  display: flex;
  flex-direction: column;
  gap: 0.85rem;
  min-width: 0;
}

.mark-setup-mode {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 0.75rem;
}

.mark-setup-mode-btn {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  width: 100%;
  padding: 0.75rem 0.9rem;
  border: 1px solid var(--border-strong);
  border-radius: 10px;
  background: var(--panel-raised);
  color: var(--text);
  font: inherit;
  font-size: 0.92rem;
  text-align: left;
  cursor: pointer;
}

.mark-setup-mode-btn:hover {
  border-color: var(--accent);
}

.mark-setup-mode-btn:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

.mark-setup-mode-btn.is-active {
  border-color: var(--accent);
  box-shadow: inset 0 0 0 1px var(--accent);
}

.mark-setup-mode-icon {
  flex: 0 0 auto;
  width: 20px;
  height: 20px;
  color: var(--accent);
}

/* Reserved on both buttons so an active one never shifts its own label. */
.mark-setup-mode-check {
  flex: 0 0 auto;
  width: 20px;
  height: 20px;
  margin-left: auto;
  color: var(--accent);
  visibility: hidden;
}

.mark-setup-mode-btn.is-active .mark-setup-mode-check {
  visibility: visible;
}

.mark-setup-existing label {
  font-size: 0.78rem;
  font-weight: 700;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-muted);
}

.mark-setup-existing select {
  width: 100%;
}

.mark-setup-tip {
  display: flex;
  align-items: flex-start;
  gap: 0.7rem;
  margin: 1.25rem 0 0;
  padding-top: 1.25rem;
  border-top: 1px solid var(--border);
  font-size: 0.85rem;
  line-height: 1.5;
  color: var(--text-muted);
}

.mark-setup-tip svg {
  flex: 0 0 auto;
  width: 20px;
  height: 20px;
  color: var(--accent-scale);
}

@media (max-width: 860px) {
  #mark-setup-picker .mark-setup-field {
    grid-template-columns: 56px minmax(0, 1fr);
  }

  .mark-setup-icon {
    width: 56px;
    height: 56px;
  }

  .mark-setup-icon svg {
    width: 24px;
    height: 24px;
  }

  .mark-setup-controls {
    grid-column: 1 / -1;
  }

  .mark-setup-mode {
    grid-template-columns: 1fr;
  }
}

/* A remark, not an error: deliberately not styled like .form-error, which
   this app uses for things that actually went wrong. See the template's
   own comment and ammoCaliberIsUnfamiliar in marking_logic.js. */
.caliber-notice {
  display: flex;
  align-items: flex-start;
  gap: 0.6rem;
  margin: 0 0 0.75rem;
  padding: 0.7rem 0.85rem;
  border: 1px solid var(--border-strong);
  border-left: 3px solid var(--accent-scale);
  border-radius: 8px;
  background: var(--panel-raised);
  font-size: 0.85rem;
  line-height: 1.45;
  color: var(--text-muted);
}

.caliber-notice[hidden] {
  display: none;
}

.caliber-notice svg {
  flex: 0 0 auto;
  width: 18px;
  height: 18px;
  margin-top: 0.1rem;
  color: var(--accent-scale);
}

.caliber-notice #caliber-notice-text {
  flex: 1 1 auto;
}

.caliber-notice .link-button {
  flex: 0 0 auto;
  white-space: nowrap;
}

/* A dialog's own lead paragraph sits inside a .profile-form, which is a
   grid of minmax(180px, 1fr) columns -- so without this it lands in ONE
   column and wraps to a ~190px ribbon inside a 650px dialog, which is
   what the Mark tab's two calibration dialogs have been doing. Measured
   11 September 2026: form 650px, message 194px. .form-actions already
   carries the same span for the same reason. */
.dialog-lead {
  grid-column: 1 / -1;
  margin-top: 0;
}

/* Correcting a saved target's scale — the button on the photo panel, the
   note recording that a correction happened, and the dialog it opens. */
.target-recalibrate-button {
  display: block;
  margin-top: 0.35rem;
  font-size: 0.75rem;
  padding-left: 0;
}

/* Small and plain on purpose. A recalibrated target is more trustworthy
   than it was, so this is a visible cause for figures that moved, never a
   warning about them (see never-overstate-what-is-known). */
.target-scale-note {
  margin: 0.35rem 0 0;
  color: var(--text-muted);
  font-size: 0.72rem;
}

.target-scale-result {
  margin: 0.35rem 0 0;
  color: #22c55e;
  font-size: 0.72rem;
}

/* Amber, the colour this app already uses to say "this is worth a second
   look before you continue" (the Mark tab's carried-over note). No
   display property of its own, so the plain `hidden` attribute works --
   see tests/templates/test_hidden_attribute_is_not_overridden.py. */
.recalibrate-run-note {
  color: #f59e0b;
  font-size: 0.85rem;
}

#recalibrate-dialog {
  max-width: min(92vw, 860px);
}

/* Bounded in BOTH directions. The photo is drawn at its natural
   resolution — often 3000px+ — so without a max height the dialog grows
   past the viewport and the Cancel button ends up off screen; without a
   min height a portrait photo leaves nothing to drag a line across. */
.recalibrate-canvas-wrap {
  margin: 0.5rem 0;
  display: flex;
  justify-content: center;
  background: var(--panel-raised);
  border: 1px solid var(--border);
  border-radius: 6px;
  overflow: hidden;
}

#recalibrate-canvas {
  display: block;
  max-width: 100%;
  max-height: min(55vh, 460px);
  width: auto;
  height: auto;
  cursor: crosshair;
}

/* The reason a pooled cost total cannot be stated. Small and muted: it is
   an explanation for the value above it, not a warning — an unknown total
   is a fixable gap in what was recorded, not something wrong. */
.cost-unknown-reason {
  margin: -0.2rem 0 0.5rem;
  color: var(--text-muted);
  font-size: 0.78rem;
}

.activity-tooltip-reason {
  color: var(--text-muted);
  font-size: 0.72rem;
  margin-top: 0.15rem;
}

/* The ammo form's price hint spans the grid because it governs BOTH price
   fields, not the one it happens to follow. .field wide gives it the span;
   this drops the label/input spacing .field would otherwise add to a
   paragraph that has neither. */
.ammo-price-hint {
  margin-top: -0.35rem;
}

/* A populated practice body stacks several groups, so it needs the
   flex-direction every multi-block hub card body needs -- the convention
   tests/templates/test_populated_body_flex_direction.py exists to enforce,
   and which it caught on this card's first commit. */
#practice-body.practice-body--populated {
  flex-direction: column;
  align-items: stretch;
  gap: 0.25rem;
}

/* The practice card. Rows are links to their own evidence, so they are
   styled as rows rather than as visible hyperlinks -- the colour and
   underline of a link on every row would read as decoration and pull the
   eye away from the figures, which are the content. */
.practice-group {
  margin-bottom: 1.1rem;
}

.practice-group-title {
  margin: 0 0 0.15rem;
  font-size: 0.8rem;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--text-muted);
}

.practice-group-note {
  margin: 0 0 0.5rem;
  font-size: 0.78rem;
  color: var(--text-muted);
}

.practice-row {
  display: flex;
  align-items: baseline;
  gap: 0.5rem;
  padding: 0.35rem 0;
  border-bottom: 1px solid var(--border);
  color: var(--text);
  text-decoration: none;
}

.practice-row:last-child {
  border-bottom: none;
}

.practice-row:hover .practice-row-name {
  color: var(--accent);
}

.practice-row-swatch {
  width: 10px;
  height: 10px;
  border-radius: 2px;
  flex: 0 0 auto;
}

.practice-row-name {
  flex: 1 1 auto;
  font-size: 0.9rem;
}

.practice-row-figures {
  font-family: var(--font-mono);
  font-size: 0.8rem;
  color: var(--text-muted);
  text-align: right;
}

.practice-nothing-overdue {
  margin: 0 0 1rem;
}

/* The practice card's "Trailing your other guns" rows: distance, a mini
   target, the figures, and the ratio badge. */
/* SIZED FOR THE CARD, NOT THE VIEWPORT. This card occupies one hub grid
   column and is about 460px wide at every viewport width the hub has --
   measured, not assumed. So the four columns here are budgeted against
   that width rather than left to a media query, which cannot fire for a
   card whose width does not track the viewport: distance and badge are
   fixed and narrow, the mini target is its own drawn size, and the
   figures column takes what is left and stacks its two blocks. */
/* THREE PANELS, LEFT TO RIGHT IN THE RULE'S ORDER. Trailing takes half
   because it is the only one drawing anything -- a mini target, two
   figure blocks and a ratio badge per row -- while the other two are
   short text rows that read fine in a quarter. Measured rather than
   guessed: at 25/50/25 on a 1500px viewport Trailing's own row columns
   come out close to the 460px the card used to have all to itself, which
   is the width that layout was already tuned for.

   The card body itself stays a COLUMN flex container (see
   tests/templates/test_populated_body_flex_direction.py); this grid is
   nested inside it rather than replacing it. */
.practice-panels {
  display: grid;
  /* 25 / 37.5 / 37.5. Trailing gave up the width, not Not-shot-lately:
     that panel's detail line ("32 days ago · usually every 4 days") needs
     about 248px and has the least slack of the three, while Trailing's
     figures column falls to 218px, still far more than the 129px it had
     when this card was a single hub column. */
  grid-template-columns: 2fr 3fr 3fr;
  gap: 1.1rem;
  align-items: start;
  width: 100%;
}

.practice-panel {
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}

/* A separator rather than a box: three bordered cards inside a bordered
   card reads as three cards, and these are three readings of one
   question. */
.practice-panel + .practice-panel {
  border-left: 1px solid var(--border);
  padding-left: 1.1rem;
}

.practice-panel-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.5rem;
  min-height: 2rem;
}

.practice-panel-title {
  margin: 0;
  font-size: 0.95rem;
  font-weight: 600;
  color: var(--text);
}

.practice-panel-body {
  min-width: 0;
}

/* The rounds panel: one group per distance, horizontal bars within it. */
.practice-rounds-group {
  margin-bottom: 0.9rem;
}

.practice-rounds-group:last-of-type {
  margin-bottom: 0;
}

.practice-rounds-badge {
  margin: 0 0 0.3rem;
  font-family: var(--font-mono);
  font-size: 0.75rem;
  font-weight: 700;
  color: var(--text);
}

.practice-rounds-row {
  display: grid;
  /* 8.75rem for the name because "S&W 686 Double Action" measures 132px
     and was being truncated at 120. Measured, not guessed. */
  grid-template-columns: 8.75rem 1fr 2.1rem;
  align-items: center;
  gap: 0.4rem;
  padding: 0.09rem 0;
  color: var(--text);
  text-decoration: none;
}

.practice-rounds-row:hover .practice-rounds-name {
  color: var(--accent);
}

.practice-rounds-name {
  font-size: 0.72rem;
  text-align: right;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* position: relative so both the bar and the reference mark are placed
   against the SAME box -- which is what makes the dotted line land at the
   same x as the bars without measuring a column width anywhere. */
.practice-rounds-track {
  position: relative;
  height: 0.72rem;
  background: var(--panel);
  border-radius: 2px;
}

.practice-rounds-fill {
  display: block;
  height: 100%;
  border-radius: 2px;
}

.practice-rounds-mark {
  position: absolute;
  top: -0.09rem;
  bottom: -0.09rem;
  border-left: 1px dotted var(--text-muted);
  /* Snapped to the pixel grid for the same reason the practice card's
     crosshair is: a 1px line on a fractional offset renders as two
     half-intensity greys instead of one line. */
  shape-rendering: crispEdges;
}

.practice-rounds-value {
  font-family: var(--font-mono);
  font-size: 0.72rem;
  text-align: right;
}

.practice-rounds-legend {
  margin: 0.7rem 0 0;
  font-size: 0.72rem;
  color: var(--text-muted);
}

/* In a quarter-width column a row's two halves cannot share one line
   without breaking mid-phrase. The name keeps the first line and the
   figures take the whole of the next, aligned under the name rather than
   under the colour swatch. */
.practice-panel--gaps .practice-row,
.practice-panel--neglect .practice-row {
  flex-wrap: wrap;
}

.practice-panel--gaps .practice-row-figures,
.practice-panel--neglect .practice-row-figures {
  flex: 1 0 100%;
  margin-left: 1rem;
  text-align: left;
}

/* Below this the three panels stack in the same order, which is what a
   single-column grid does with no reordering. The separator becomes a
   rule above each panel rather than beside it. */
@media (max-width: 1100px) {
  .practice-panels {
    grid-template-columns: 1fr;
    gap: 1.4rem;
  }
  .practice-panel + .practice-panel {
    border-left: none;
    padding-left: 0;
    border-top: 1px solid var(--border);
    padding-top: 1.2rem;
  }
}

.practice-distance-row {
  display: grid;
  grid-template-columns: auto auto minmax(0, 1fr) auto;
  align-items: start;
  gap: 0.6rem;
  padding: 0.8rem 0.9rem;
  margin-bottom: 0.6rem;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--panel-raised);
  color: var(--text);
  text-decoration: none;
}

.practice-distance-row:hover {
  border-color: var(--border-strong);
}

.practice-distance {
  display: flex;
  flex-direction: column;
  line-height: 1.05;
}

.practice-distance-number {
  font-size: 1.6rem;
  font-weight: 700;
}

.practice-distance-unit {
  font-size: 0.62rem;
  letter-spacing: 0.12em;
  color: var(--text-muted);
}

.practice-mini {
  position: relative;
  flex: 0 0 auto;
}

.practice-mini-ring {
  fill: none;
  stroke: var(--border-strong);
  stroke-width: 1;
}

/* POINT OF AIM, AND IT HAS TO BE SEEN. Every dot on this target is a
   position relative to this crosshair, so it is the one mark the picture
   cannot be read without -- the same argument personal-bests-single-
   target-grid makes for drawing its own crosshair in the headline text
   colour rather than the fainter structure colours: on a target this
   small it is the only frame of reference there is, and it needs to read
   as unmistakably there rather than merely felt.

   It was briefly var(--text-muted) at 0.45 opacity, which is how a
   reference line disappears: thin AND dim AND grey is three reductions
   where one was wanted. Thin and solid is the restraint; the colour is
   not where that restraint belongs. */
.practice-mini-cross {
  /* Pure white at full opacity, and NOT var(--text) (#e6e8eb at 0.9),
     which was tried first and still could not be seen. Two reasons it
     needs to be brighter than body text: the dots are painted over the
     middle of it, so only the arms outside the cluster are ever visible,
     and a 1px line carries far less colour than a glyph does. */
  stroke: #ffffff;
  stroke-width: 1;
  opacity: 1;
  /* THE REASON WHITE ALONE DID NOT WORK. The crosshair sits at exactly
     half the viewBox, so a 1px stroke spans 55.5 to 56.5 and straddles a
     pixel boundary; the browser then antialiases it into TWO columns at
     50% coverage. Measured off a screenshot: pure white was arriving as
     (149, 151, 155), a mid grey, twice over. crispEdges snaps it to the
     pixel grid so one column renders solid, and leaves the geometry
     untouched -- which matters, because the dots are positioned from
     that same centre and must not move to make a line look better. */
  shape-rendering: crispEdges;
}

.practice-mini-dot {
  stroke-width: 2;
  cursor: pointer;
}

/* On the horizontal arm at the ring it measures, so it reads as the
   radius from the crosshair out to that ring -- which is what it is. */
.practice-mini-scale {
  position: absolute;
  right: 0;
  top: 50%;
  transform: translateY(-50%);
  font-family: var(--font-mono);
  font-size: 0.62rem;
  color: var(--text-muted);
  background: var(--panel-raised);
  padding-left: 1px;
}

.practice-row-main {
  min-width: 0;
}

.practice-trailing-name {
  display: flex;
  align-items: center;
  gap: 0.45rem;
  font-size: 1.05rem;
  font-weight: 600;
  margin-bottom: 0.4rem;
}

.practice-figures {
  display: flex;
  flex-direction: column;
  gap: 0.35rem;
}

.practice-figure {
  display: flex;
  flex-direction: column;
  padding: 0.3rem 0.5rem;
  border: 1px solid var(--border);
  border-radius: 6px;
  min-width: 0;
}

.practice-figure-value {
  font-family: var(--font-mono);
  font-size: 1rem;
  font-weight: 700;
}

.practice-figure-label {
  font-size: 0.68rem;
  color: var(--text-muted);
}

.practice-no-trailing {
  margin: 0;
  font-size: 0.9rem;
  color: var(--text);
}

.practice-provenance {
  grid-column: 1 / -1;
  margin: 0.4rem 0 0;
  font-family: var(--font-mono);
  font-size: 0.7rem;
  color: var(--text-muted);
}

/* The row's right-hand column: the ratio badge with the left-out note
   directly beneath it, beside the figures rather than below the target. */
.practice-row-aside {
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: 0.4rem;
  width: 5.4rem;
  text-align: right;
}

/* NEUTRAL on purpose. Green means "improved" everywhere else in this app
   and this badge is the opposite; the firearm's own colour stays on the
   small square beside its name. */
.practice-ratio-badge {
  display: flex;
  flex-direction: column;
  align-items: center;
  padding: 0.35rem 0.3rem;
  width: 100%;
  border: 1px solid var(--border-strong);
  border-radius: 8px;
  background: var(--panel);
}

.practice-ratio-value {
  font-family: var(--font-mono);
  font-size: 1.2rem;
  font-weight: 700;
  color: var(--text);
}

.practice-ratio-label {
  font-size: 0.6rem;
  letter-spacing: 0.1em;
  color: var(--text-muted);
}

/* The sixth tooltip in this codebase. Every shared property matches the
   other five exactly -- tests/templates/test_tooltips_agree.py caught this
   one the moment it was written and listed each difference, which is that
   derived test doing precisely what it exists for.

   The ONE deliberate difference is position: fixed rather than absolute,
   because this tooltip is appended to <body> and positioned from
   clientX/clientY, where the others are positioned inside their own
   chart's relatively-positioned wrapper. It carries its own modifier
   class for that, the way .speed-accuracy-tooltip--below does, so the
   agreement test still sees a tooltip that matches its siblings.

   See practice.js's own note on why the shared-helper refactor Claude.md
   calls for at the sixth was reported rather than bundled here. */
.practice-tooltip {
  position: absolute;
  z-index: 20;
  padding: 0.5rem 0.65rem;
  border: 1px solid var(--border-strong);
  border-radius: 6px;
  background: var(--panel-raised);
  color: var(--text);
  font-size: 0.75rem;
  line-height: 1.5;
  white-space: nowrap;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35);
  pointer-events: none;
}

/* Positioned from viewport coordinates against <body>, so it needs fixed
   and a higher stacking order than a tooltip living inside one card. */
.practice-tooltip-muted {
  color: var(--text-muted);
}

.practice-tooltip--floating {
  position: fixed;
  z-index: 50;
  display: flex;
  flex-direction: column;
  gap: 0.1rem;
}

.practice-tooltip[hidden] {
  display: none !important;
}

/* The mini target is the one fixed-width thing in the row, so it is what
   gives way first when the card is narrower still. */
@media (max-width: 720px) {
  .practice-mini,
  .practice-mini-svg {
    width: 96px;
    height: 96px;
  }
}
