:root {
  --bg: #15171a;
  --panel: #1c1f24;
  --panel-hover: #23262c;
  --border: #2a2e35;
  --text: #ece9e2;
  --text-dim: #8b8f98;
  --sharp: #4fd1c5;
  --sharp-bright: #6fe6da;
  --mid: #e8a33d;
  --soft: #e2574c;
  --keep: #6fcf87;
  --font-display: "Space Grotesk", system-ui, sans-serif;
  --font-mono: "IBM Plex Mono", ui-monospace, monospace;
  --glow-sharp: rgba(79, 209, 197, 0.28);
  --shadow-lift: 0 10px 30px rgba(0, 0, 0, 0.45);
  /* The two ambient background washes and the sticky topbar fill were
     hardcoded inline before. They're variables now so an event theme
     (see the gallery-style section at the end of this file) can restate
     the whole palette in one block instead of re-declaring rules. */
  --wash-1: rgba(79, 209, 197, 0.07);
  --wash-2: rgba(232, 163, 61, 0.05);
  --topbar-bg: rgba(28, 31, 36, 0.55);
  /* A handful of elements (comment rows, tag chips, the welcome
     banner, the speed-cull hint) want to look subtly "raised" off the
     surrounding panel/background - a plain white tint works for that
     in dark mode (lighter = raised), but the same rgba(255,255,255,x)
     against light mode's already near-white panels is invisible, so
     this flips direction per theme rather than using one fixed value
     (see body.theme-light below, and item #46's own dark-mode-
     consistency audit that found this). */
  --surface-raise: rgba(255, 255, 255, 0.04);
}

/* Light theme override - same variable names, flipped palette. Applied
   via a class on <body> (see the theme toggle button's JS) rather than
   prefers-color-scheme, so it's an explicit opt-in the person controls
   and remembers, not something that silently follows OS settings. */
body.theme-light {
  --bg: #f4f2ec;
  --panel: #ffffff;
  --panel-hover: #ece9e1;
  --border: #d9d5c9;
  --text: #1c1f24;
  --text-dim: #6b6f78;
  --sharp: #1f8f83;
  --sharp-bright: #17a396;
  --mid: #b9791f;
  --soft: #c2392e;
  --keep: #2f8f4e;
  --glow-sharp: rgba(31, 143, 131, 0.16);
  --shadow-lift: 0 10px 30px rgba(0, 0, 0, 0.10);
  --wash-1: rgba(31, 143, 131, 0.05);
  --wash-2: rgba(185, 121, 31, 0.04);
  --topbar-bg: rgba(255, 255, 255, 0.72);
  --surface-raise: rgba(0, 0, 0, 0.035);
}

* { box-sizing: border-box; }

body {
  margin: 0;
  background:
    radial-gradient(1100px 620px at 12% -8%, var(--wash-1), transparent 60%),
    radial-gradient(900px 520px at 100% 0%, var(--wash-2), transparent 55%),
    var(--bg);
  background-attachment: fixed;
  color: var(--text);
  font-family: var(--font-display);
  min-height: 100vh;
}

::-webkit-scrollbar { width: 11px; height: 11px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb {
  background: var(--panel-hover);
  border: 3px solid var(--bg);
  border-radius: 999px;
}
::-webkit-scrollbar-thumb:hover { background: var(--border); }

/* ---------- global keyboard focus ring ---------- */
/*
   :focus-visible specifically (not :focus) - only shows for real
   keyboard navigation, never for a mouse click, which matters because
   plenty of elements already do their own `:focus { outline: none;
   border-color: var(--sharp); }` treatment and this must never fight
   that. It doesn't: those are element+pseudo-class selectors (e.g.
   `select:focus`), which are always higher specificity than this bare
   `:focus-visible`, so this only ever fills in the gaps - anything
   without its own explicit focus styling (plain buttons, links,
   checkboxes...) that previously fell back to the browser's stock blue
   outline, clashing with this app's whole dark/teal palette.
*/
:focus-visible {
  outline: 2px solid var(--sharp);
  outline-offset: 2px;
}

/* ---------- theme-switch page transition ---------- */
/*
   A "flash to solid, then fade away" reveal rather than a literal
   crossfade between old/new screenshots (which would need capturing
   the page as an image first) - .flashing is added BEFORE the
   .theme-light class toggles, so the overlay briefly shows solid using
   the OLD theme's --bg; the moment .theme-light toggles, --bg
   recomputes instantly everywhere (CSS custom properties update
   synchronously, no transition needed for that part) including on this
   overlay's own background, so by the time .flashing is removed a
   frame later the overlay is already sitting on the NEW color and
   simply fades itself out via opacity, revealing the newly-themed page
   underneath. The two-rAF delay in app.js/client.js before removing
   .flashing exists for the same reason the login-card entrance
   animation needs one: the browser needs a paint in between setting
   the starting (opaque) state and starting the transition, or the
   fade-out never visibly plays at all.
*/
.theme-transition-overlay {
  position: fixed;
  inset: 0;
  z-index: 500;
  background: var(--bg);
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.3s ease;
}
.theme-transition-overlay.flashing {
  opacity: 1;
  transition: none;
}
@media (prefers-reduced-motion: reduce) {
  .theme-transition-overlay { transition: none; }
}

.topbar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 18px 28px;
  border-bottom: 1px solid var(--border);
  background: var(--topbar-bg);
  backdrop-filter: blur(10px);
  position: sticky;
  top: 0;
  z-index: 30;
  box-shadow: 0 0 0 rgba(0, 0, 0, 0);
  transition: box-shadow 0.2s ease;
}
/* .scrolled is toggled in app.js's existing scroll listener once the
   page has moved even a few px - the topbar goes from "flush with the
   top of the page" to "floating over content that's now sliding under
   it", and a shadow is what actually communicates that visually; the
   border-bottom alone reads the same in both states. */
.topbar.scrolled {
  box-shadow: 0 4px 18px rgba(0, 0, 0, 0.22);
}
body.theme-light .topbar.scrolled {
  box-shadow: 0 4px 18px rgba(0, 0, 0, 0.08);
}

.brand { display: flex; align-items: center; gap: 10px; font-weight: 700; font-size: 1.25rem; letter-spacing: -0.02em; }
.brand-mark {
  color: var(--sharp);
  font-size: 1.1rem;
  filter: drop-shadow(0 0 6px var(--glow-sharp));
}
.brand-logo-sm {
  height: 38px;
  width: auto;
  display: block;
  border-radius: 6px;
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.35);
}
.brand-logo-lg {
  height: 96px;
  width: auto;
  display: block;
  border-radius: 14px;
  box-shadow: var(--shadow-lift), 0 0 0 1px rgba(255, 255, 255, 0.03);
}

/* ---------- theme toggle icon (sun/moon crossfade) ----------
   One SVG holding both a sun group and a moon path, permanently
   overlaid in the same 24x24 viewBox rather than swapped in JS - an
   emoji glyph swap (the old 🌙/☀️ textContent approach) is an instant,
   un-animatable content change, so getting an actual transition
   required real vector shapes CSS can animate between instead. Toggled
   via .icon-light (see app.js/client.js's applyTheme) rather than a
   one-shot keyframe, since this reflects a persistent state (which
   theme is active right now), not a momentary event. */
.theme-toggle-icon {
  width: 16px;
  height: 16px;
  display: inline-block;
  vertical-align: -3px;
  margin-right: 6px;
  overflow: visible;
}
.theme-toggle-icon .theme-icon-sun,
.theme-toggle-icon .theme-icon-moon {
  transform-origin: 12px 12px;
  transition: opacity 0.35s ease, transform 0.45s cubic-bezier(0.34, 1.2, 0.64, 1);
}
.theme-toggle-icon .theme-icon-sun {
  opacity: 0;
  transform: rotate(-90deg) scale(0.4);
}
.theme-toggle-icon .theme-icon-moon {
  opacity: 1;
  transform: rotate(0deg) scale(1);
}
.theme-toggle-icon.icon-light .theme-icon-sun {
  opacity: 1;
  transform: rotate(0deg) scale(1);
}
.theme-toggle-icon.icon-light .theme-icon-moon {
  opacity: 0;
  transform: rotate(90deg) scale(0.4);
}
@media (prefers-reduced-motion: reduce) {
  .theme-toggle-icon .theme-icon-sun,
  .theme-toggle-icon .theme-icon-moon { transition: none; }
}

.topbar-actions {
  display: flex;
  align-items: center;
  gap: 10px;
  flex: 1;
  justify-content: flex-end;
}

/* The three actions that actually drive a culling session, kept
   visually together and ahead of everything else. Everything secondary
   now lives in a dropdown instead of competing with these for
   attention. */
.topbar-primary {
  display: flex;
  align-items: center;
  /* Wider than the 8px inside a group, so the three clusters read as
     three decisions rather than five equally-weighted buttons. The
     dividers come from .toolbar-group, which this reuses. */
  gap: 18px;
  margin-right: 14px;
}
/* The menus at the end (activity, admin, account) are a different kind
   of thing from the queue controls beside them - they open panels
   rather than doing something - so the boundary between the two gets
   the same hairline the groups use. */
.topbar-actions > .topbar-menu:first-of-type::before {
  content: "";
  align-self: center;
  height: 22px;
  margin-right: 4px;
  border-left: 1px solid color-mix(in srgb, var(--text-dim) 45%, transparent);
}
.topbar-actions > .topbar-menu:first-of-type {
  display: flex;
  align-items: center;
  gap: 10px;
}

.topbar-menu { position: relative; }
.topbar-menu-dropdown {
  display: none;
  position: absolute;
  top: 100%;
  left: 0;
  margin-top: 6px;
  flex-direction: column;
  gap: 6px;
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: 10px;
  padding: 8px;
  box-shadow: var(--shadow-lift);
  z-index: 45;
  min-width: 190px;
}
.topbar-menu-dropdown.open { display: flex; }
.topbar-menu-dropdown .btn {
  width: 100%;
  text-align: left;
  border-color: transparent;
  background: transparent;
}
.topbar-menu-dropdown .btn:hover { background: var(--panel-hover); transform: none; }

/* A section label inside a longer dropdown (Admin, Account) - purely
   organizational, not interactive. Introduced alongside consolidating
   several previously-separate topbar menus into fewer, longer ones
   (see the Admin/Account dropdown HTML) - flattening several menus
   into one only actually reduces clutter for a newcomer if the result
   still reads as organized groups rather than one long undifferentiated
   list of buttons, which a plain merge without these labels would have
   been. */
.dropdown-section-label {
  font-family: var(--font-mono);
  font-size: 0.68rem;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--text-dim);
  padding: 8px 8px 2px;
}
.dropdown-section-label:first-child { padding-top: 2px; }

/* Right-anchored variant for the account menu, which sits hard against
   the right edge - a left-anchored panel there would hang off-screen. */
.topbar-menu-dropdown-right { left: auto; right: 0; }

/* "Details ▾" scan/scoring activity log (see app.js's loadActivityLog)
   - wider and taller than the plain button-list dropdowns above, since
   this holds a scrollable list of filenames/stages rather than a
   handful of menu actions. */
.activity-log-dropdown {
  width: 380px;
  max-width: 90vw;
  padding: 10px;
}
.activity-log-header {
  font-size: 12px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.03em;
  color: var(--text-dim);
  padding: 0 2px 6px;
}
.activity-log-list {
  max-height: 320px;
  overflow-y: auto;
  display: flex;
  flex-direction: column-reverse; /* newest entry (last in the array) visually on top, no manual scroll needed to see what just happened */
  gap: 2px;
}
.activity-log-list .activity-log-entry {
  display: flex;
  gap: 8px;
  padding: 4px 2px;
  font-size: 12.5px;
  font-family: var(--font-mono, monospace);
  border-bottom: 1px solid var(--border);
  color: var(--text);
  line-height: 1.4;
}
.activity-log-list .activity-log-entry:first-child { border-bottom: none; }
.activity-log-list .activity-log-time {
  color: var(--text-dim);
  flex-shrink: 0;
  white-space: nowrap;
}
.activity-log-list .activity-log-message {
  overflow-wrap: anywhere;
}
.activity-log-empty {
  font-size: 13px;
  color: var(--text-dim);
  padding: 12px 4px;
  text-align: center;
}

.account-menu-btn {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 6px 10px 6px 6px;
}
.account-avatar {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 26px;
  height: 26px;
  border-radius: 50%;
  background: linear-gradient(135deg, var(--sharp), #3fb8ac);
  color: #0b1615;
  font-weight: 700;
  font-size: 0.8rem;
  flex: none;
}
.menu-caret { color: var(--text-dim); font-size: 0.7rem; }

/* A pressed/expanded look for toggles that reveal a panel (More
   filters), so an open row is visibly attached to its button. */
.btn-active {
  background: var(--panel-hover);
  border-color: var(--sharp);
  color: var(--text);
}
.filter-count[hidden] { display: none; }
/* One shared geometry for every "small count pill" in the app - this
   used to be three separate near-duplicate rules (.filter-count,
   .cart-badge, .review-badge) that had each drifted slightly apart:
   different padding, different border-radius (999px pill vs a fixed
   10px that looked more like a rounded rect at larger counts), no
   min-width/height on one of them so it sized purely off its own
   content instead of staying a consistent dot-ish size. Consolidated
   into one base rule all three classes share, with color kept as the
   only real per-class variation - amber for .review-badge is a
   deliberate "needs attention" signal distinct from the routine teal
   count elsewhere, not an oversight to fix away. */
.filter-count,
.cart-badge:not([hidden]),
.review-badge:not([hidden]) {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 18px;
  height: 18px;
  padding: 0 5px;
  margin-left: 4px;
  border-radius: 999px;
  font-size: 0.7rem;
  font-weight: 700;
  line-height: 1;
}
.filter-count,
.cart-badge:not([hidden]) {
  background: var(--sharp);
  color: #0b1615;
}
.review-badge:not([hidden]) {
  background: var(--mid);
  color: #1a1300;
}

/* Pushes the culling action buttons to the far end of the controls bar,
   with an actual visual divider (not just spacing) so filters and
   actions read as two distinct halves rather than one undifferentiated
   row of controls - the border/padding below is what actually delivers
   that separation; the gap alone wasn't enough once this row grew to
   include Undo/Redo, Speed cull, and Select multiple alongside the
   original filter controls. */
.controls-actions {
  display: flex; align-items: center; gap: 8px; flex: none;
  padding-left: 16px;
  border-left: 1px solid var(--border);
}
.btn-cluster { display: flex; gap: 4px; }
.btn-cluster .btn { padding: 9px 12px; }

/* Button labels are short and known - letting them wrap mid-phrase
   ("Scan for new / photos") makes the bar taller and reads as broken
   rather than responsive. They shrink out of the row into the hamburger
   at 860px instead. */
.topbar .btn, .controls .btn { white-space: nowrap; }

.secondary-controls {
  background: var(--panel);
  gap: 18px;
  padding-top: 12px;
  padding-bottom: 12px;
}

/* Collapsible section header (scoring calibration) */
.panel-toggle {
  display: flex;
  align-items: center;
  gap: 8px;
  width: 100%;
  padding: 4px 0;
  background: none;
  border: none;
  color: var(--text);
  font-family: var(--font-display);
  font-weight: 600;
  font-size: 0.85rem;
  cursor: pointer;
  text-align: left;
}
.panel-toggle:hover { color: var(--sharp); }
.panel-toggle-caret { color: var(--text-dim); font-size: 0.75rem; }
.panel-toggle-hint {
  font-family: var(--font-mono);
  font-weight: 400;
  font-size: 0.72rem;
  color: var(--text-dim);
}
.calibration-body {
  display: flex;
  flex-direction: column;
  gap: 8px;
  padding-top: 10px;
}
.calibration-body[hidden] { display: none; }

/* Global baseline for every plain <select> in the app - a handful of
   more specific rules (.sort-group select, .upload-section select,
   etc.) override this with their own sizing/spacing where they exist,
   but this is the fallback that guarantees a <select> dropped anywhere
   else in the app (a modal form field, say) still gets the dark theme
   instead of silently falling back to the browser's plain default
   appearance - which is exactly what happened to a couple of dropdowns
   that predated this rule and sat outside every specific selector. */
/* Text-entry controls had NO base rule at all. `select` was styled here
   from the start, so dropdowns looked like the app while every text,
   number, search, email, password and date field next to them fell back
   to the browser default - a white box with a blue focus ring, on a
   dark page. There are over a hundred of them, and per-component rules
   existed for only a handful.

   This is an element selector, so it is the weakest thing in the file:
   every existing `.some-panel input` rule still wins. It sets the floor
   the app never had, without overriding a single deliberate choice. */
input[type="text"],
input[type="number"],
input[type="search"],
input[type="email"],
input[type="password"],
input[type="date"],
input[type="time"],
input[type="url"],
input:not([type]),
textarea {
  font-family: var(--font-display);
  font-size: 0.85rem;
  padding: 7px 9px;
  border-radius: 6px;
  border: 1px solid var(--border);
  background: var(--panel);
  color: var(--text);
}
input::placeholder,
textarea::placeholder { color: var(--text-dim); opacity: 1; }
input[type="text"]:focus,
input[type="number"]:focus,
input[type="search"]:focus,
input[type="email"]:focus,
input[type="password"]:focus,
input[type="date"]:focus,
input[type="time"]:focus,
input[type="url"]:focus,
input:not([type]):focus,
textarea:focus { outline: none; border-color: var(--sharp); }

/* Checkboxes, radios and range tracks are drawn by the browser and take
   its accent, not the app's. One property re-points all of them at the
   palette - the sliders were rendering in Chrome's default blue, which
   is the one colour in the UI that belongs to no theme. */
input[type="checkbox"],
input[type="radio"],
input[type="range"] { accent-color: var(--sharp); }

select {
  font-family: var(--font-display);
  font-size: 0.85rem;
  padding: 7px 9px;
  border-radius: 6px;
  border: 1px solid var(--border);
  background: var(--panel);
  color: var(--text);
}
select:focus { outline: none; border-color: var(--sharp); }

.btn {
  /* Several .btn elements are anchors, not buttons (the PDF exports use
     `download`, which only an <a> can do). They were picking up the
     default link underline, so two buttons in the same row rendered
     underlined and the rest did not - which reads as a mistake rather
     than a distinction, because it is one. */
  text-decoration: none;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 7px;
  font-family: var(--font-display);
  font-weight: 600;
  font-size: 0.9rem;
  padding: 9px 16px;
  border-radius: 7px;
  border: 1px solid var(--border);
  background: var(--panel);
  color: var(--text);
  cursor: pointer;
  transition: background 0.15s ease, border-color 0.15s ease, transform 0.12s ease, box-shadow 0.15s ease;
}
.btn:hover { background: var(--panel-hover); transform: translateY(-1px); }
/* translateY(0) alone just cancels the hover lift - :active fires for
   the whole duration of the mouse being down, not just the moment of
   the click, so without an actual scale-down the button just "un-lifts"
   rather than giving any real press feedback. */
.btn:active { transform: translateY(0) scale(0.96); }
.btn:disabled { opacity: 0.4; cursor: default; pointer-events: none; }
.btn-accent {
  background: linear-gradient(135deg, var(--sharp), #3fb8ac);
  color: #0b1615;
  border-color: var(--sharp);
  font-weight: 700;
}
.btn-accent:hover {
  filter: brightness(1.06);
  box-shadow: 0 4px 18px var(--glow-sharp);
}
.btn-reject { background: transparent; color: var(--soft); border-color: var(--soft); }
.btn-reject:hover { background: rgba(226, 87, 76, 0.12); }

.queue-status {
  font-family: var(--font-mono);
  font-size: 0.8rem;
  color: var(--text-dim);
  min-height: 1em;
}

.offline-queue-indicator {
  font-family: var(--font-mono);
  font-size: 0.75rem;
  color: #ffb400;
  padding: 3px 8px;
  border-radius: 999px;
  background: rgba(255, 180, 0, 0.12);
  border: 1px solid rgba(255, 180, 0, 0.35);
}

.status-panel {
  gap: 18px;
  align-items: center;
  padding: 8px 28px;
  background: var(--panel);
  border-bottom: 1px solid var(--border);
  font-family: var(--font-mono);
  font-size: 0.78rem;
  color: var(--text-dim);
}
.status-panel:not([hidden]) { display: flex; }
.status-panel span:first-child { color: var(--sharp); }
.status-progress-bar {
  width: 120px;
  height: 5px;
  border-radius: 999px;
  background: var(--border);
  overflow: hidden;
  flex-shrink: 0;
}
.status-progress-bar[hidden] { display: none; }
.status-progress-fill {
  height: 100%;
  width: 0%;
  background: var(--sharp);
  border-radius: inherit;
  /* Driven by app.js setting an explicit width % each poll tick
     (see pollStatus - width comes from worker.status()'s new
     batch_progress field), so this transition is just smoothing
     between one poll's value and the next (they land every ~4s) rather
     than animating anything continuously on its own. */
  transition: width 0.6s ease;
}

.session-stats-panel {
  padding: 10px 28px;
  background: var(--panel);
  border-bottom: 1px solid var(--border);
  font-family: var(--font-mono);
  font-size: 0.82rem;
  color: var(--text);
  line-height: 1.6;
}
.session-stats-panel strong { color: var(--sharp); }
.session-ratio-bar {
  display: flex;
  width: 100%;
  height: 6px;
  border-radius: 999px;
  overflow: hidden;
  margin: 6px 0;
  background: var(--border);
}
.session-ratio-good {
  background: var(--keep);
  transition: width 0.3s ease;
}
.session-ratio-reject {
  background: var(--soft);
  transition: width 0.3s ease;
}

/* ---------- scoring trends dashboard ---------- */

.trends-modal-inner { max-width: min(820px, 95vw); }
.trends-chart-wrap-inner { position: relative; }
.trends-svg {
  width: 100%;
  height: auto;
  margin-top: 12px;
  cursor: crosshair;
}
.trends-guideline {
  stroke: var(--border);
  stroke-width: 1;
  pointer-events: none;
}
.trends-tooltip {
  position: absolute;
  top: 8px;
  transform: translateX(8px);
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: 6px;
  padding: 6px 10px;
  font-family: var(--font-mono);
  font-size: 0.72rem;
  color: var(--text);
  box-shadow: 0 6px 18px rgba(0, 0, 0, 0.4);
  pointer-events: none;
  white-space: nowrap;
  z-index: 2;
}
.trends-tooltip strong { color: var(--sharp); }
.trends-tooltip-flip { transform: translateX(calc(-100% - 8px)); }

.trends-comparison-row {
  display: flex;
  flex-wrap: wrap;
  gap: 12px;
  align-items: center;
  font-family: var(--font-mono);
  font-size: 0.8rem;
  margin: 8px 0 4px;
}
.trends-delta { font-weight: 600; }
.trends-delta-up { color: var(--keep); }
.trends-delta-down { color: var(--soft); }
.trends-legend {
  display: flex;
  gap: 20px;
  font-size: 0.8rem;
  color: var(--text-dim);
  margin: 6px 0 10px;
}
.trends-swatch {
  display: inline-block;
  width: 14px;
  height: 3px;
  margin-right: 6px;
  vertical-align: middle;
}
.trends-swatch-dashed {
  background: repeating-linear-gradient(to right, var(--mid) 0, var(--mid) 4px, transparent 4px, transparent 7px);
}

/* ---------- slideshow mode (client gallery) ---------- */

.slideshow-overlay {
  position: fixed;
  inset: 0;
  background: #000;
  z-index: 100;
  align-items: center;
  justify-content: center;
}
.slideshow-overlay:not([hidden]) { display: flex; }
.slideshow-overlay img {
  max-width: 100vw;
  max-height: 100vh;
  object-fit: contain;
}
.slideshow-controls {
  position: absolute;
  bottom: 24px;
  left: 50%;
  transform: translateX(-50%);
  display: flex;
  gap: 10px;
  background: rgba(12, 13, 15, 0.7);
  padding: 10px 16px;
  border-radius: 999px;
  backdrop-filter: blur(4px);
}
.slideshow-progress {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  height: 3px;
  background: rgba(255, 255, 255, 0.1);
}
#slideshowProgressBar {
  height: 100%;
  width: 0%;
  background: var(--sharp);
  transition: width linear;
}

.tabs {
  position: relative;
  display: flex;
  gap: 4px;
  padding: 10px 28px 0;
  border-bottom: 1px solid var(--border);
}
.tab {
  font-family: var(--font-display);
  font-weight: 600;
  font-size: 0.85rem;
  color: var(--text-dim);
  background: transparent;
  border: none;
  border-bottom: 2px solid transparent;
  padding: 10px 14px;
  cursor: pointer;
  transition: color 0.15s ease;
}
.tab:hover { color: var(--text); }
.tab.active { color: var(--sharp); }
/* The moving underline (see updateTabIndicator in app.js) replaces the
   old per-tab border-bottom-color swap - that changed color instantly
   with no sense of travel between tabs, which reads as static rather
   than as one continuous strip that slides to wherever you clicked.
   Positioned/sized in JS (via each tab's own offsetLeft/offsetWidth,
   which CSS alone has no way to read) and animated here via transition
   on left/width - this only ever runs on a tab click, a handful of
   times per session, so there's no scroll/drag-linked performance
   concern that would push toward a transform-only alternative. */
.tab-indicator {
  position: absolute;
  bottom: -1px;
  height: 2px;
  background: var(--sharp);
  border-radius: 2px 2px 0 0;
  transition: left 0.28s cubic-bezier(0.65, 0, 0.35, 1), width 0.28s cubic-bezier(0.65, 0, 0.35, 1);
  pointer-events: none;
}
.gallery-tabs { display: flex; gap: 6px; padding: 14px 28px; border-bottom: 1px solid var(--border); align-items: center; }

/* ---------- favorite collections (client gallery) ---------- */

.favorite-collections-row {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  align-items: center;
  padding: 0 28px 14px;
}
.favorite-collections-row[hidden] { display: none; }
.collection-pill {
  font-family: var(--font-mono);
  font-size: 0.78rem;
  padding: 5px 12px;
  border-radius: 999px;
  border: 1px solid var(--border);
  background: none;
  color: var(--text-dim);
  cursor: pointer;
  transition: border-color 0.15s ease, color 0.15s ease;
}
.collection-pill:hover { color: var(--text); }
.collection-pill.active {
  border-color: var(--sharp);
  color: var(--sharp);
  background: var(--wash-1);
}

/* All Photos -> Favorites -> Album Picks is a real progression (browse,
   then curate, then finalize), so it earns a heavier, more considered
   control than plain admin tabs get - a segmented pill bar rather than
   an underline. Scoped to .gallery-tabs specifically; the bare .tab
   class is shared with the admin app's own tabs and stays untouched. */
.gallery-tabs .tab {
  border-bottom: none;
  border-radius: 999px;
  padding: 8px 16px;
  background: transparent;
}
.gallery-tabs .tab:hover { background: var(--panel-hover); }
.gallery-tabs .tab.active {
  background: var(--panel);
  color: var(--sharp);
  box-shadow: inset 0 0 0 1px var(--border);
}

.gallery-search {
  margin-left: auto;
  margin-bottom: 8px;
  font-family: var(--font-mono);
  font-size: 0.82rem;
  padding: 7px 12px;
  border-radius: 6px;
  border: 1px solid var(--border);
  background: var(--panel);
  color: var(--text);
  width: 200px;
  max-width: 40vw;
}

.comment-section {
  margin-top: 12px;
  padding-top: 12px;
  border-top: 1px solid var(--border);
}
.comment-list {
  display: flex;
  flex-direction: column;
  gap: 6px;
  margin-bottom: 8px;
  max-height: 120px;
  overflow-y: auto;
}
.comment-row {
  font-size: 0.85rem;
  color: var(--text);
  background: var(--surface-raise);
  border-radius: 6px;
  padding: 6px 10px;
}
.comment-row.resolved { color: var(--text-dim); }
.comment-input-row {
  display: flex;
  gap: 8px;
  align-items: flex-end;
}
.comment-input-row textarea { flex: 1 1 auto; }

.request-chip-row {
  display: flex;
  flex-wrap: wrap;
  gap: 12px;
  margin-bottom: 8px;
}
.request-chip {
  font-size: 0.78rem;
  border: 1px solid var(--border);
  border-radius: 14px;
  padding: 4px 10px;
  text-decoration: none;
}
.request-chip:hover { border-color: var(--sharp); }
.request-chip.active { color: var(--sharp); border-color: var(--sharp); background: rgba(255,255,255,0.04); }

.view[hidden] { display: none; }

/* Explanatory prose, not machine output. This carried the monospace
   face, which is why the app read as a terminal: `hint-text` is used
   for the long descriptive paragraphs at the top of most panels, so the
   single most common block of running text on the page was set in the
   face reserved for paths and filenames. The consequence was not only
   that prose looked like code - it was that a real path inside one of
   these paragraphs became invisible, because it matched the sentence
   around it. Mono now means machine text again, and `code` below is
   what makes a path stand out.

   The max-width is measured in `ch`, so it tracks the font: these
   paragraphs ran the full window, about 190 characters per line at
   1500px, against a comfortable maximum near 80. */
.hint-text {
  font-family: var(--font-display);
  font-size: 0.78rem;
  line-height: 1.45;
  color: var(--text-dim);
  margin: 0;
  max-width: 78ch;
}
.hint-text code {
  font-family: var(--font-mono);
  color: var(--text);
  background: var(--panel);
  padding: 1px 5px;
  border-radius: 4px;
}

.checklist-push-banner {
  font-size: 0.85rem;
  padding: 8px 12px;
  margin: 6px 0;
  border-radius: 8px;
  background: rgba(90, 160, 255, 0.12);
  border: 1px solid rgba(90, 160, 255, 0.35);
  color: var(--text);
}
.checklist-push-banner strong {
  color: var(--sharp, #5aa0ff);
}
.pin-picker {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  align-items: center;
  margin: 4px 0 8px 12px;
  padding: 8px;
  border-left: 2px solid var(--border);
  font-size: 0.82rem;
}
.pin-picker select { max-width: 200px; }

.checklist-alt-row {
  margin-top: 4px;
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  align-items: baseline;
  font-size: 0.8rem;
}
.checklist-alt { font-size: 0.8rem; }
/* Shown when eBird's region species list could not be fetched. The
   checklist below it still renders, so this has to be visibly a warning
   rather than another hint line - the failure mode it explains is a list
   that looks fine and is merely short. */
.checklist-region-warning,
.inline-warning {
  margin-top: 6px;
  padding: 8px 10px;
  border-radius: 6px;
  border: 1px solid #8a6d1f;
  background: rgba(190, 150, 40, 0.13);
  color: #e8cf8a;
  font-size: 0.82rem;
  line-height: 1.4;
}

/* ---------- live eBird "seen near you" ---------- */

.checklist-nearby {
  margin: 8px 0;
  padding: 10px 12px;
  border-radius: 8px;
  /* A third hue again: the blue banner is birds expected here
     historically, the green panel is birds you already have photos of,
     and this amber one is birds someone reported THIS WEEK. Three
     different claims sitting next to each other; sharing a colour would
     merge them visually. */
  background: rgba(230, 175, 90, 0.10);
  border: 1px solid rgba(230, 175, 90, 0.30);
}
.checklist-nearby-head { font-size: 0.85rem; margin-bottom: 8px; }
/* The header is a <button> so it can toggle the panel; these strip the
   button chrome back to the plain text row it replaced, without losing
   keyboard focus or the aria-expanded state a real button carries. */
.checklist-nearby-toggle {
  display: flex;
  align-items: baseline;
  gap: 6px;
  width: 100%;
  padding: 0;
  margin-bottom: 8px;
  border: none;
  background: none;
  color: inherit;
  font: inherit;
  text-align: left;
  cursor: pointer;
}
.checklist-nearby-toggle:hover strong { text-decoration: underline; }
.checklist-nearby-toggle:focus-visible {
  outline: 2px solid #e6af5a;
  outline-offset: 2px;
  border-radius: 4px;
}
/* Collapsed panels have no chip row, so the header's own bottom margin
   would leave a gap inside an otherwise empty box. */
.checklist-nearby-toggle:last-child { margin-bottom: 0; }
.nearby-caret { opacity: 0.7; font-size: 0.75rem; }
.checklist-nearby-head strong { color: #e6af5a; }
.checklist-nearby-row { display: flex; flex-wrap: wrap; gap: 6px; }
.nearby-chip {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 4px 10px;
  border-radius: 999px;
  font-size: 0.82rem;
  color: var(--text);
  background: rgba(255, 255, 255, 0.04);
  border: 1px solid var(--border);
  cursor: default;
}
/* Rare sightings carry a star as well as the border colour - the same
   reason the out-of-region suggestion chips carry a plane glyph. */
.nearby-chip.nearby-rare { border-color: rgba(230, 175, 90, 0.65); }
.nearby-chip .nearby-when {
  font-family: var(--font-mono);
  font-size: 0.72rem;
  opacity: 0.6;
}

.lightbox-related {
  font-size: 0.8rem;
  margin-top: 6px;
  padding: 6px 10px;
  border-radius: 6px;
  background: rgba(230, 175, 90, 0.12);
  border: 1px solid rgba(230, 175, 90, 0.35);
}
.lightbox-related strong { color: #e6af5a; }

.checklist-hotspots {
  margin: 8px 0;
  padding: 10px 12px;
  border-radius: 8px;
  /* Deliberately quieter than the amber sightings panel above it:
     hotspots are standing reference, not news. */
  background: rgba(255, 255, 255, 0.03);
  border: 1px solid var(--border);
}
.checklist-hotspots-head strong { color: var(--text); }
.hotspot-list { display: flex; flex-direction: column; gap: 3px; }
.hotspot-row {
  display: flex;
  gap: 10px;
  align-items: baseline;
  font-size: 0.82rem;
}
.hotspot-km {
  font-family: var(--font-mono);
  font-size: 0.75rem;
  min-width: 60px;
  text-align: right;
  opacity: 0.8;
}

/* ---------- checklist completion progress ---------- */

.checklist-progress {
  margin: 8px 0 4px;
}
.checklist-progress-label {
  font-size: 0.85rem;
  margin-bottom: 5px;
}
.checklist-progress-label strong { color: #78c88c; }
.checklist-progress-bar {
  height: 6px;
  border-radius: 999px;
  background: rgba(255, 255, 255, 0.07);
  overflow: hidden;
}
.checklist-progress-fill {
  height: 100%;
  border-radius: 999px;
  background: #78c88c;
  /* Matches the suggestions panel's green rather than the push
     banner's blue: both this and that panel are about birds you HAVE,
     while the blue banner is about ones you have not photographed. */
  transition: width 0.3s ease;
}

/* ---------- "photographed but not ticked off" suggestions ---------- */

.checklist-suggestions {
  margin: 6px 0;
  padding: 10px 12px;
  border-radius: 8px;
  background: rgba(120, 200, 140, 0.10);
  border: 1px solid rgba(120, 200, 140, 0.30);
}
/* Deliberately a different hue from .checklist-push-banner above. The
   two sit next to each other and say opposite things - that banner is
   birds you have NOT photographed and might look for, this panel is
   birds you HAVE photographed and only need to tick off. Same colour
   for both would read as one list. */
.checklist-suggestions-head {
  font-size: 0.85rem;
  margin-bottom: 8px;
}
.checklist-suggestions-head strong { color: #78c88c; }
.checklist-suggestions-row {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
}
.checklist-suggestion-chip {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 4px 10px;
  border-radius: 999px;
  font-size: 0.82rem;
  font-family: inherit;
  cursor: pointer;
  color: var(--text);
  background: rgba(255, 255, 255, 0.04);
  border: 1px solid var(--border);
  transition: background 0.15s ease, border-color 0.15s ease;
}
.checklist-suggestion-chip:hover {
  background: rgba(255, 255, 255, 0.09);
  border-color: #78c88c;
}
.checklist-suggestion-chip .chip-count {
  font-family: var(--font-mono);
  font-size: 0.72rem;
  opacity: 0.7;
}
/* Photographed inside the region currently on screen - the ones most
   likely to be worth ticking off right now, so they get the accent.
   region-unknown (no GPS anywhere on the photos) stays deliberately
   neutral rather than being styled as a problem: a missing coordinate
   says nothing about the bird. */
.checklist-suggestion-chip.region-in { border-color: rgba(120, 200, 140, 0.55); }
.checklist-suggestion-chip.region-out { opacity: 0.72; }
.checklist-suggestions-more {
  margin-top: 8px;
  font-size: 0.8rem;
  padding: 3px 10px;
}
/* Ring on picker thumbnails already tagged with the species being
   marked. Uses outline rather than border so it does not resize the
   110px thumbnail and reflow the grid relative to its neighbours. */
.picker-species-match {
  outline: 2px solid #78c88c;
  outline-offset: 2px;
  border-radius: 6px;
}

.checklist-gallery-wrap {
  margin-top: 8px;
  padding-top: 8px;
  border-top: 1px solid var(--border);
}
.checklist-gallery-strip {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin-bottom: 6px;
}
.checklist-gallery-item {
  position: relative;
  width: 56px;
  height: 56px;
}
.checklist-gallery-item img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  border-radius: 5px;
}
.checklist-gallery-item button {
  position: absolute;
  top: -4px;
  right: -4px;
  width: 18px;
  height: 18px;
  line-height: 16px;
  padding: 0;
  border-radius: 50%;
  border: none;
  background: rgba(0, 0, 0, 0.75);
  color: #fff;
  font-size: 0.65rem;
  cursor: pointer;
}
.checklist-gallery-item button:hover { background: var(--reject, #e05); }

/* Bottom-right of a card can carry two badges (shooter attribution +
   burst-group size) - both used to independently position themselves
   at "bottom: 8px; right: 8px", so a photo with both simply lost the
   shooter badge underneath the group-size one, silently. They're now
   two plain (non-positioned) chips inside .card-badges-br below, which
   is the thing that's actually absolutely positioned. */
.card-badges-br {
  position: absolute;
  bottom: 8px; right: 8px;
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: 4px;
}
.group-badge {
  font-family: var(--font-mono);
  font-size: 0.68rem;
  color: var(--text);
  background: rgba(12, 13, 15, 0.75);
  padding: 3px 7px;
  border-radius: 999px;
  border: 1px solid var(--border);
}

.controls:not([hidden]) {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 24px;
  padding: 16px 28px;
  border-bottom: 1px solid var(--border);
}

/* The primary Cull bar is two halves: filters that wrap freely on the
   left, actions pinned to the right. The outer bar deliberately does
   NOT wrap - only .controls-filters below does - so the actions stay on
   the first line at every width instead of dropping onto a near-empty
   second row the moment the filters get one item too wide. Placed after
   the base .controls rule above (same selector specificity) so it wins
   the cascade on source order rather than needing !important. */
.primary-controls:not([hidden]) { flex-wrap: nowrap; gap: 16px; }
.controls-filters {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 16px 20px;
  flex: 1 1 auto;
  min-width: 0;
}

.filter-group { display: flex; gap: 8px; }
.filter-group input[type="search"] {
  font-family: var(--font-display);
  font-size: 0.85rem;
  padding: 6px 10px;
  border-radius: 6px;
  border: 1px solid var(--border);
  background: var(--panel);
  color: var(--text);
  width: 140px;
}
.filter-group input[type="search"]:focus {
  outline: none;
  border-color: var(--sharp);
}
/* Removes Chrome/Safari's default "x" clear icon and magnifier icon
   for type="search" - both render with a mismatched, un-themeable
   native appearance that clashes with this app's own dark styling more
   than a plain input would. */
.filter-group input[type="search"]::-webkit-search-cancel-button,
.filter-group input[type="search"]::-webkit-search-decoration {
  -webkit-appearance: none;
  appearance: none;
}
.chip {
  font-family: var(--font-mono);
  font-size: 0.8rem;
  padding: 6px 12px;
  border-radius: 999px;
  border: 1px solid var(--border);
  background: transparent;
  color: var(--text-dim);
  cursor: pointer;
}
.chip.active { color: var(--bg); background: var(--text); border-color: var(--text); }

.sort-group:not([hidden]), .threshold-group:not([hidden]) {
  display: flex; align-items: center; gap: 8px;
  font-family: var(--font-mono); font-size: 0.8rem; color: var(--text-dim);
}

.calibration-panel {
  padding: 12px 28px;
  background: var(--panel);
  border-bottom: 1px solid var(--border);
  display: flex;
  flex-direction: column;
  gap: 8px;
}
.calibration-row {
  display: flex;
  align-items: center;
  gap: 20px;
  flex-wrap: wrap;
}
.gamma-group, .ceiling-group {
  display: flex; align-items: center; gap: 8px;
  font-family: var(--font-display); font-size: 0.8rem; color: var(--text-dim);
}
.gamma-group input[type="range"] { width: 140px; }
.ceiling-group input[type="number"] {
  width: 80px;
  background: var(--bg);
  color: var(--text);
  border: 1px solid var(--border);
  border-radius: 4px;
  padding: 4px 6px;
  font-family: var(--font-mono);
}
.calibration-spinner {
  font-family: var(--font-mono);
  font-size: 0.78rem;
  color: var(--mid);
}
.calibration-diagnostic, .calibration-result {
  font-family: var(--font-mono);
  font-size: 0.75rem;
  color: var(--text-dim);
}
.calibration-result.warn { color: var(--soft); }
.calibration-result.ok { color: var(--sharp); }

.camera-calibration-section {
  margin-top: 12px;
  padding-top: 10px;
  border-top: 1px solid var(--border);
}
.camera-calibration-header {
  font-size: 0.82rem;
  font-weight: 600;
  color: var(--soft);
  margin-bottom: 8px;
}
.camera-calibration-list {
  display: flex;
  flex-direction: column;
  gap: 6px;
}
.camera-calibration-row {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 0.8rem;
}
.camera-calibration-name {
  flex: 1 1 auto;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.camera-calibration-multiplier {
  width: 70px;
}

/* ---------- print-order success state (client gallery cart) ---------- */

.order-success {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 14px;
  padding: 32px 16px;
  text-align: center;
}
.order-success[hidden] { display: none; }
.order-success-check {
  width: 84px;
  height: 84px;
}
.order-success-check-circle {
  stroke: var(--keep);
  stroke-width: 2;
  stroke-miterlimit: 10;
  /* Drawn via the classic stroke-dasharray/dashoffset trick: the dash
     length is set to the circle's own circumference (2 * pi * r, r=24)
     so one full dash IS the whole circle, then offset by that same
     amount to start fully hidden and animate to 0 (fully drawn). */
  stroke-dasharray: 151;
  stroke-dashoffset: 151;
}
.order-success-check-mark {
  stroke: var(--keep);
  stroke-width: 3;
  stroke-linecap: round;
  stroke-linejoin: round;
  /* Approximate path length for the checkmark polyline itself - doesn't
     need to be exact, just long enough that the dash fully covers the
     drawn path with room to spare. */
  stroke-dasharray: 40;
  stroke-dashoffset: 40;
}
/* Class re-added in JS (client.js) on every successful order, not just
   left to play once on page load - see the reflow-forcing comment at
   that call site for why a simple class toggle alone wouldn't restart
   an already-completed CSS animation on a second order. */
.order-success-check-play .order-success-check-circle {
  animation: order-check-circle 0.5s ease-out forwards;
}
.order-success-check-play .order-success-check-mark {
  animation: order-check-mark 0.35s 0.4s ease-out forwards;
}
@keyframes order-check-circle {
  to { stroke-dashoffset: 0; }
}
@keyframes order-check-mark {
  to { stroke-dashoffset: 0; }
}
.order-success-text {
  font-family: var(--font-mono);
  font-size: 0.85rem;
  color: var(--text);
  max-width: 320px;
}
@media (prefers-reduced-motion: reduce) {
  .order-success-check-circle,
  .order-success-check-mark {
    animation: none !important;
    stroke-dashoffset: 0;
  }
}

/* ---------- info icon / tooltip (replaces long inline explanations) ---------- */

.info-icon {
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 15px;
  height: 15px;
  border-radius: 50%;
  border: 1px solid var(--text-dim);
  color: var(--text-dim);
  font-family: var(--font-mono);
  font-size: 0.65rem;
  font-style: italic;
  font-weight: 700;
  line-height: 1;
  cursor: help;
  margin-left: 6px;
  vertical-align: middle;
  flex-shrink: 0;
  transition: border-color 0.15s ease, color 0.15s ease;
}
.info-icon:hover, .info-icon:focus, .info-icon.open { border-color: var(--sharp); color: var(--sharp); outline: none; }
.info-icon .info-tooltip,
.error-chip .info-tooltip {
  position: absolute;
  bottom: calc(100% + 9px);
  left: 50%;
  transform: translateX(-50%);
  width: max-content;
  max-width: 280px;
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 10px 12px;
  font-family: var(--font-display);
  font-size: 0.78rem;
  font-style: normal;
  font-weight: 400;
  color: var(--text);
  line-height: 1.45;
  box-shadow: var(--shadow-lift);
  opacity: 0;
  visibility: hidden;
  transition: opacity 0.12s ease;
  z-index: 60;
  text-align: left;
  pointer-events: none;
}
.info-icon:hover .info-tooltip,
.info-icon:focus .info-tooltip,
.info-icon.open .info-tooltip,
.error-chip:hover .info-tooltip,
.error-chip:focus .info-tooltip,
.error-chip.open .info-tooltip {
  opacity: 1;
  visibility: visible;
}
.users-modal-title { display: flex; align-items: center; }

/* .card has overflow:hidden (needed for the photo's rounded corners),
   which silently clips .info-tooltip's normal centered-and-upward pop -
   invisible even though its computed position/opacity all say
   "visible", since the clip happens at the ancestor, not the tooltip
   itself. This isn't just a vertical problem either: .info-icon's
   horizontal centering assumes a roomy inline context (a text row with
   space on both sides), but score-chip sits pinned to the card's own
   top-right corner - centering a 280px-wide tooltip on it overflows a
   ~220px-wide card on BOTH sides regardless of which way it opens.
   Anchoring to the chip's own right edge and capping the width instead
   keeps the whole thing inside the card's clipped bounds: it can only
   ever extend left and down from a corner that's already inset 8px
   from the card's real edge. */
.error-chip .info-tooltip {
  bottom: auto;
  top: calc(100% + 9px);
  left: auto;
  right: 0;
  transform: none;
  max-width: 190px;
}

.auto-toggle {
  display: flex;
  align-items: center;
  gap: 4px;
  font-family: var(--font-mono);
  font-size: 0.78rem;
  color: var(--text-dim);
  cursor: pointer;
}
#ceilingInput:disabled { opacity: 0.5; cursor: not-allowed; }
#newClientExpireDays:disabled { opacity: 0.5; cursor: not-allowed; }

/* ---------- storage panel ---------- */

.storage-disk-summary { margin-bottom: 16px; }
.storage-bar {
  width: 100%;
  height: 14px;
  border-radius: 7px;
  background: #0d0e10;
  border: 1px solid var(--border);
  overflow: hidden;
  margin-bottom: 8px;
}
.storage-bar-fill {
  height: 100%;
  background: linear-gradient(90deg, var(--sharp), var(--mid));
  transition: width 0.3s ease;
}
.storage-bar-fill.warn { background: linear-gradient(90deg, var(--mid), var(--soft)); }

.storage-over-limit {
  color: var(--soft);
  font-weight: 600;
}

/* ---------- expiration inline edit (Clients panel) ---------- */

.expiry-cell { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-family: var(--font-mono); font-size: 0.78rem; }
.expiry-cell.expired { color: var(--soft); }
.expiry-edit-form { display: none; flex-direction: column; align-items: flex-start; gap: 8px; margin-top: 6px; }
.users-table td:last-child .expiry-edit-form.open { flex-basis: 100%; }
.expiry-edit-form.open { display: flex; }

/* ---------- testimonial quote cards ---------- */

.testimonial-card {
  background: var(--wash-1);
  border: 1px solid var(--border);
  border-radius: 10px;
  padding: 14px 16px;
  margin-bottom: 10px;
}
.testimonial-stars {
  color: var(--sharp);
  letter-spacing: 2px;
  font-size: 0.9rem;
}
.testimonial-comment {
  position: relative;
  margin: 8px 0 10px;
  padding-left: 18px;
  font-size: 0.9rem;
  color: var(--text);
  line-height: 1.5;
}
.testimonial-quote-mark {
  position: absolute;
  left: -4px;
  top: -6px;
  font-family: var(--font-display);
  font-size: 2rem;
  line-height: 1;
  color: var(--sharp);
  opacity: 0.5;
}
.testimonial-footer {
  display: flex;
  justify-content: space-between;
  gap: 10px;
  font-family: var(--font-mono);
  font-size: 0.72rem;
  color: var(--text-dim);
}
.testimonial-publish-ok { color: var(--keep); }

/* ---------- weather auto-fill confirmation chip ---------- */

.weather-chip {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  padding: 3px 10px;
  margin-left: 8px;
  border-radius: 999px;
  background: var(--wash-1);
  border: 1px solid var(--border);
  font-size: 0.75rem;
  color: var(--text-dim);
}
.weather-chip[hidden] { display: none; }
.expiry-edit-row { display: flex; align-items: center; gap: 6px; }
.expiry-edit-form input[type="number"] {
  width: 60px;
  font-family: var(--font-mono);
  padding: 4px 6px;
  border-radius: 4px;
  border: 1px solid var(--border);
  background: var(--panel);
  color: var(--text);
}
.sort-group select {
  background: var(--panel); color: var(--text); border: 1px solid var(--border);
  border-radius: 4px; padding: 5px 8px; font-family: var(--font-mono); font-size: 0.8rem;
}
#minScoreVal { color: var(--text); }

/* :not([hidden]) - same pattern as .controls/.sort-group above and
   for the same reason: an unconditional `display: grid` here silently
   overrides the browser's native [hidden] behavior, so a grid element
   toggled hidden via JS (resultsList.hidden = true, race.js) would
   stay visibly on screen underneath whatever replaced it - exactly
   the bug that kept the public search page's results grid showing
   through behind the gallery view after clicking a racer. */
.grid:not([hidden]) {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
  gap: 14px;
  padding: 24px 28px;
}

.card {
  position: relative;
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: 9px;
  overflow: hidden;
  cursor: pointer;
  transition: transform 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
  /* Browser-native virtualization: skips layout/paint/style work for
     any card far enough off-screen, without removing it from the DOM -
     unlike a typical JS windowing library (which unmounts off-screen
     items entirely), this requires ZERO changes to selection, drag-
     select, keyboard nav, or burst-collapsing, all of which depend on
     every loaded card staying a real, always-present DOM element (see
     observeCardImageForUnload's own comment in app.js, which already
     documents this same constraint for decoded image memory - this is
     the same idea one layer up, for layout/paint cost instead of
     bitmap memory). At 10k+ cards this is the difference between the
     browser doing real layout work for every card on every scroll
     frame vs. only for the ones actually near the viewport.
     contain-intrinsic-size gives content-visibility a placeholder size
     for skipped cards so the scrollbar/page height stays stable and
     scrolling doesn't jump around as cards enter/exit the skip zone -
     approximately this app's actual card size (a 3:2 image at the
     grid's ~220px minmax column width, plus the meta/badge footer
     below it), not exact since real cards vary slightly in footer
     height depending on badges shown. */
  content-visibility: auto;
  contain-intrinsic-size: 220px 260px;
  animation: card-in 0.42s cubic-bezier(0.16, 0.84, 0.44, 1) both;
}
@keyframes card-in {
  from { opacity: 0; transform: translateY(14px) scale(0.97); }
  to { opacity: 1; transform: translateY(0) scale(1); }
}
.card:hover {
  transform: translateY(-3px);
  border-color: var(--sharp);
  box-shadow: 0 10px 26px rgba(0, 0, 0, 0.4), 0 0 0 1px var(--glow-sharp);
}
.card img {
  width: 100%;
  aspect-ratio: 3/2;
  object-fit: cover;
  display: block;
  background: #0d0e10;
  transition: transform 0.35s cubic-bezier(0.16, 0.84, 0.44, 1);
}

/* ---------- skeleton loading cards (see showSkeletonGrid in app.js) ----------
   Reuses .card's own sizing/border/radius/entrance-animation wholesale
   (it IS a .card, just with a shimmer body instead of a photo+badges) so
   the grid's layout doesn't visibly reflow the instant real cards swap
   in - same column widths, same gap, same aspect-ratio placeholder
   block. Only adds a shimmer sweep and strips the hover/click affordances
   a real card has, since a skeleton isn't interactive. */
.skeleton-card {
  cursor: default;
  pointer-events: none;
}
.skeleton-card:hover {
  transform: none;
  border-color: var(--border);
  box-shadow: none;
}
.skeleton-card::before {
  content: "";
  display: block;
  width: 100%;
  aspect-ratio: 3/2;
  background: linear-gradient(
    100deg,
    var(--panel-hover) 30%,
    var(--border) 50%,
    var(--panel-hover) 70%
  );
  background-size: 240% 100%;
  animation: skeleton-shimmer 1.4s ease-in-out infinite;
}
@keyframes skeleton-shimmer {
  0% { background-position: 100% 0; }
  100% { background-position: -100% 0; }
}
@media (prefers-reduced-motion: reduce) {
  .skeleton-card::before { animation: none; background-position: 0 0; }
}
/* The image scales up slightly on hover while the wrapper (.card-photo,
   which .card's own overflow:hidden clips against) stays put - a
   contained "zoom peek" rather than the whole card growing, so it never
   fights with the badges/checkbox that are absolutely positioned
   against that same wrapper. Kept independent of .card:hover's own
   translateY so the two can layer without either fighting the other's
   transition timing. */
.card:hover img { transform: scale(1.045); }
@media (hover: none) {
  /* No hover state on touch - don't ship a transition that will never
     visibly resolve either way. */
  .card img { transition: none; }
  .card:hover img { transform: none; }
}

/* All the overlay badges (score, subject, owner, shooter, group-size,
   select checkbox) are absolutely positioned against THIS wrapper, not
   against .card itself - .card also contains .card-meta below the
   photo (filename/tags/decision), and a badge positioned "bottom: 8px"
   relative to the whole card used to land on top of that text instead
   of on the photo. Nothing else needs to change for that fix: an
   absolutely-positioned child resolves against its nearest positioned
   ancestor, so simply nesting the badges one level deeper here (see
   app.js's makeScoreCard) is what moves them - the top:/bottom:/left:/
   right: values themselves stay exactly as they were. */
.card-photo { position: relative; }

/* ---------- fanned-deck stack visual (collapsed burst representative) ----------
   Two faint offset "edges" behind the card via box-shadow (cheaper and
   simpler than actual duplicate pseudo-elements, and box-shadow already
   respects the card's own border-radius automatically) - reads as "a
   stack of prints" at a glance. Only applied via .card-stacked, added
   in app.js's makeScoreCard exclusively for the single representative
   card standing in for a COLLAPSED burst group - see the groupSize
   comment at that call site for why every other card never gets this. */
.card.card-stacked {
  box-shadow:
    4px 5px 0 -1px var(--panel),
    4px 5px 0 0 var(--border),
    8px 10px 0 -1px var(--panel),
    8px 10px 0 0 var(--border);
}
.card.card-stacked:hover {
  box-shadow:
    4px 5px 0 -1px var(--panel),
    4px 5px 0 0 var(--sharp),
    8px 10px 0 -1px var(--panel),
    8px 10px 0 0 var(--sharp),
    0 10px 26px rgba(0, 0, 0, 0.4), 0 0 0 1px var(--glow-sharp);
}

/* ---------- multi-select bulk actions (Cull tab) ---------- */

.card-select-checkbox {
  position: absolute;
  top: 8px; left: 8px;
  width: 30px; height: 30px;
  border-radius: 6px;
  border: 2px solid rgba(255, 255, 255, 0.5);
  background: rgba(12, 13, 15, 0.55);
  color: #0b1615;
  align-items: center;
  justify-content: center;
  font-weight: 700;
  font-size: 1rem;
  z-index: 6;
  cursor: pointer;
}
.card-select-checkbox:hover { border-color: var(--sharp); }
.card.selected .card-select-checkbox {
  background: var(--sharp);
  border-color: var(--sharp);
}
.card.selected {
  border-color: var(--sharp);
  box-shadow: 0 0 0 2px var(--glow-sharp);
}
#selectModeBtn.active { background: var(--sharp); color: #0b1615; border-color: var(--sharp); }

/* The select checkbox and the subject badge both anchor top-left - in
   normal browsing the checkbox is display:none so there's no conflict,
   but with Select multiple active they'd otherwise sit exactly on top
   of each other. Shift the badge clear of the checkbox's 30px box
   rather than the other way around, since the checkbox is an
   interactive control (needs a stable click target) and the badge is
   read-only text. */
.card.select-mode .subject-badge { left: 44px; }

/* ---------- favorite heart button (client gallery) ---------- */

.favorite-btn {
  font-family: var(--font-display);
  font-size: 1.1rem;
  line-height: 1;
  color: #fff;
  background: rgba(10, 11, 13, 0.55);
  border: 1px solid rgba(255, 255, 255, 0.25);
  border-radius: 50%;
  width: 34px;
  height: 34px;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  transition: background 0.15s ease, transform 0.1s ease, color 0.15s ease;
}
.favorite-btn:not([hidden]) { display: flex; }
.favorite-btn:hover { transform: scale(1.08); }
.favorite-btn.active { color: var(--soft); }

/* ---------- favorite "becoming a favorite" burst (see client.js) ----------
   Two effects layered on the one class: the button itself pops (scale
   overshoot then settle) via a keyframe on .favorite-btn, and a ring
   expands and fades from its center via ::after - the classic "like
   button" ping, done in the app's own --soft (heart) color rather than
   a generic red so it stays inside the existing palette. ::after rather
   than ::before since .card-favorite-btn already has no other
   pseudo-element claiming that slot. */
.favorite-btn.favorite-burst {
  animation: favorite-pop 0.45s cubic-bezier(0.34, 1.56, 0.64, 1);
}
@keyframes favorite-pop {
  0% { transform: scale(1); }
  35% { transform: scale(1.35); }
  100% { transform: scale(1); }
}
.favorite-btn.favorite-burst::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: 50%;
  border: 2px solid var(--soft);
  animation: favorite-ring 0.5s ease-out;
  pointer-events: none;
}
@keyframes favorite-ring {
  from { transform: scale(1); opacity: 0.9; }
  to { transform: scale(1.9); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
  .favorite-btn.favorite-burst,
  .favorite-btn.favorite-burst::after { animation: none; }
}
.card-favorite-btn {
  position: absolute;
  top: 8px;
  left: 8px;
  z-index: 5;
}
.card-print-btn {
  position: absolute;
  top: 8px;
  right: 8px;
  z-index: 5;
  font-size: 1rem;
}
#favoriteBtn {
  position: absolute;
  top: 12px;
  right: 12px;
  z-index: 5;
}
.album-pick-btn {
  position: absolute;
  top: 12px;
  right: 54px;
  z-index: 5;
}
.album-pick-btn.active { color: var(--mid); }
.card-album-pick-btn {
  position: absolute;
  top: 8px;
  left: 46px;
  z-index: 5;
}
.card-share-btn {
  position: absolute;
  top: 8px;
  right: 84px;
  z-index: 5;
  font-size: 0.95rem;
}

/* ---------- print ordering (client gallery) ---------- */

.cart-badge[hidden] { display: none; }

.print-modal-inner { max-width: min(380px, 92vw); }
.print-preview-wrap { display: flex; flex-direction: column; align-items: center; margin-bottom: 4px; gap: 6px; }
.print-preview-box {
  width: 100%;
  max-width: 260px;
  border-radius: 6px;
  overflow: hidden;
  background: #0d0e10;
  border: 1px solid var(--border);
  transition: aspect-ratio 0.2s ease, padding 0.15s ease, background 0.15s ease;
  cursor: grab;
  touch-action: none;
}
.print-preview-box:active { cursor: grabbing; }
.print-preview-box img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  border-radius: 2px;
  user-select: none;
  -webkit-user-drag: none;
}
.print-preview-box.has-border {
  padding: 14px;
  background: #f4f2ee;
}
.print-crop-hint { font-size: 11px; color: var(--text-dim); text-align: center; }
.print-zoom-row {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 12px;
  width: 100%;
  max-width: 260px;
}
.print-zoom-row input[type="range"] { flex: 1; }
.warn-text { color: var(--soft); }
.print-border-row {
  display: flex;
  gap: 16px;
  font-family: var(--font-mono);
  font-size: 0.85rem;
  color: var(--text-dim);
}
.print-border-row label { display: flex; align-items: center; gap: 5px; cursor: pointer; }

/* ---------- gallery empty/informational states ---------- */

#downloadsDisabledNote { color: var(--mid); }

/* ---------- collapsible topbar menu (hamburger on small screens) ---------- */

.menu-toggle-btn {
  display: none;
  background: var(--panel);
  border: 1px solid var(--border);
  color: var(--text);
  width: 40px;
  height: 40px;
  border-radius: 7px;
  font-size: 1.2rem;
  line-height: 1;
  cursor: pointer;
  align-items: center;
  justify-content: center;
}
.menu-toggle-btn:hover { background: var(--panel-hover); }

.admin-menu { position: relative; }
.admin-menu-dropdown {
  display: none;
  position: absolute;
  top: 100%;
  left: 0;
  margin-top: 6px;
  flex-direction: column;
  gap: 6px;
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: 10px;
  padding: 8px;
  box-shadow: var(--shadow-lift);
  z-index: 45;
  min-width: 190px;
}
.admin-menu-dropdown.open { display: flex; }
.admin-menu-dropdown .btn {
  width: 100%;
  text-align: left;
  border-color: transparent;
  background: transparent;
}
.admin-menu-dropdown .btn:hover { background: var(--panel-hover); transform: none; }

@media (max-width: 860px) {
  .menu-toggle-btn { display: flex; }
  .topbar { position: relative; }
  .topbar-actions {
    display: none;
    position: absolute;
    top: 100%;
    left: 0;
    right: 0;
    flex-direction: column;
    align-items: stretch;
    background: var(--panel);
    border: 1px solid var(--border);
    border-top: none;
    padding: 12px;
    gap: 8px;
    box-shadow: var(--shadow-lift);
    z-index: 40;
  }
  .topbar-actions.open { display: flex; }
  .topbar-actions .btn {
    width: 100%;
    text-align: center;
  }
  .topbar-actions .whoami,
  .topbar-actions .queue-status {
    padding: 6px 4px;
  }
  /* Inside the hamburger panel there's no horizontal room to lay
     anything out in a row, and no reason to - everything becomes one
     full-width stack. */
  .topbar-primary {
    flex-direction: column;
    align-items: stretch;
    gap: 8px;
    margin-right: 0;
  }

  /* Below the topbar's own breakpoint there's no room left to keep
     actions pinned to one line without the filters getting crushed -
     let the primary Cull bar wrap normally again here, same as every
     other .controls row. */
  .primary-controls:not([hidden]) { flex-wrap: wrap; }
  .controls-actions {
    flex: 1 1 100%; justify-content: flex-end;
    /* The vertical divider from the desktop layout only reads as a
       divider when actions sit beside filters on the same line - once
       this drops to its own full-width row (immediately below), a
       border-left there would look like a stray vertical line at the
       start of an otherwise-empty row instead. A top border carries
       the same "separate group" meaning but actually fits a row that
       now sits above/below its neighbor rather than beside it. */
    border-left: none;
    padding-left: 0;
    padding-top: 8px;
    border-top: 1px solid var(--border);
  }
  /* The dropdowns stop being floating panels here: absolutely
     positioning a menu inside an already-absolute hamburger panel puts
     it on top of the items below it. Inline + indented instead. */
  .topbar-menu-dropdown,
  .admin-menu-dropdown {
    position: static;
    margin-top: 6px;
    box-shadow: none;
    background: transparent;
    border-left: 2px solid var(--border);
    border-top: none;
    border-right: none;
    border-bottom: none;
    border-radius: 0;
    padding: 0 0 0 10px;
    min-width: 0;
  }
  .topbar-menu-dropdown .btn,
  .admin-menu-dropdown .btn { text-align: left; }
  .account-menu-btn { justify-content: center; }
  .whoami { max-width: none; }
}

/* ---------- mobile filters drawer (Cull tab) ----------
   Class-based rather than a plain @media query, deliberately - driven
   by JS (see applyMobileMode in app.js), which computes this from
   EITHER the same 860px width check the topbar hamburger above uses,
   OR a manual "Always use mobile layout" override in the account menu.
   That override is the whole reason this needs to be class-based: a
   pure @media block has no way to also fire when someone has manually
   forced mobile mode on a wide screen (a desktop window they've
   deliberately resized narrow, or just prefer the compact layout on a
   tablet where auto-detection guessed wrong). */
body.mobile-mode .mobile-filters-toggle {
  display: inline-flex;
  justify-content: space-between;
  align-items: center;
  width: 100%;
}
body.mobile-mode .mobile-filters-toggle::after {
  content: "▾";
  margin-left: 8px;
  transition: transform 0.15s ease;
}
body.mobile-mode .mobile-filters-toggle[aria-expanded="true"]::after {
  transform: rotate(180deg);
}
body.mobile-mode .primary-controls:not([hidden]) {
  /* The bar itself becomes a simple centered column: the Filters
     toggle on its own line, then whatever's revealed/actions below -
     instead of the desktop's "filters wrap freely, actions pinned
     right" two-halves layout (see .controls-filters/.controls-actions
     above), which is exactly the "everything scrunched into one row
     that wraps unevenly" effect being fixed here. */
  flex-direction: column;
  align-items: stretch;
  flex-wrap: nowrap;
  gap: 10px;
  padding: 12px 16px;
}
body.mobile-mode .controls-filters {
  /* Collapsed by default - see mobile-drawer-open below for the
     revealed state, toggled by mobileFiltersToggleBtn in app.js. */
  display: none;
  flex-direction: column;
  align-items: stretch;
  gap: 12px;
  width: 100%;
}
body.mobile-mode .controls-filters.mobile-drawer-open {
  display: flex;
}
/* Every filter row becomes a full-width stack instead of a row of
   differently-sized inline controls - this is the actual "centered,
   not scrunched" fix: each control gets the full available width
   (and therefore lines up with every other control's left/right
   edges) instead of being exactly as wide as its own content and
   sitting wherever it happened to wrap to. */
body.mobile-mode .filter-group,
body.mobile-mode .sort-group,
body.mobile-mode .threshold-group {
  flex-direction: column;
  align-items: stretch;
  gap: 4px;
  width: 100%;
}
body.mobile-mode .filter-group select,
body.mobile-mode .filter-group input,
body.mobile-mode .sort-group select,
body.mobile-mode .threshold-group input[type="range"] {
  width: 100%;
}
body.mobile-mode .auto-toggle {
  justify-content: flex-start;
}
body.mobile-mode #moreFiltersRow:not([hidden]) {
  flex-direction: column;
  align-items: stretch;
  width: 100%;
  padding-left: 0;
  border-left: none;
  border-top: 1px solid var(--border);
  padding-top: 10px;
}
body.mobile-mode .controls-actions {
  border-left: none;
  border-top: 1px solid var(--border);
  padding-left: 0;
  padding-top: 10px;
  width: 100%;
  justify-content: center;
  flex-wrap: wrap;
}
/* The grid itself already centers naturally at a single-column mobile
   width (auto-fill + 1fr stretches that one column to fill the
   available space), so the actual fix needed here is tighter, more
   consistent side padding than the desktop 28px - not a width/columns
   change. */
body.mobile-mode .grid:not([hidden]) {
  padding: 16px;
}

/* ---------- mobile polish (larger tap targets, mainly for the client gallery) ---------- */

@media (max-width: 640px) {
  .btn { padding: 11px 18px; font-size: 0.95rem; }
  .nav-arrow { width: 50px; height: 50px; font-size: 1.9rem; }
  .favorite-btn { width: 40px; height: 40px; font-size: 1.3rem; }
  .topbar { padding: 14px 16px; gap: 10px; }
  .topbar-actions { gap: 10px; }
  .card-filename { font-size: 0.85rem; }
}

.score-chip {
  position: absolute;
  top: 8px; right: 8px;
  font-family: var(--font-mono);
  font-weight: 600;
  font-size: 0.78rem;
  padding: 3px 8px;
  border-radius: 999px;
  background: rgba(12, 13, 15, 0.75);
  backdrop-filter: blur(2px);
  border: 1px solid currentColor;
}
/* A thin proportional ring around the chip, filling to the score's own
   percentage instead of the flat currentColor border just being on or
   off - a glance at how FULL the ring is reads faster than reading the
   number itself while scanning a big grid. Drawn as a conic-gradient
   pseudo-element sized slightly larger than the chip rather than
   touching border directly, so the existing 1px currentColor border
   (which several other rules already key off) stays completely
   untouched. --score-pct is set inline per-card in app.js's
   makeScoreCard, right next to where it already sets chip.style.color
   from the same score. Falls back to a full ring (100%) if a chip is
   ever rendered without the variable set, rather than an invisible one. */
.score-chip {
  --score-pct: 100%;
}
.score-chip::before {
  content: "";
  position: absolute;
  inset: -3px;
  border-radius: inherit;
  padding: 2px;
  background: conic-gradient(currentColor var(--score-pct), transparent var(--score-pct));
  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite: xor;
  mask-composite: exclude;
  opacity: 0.85;
  pointer-events: none;
}

/* Applied in JS (makeScoreCard) for scores >=90 - a soft pulsing glow
   that flags the sharpest shots at a glance while scanning a big grid,
   rather than making someone read every number to find the standouts.
   Uses the same --glow-sharp color already used for the card's own
   hover glow, so it reads as "extra of the thing this app already means
   by teal", not a new, unrelated color introduced just for this. */
.score-chip-hot {
  animation: chip-glow-pulse 2.4s ease-in-out infinite;
}
@keyframes chip-glow-pulse {
  0%, 100% { box-shadow: 0 0 0 0 var(--glow-sharp); }
  50% { box-shadow: 0 0 9px 3px var(--glow-sharp); }
}

.subject-badge {
  position: absolute;
  top: 8px; left: 8px;
  font-size: 0.7rem;
  font-family: var(--font-mono);
  color: var(--text-dim);
  background: rgba(12, 13, 15, 0.75);
  padding: 3px 7px;
  border-radius: 999px;
  border: 1px solid var(--border);
}

.confidence-badge {
  position: absolute;
  /* Directly below subject-badge (top:8px + its own ~24px height +
     4px gap), same left edge, so the two stack vertically rather
     than overlapping - subject-badge only ever shows when
     primary_subject is set, which is the same condition this badge
     is nested under in app.js, so they always appear together. */
  top: 36px; left: 8px;
  font-size: 0.66rem;
  font-family: var(--font-mono);
  color: var(--text-dim);
  background: rgba(12, 13, 15, 0.65);
  padding: 2px 6px;
  border-radius: 999px;
  border: 1px solid var(--border);
}

.owner-badge {
  position: absolute;
  bottom: 8px; left: 8px;
  font-size: 0.68rem;
  font-family: var(--font-mono);
  color: #ffd60a;
  background: rgba(12, 13, 15, 0.8);
  padding: 3px 7px;
  border-radius: 999px;
  border: 1px solid rgba(255, 214, 10, 0.4);
}

.shooter-badge {
  font-size: 0.68rem;
  font-family: var(--font-mono);
  color: var(--text-dim);
  background: rgba(12, 13, 15, 0.8);
  padding: 3px 7px;
  border-radius: 999px;
  border: 1px solid var(--border);
}
.rating-badge {
  display: flex;
  align-items: center;
  gap: 4px;
  font-size: 0.68rem;
  color: var(--mid);
  background: rgba(12, 13, 15, 0.8);
  padding: 3px 7px;
  border-radius: 999px;
  border: 1px solid var(--border);
}
.rating-badge-dot {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  flex-shrink: 0;
}
.rating-badge-dot-red { background: #e2574c; }
.rating-badge-dot-yellow { background: #e8c33d; }
.rating-badge-dot-green { background: #6fcf87; }
.rating-badge-dot-blue { background: #4fa3d1; }
.rating-badge-dot-purple { background: #a374d1; }

.edited-card-delete {
  position: absolute;
  top: 8px; right: 8px;
  width: 26px; height: 26px;
  border-radius: 999px;
  border: 1px solid var(--border);
  background: rgba(12, 13, 15, 0.75);
  color: var(--text-dim);
  cursor: pointer;
  font-size: 0.85rem;
  line-height: 1;
  opacity: 0;
  transition: opacity 0.12s ease, color 0.12s ease, border-color 0.12s ease;
}
.card:hover .edited-card-delete { opacity: 1; }
.edited-card-delete:hover { color: var(--soft); border-color: var(--soft); }

.edited-card-portfolio {
  position: absolute;
  top: 8px; right: 40px;
  width: 26px; height: 26px;
  border-radius: 999px;
  border: 1px solid var(--border);
  background: rgba(12, 13, 15, 0.75);
  color: var(--text-dim);
  cursor: pointer;
  font-size: 0.85rem;
  line-height: 1;
  opacity: 0;
  transition: opacity 0.12s ease, color 0.12s ease, border-color 0.12s ease;
}
.card:hover .edited-card-portfolio { opacity: 1; }
.edited-card-portfolio:hover { color: var(--sharp); border-color: var(--sharp); }

.edited-card-select {
  position: absolute;
  top: 8px; left: 8px;
  width: 20px; height: 20px;
  cursor: pointer;
  z-index: 2;
  opacity: 0;
  transition: opacity 0.12s ease;
}
.card:hover .edited-card-select,
.edited-card-select:checked {
  opacity: 1;
}
.card.card-selected {
  outline: 2px solid var(--sharp);
  outline-offset: -2px;
}

/* A touch device has no persistent hover state - a :hover-only reveal
   leaves these two buttons either invisible outright, or needing a
   "wasted" first tap just to reveal them (some mobile browsers
   simulate :hover on tap, requiring a second tap to actually activate
   the button) before a real tap can hit them. `hover: none` is true for
   any pointer that can't hover at all (touchscreens), so this shows
   them permanently there while leaving the reveal-on-hover behavior
   alone for anyone with a mouse or trackpad. */
@media (hover: none) {
  .edited-card-delete,
  .edited-card-portfolio {
    opacity: 1;
  }
}

.portfolio-picker {
  position: absolute;
  z-index: 60;
  display: flex;
  flex-direction: column;
  gap: 6px;
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 8px;
  box-shadow: var(--shadow-lift);
  min-width: 180px;
}
.portfolio-picker .btn { width: 100%; text-align: left; }

.portfolio-items-list {
  display: flex;
  flex-direction: column;
  gap: 6px;
  margin-top: 10px;
}
.portfolio-item-row {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 6px;
  border: 1px solid var(--border);
  border-radius: 6px;
}
.portfolio-item-row img {
  width: 48px;
  height: 48px;
  object-fit: cover;
  border-radius: 4px;
  flex-shrink: 0;
}
.portfolio-item-filename { flex: 1 1 auto; font-size: 0.85rem; }

.face-cluster-row { padding: 14px; }
.face-cluster-row .face-cluster-thumb {
  width: 180px;
  height: 180px;
  object-fit: cover;
  border-radius: 10px;
  flex-shrink: 0;
}

.faces-modal-inner.users-modal-inner {
  max-width: min(900px, 95vw);
}

.clients-modal-inner.users-modal-inner {
  max-width: min(920px, 95vw);
}

.card-meta {
  padding: 8px 10px;
  display: flex;
  flex-direction: column;
  gap: 4px;
}
.card-filename {
  font-family: var(--font-mono);
  font-size: 0.72rem;
  color: var(--text-dim);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.card-decision { font-size: 0.72rem; font-family: var(--font-mono); font-weight: 600; letter-spacing: 0.03em; }
.card-decision-good { color: var(--keep); }
.card-decision-reject { color: var(--soft); }
.card-tags {
  display: flex;
  flex-wrap: wrap;
  gap: 4px;
  margin: 2px 0;
}
.card-tag-chip {
  font-family: var(--font-mono);
  font-size: 0.65rem;
  color: var(--text-dim, #9aa0a6);
  background: var(--surface-raise);
  border: 1px solid var(--border);
  border-radius: 10px;
  padding: 1px 7px;
}
.prior-decision-note {
  font-size: 0.72rem;
  font-family: var(--font-mono);
  color: var(--mid);
  line-height: 1.5;
}
.card-denoise { font-size: 0.68rem; font-family: var(--font-mono); color: var(--mid); }
.card-denoise-done { color: var(--sharp); }

.grouped-container {
  display: flex;
  flex-direction: column;
  gap: 32px;
  padding: 24px 28px;
  position: relative;
  /* Cursor-follow glow (see app.js's mousemove listener on #grid,
     which is what #grouped-container's id="grid" element renders as) -
     a soft radial tint that tracks the pointer, painted as this
     container's own CSS background rather than a separate DOM
     element/z-index layer. That matters here specifically: every
     photo grid render does `grid.innerHTML = ""` and rebuilds from
     scratch (see loadPhotos), which would silently wipe out any actual
     child node placed here for this - a background-image on the
     container itself survives that wipe untouched, since it's not a
     child at all. Painted BEHIND the grid's own children by normal box
     stacking (no z-index needed, and none can go wrong), so it only
     shows through the existing gaps/padding between cards - it can
     never wash out or obscure a photo. --glow-x/--glow-y default far
     off-canvas so nothing renders until the first real mousemove. */
  background-image: radial-gradient(
    340px circle at var(--glow-x, -600px) var(--glow-y, -600px),
    var(--glow-sharp),
    transparent 70%
  );
  background-repeat: no-repeat;
}
@media (hover: none) {
  /* No cursor on touch - this is purely a mouse-hover ambience effect
     with nothing meaningful to track there, so skip it rather than
     leave a dead custom-property pair that never updates. */
  .grouped-container { background-image: none; }
}
.folder-group .grid {
  padding: 0;
}
.folder-heading {
  font-family: var(--font-mono);
  font-size: 0.8rem;
  font-weight: 600;
  color: var(--text-dim);
  text-transform: uppercase;
  letter-spacing: 0.04em;
  margin: 0 0 12px;
  padding-bottom: 8px;
  border-bottom: 1px solid var(--border);
}

.empty-state {
  text-align: center;
  color: var(--text-dim);
  padding: 80px 20px;
  font-size: 0.95rem;
}

/* ---------- first-run onboarding walkthrough ---------- */

.onboarding-walkthrough {
  max-width: 560px;
  margin: 60px auto;
  padding: 32px 36px;
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: 12px;
  box-shadow: var(--shadow-lift);
}
.onboarding-walkthrough h2 {
  margin: 0 0 4px;
  font-size: 1.3rem;
  color: var(--sharp);
}
.onboarding-steps {
  margin: 16px 0;
  padding-left: 22px;
  display: flex;
  flex-direction: column;
  gap: 14px;
  font-size: 0.88rem;
  color: var(--text);
  line-height: 1.5;
}
.onboarding-steps li::marker {
  color: var(--sharp);
  font-weight: 700;
}
.onboarding-steps code {
  display: block;
  margin-top: 6px;
  padding: 6px 10px;
  background: #0d0e10;
  border: 1px solid var(--border);
  border-radius: 6px;
  font-family: var(--font-mono);
  font-size: 0.8rem;
  color: var(--sharp);
  word-break: break-all;
}

.welcome-banner {
  background: var(--surface-raise);
  border: 1px solid var(--border);
  border-left: 3px solid var(--sharp);
  border-radius: 6px;
  padding: 12px 16px;
  margin: 0 20px 16px;
  color: var(--text);
  font-size: 0.95rem;
  line-height: 1.4;
  white-space: pre-wrap;
}

.lightbox {
  position: fixed; inset: 0;
  background: rgba(10, 11, 13, 0.92);
  backdrop-filter: blur(3px);
  align-items: center; justify-content: center;
  z-index: 50;
  padding: 24px;
}
.lightbox:not([hidden]) {
  display: flex;
}

/* ---------- Speed Cull decision flash ---------- */

.speed-cull-flash {
  position: fixed;
  inset: 0;
  z-index: 55;
  pointer-events: none;
  opacity: 0;
}
.speed-cull-flash.flash-good {
  background: var(--keep);
  animation: speed-cull-flash-pulse 0.35s ease-out;
}
.speed-cull-flash.flash-reject {
  background: var(--soft);
  animation: speed-cull-flash-pulse 0.35s ease-out;
}
@keyframes speed-cull-flash-pulse {
  0% { opacity: 0.28; }
  100% { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
  .speed-cull-flash.flash-good,
  .speed-cull-flash.flash-reject { animation: none; }
}

/* ---------- session-end confetti ---------- */

.confetti-burst {
  position: fixed;
  inset: 0;
  pointer-events: none;
  z-index: 300;
  overflow: hidden;
}
.confetti-piece {
  position: absolute;
  top: 40%;
  width: 8px;
  height: 8px;
  border-radius: 2px;
  opacity: 0;
  animation: confetti-fall 1.2s ease-out forwards;
  animation-delay: var(--delay, 0ms);
}
@keyframes confetti-fall {
  0% { opacity: 1; transform: translate(0, 0) rotate(0deg); }
  100% { opacity: 0; transform: translate(var(--x, 0), 220px) rotate(var(--rot, 180deg)); }
}
@media (prefers-reduced-motion: reduce) {
  .confetti-piece { animation: none; opacity: 0; }
}
.lightbox-inner {
  position: relative;
  max-width: min(92vw, 1200px);
  max-height: 92vh;
  display: flex; flex-direction: column;
  background: var(--panel);
  border-radius: 10px;
  overflow-y: auto;
  overflow-x: hidden;
  border: 1px solid var(--border);
  box-shadow: 0 20px 60px rgba(0, 0, 0, 0.55), 0 0 0 1px rgba(255, 255, 255, 0.03);
}
/* Scoped to :not([hidden]) on the PARENT .lightbox, not just given to
   .lightbox-inner directly - a CSS animation only (re)starts when it
   newly applies, and .lightbox itself is what actually toggles
   [hidden] on open/close (see app.js's openLightbox/closeLightbox).
   Flipping between photos with the arrows or Speed cull never touches
   that attribute - the modal stays open and just swaps its contents -
   so this plays once on genuine open and stays silent through however
   many photos get flipped through afterward, rather than replaying a
   little pop on every single arrow-key press. */
.lightbox:not([hidden]) .lightbox-inner {
  animation: lightbox-pop-in 0.26s cubic-bezier(0.16, 0.84, 0.44, 1) both;
}
@keyframes lightbox-pop-in {
  from { opacity: 0; transform: scale(0.96) translateY(8px); }
  to { opacity: 1; transform: scale(1) translateY(0); }
}
.image-wrap {
  position: relative;
  display: flex;
  justify-content: center;
}

.nav-arrow:not([hidden]) {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  background: rgba(12, 13, 15, 0.65);
  border: 1px solid var(--border);
  color: var(--text);
  width: 44px;
  height: 44px;
  border-radius: 50%;
  font-size: 1.6rem;
  line-height: 1;
  cursor: pointer;
  z-index: 15;
  display: flex;
  align-items: center;
  justify-content: center;
}
.nav-arrow:hover { background: rgba(12, 13, 15, 0.9); border-color: var(--sharp); }
.nav-arrow-left { left: 10px; }
.nav-arrow-right { right: 10px; }
.nav-arrow[hidden] { display: none; }

/* ---------- lightbox thumbnail filmstrip ---------- */

.lightbox-filmstrip {
  display: flex;
  gap: 6px;
  padding: 10px 8px;
  overflow-x: auto;
  scroll-behavior: smooth;
  background: #0d0e10;
  border-top: 1px solid var(--border);
  /* .lightbox-inner is a column flex container, so this is a flex ITEM. Once
     the image + this strip + the (tall) meta panel exceed its max-height:92vh,
     the flex algorithm looks for something to compress instead of letting
     .lightbox-inner's own overflow-y:auto scroll.

     Of the three children it could pick, only this one is vulnerable, and the
     reason is subtle enough to be worth writing down: a flex item's automatic
     minimum size (min-height:auto) normally floors it at its content height,
     which is what protects .image-wrap and .lightbox-meta. But that floor
     collapses to ZERO for items that are scroll containers - and setting
     overflow-x:auto above makes this one a scroll container in BOTH axes
     (per the CSS overflow spec, a non-visible value on one axis promotes the
     other to auto). So the strip alone can be squashed to nothing, and it
     fails silently: being a scroll container, it spills nothing visibly and
     grows no vertical scrollbar, it just cuts the 44px thumbnails off partway
     down.

     Measured at a 900px viewport: 25px of box for 64px of content. Restoring
     shrink:1 reproduces it; overflow-x:visible also fixes it but would kill
     the horizontal scrolling this strip exists to do. Its height is fixed
     content, so it should never be the thing that gives.
     See filmstrip_clip_test.py. */
  flex-shrink: 0;
}
.lightbox-filmstrip[hidden] { display: none; }
.filmstrip-thumb {
  flex-shrink: 0;
  width: 64px;
  height: 44px;
  padding: 0;
  border: 2px solid transparent;
  border-radius: 5px;
  overflow: hidden;
  cursor: pointer;
  opacity: 0.55;
  transition: opacity 0.15s ease, border-color 0.15s ease;
  background: none;
}
.filmstrip-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
/* Higher specificity than the generic .lightbox-inner img rule below
   (which sets object-fit: contain for the MAIN photo) - without this,
   the two rules have EQUAL specificity and whichever comes later in
   the stylesheet wins by source order alone, which is exactly the kind
   of fragile-to-future-edits collision worth avoiding explicitly
   rather than depending on today's rule ordering staying put. */
.lightbox-filmstrip .filmstrip-thumb img { object-fit: cover; }
.filmstrip-thumb:hover { opacity: 0.85; }
.filmstrip-thumb.active {
  opacity: 1;
  border-color: var(--sharp);
}

/* ---------- drag-rectangle multi-select ---------- */

.drag-select-box {
  position: fixed;
  z-index: 100;
  border: 1px solid var(--sharp);
  background: var(--glow-sharp);
  pointer-events: none;
}

.lightbox-inner img {
  max-height: 74vh;
  object-fit: contain;
  background: #0d0e10;
  width: 100%;
  transition: opacity 0.15s ease;
}
/* The filmstrip (below) adds its own ~65px row (10px+10px padding +
   44px thumbnail height + a 1px border) whenever it's showing - the
   fixed 74vh above doesn't know about that, so on shorter viewports
   the image + filmstrip + info panel together used to overflow
   .lightbox-inner's own 92vh cap, crowding the photo against the
   magnify/denoise controls below it. Shrinking the image by roughly
   that same amount whenever the filmstrip is present (toggled by
   app.js's renderLightboxFilmstrip alongside the filmstrip's own
   [hidden] state) keeps everything visible without needing to scroll
   to see the controls, matching how it already looks with no filmstrip. */
.lightbox-inner.has-filmstrip img {
  max-height: 64vh;
}
/* See app.js: added right before the src swap on nav, removed on the
   new image's "load" event. Deliberately short (0.15s) and one-way -
   this is meant to soften a hard cut between two already-fast-loading
   local preview images, not create a lingering crossfade effect; if the
   network is slow enough that the fade-out sits fully transparent for
   a while, that's the existing global loading bar's job to communicate,
   not this. */
.lightbox-inner img.img-swapping { opacity: 0; }
@media (prefers-reduced-motion: reduce) {
  .lightbox-inner img { transition: none; }
}

.detection-overlay {
  position: absolute;
  pointer-events: none;
  z-index: 8;
}
.detection-overlay[hidden] { display: none; }
.detection-overlay .bbox-rect {
  fill: none;
  stroke: #ffa726;
  stroke-width: 4;
  stroke-linecap: square;
  stroke-linejoin: miter;
  /* No dash pattern here anymore - this is now a corner-bracket path
     (four short open L-marks, see app.js's cornerBracketPath), not a
     full rectangle outline. A dash pattern applied to that path would
     chop the already-short corner marks into broken fragments instead
     of clean solid brackets - the classic camera AF-box look this is
     going for needs solid lines. */
  vector-effect: non-scaling-stroke;
  filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.95)) drop-shadow(0 0 1px rgba(0, 0, 0, 0.95));
}
.detection-overlay .region-rect {
  fill: rgba(38, 198, 218, 0.18);
  stroke: #26c6da;
  stroke-width: 3;
  vector-effect: non-scaling-stroke;
  filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.95)) drop-shadow(0 0 1px rgba(0, 0, 0, 0.95));
}
/* The region that actually determined the photo's overall_score (the
   highest-scoring detection - see pipeline.py's `best = max(scored, ...)`).
   Distinct gold color so it's immediately obvious which subject/region the
   displayed score is *for*, when a photo has multiple detections. */
.detection-overlay .region-rect.primary-region {
  fill: rgba(255, 214, 10, 0.22);
  stroke: #ffd60a;
  stroke-width: 4;
}
.detection-overlay .bbox-rect.secondary,
.detection-overlay .region-rect.secondary {
  opacity: 0.55;
}
.overlay-toggle-label {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  font-size: 0.85rem;
  color: var(--text-dim, #9aa0a6);
  cursor: pointer;
  margin-left: auto;
}
.overlay-legend:not([hidden]) {
  display: flex;
  align-items: center;
  gap: 6px;
  font-size: 0.8rem;
  color: var(--text-dim, #9aa0a6);
  margin-top: 4px;
}
.overlay-legend .legend-swatch {
  display: inline-block;
  width: 14px;
  height: 10px;
  border-radius: 2px;
  margin-left: 10px;
}
.overlay-legend .legend-swatch:first-child { margin-left: 0; }
/* Solid border now, not dashed - matches the corner-bracket overlay's
   solid stroke (see .detection-overlay .bbox-rect above) rather than
   the old full-dashed-rectangle look this used to preview. Left as a
   plain small square rather than attempting a literal four-corner-
   bracket icon at this size - at 12-14px, corner marks that small
   would be illegible squiggles rather than a recognizable preview. */
.legend-bbox { border: 2px solid #ffa726; border-radius: 2px; }
.legend-region { background: rgba(38, 198, 218, 0.3); border: 2px solid #26c6da; }
.legend-primary { background: rgba(255, 214, 10, 0.3); border: 2px solid #ffd60a; }
.overlay-legend[hidden] { display: none; }

.rescore-row {
  margin-top: 10px;
  padding-top: 10px;
  border-top: 1px solid var(--border);
  flex-wrap: wrap;
}
.rescore-row .hint-text {
  flex-basis: 100%;
  margin: 4px 0 0;
}

.magnifier {
  position: absolute;
  width: 240px;
  height: 240px;
  border-radius: 50%;
  border: 2px solid var(--sharp);
  box-shadow: 0 0 0 3px rgba(10, 11, 13, 0.6), 0 6px 20px rgba(0, 0, 0, 0.6);
  pointer-events: none;
  background-repeat: no-repeat;
  background-color: #0d0e10;
  z-index: 10;
  display: none;
}
.magnifier.visible { display: block; }

@media (max-width: 600px) {
  .magnifier { width: 160px; height: 160px; }
}

.denoise-compare:not([hidden]) {
  display: flex;
  width: 100%;
  gap: 2px;
}
.denoise-compare[hidden] { display: none; }
/* ---------- slide compare mode (drag-to-reveal) ----------
   An alternative to the side-by-side panes below: both images stack
   directly on top of each other at full width, with the After layer
   clipped via clip-path so only the portion right of --slide-pos shows
   through, revealing Before underneath on the left. Toggled via
   app.js's enterSlideMode/exitSlideMode - the underlying <img>s and
   their loading/shimmer state are shared with the side-by-side view
   unchanged, only the layout differs. */
.denoise-compare.slide-mode {
  position: relative;
  display: block;
  aspect-ratio: var(--compare-aspect, 3 / 2);
  max-height: 74vh;
  background: #0d0e10;
  cursor: ew-resize;
  touch-action: none;
}
.denoise-compare.slide-mode .compare-pane {
  position: absolute;
  inset: 0;
  flex: none;
  width: 100%;
  aspect-ratio: auto;
  max-height: none;
}
.denoise-compare.slide-mode .compare-pane-after {
  clip-path: inset(0 0 0 var(--slide-pos, 50%));
}
.denoise-compare.slide-mode .compare-pane img {
  /* Both layers need to fill the exact same box, pixel for pixel, or
     the clip-path reveal line would show two misaligned images instead
     of one continuous photo - the side-by-side view's img rule
     (max-height: 74vh, no explicit height) lets each pane size itself
     off its own aspect-ratio-locked pane, which is fine when the panes
     are separate, but here they're stacked on top of each other. */
  height: 100%;
  max-height: 100%;
}
.denoise-compare.slide-mode .compare-label {
  /* The side-by-side labels don't translate to slide mode - "After"'s
     label sits at top-left, which is exactly the region the clip-path
     hides for most handle positions, and the drag-to-reveal gesture
     itself already communicates "left of the line is one image, right
     is the other" without needing a label to say so. */
  display: none;
}
.compare-slide-handle {
  position: absolute;
  top: 0; bottom: 0;
  left: var(--slide-pos, 50%);
  width: 0;
  z-index: 12;
  cursor: ew-resize;
  touch-action: none;
}
.compare-slide-handle[hidden] { display: none; }
.compare-slide-handle::before {
  content: "";
  position: absolute;
  top: 0; bottom: 0;
  left: -1px;
  width: 2px;
  background: #fff;
  box-shadow: 0 0 6px rgba(0, 0, 0, 0.7);
}
.compare-slide-grip {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: 40px;
  height: 40px;
  border-radius: 50%;
  background: rgba(12, 13, 15, 0.75);
  border: 2px solid #fff;
  color: #fff;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 1.1rem;
  box-shadow: 0 4px 14px rgba(0, 0, 0, 0.5);
}
.compare-pane {
  position: relative;
  flex: 1 1 50%;
  min-width: 0;
  display: flex;
  overflow: hidden;
  /* Reserves the pane's box at the actual photo's own aspect ratio
     (set as --compare-aspect in app.js's enterDenoisePreview, from the
     photo's known width/height) BEFORE either the before or after
     image has loaded - previously this had no intrinsic size at all
     until the <img> inside it finished loading, so the whole compare
     view collapsed to near-zero height and then jumped/reflowed once
     images arrived, which is what made content look cut off at the top
     of the lightbox while denoising. Falls back to a generic 3/2 guess
     if somehow set before enterDenoisePreview runs. */
  aspect-ratio: var(--compare-aspect, 3 / 2);
  max-height: 74vh;
  background: #0d0e10;
}
.compare-pane img {
  width: 100%;
  max-height: 74vh;
  object-fit: contain;
  background: #0d0e10;
  /* transform-origin: center matches the math in editorApplyCompareTransform
     (app.js) - that function computes pan offsets assuming scale()
     grows outward from the image's own center point, same as this
     default value, but stated explicitly rather than relying on the
     CSS default never changing out from under it. */
  transform-origin: center;
  /* Starts invisible and fades in once loaded (see .compare-img-loaded,
     toggled in app.js on the image's own load/error event) rather than
     just popping in - the pane itself already has its final size
     reserved via aspect-ratio above, so this is purely about not
     showing a half-rendered/blank image while the shimmer placeholder
     (.compare-pane::before below) is still meant to be the visible
     thing. */
  opacity: 0;
  transition: opacity 0.2s ease;
}
.compare-pane img.compare-img-loaded { opacity: 1; }
/* The shimmer placeholder itself - same shimmer sweep as the grid's
   .skeleton-card, reused here rather than inventing a second loading
   pattern, so "this app is fetching something" reads consistently
   everywhere. Sits behind the <img> (which is same-stacking-context,
   later in source order) and fades out once that pane's image has
   loaded. Each pane tracks its OWN loaded state independently - Before
   loads once per photo and stays put, After reloads on every strength
   change, so they're very often in different states from each other at
   any given moment. */
.compare-pane::before {
  content: "";
  position: absolute;
  inset: 0;
  background: linear-gradient(
    100deg,
    var(--panel-hover) 30%,
    var(--border) 50%,
    var(--panel-hover) 70%
  );
  background-size: 240% 100%;
  animation: skeleton-shimmer 1.4s ease-in-out infinite;
  opacity: 1;
  transition: opacity 0.2s ease;
  /* Critical: without this, the magnifier broke entirely. This is an
     absolutely-positioned box inside a flex container (.compare-pane) -
     per normal CSS painting order, a positioned descendant paints ON
     TOP OF in-flow content regardless of DOM/source order (::before
     textually comes first, but "positioned" beats "in-flow" for paint
     order every time). So even once faded to opacity:0 after loading,
     this shimmer overlay was still sitting above the real <img> and
     silently swallowing every mousemove/hover event meant for it -
     the magnifier's own listeners are on the <img> itself, which never
     received a single event once the shimmer was in place. Same
     invisible-but-still-hit-testable trap any opacity:0 overlay falls
     into without this.
  */
  pointer-events: none;
}
.compare-pane.compare-pane-loaded::before { opacity: 0; }
@media (prefers-reduced-motion: reduce) {
  .compare-pane::before { animation: none; background-position: 0 0; }
}
.compare-label {
  position: absolute;
  top: 8px; left: 8px;
  font-size: 0.7rem;
  font-family: var(--font-mono);
  color: var(--text-dim);
  background: rgba(12, 13, 15, 0.75);
  padding: 3px 7px;
  border-radius: 999px;
  border: 1px solid var(--border);
  z-index: 5;
}
.magnifier.loading::after {
  content: "Loading full-res…";
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  text-align: center;
  font-family: var(--font-mono);
  font-size: 0.72rem;
  color: var(--text-dim);
  padding: 20px;
}

.magnify-row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 10px 16px;
}
.magnify-row .btn.active { background: var(--sharp); color: #0b1615; border-color: var(--sharp); }

.zoom-label {
  align-items: center;
  gap: 8px;
  font-family: var(--font-mono);
  font-size: 0.75rem;
  color: var(--text-dim);
}
.zoom-label:not([hidden]) { display: flex; }
.zoom-label input[type="range"] { width: 100px; }
.lightbox-close {
  position: absolute; top: 10px; right: 10px;
  background: rgba(12,13,15,0.7); color: var(--text);
  border: 1px solid var(--border); border-radius: 6px;
  width: 32px; height: 32px; cursor: pointer; font-size: 1rem;
}
.editor-fullscreen-btn {
  right: 50px;
}
.editor-fullscreen-btn.btn-active {
  background: var(--sharp, #4fd1c5);
  color: #0d0e10;
}
.lightbox-meta { padding: 14px 18px; display: flex; flex-direction: column; gap: 10px; }
.lightbox-title { font-family: var(--font-mono); font-size: 0.85rem; color: var(--text-dim); }
.lightbox-score { font-family: var(--font-mono); font-weight: 600; font-size: 1rem; }

/* ---------- star rating + color label (lightbox) ---------- */

.star-color-row {
  display: flex;
  align-items: center;
  gap: 16px;
  margin: 4px 0;
}
.star-picker {
  display: flex;
  gap: 2px;
}
.star-btn {
  background: none;
  border: none;
  padding: 0;
  font-size: 1.3rem;
  line-height: 1;
  color: var(--border);
  cursor: pointer;
  transition: color 0.1s ease, transform 0.1s ease;
}
.star-btn:hover { transform: scale(1.15); }
.star-btn.filled { color: var(--mid); }
.color-label-picker {
  display: flex;
  gap: 6px;
}
.color-swatch-btn {
  width: 16px;
  height: 16px;
  border-radius: 50%;
  border: 2px solid transparent;
  background: var(--swatch-color, var(--border));
  cursor: pointer;
  padding: 0;
  transition: transform 0.1s ease, border-color 0.1s ease;
}
.color-swatch-btn:hover { transform: scale(1.15); }
.color-swatch-btn.active {
  border-color: var(--text);
  box-shadow: 0 0 0 2px var(--panel), 0 0 0 3px var(--swatch-color, var(--border));
}
.btn-good { background: transparent; color: var(--keep); border-color: var(--keep); }
.btn-good:hover { background: rgba(111, 207, 135, 0.12); }
.btn-good.active { background: var(--keep); color: #0b1615; }

.status-select {
  font-family: var(--font-mono);
  font-size: 0.78rem;
  padding: 6px 8px;
  border-radius: 6px;
  border: 1px solid var(--border);
  background: var(--panel);
  color: var(--text);
}
.status-select.fulfilled { border-color: var(--keep); color: var(--keep); }

.has-badge { border-color: var(--mid); color: var(--mid); }

.bulk-actions-bar {
  align-items: center;
  gap: 10px;
  padding: 8px 12px;
  margin-bottom: 8px;
  background: var(--surface-raise);
  border: 1px solid var(--border);
  border-radius: 8px;
  flex-wrap: wrap;
}
.bulk-actions-bar:not([hidden]) { display: flex; }

.order-item-row {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 4px 0;
  font-size: 0.85rem;
}
.order-item-row span { flex: 1 1 auto; }
.order-qty-input {
  width: 55px;
  font-family: var(--font-mono);
  padding: 4px 6px;
  border-radius: 6px;
  border: 1px solid var(--border);
  background: var(--panel);
  color: var(--text);
}

.totp-qr-img {
  display: block;
  width: 220px;
  height: 220px;
  margin: 8px auto;
  border-radius: 8px;
  background: #fff;
  padding: 10px;
}
.setup-step-eyebrow {
  font-family: var(--font-mono);
  font-size: 0.7rem;
  font-weight: 700;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--sharp);
  margin: 14px 0 4px;
}

.totp-secret-display {
  font-family: var(--font-mono);
  font-size: 0.95rem;
  letter-spacing: 0.05em;
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: 6px;
  padding: 10px;
  margin: 8px 0;
  word-break: break-all;
  text-align: center;
  color: var(--text-dim);
}

.order-status-badge {
  display: inline-block;
  font-family: var(--font-mono);
  font-size: 0.72rem;
  padding: 3px 9px;
  border-radius: 999px;
  border: 1px solid var(--border);
  margin-left: 6px;
}
.order-status-ordered { color: var(--text-dim); }
.order-status-in_progress { color: var(--mid); border-color: var(--mid); }
.order-status-shipped { color: var(--sharp); border-color: var(--sharp); }
.order-status-fulfilled { color: var(--keep); border-color: var(--keep); }
.order-status-payment-incomplete { color: var(--soft); border-color: var(--soft); }

/* ---------- print order status timeline ---------- */

.order-status-timeline {
  display: flex;
  align-items: flex-start;
  margin: 10px 0 4px;
}
.order-status-step {
  flex: 1;
  position: relative;
  text-align: center;
}
/* The connecting line between steps - drawn on every step except the
   first (::before starts at that step's own dot and extends left to
   the previous one) so the line only ever spans BETWEEN two dots, not
   dangling off either end of the whole row. */
.order-status-step:not(:first-child)::before {
  content: "";
  position: absolute;
  top: 5px;
  right: 50%;
  width: 100%;
  height: 2px;
  background: var(--border);
  z-index: 0;
}
.order-status-step.done:not(:first-child)::before { background: var(--keep); }
.order-status-step-dot {
  position: relative;
  z-index: 1;
  display: block;
  width: 11px;
  height: 11px;
  margin: 0 auto 4px;
  border-radius: 50%;
  background: var(--panel);
  border: 2px solid var(--border);
}
.order-status-step.done .order-status-step-dot { background: var(--keep); border-color: var(--keep); }
.order-status-step.current .order-status-step-dot {
  border-color: var(--sharp);
  box-shadow: 0 0 0 3px var(--glow-sharp);
}
.order-status-step-label {
  font-family: var(--font-mono);
  font-size: 0.68rem;
  color: var(--text-dim);
}
.order-status-step.done .order-status-step-label,
.order-status-step.current .order-status-step-label { color: var(--text); }

.feedback-stars {
  display: flex;
  gap: 6px;
  font-size: 2rem;
  margin: 12px 0;
}
.feedback-star {
  background: none;
  border: none;
  cursor: pointer;
  color: var(--border);
  transition: color 0.1s ease;
  padding: 0;
  line-height: 1;
}
.feedback-star.filled { color: var(--mid); }
.btn-reject.active { background: var(--soft); color: #1a0b0a; }

.rating-row { display: flex; align-items: center; gap: 10px; }

.save-confirm {
  font-family: var(--font-mono);
  font-size: 0.75rem;
  color: var(--keep);
  opacity: 0;
  transition: opacity 0.2s ease;
}
.save-confirm.visible { opacity: 1; }

/* ---------- global loading bar ---------- */
/* A single shared indicator (see beginLoading/endLoading in app.js)
   rather than a separate spinner per grid - there are five different
   fetch-and-rebuild functions (Cull, Top picks, Edited, Client photos,
   and their filter-triggered reloads) and none of them had ANY loading
   feedback before this: changing a filter or switching tabs just left
   the previous grid sitting on screen with zero indication a refresh
   was even happening until it suddenly swapped, which reads as "did
   that click even register?" on a slow connection or a big library. */
.global-loading-bar {
  position: fixed;
  top: 0; left: 0; right: 0;
  height: 2.5px;
  z-index: 500;
  overflow: hidden;
  background: transparent;
}
.global-loading-bar span {
  display: block;
  position: absolute;
  top: 0; bottom: 0;
  width: 40%;
  background: var(--sharp);
  box-shadow: 0 0 8px var(--glow-sharp);
  animation: global-loading-sweep 1.1s ease-in-out infinite;
}
@keyframes global-loading-sweep {
  0% { left: -40%; }
  100% { left: 100%; }
}
@media (prefers-reduced-motion: reduce) {
  .global-loading-bar span { animation: none; left: 0; width: 100%; opacity: 0.6; }
}

.toast-container {
  position: fixed;
  bottom: 20px;
  left: 50%;
  transform: translateX(-50%);
  z-index: 200;
  display: flex;
  flex-direction: column-reverse;
  gap: 8px;
  align-items: center;
  pointer-events: none;
}
.toast {
  position: relative;
  overflow: hidden;
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 10px 16px;
  font-size: 0.85rem;
  color: var(--text);
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
  opacity: 0;
  /* Starts a little lower and slides up into place on show, rather
     than just fading in place - the extra bit of travel is what makes
     it read as "arriving" instead of "materializing". */
  transform: translateY(10px);
  transition: opacity 0.22s ease, transform 0.22s cubic-bezier(0.16, 0.84, 0.44, 1);
  pointer-events: none;
  white-space: nowrap;
}
.toast.visible { opacity: 1; transform: translateY(0); }
/* Leaving state - a distinct class rather than just re-removing
   .visible, so it can fade/collapse out independently instead of
   snapping straight to display:none the instant its timer fires. */
.toast.leaving {
  opacity: 0;
  transform: translateY(-6px) scale(0.97);
}
/* A thin progress bar along the toast's own bottom edge, animated via
   CSS only (width 100% -> 0% over the same duration the JS timeout
   uses) - communicates "this is about to disappear" and roughly how
   much time is left, rather than a toast just vanishing with zero
   warning. Uses --sharp so it reads as this app's own accent, not a
   generic progress-bar gray. */
.toast::after {
  content: "";
  position: absolute;
  left: 0; bottom: 0;
  height: 2px;
  width: 100%;
  background: var(--sharp);
  opacity: 0.55;
  animation: toast-countdown 2.6s linear forwards;
}
@keyframes toast-countdown {
  from { width: 100%; }
  to { width: 0%; }
}
@media (prefers-reduced-motion: reduce) {
  .toast::after { animation: none; }
}

.speed-cull-hint {
  font-size: 0.78rem;
  color: var(--text-dim, #9aa0a6);
  background: var(--surface-raise);
  border: 1px solid var(--border);
  border-radius: 6px;
  padding: 6px 10px;
  margin: 4px 0;
}
.speed-cull-hint kbd {
  font-family: var(--font-mono);
  background: var(--panel-hover, #22252a);
  border: 1px solid var(--border);
  border-radius: 4px;
  padding: 1px 5px;
  font-size: 0.72rem;
  color: var(--text);
}

/* Deliberately quieter than .speed-cull-hint above (no background/
   border box) - this is always-on ambient info for the common case
   (just browsing), not an active-mode banner like Speed cull's, so it
   shouldn't compete for attention the same way. Toggled opposite to
   speed-cull-hint in updateSpeedCullHint() - only one of the two is
   ever visible at once. */
.lightbox-nav-hint {
  font-size: 0.74rem;
  color: var(--text-dim, #9aa0a6);
  margin: 2px 0 4px;
}
.lightbox-nav-hint kbd {
  font-family: var(--font-mono);
  background: var(--panel-hover, #22252a);
  border: 1px solid var(--border);
  border-radius: 4px;
  padding: 1px 5px;
  font-size: 0.68rem;
  color: var(--text-dim);
}

.denoise-status {
  font-family: var(--font-mono);
  font-size: 0.75rem;
  color: var(--mid);
}
.denoise-status.done { color: var(--sharp); }
.denoise-status.error { color: var(--soft); }

.denoise-panel:not([hidden]) {
  display: flex;
  flex-direction: column;
  gap: 8px;
  padding: 10px 0;
}
.denoise-panel[hidden] { display: none; }
.denoise-toggle-label {
  display: flex;
  align-items: center;
  gap: 6px;
  font-size: 0.85rem;
  color: var(--text-dim, #9aa0a6);
  cursor: pointer;
}
.denoise-strength-row:not([hidden]) {
  display: flex;
  align-items: center;
  gap: 10px;
  flex-wrap: wrap;
}
.denoise-strength-row[hidden] { display: none; }
.denoise-preview-active { border-color: var(--sharp); color: var(--sharp); }

/* ---------- login screen ---------- */

.login-screen:not([hidden]) {
  position: fixed;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  /* Same two-wash formula the client-page login already used, plus a
     third slow-drifting glow layered underneath. `background-position`
     is animated rather than the gradient itself being recomputed, so
     this stays cheap (a compositor-friendly property, not layout/paint
     work) even though it runs indefinitely for as long as the screen
     is up - unlike the card-entrance animations elsewhere, which all
     play once and stop, this one is meant to sit and breathe quietly
     behind the (still, static) login card. */
  background:
    radial-gradient(720px 480px at 50% 38%, var(--wash-1, rgba(79, 209, 197, 0.10)), transparent 65%),
    radial-gradient(900px 600px at var(--drift-x, 20%) var(--drift-y, 80%), var(--wash-2, rgba(232, 163, 61, 0.05)), transparent 60%),
    var(--bg, #0b0c0e);
  background-size: 100% 100%, 220% 220%, 100% 100%;
  animation: login-bg-drift 22s ease-in-out infinite alternate;
  z-index: 100;
}
@keyframes login-bg-drift {
  from { background-position: 0 0, 0% 100%, 0 0; }
  to   { background-position: 0 0, 100% 0%, 0 0; }
}
.login-screen[hidden] { display: none; }

/* Language switcher (item #37) - a plain <select>, deliberately not
   styled as a full custom dropdown, so it stays free/cheap on both the
   login screen (positioned top-right, over the drifting background)
   and the logged-in topbar (inline among the other topbar-actions
   buttons, sized to match them). */
.lang-switcher {
  background: var(--panel);
  color: var(--text);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 6px 8px;
  font-size: 0.82rem;
  font-family: inherit;
}
#langSwitcherLogin {
  position: absolute;
  top: 16px;
  right: 16px;
  z-index: 101;
}
@keyframes login-card-in {
  from { opacity: 0; transform: translateY(10px) scale(0.98); }
  to { opacity: 1; transform: translateY(0) scale(1); }
}
.login-card {
  position: relative;
  flex-direction: column;
  gap: 14px;
  width: min(340px, 90vw);
  padding: 32px 28px;
  background: linear-gradient(180deg, var(--panel), #191c21);
  border: 1px solid var(--border);
  border-radius: 14px;
  /* Adds the same soft glow-ring the client gallery's login card has
     had all along (0 0 48px -16px var(--glow-sharp)) - this was the
     one place in the main app that still looked flatter than its
     client-facing counterpart for no real reason. */
  box-shadow: var(--shadow-lift), 0 0 0 1px rgba(255, 255, 255, 0.02), 0 0 48px -16px var(--glow-sharp);
  animation: login-card-in 0.35s ease both;
}
/* A faint grain layer on dark themes only - cheap SVG feTurbulence data
   URI, ~2.5% opacity, mix-blend so it reads as texture on the gradient
   rather than a visible speckle on top of it. Skipped entirely on
   .theme-light: grain reads as "premium dark UI" (Linear/Vercel-style)
   but just looks like a dirty screen on a light background. */
body:not(.theme-light) .login-card::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  pointer-events: none;
  opacity: 0.05;
  mix-blend-mode: overlay;
  background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/></filter><rect width='100%25' height='100%25' filter='url(%23n)'/></svg>");
}
@media (prefers-reduced-motion: reduce) {
  .login-screen:not([hidden]) { animation: none; }
}
.login-card:not([hidden]) { display: flex; }
.login-brand { justify-content: center; margin-bottom: 6px; font-size: 1.3rem; }

/* ---- client.html only: the gallery's own arrival moment ----
   This still renders before /api/me returns, so it can't know the
   client's event theme yet (see applyEventTheme in client.js) - it
   stays a generic, unthemed "front door" rather than guessing. It
   uses the same two-wash formula as the rest of the app instead of a
   one-off hardcoded glow, and names what the screen actually is
   ("private gallery access") rather than leaving the logo to carry
   that on its own. */
.client-page .login-screen:not([hidden]) {
  background:
    radial-gradient(1100px 620px at 12% -8%, var(--wash-1), transparent 60%),
    radial-gradient(900px 520px at 100% 0%, var(--wash-2), transparent 55%),
    var(--bg, #0b0c0e);
}
.client-page .login-card {
  width: min(368px, 90vw);
  padding: 36px 32px 32px;
  border-radius: 16px;
  box-shadow: var(--shadow-lift), 0 0 0 1px rgba(255, 255, 255, 0.02), 0 0 48px -16px var(--glow-sharp);
}
.client-page .login-brand {
  flex-direction: column;
  align-items: center;
  gap: 0;
}
.client-page .login-brand::after {
  content: "Private gallery access";
  display: block;
  margin-top: 12px;
  font-family: var(--font-mono);
  font-size: 0.66rem;
  font-weight: 500;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--text-dim);
  text-align: center;
}
.login-label {
  flex-direction: column;
  gap: 6px;
  font-size: 0.85rem;
  color: var(--text-dim, #9aa0a6);
}
.login-label:not([hidden]) { display: flex; }
.login-label input {
  font-family: var(--font-mono);
  padding: 10px 11px;
  border-radius: 7px;
  border: 1px solid var(--border);
  background: var(--panel);
  color: var(--text);
  transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.login-label input:focus {
  outline: none;
  border-color: var(--sharp);
  box-shadow: 0 0 0 3px var(--glow-sharp);
}
.login-submit { margin-top: 4px; }

/* ---------- password show/hide toggle ---------- */

.password-field-wrap {
  position: relative;
  display: flex;
}
.password-field-wrap input {
  width: 100%;
  /* Room for the eye icon so typed text never runs underneath it. */
  padding-right: 38px;
}
.password-toggle-btn {
  position: absolute;
  top: 50%;
  right: 6px;
  transform: translateY(-50%);
  display: flex;
  align-items: center;
  justify-content: center;
  width: 28px;
  height: 28px;
  padding: 0;
  border: none;
  background: none;
  color: var(--text-dim);
  cursor: pointer;
  border-radius: 6px;
  transition: color 0.15s ease;
}
.password-toggle-btn:hover { color: var(--text); }
/* One SVG holding both an open-eye shape and a slashed-eye shape,
   permanently overlaid - same "toggle a class, let CSS crossfade
   between two always-present shapes" approach as the theme toggle's
   sun/moon icon, rather than swapping which icon is drawn via JS.
   .showing (added in app.js on click) reflects "the password is
   currently visible as plain text", so the SLASHED eye is what's
   showing then - toggling to hide it again is the more universally
   understood convention (a crossed-out eye means "hidden/off"). */
.password-toggle-btn .eye-closed-shape { display: none; }
.password-toggle-btn.showing .eye-open-shape { display: none; }
.password-toggle-btn.showing .eye-closed-shape { display: inline; }

/* ---------- password strength meter ---------- */

.password-strength-meter {
  width: 100%;
  height: 4px;
  border-radius: 999px;
  background: var(--border);
  overflow: hidden;
  margin-top: 2px;
}
.password-strength-fill {
  height: 100%;
  width: 0%;
  border-radius: inherit;
  transition: width 0.25s ease, background-color 0.25s ease;
}
.password-strength-1 { background: var(--soft); }
.password-strength-2 { background: var(--mid); }
.password-strength-3 { background: var(--sharp); }
.password-strength-4 { background: var(--keep); }
.password-strength-label {
  font-family: var(--font-mono);
  font-size: 0.7rem;
  color: var(--text-dim);
}
.login-error {
  font-size: 0.82rem;
  color: var(--soft);
  text-align: center;
}
.login-error[hidden] { display: none; }
.login-success {
  font-size: 0.82rem;
  color: var(--keep, var(--sharp));
  text-align: center;
}
.login-success[hidden] { display: none; }
.link-btn {
  background: none;
  border: none;
  color: var(--text-dim, #9aa0a6);
  font-size: 0.8rem;
  text-decoration: underline;
  cursor: pointer;
  padding: 0;
  align-self: center;
}
.link-btn:hover { color: var(--sharp); }
.expand-burst-btn { margin: 6px 0 4px; display: block; }
.load-more-row {
  display: flex;
  justify-content: center;
  padding: 24px 0;
}

/* ---------- register modal ---------- */

.login-modal-inner {
  background: transparent;
  border: none;
  box-shadow: none;
  padding: 0;
  max-width: min(360px, 92vw);
  overflow: visible;
}
.login-card-modal { margin: 0; }
#registerModal { z-index: 150; }
.honeypot-field {
  position: absolute;
  left: -9999px;
  width: 1px;
  height: 1px;
  overflow: hidden;
}

/* ---------- topbar user info ---------- */

.whoami {
  font-family: var(--font-mono);
  font-size: 0.8rem;
  color: var(--text-dim, #9aa0a6);
  padding: 0;
  max-width: 140px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.whoami .admin-badge {
  color: var(--sharp);
  margin-left: 4px;
}

/* ---------- compare mode (side-by-side burst comparison) ---------- */

/* ---------- themed confirm/prompt/alert dialog ---------- */
/* Small and centered rather than the app's usual near-full-width
   modals - this replaces confirm()/prompt()/alert(), which are always
   compact, so matching that footprint (instead of reusing a bigger
   modal size) keeps it reading as "a quick decision", not "a whole
   panel of settings". */
#themedDialogModal {
  /* Reuses the shared .lightbox class (z-index: 50), same as every
     other modal in the app (print orders, users, clients, etc). That's
     fine when only one is open, but this dialog is specifically the
     one used to confirm/prompt/alert *from inside* those other
     modals (e.g. "Delete this print order?") - with equal z-index,
     stacking falls back to DOM order, and #themedDialogModal happens
     to sit earlier in index.html than most of the modals that invoke
     it, so it was painting underneath them instead of on top.
     Explicit override, same pattern as #registerModal below, keeps it
     above every z-index:50 modal regardless of DOM position. */
  z-index: 200;
}
.themed-dialog-inner {
  padding: 22px 24px;
  max-width: min(380px, 90vw);
  text-align: left;
}
.themed-dialog-message {
  margin: 0 0 16px;
  color: var(--text);
  font-size: 0.92rem;
  line-height: 1.5;
  /* prompt()'s message can be a two-line explanation (see the
     type-DELETE-to-confirm call sites) - preserve those line breaks
     rather than collapsing them into one run-on sentence. */
  white-space: pre-line;
}
.themed-dialog-input {
  width: 100%;
  margin-bottom: 16px;
  padding: 9px 11px;
  border-radius: 6px;
  border: 1px solid var(--border);
  background: var(--panel);
  color: var(--text);
  font-family: var(--font-mono);
  font-size: 0.88rem;
}
.themed-dialog-input:focus { outline: none; border-color: var(--sharp); }
.themed-dialog-input[hidden] { display: none; }
.themed-dialog-actions {
  display: flex;
  justify-content: flex-end;
  gap: 10px;
}

.compare-mode-inner {
  padding: 24px;
  max-width: min(1200px, 95vw);
  max-height: 90vh;
  overflow-y: auto;
}
.compare-mode-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
  gap: 16px;
  margin-top: 12px;
}
.compare-mode-card {
  position: relative;
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: 9px;
  overflow: hidden;
}
.compare-mode-card img {
  width: 100%;
  aspect-ratio: 3/2;
  object-fit: cover;
  display: block;
  background: #0d0e10;
  cursor: pointer;
}
.compare-mode-actions {
  display: flex;
  gap: 8px;
  margin-top: 8px;
}
.compare-mode-actions .btn { flex: 1 1 auto; }
/* Gold to match the app's existing "this is the important one" color
   (see .primary-region on the detection overlay) - deliberately distinct
   from the good/reject green/red pair since it's a different KIND of
   action (batch-decide the whole burst at once), not a third decision
   state of its own. */
.btn-keeper {
  width: 100%;
  margin-top: 8px;
  background: transparent;
  color: #ffd60a;
  border-color: #ffd60a;
}
.btn-keeper:hover { background: rgba(255, 214, 10, 0.14); }
.compare-btn {
  margin-left: 10px;
  font-size: 0.78rem;
}

/* ---------- basic in-app photo editor ---------- */

.editor-modal-inner {
  padding: 24px;
  max-width: min(1400px, 96vw);
  max-height: 92vh;
  overflow-y: auto;
}
/* Real full-screen mode (see editorFullscreenBtn in app.js, which calls
   editorModal.requestFullscreen() - a DIFFERENT thing from Focus mode
   above, which only expands within the browser's own window/chrome).
   Targets an explicit class (.editor-fullscreen-active, toggled by the
   fullscreenchange listener in app.js) rather than relying only on the
   :fullscreen pseudo-class matching - gives predictable, testable
   control over the exact layout instead of hoping the cascade works
   out, and matters here specifically because .lightbox (this modal's
   own parent class) has its own `padding: 24px` and flex centering
   that were STILL constraining .editor-modal-inner's effective
   available width even once the modal itself became the fullscreen
   element - confirmed by testing: max-width/width alone on
   .editor-modal-inner left real, visible margins on both sides instead
   of truly filling the screen, because the padded, centering PARENT
   was never told to stop constraining its child. */
.editor-fullscreen-active.lightbox {
  padding: 0;
  align-items: stretch;
  justify-content: stretch;
}
.editor-fullscreen-active .editor-modal-inner {
  max-width: 100vw;
  max-height: 100vh;
  width: 100vw;
  height: 100vh;
  border-radius: 0;
  padding: 14px;
}
.editor-fullscreen-active .editor-preview-wrap,
.editor-fullscreen-active .editor-preview-wrap img {
  max-height: calc(100vh - 28px);
}
/* Focus mode (see editorFocusModeBtn in app.js) - hides the title/
   description header and gives the modal nearly the whole viewport,
   for someone who wants to concentrate on the image without the rest
   of the page's chrome around it. Keeps the same settings panel and
   toolbar (this isn't a "hide everything" mode, since the sliders are
   still how editing actually happens) - just reclaims the vertical
   space the header was using and lets the preview grow into it. */
.editor-modal-inner.editor-distraction-free {
  max-width: 99vw;
  max-height: 97vh;
  padding: 10px;
}
.editor-distraction-free #editorModalHeader {
  display: none;
}
.editor-distraction-free .editor-preview-wrap,
.editor-distraction-free .editor-preview-wrap img {
  max-height: calc(97vh - 20px);
}
/* Large text / high contrast mode (see editorA11yModeBtn in app.js) -
   bumps the editor's own text sizes and sharpens contrast. Most of this
   file's font-size rules are in rem (root-em, always relative to the
   PAGE's root font-size, not any local ancestor) specifically so the
   rest of the app scales together as a unit - which means a plain
   "bigger font-size on the modal" trick wouldn't cascade down to any
   of them. Each rem-sized rule that matters for actually reading and
   using the editor's controls gets its own explicit, larger override
   here instead. */
.editor-a11y-mode .editor-slider-label {
  font-size: 1.05rem;
  color: #d0d0d0;
}
.editor-a11y-mode .editor-slider-val-input {
  font-size: 1.05rem;
  width: 62px;
  padding: 4px 6px;
  border-color: #565656;
}
.editor-a11y-mode .editor-accordion-section > summary {
  font-size: 1.05rem;
  padding: 14px 2px;
}
.editor-a11y-mode .editor-toolbar-row .btn,
.editor-a11y-mode .editor-presets-row .btn {
  font-size: 0.95rem;
  padding: 8px 12px;
}
.editor-a11y-mode .hint-text {
  font-size: 0.95rem;
  color: #b8b8b8;
}
.editor-a11y-mode .editor-panel {
  border-color: #565656;
}
.editor-a11y-mode .editor-accordion-section {
  border-bottom-color: #565656;
}
.editor-a11y-mode .editor-slider-label input[type="range"]::-webkit-slider-thumb {
  width: 18px;
  height: 18px;
  background: #fff;
}
.editor-a11y-mode .editor-slider-label input[type="range"]::-moz-range-thumb {
  width: 18px;
  height: 18px;
  background: #fff;
}
.editor-a11y-mode .editor-slider-label input[type="range"]::-webkit-slider-runnable-track,
.editor-a11y-mode .editor-slider-label input[type="range"]::-moz-range-track {
  height: 4px;
  background: #6a6a6a;
}
/* Sits as a direct child of #editorModal (a SIBLING of .editor-modal-
   inner, not nested inside it) - #editorModal is itself position:fixed;
   inset:0 (see .lightbox), so this covers the entire modal regardless
   of how far .editor-modal-inner's own content has been scrolled,
   rather than only the currently-visible portion. Blocks pointer events
   for everything underneath by simply existing on top - no need to
   thread a `disabled`/locked check through every individual slider,
   crop handle, and local-adjustment control this editor has grown. */
.editor-lock-overlay {
  position: fixed;
  inset: 0;
  z-index: 51;
  background: rgba(10, 11, 13, 0.88);
  backdrop-filter: blur(2px);
  display: flex;
  align-items: center;
  justify-content: center;
}
.editor-lock-overlay[hidden] { display: none; }
.editor-lock-message {
  text-align: center;
  color: var(--text);
  font-size: 1.1rem;
  font-weight: 600;
  max-width: 320px;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 8px;
}
.editor-lock-icon {
  font-size: 2.4rem;
}
.editor-lock-message .btn {
  margin-top: 8px;
}
.editor-layout {
  display: flex;
  gap: 20px;
  margin-top: 12px;
  align-items: flex-start;
  flex-wrap: wrap;
}
/* Copy/paste develop settings - sits directly above "Reset all" since
   the three are the same kind of whole-photo action. */
/* Small, muted, and always on screen. It only matters when something is
   wrong, but when it is wrong it is the first thing worth knowing. */
.build-badge {
  font-size: 0.68rem;
  color: var(--muted);
  margin-left: 6px;
  opacity: 0.75;
}

.editor-sync-row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
  margin-bottom: 8px;
}
.editor-clipboard-status {
  font-size: 0.82rem;
  color: var(--muted);
}

.editor-preview-wrap {
  /* Without this the browser claims a touch drag for scrolling/pinch
     BEFORE the page sees it: pointerdown arrives, then the gesture is
     taken over and pointermove never comes (or arrives once and is
     followed by pointercancel). Every drag gesture over the preview -
     pan, zoom marquee, removal brush, lasso, crop - is handled in JS,
     so the browser has nothing useful to do with these touches and
     should not be given the chance. This is what actually made the
     editor unusable on a touchscreen; converting the handlers to
     pointer events alone does not fix it. */
  touch-action: none;
  position: sticky;
  top: 0;
  z-index: 5;
  /* Matches .editor-modal-inner's own max-height (92vh) minus its
     top+bottom padding (24px each) - without this, a tall portrait
     photo shown at full column width could grow taller than the
     visible modal area entirely, which would look broken once this
     wrap is pinned in place via sticky positioning while the (usually
     much taller) settings panel scrolls past it. */
  max-height: calc(92vh - 48px);
  flex: 2 1 480px;
  min-width: 320px;
  background: #0d0e10;
  border: 1px solid var(--border);
  border-radius: 9px;
  overflow: auto;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: crosshair;
}
/* Zoom mode (see editorZoomLevel/editorApplyZoom in app.js) drops the
   centering above - verified with a real test before shipping that
   flex's align-items/justify-content:center, combined with overflow:
   auto on an element BIGGER than its container, clamps the scrollable
   range to roughly half what it should be (browsers only make the
   "excess" on one side scrollable when the oversized child is
   centered, not the true overflow in both directions) - confirmed via
   scrollWidth/scrollHeight and max achievable scrollLeft/scrollTop
   both coming out significantly short of the actual zoomed image size.
   Switching to flex-start while zoomed restores the full, correct
   scroll range; the initial view is still centered by setting
   scrollLeft/scrollTop programmatically once, rather than relying on
   centering to do it. */
.editor-preview-wrap.editor-preview-zoomed {
  align-items: flex-start;
  justify-content: flex-start;
  cursor: grab;
}
.editor-preview-wrap.editor-preview-zoomed.editor-preview-panning {
  cursor: grabbing;
}
/* width:auto + max-width:100% (rather than the width:100% + object-fit:
   contain this used to be) so the image's LAYOUT box is always exactly
   its PAINTED box. Under the old rules a portrait photo whose height
   got clamped by max-height kept a full-width layout box and object-fit
   letterboxed the real pixels INSIDE it - measured in a real browser at
   699x780 layout vs 585x780 painted, i.e. 57px of dead space down each
   side. Every overlay here (crop, clipping, local-mask, GPU, diff) and
   every click->image-fraction conversion (local spot placement,
   eyedropper, two-point straighten, dust/content-aware markers) derives
   its geometry from editorPreviewImg's offsetLeft/offsetWidth, so all of
   them silently mis-mapped by that much on portrait photos - a crop
   dragged to the visual edge of the frame was really cropping into
   nothing, and the eyedropper sampled the wrong pixel. Sizing the box
   to its content makes offset* tell the truth, which fixes every one of
   those call sites at once instead of one at a time (and keeps future
   ones correct by default). The flex centering that was already here
   then supplies a nonzero offsetLeft, which the overlay code was
   already written to add in - see editorHandleTwoPointStraightenClick's
   comment, which anticipated exactly this.
   Tradeoff: an image SMALLER than the preview box now displays at its
   own size rather than being upscaled to fill it. The editor always
   requests a full-resolution decode (see EDITOR_PREVIEW_MAX_DIM's
   removal in main.py), so that's a rare case, and not upscaling is
   better rendering anyway. */
.editor-preview-wrap img {
  width: auto;
  height: auto;
  max-width: 100%;
  max-height: calc(92vh - 48px);
  display: block;
}
/* Zoom mode sets an EXPLICIT pixel width/height via JS (editorApplyZoom)
   instead of the 100%-of-container sizing above, so these need to
   override rather than combine with it - max-height in particular has
   to go, since a zoomed-in image is deliberately meant to exceed the
   wrap's own height (that's what makes it scrollable). */
.editor-preview-wrap.editor-preview-zoomed img {
  width: auto;
  height: auto;
  max-width: none;
  max-height: none;
}
/* The rubber-band rectangle drawn while dragging a marquee zoom (see
   editorZoomToMarquee in app.js). Positioned in the wrap's PADDING-box
   coordinates, the same space every other absolutely-positioned child
   here uses, so it keeps tracking the photo correctly while the wrap is
   scrolled. pointer-events:none so it can never swallow the mousemove/
   mouseup that are still driving the drag underneath it. */
.editor-zoom-marquee {
  position: absolute;
  border: 1px dashed #fff;
  background: rgba(255, 255, 255, 0.12);
  box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.55);
  pointer-events: none;
  z-index: 12;
}
/* While a marquee is being dragged the cursor should stay a crosshair
   even over a zoomed-in image, whose default is the grab/grabbing pan
   cursor - otherwise starting an Alt+drag marquee while zoomed shows a
   "grabbing" hand that contradicts what the drag is actually doing. */
.editor-preview-wrap.editor-preview-marqueeing,
.editor-preview-wrap.editor-preview-zoomed.editor-preview-marqueeing {
  cursor: crosshair;
}
/* Numeric slider fields are scrubbable (drag left/right to change the
   value - see the pointer handlers in app.js), so they advertise that
   with a horizontal-resize cursor. The active class is applied only once
   a drag has actually started, so a plain click-to-type still behaves
   like an ordinary text field. */
.editor-slider-val-input {
  cursor: ew-resize;
}
.editor-slider-val-input.editor-slider-val-scrubbing {
  cursor: ew-resize;
  user-select: none;
  background: var(--panel, #1a1c1f);
}
/* Camera Raw-style clipping indicators sitting in the histogram's top
   corners. The stack is the positioning context; the canvas keeps its
   own layout untouched so nothing about the histogram drawing changes. */
.editor-histogram-stack {
  position: relative;
  display: inline-block;
  line-height: 0;
}
.editor-clip-tri {
  position: absolute;
  top: 3px;
  width: 0;
  height: 0;
  padding: 0;
  border: none;
  background: none;
  cursor: pointer;
  /* Drawn as a CSS triangle via borders, so the border-* properties
     below are the SHAPE - the lit/unlit state is carried by the colour
     of the diagonal edges, not by a background. */
  border-top: 9px solid var(--border, #555);
  opacity: 0.55;
}
.editor-clip-tri-left {
  left: 3px;
  border-right: 9px solid transparent;
}
.editor-clip-tri-right {
  right: 3px;
  border-left: 9px solid transparent;
}
.editor-clip-tri:hover {
  opacity: 0.85;
}
/* "There is clipping at this end right now" - set from the histogram
   data on every refresh, independent of whether the overlay is on. */
.editor-clip-tri-left.editor-clip-present {
  border-top-color: #46a0ff;
  opacity: 1;
}
.editor-clip-tri-right.editor-clip-present {
  border-top-color: #ff4b4b;
  opacity: 1;
}
/* "The overlay for this end is switched on" - a ring around the
   triangle, so an active toggle stays distinguishable from a merely
   lit one. */
.editor-clip-tri[aria-pressed="true"] {
  outline: 2px solid var(--text, #eee);
  outline-offset: 2px;
  opacity: 1;
}
/* Sub-headings inside the Color Mixer section (Hue / Saturation /
   Luminance). Not a <summary> because these are not independently
   collapsible - they label the three groups of eight bands within one
   already-collapsible section. */
.editor-mixer-group-label {
  font-size: 0.78rem;
  font-weight: 600;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--muted, #9aa0a6);
  margin: 6px 0 2px;
}
/* Compare mode overlays the preview area rather than sitting beside the
   photo in it.

   editorPreviewWrap is a flex ROW, and entering compare only sets the
   preview image to visibility:hidden - which deliberately keeps it laid
   out so the overlay-positioning maths stays valid. But a
   visibility:hidden flex item still takes its full share of the row, so
   the compare view was competing with the photo for width and losing:
   measured at 2px wide with both panes at 0x0, which renders as the
   panes' near-black background and nothing else. (It got worse when the
   preview image moved to width:auto - its flex basis became the photo's
   8554px natural width, so it claimed almost the entire row instead of
   roughly half.)

   Taking the compare view out of flow removes the competition entirely:
   it fills the wrap, the hidden image underneath keeps defining the
   wrap's height at the right aspect, and nothing about the overlay
   positioning maths changes. The wrap is position:sticky, which
   establishes a containing block for absolutely positioned descendants
   just as position:relative would. */
.editor-preview-wrap > .denoise-compare:not([hidden]) {
  position: absolute;
  inset: 0;
  width: auto;
  z-index: 6;
}
/* Inside that absolutely-positioned box the panes get their height from
   the flex container itself, so the aspect-ratio reservation that
   matters before images load in the denoise dialog would instead fight
   the fixed box here and re-collapse the panes. */
.editor-preview-wrap > .denoise-compare:not([hidden]) .compare-pane {
  aspect-ratio: auto;
  max-height: none;
  align-items: center;
  justify-content: center;
}
.editor-preview-wrap > .denoise-compare:not([hidden]) .compare-pane img {
  max-height: 100%;
  object-fit: contain;
}
/* Icon rail. The buttons used to carry a mix of colour emoji, thin
   monochrome text glyphs (arrows, warning signs) and, in two cases, a
   full sentence of label text - three different visual weights in one
   20px-wide column, which is what made the rail read as unfinished.
   They are now one stroke-drawn SVG set inheriting currentColor, so
   every icon has the same weight and picks up hover/active state the
   same way. */
.rail-ico {
  width: 19px;
  height: 19px;
  display: block;
  margin: 0 auto;
  /* Non-scaling stroke keeps the 1.7px line identical whether an icon
     is drawn at 19px here or scaled up in accessibility mode. */
  vector-effect: non-scaling-stroke;
}
.editor-icon-rail button {
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--muted, #9aa0a6);
  transition: color 0.12s ease, background 0.12s ease;
}
/* Same trap as the guards above, introduced by the display:flex on the
   rule immediately preceding this one: editorBatchStraightenBtn and
   editorCompareModeToggle are both shown conditionally via the hidden
   attribute, and without this they sat permanently in the rail. */
.editor-icon-rail button[hidden] {
  display: none;
}
.editor-icon-rail button:hover:not(:disabled) {
  color: var(--text, #eee);
}
.editor-icon-rail button:disabled .rail-ico {
  opacity: 0.35;
}
/* Deliberately NO colour override for .btn-active here. That rule
   already sets its own background and color:var(--text), and some rail
   buttons additionally carry an accent background of their own - an
   accent-coloured icon on top of that is the same colour as what it
   sits on, which rendered the active button as a solid block with no
   glyph visible at all. Letting the icon inherit whatever colour
   .btn-active sets keeps it legible in both cases. */
/* [hidden] guards.
   The browser's own `[hidden] { display: none }` comes from the UA
   stylesheet, which any class rule setting a display value silently
   beats. Nine classes here did exactly that, so fourteen elements
   marked hidden in the markup rendered anyway - including
   editorHistoryPanel and editorBranchesPanel, which showed as empty
   bordered boxes stacked in the middle of the editor panel with
   nothing in them. The file already used this pattern in 25 places;
   these are the ones that were missed. */
.calibration-row[hidden],
.editor-crop-edit-panel[hidden],
.editor-export-row[hidden],
.editor-format-label[hidden],
.editor-history-panel[hidden],
.editor-presets-compare-grid[hidden],
.editor-presets-row[hidden],
.gps-edit-form[hidden],
.trends-comparison-row[hidden] {
  display: none;
}
/* The two white-balance actions sit at the top of Basic, above
   Temperature/Tint. As full-width stacked buttons they read as the most
   important controls in the panel, which they are not - they are
   occasional shortcuts for setting the same two sliders directly below
   them. Paired on one row, they take a third of the vertical space and
   stop competing with the sliders they feed. */
.editor-wb-actions {
  display: flex;
  gap: 6px;
  margin-bottom: 4px;
}
.editor-wb-actions[hidden] { display: none; }
.editor-wb-actions .btn {
  flex: 1 1 0;
  min-width: 0;
  padding: 5px 8px;
  font-size: 0.82rem;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
/* Shared progress bar.
   Two honest states, and no third one that guesses:
     - indeterminate (.editor-progress-indeterminate): a stripe sweeping
       the track, used while the SERVER is working. Nothing reports
       progress from a numpy render, so a percentage there would be
       invented.
     - determinate (a width set on the fill): used only while the
       response body is actually downloading, where Content-Length makes
       the number real. That is the part worth showing on a large
       refinement frame or an export. */
.editor-progress {
  display: block;
  width: 100%;
  height: 3px;
  margin-top: 5px;
  border-radius: 2px;
  background: rgba(255, 255, 255, 0.16);
  overflow: hidden;
}
.editor-progress-wide { height: 4px; margin-top: 6px; }
.editor-progress-fill {
  display: block;
  height: 100%;
  width: 0%;
  border-radius: 2px;
  background: var(--accent, #4fd1c5);
  transition: width 0.15s linear;
}
.editor-progress-fill.editor-progress-indeterminate {
  width: 35%;
  transition: none;
  animation: editor-progress-sweep 1.05s ease-in-out infinite;
}
@keyframes editor-progress-sweep {
  0%   { transform: translateX(-110%); }
  100% { transform: translateX(320%); }
}
@media (prefers-reduced-motion: reduce) {
  /* A sweeping stripe is exactly the kind of continuous motion this
     query exists to suppress; the bar still communicates "busy" as a
     static partially-filled track. */
  .editor-progress-fill.editor-progress-indeterminate {
    animation: none;
    width: 100%;
    opacity: 0.5;
  }
}
.editor-export-progress[hidden] { display: none; }
.editor-export-progress { margin-top: 2px; }
/* Beginner / Advanced mode.
   Beginner hides everything marked data-adv rather than removing or
   disabling it, so nothing about the adjustments, their values or the
   render path changes - an edit made in Beginner is byte-identical to
   the same edit made in Advanced. Switching back reveals the panels
   exactly as they were left, which is why this is a display rule and
   not a rebuild of the panel. */
.editor-mode-beginner [data-adv] {
  display: none !important;
}
.editor-mode-switch {
  display: inline-flex;
  margin-left: 14px;
  border: 1px solid var(--border);
  border-radius: 6px;
  overflow: hidden;
  vertical-align: middle;
}
.editor-mode-btn {
  background: none;
  border: none;
  color: var(--muted, #9aa0a6);
  font-size: 0.76rem;
  padding: 3px 12px;
  cursor: pointer;
}
.editor-mode-btn + .editor-mode-btn { border-left: 1px solid var(--border); }
.editor-mode-btn[aria-pressed="true"] {
  background: var(--panel-hover);
  color: var(--text);
}

/* Camera Raw's panel rhythm is considerably tighter than this was -
   more controls visible without scrolling is most of what makes it feel
   organised rather than sprawling. */
.editor-slider-label {
  margin-bottom: 7px;
  font-size: 0.82rem;
}
.editor-accordion-section > summary {
  padding: 7px 2px;
  font-size: 0.84rem;
}
.editor-slider-group { padding-top: 2px; }

/* Colour sliders get the gradient track Camera Raw uses, so the
   direction of travel is readable without moving the handle. */
#editorTemp { background-image: linear-gradient(90deg, #4a7fd4 0%, #9aa6b5 50%, #e8c15a 100%); }
#editorTint { background-image: linear-gradient(90deg, #5ac46a 0%, #9aa6b5 50%, #c45ac4 100%); }
#editorSaturation, #editorVibrance {
  background-image: linear-gradient(90deg, #7d8590 0%, #9aa6b5 35%, #d46a6a 100%);
}
#editorTemp, #editorTint, #editorSaturation, #editorVibrance {
  background-size: 100% 3px;
  background-position: center;
  background-repeat: no-repeat;
}
.editor-preview-loading {
  position: absolute;
  top: 10px;
  right: 10px;
  background: rgba(0, 0, 0, 0.65);
  color: var(--text);
  font-size: 0.78rem;
  padding: 4px 10px;
  border-radius: 20px;
}

/* Photoshop-style drag crop overlay - positioned by JS (see
   editorPositionCropOverlay in app.js) to exactly match editorPreviewImg's
   own rendered box in PIXELS, since .editor-preview-wrap centers the image
   and can be wider than it - the box/handles/masks INSIDE the overlay are
   then positioned in PERCENTAGES of that overlay, so a window resize never
   needs a re-measure of anything but the outer overlay itself.
   pointer-events:none on the overlay (and its masks) lets clicks pass
   straight through to editorPreviewImg underneath for the existing local-
   adjustment-spot-placement click handler - only the box and its handles
   re-enable pointer-events so they alone are draggable. */
.editor-crop-overlay {
  position: absolute;
  pointer-events: none;
}
.editor-crop-mask {
  position: absolute;
  background: rgba(0, 0, 0, 0.55);
  pointer-events: none;
}
.editor-crop-box {
  position: absolute;
  border: 1.5px solid #fff;
  box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.6), 0 0 12px rgba(0, 0, 0, 0.35);
  cursor: move;
  pointer-events: auto;
  touch-action: none;
  box-sizing: border-box;
}
.editor-crop-grid-line {
  position: absolute;
  background: rgba(255, 255, 255, 0.35);
  pointer-events: none;
}
.editor-crop-grid-v {
  top: 0;
  bottom: 0;
  width: 1px;
}
.editor-crop-grid-h {
  left: 0;
  right: 0;
  height: 1px;
}
.editor-crop-handle {
  position: absolute;
  background: #fff;
  border: 1px solid rgba(0, 0, 0, 0.6);
  pointer-events: auto;
  touch-action: none;
  z-index: 2;
}
.editor-crop-handle-corner {
  width: 14px;
  height: 14px;
  border-radius: 2px;
}
.editor-crop-handle-corner[data-handle="tl"] { top: -7px; left: -7px; cursor: nwse-resize; }
.editor-crop-handle-corner[data-handle="tr"] { top: -7px; right: -7px; cursor: nesw-resize; }
.editor-crop-handle-corner[data-handle="bl"] { bottom: -7px; left: -7px; cursor: nesw-resize; }
.editor-crop-handle-corner[data-handle="br"] { bottom: -7px; right: -7px; cursor: nwse-resize; }
.editor-crop-handle-edge {
  border-radius: 3px;
}
.editor-crop-handle-edge[data-handle="t"] { top: -6px; left: 50%; width: 22px; height: 10px; margin-left: -11px; cursor: ns-resize; }
.editor-crop-handle-edge[data-handle="b"] { bottom: -6px; left: 50%; width: 22px; height: 10px; margin-left: -11px; cursor: ns-resize; }
.editor-crop-handle-edge[data-handle="l"] { left: -6px; top: 50%; width: 10px; height: 22px; margin-top: -11px; cursor: ew-resize; }
.editor-crop-handle-edge[data-handle="r"] { right: -6px; top: 50%; width: 10px; height: 22px; margin-top: -11px; cursor: ew-resize; }
.editor-crop-hint {
  margin: -4px 0 2px;
}
.editor-split-tone-row {
  display: flex;
  align-items: flex-end;
  gap: 12px;
}
.editor-split-tone-swatch-label {
  display: flex;
  flex-direction: column;
  gap: 3px;
  font-size: 0.82rem;
  color: #9a9a9a;
  flex: 0 0 auto;
}
.editor-split-tone-swatch-label input[type="color"] {
  width: 36px;
  height: 28px;
  padding: 0;
  border: 1px solid var(--border);
  border-radius: 4px;
  background: none;
  cursor: pointer;
}
.editor-split-tone-strength {
  flex: 1 1 auto;
}
/* Live "1920 x 1080 (16:9)" badge shown inside the crop box while its
   size/position is meaningfully non-default - positioned at the box's
   own top-left in PERCENT-of-box terms (not the overlay's), so it stays
   glued to the box regardless of where the box currently is. */
.editor-crop-readout {
  position: absolute;
  top: 4px;
  left: 4px;
  background: rgba(0, 0, 0, 0.7);
  color: #fff;
  font-size: 0.72rem;
  font-variant-numeric: tabular-nums;
  padding: 2px 6px;
  border-radius: 4px;
  pointer-events: none;
  white-space: nowrap;
  z-index: 3;
}
.editor-crop-box:focus {
  outline: 2px solid var(--sharp);
  outline-offset: 1px;
}
.editor-panel {
  flex: 1 1 280px;
  min-width: 260px;
  display: flex;
  flex-direction: column;
  gap: 14px;
  /* Adobe Camera Raw's own panel is a fixed dark neutral gray
     regardless of the host app's light/dark theme (real ACR is
     always-dark) - matched here with hardcoded colors rather than
     this app's normal --panel/--bg variables, which DO flip for
     body.theme-light elsewhere and would otherwise fight this
     specific "make the editor look like ACR" ask. */
  background: #262626;
  border: 1px solid #363636;
  border-radius: 9px;
  padding: 14px;
  color: #d8d8d8;
}
/* Collapsible sections (see the `name="editorAccordion"` on each
   <details> in index.html - opening one auto-closes the others in
   browsers that support that native grouping; in older browsers they
   simply behave as independent, ordinary collapsibles instead, which is
   a harmless fallback, not a broken one). Exists specifically to cut
   down how far a person has to scroll to reach a control they're not
   currently using - every group below was previously always fully
   expanded at once.

   Styled as a flat, continuous list with thin dividers between
   sections (ACR's own panel look) rather than the individually
   boxed/rounded cards this used to be. */
.editor-accordion-section {
  border: none;
  border-bottom: 1px solid #3a3a3a;
  border-radius: 0;
  background: transparent;
}
.editor-accordion-section:last-of-type {
  border-bottom: none;
}
.editor-accordion-section > summary {
  list-style: none;
  cursor: pointer;
  padding: 11px 2px;
  font-weight: 600;
  font-size: 0.82rem;
  color: #e8e8e8;
  letter-spacing: 0.1px;
  display: flex;
  align-items: center;
  gap: 8px;
  user-select: none;
}
.editor-accordion-section > summary::-webkit-details-marker {
  display: none;
}
.editor-accordion-section > summary::before {
  content: "▸";
  color: #9a9a9a;
  font-size: 0.7rem;
  transition: transform 0.15s ease;
}
.editor-accordion-section[open] > summary::before {
  transform: rotate(90deg);
}
.editor-accordion-section > summary:hover {
  background: rgba(255, 255, 255, 0.04);
}
.editor-accordion-section > .editor-slider-group,
.editor-accordion-section > .editor-crop-group,
.editor-accordion-section > .editor-local-group {
  border-top: none;
  padding: 2px 2px 16px;
}
.editor-accordion-section > .editor-crop-group + .editor-crop-group {
  border-top: 1px solid #3a3a3a;
  margin-top: 8px;
  padding-top: 14px;
}
.editor-presets-row {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
}
.editor-presets-row .btn {
  font-size: 0.78rem;
  padding: 5px 10px;
}
.editor-presets-select {
  width: 100%;
  background: #232323;
  color: var(--text);
  border: 1px solid #3a3a3a;
  border-radius: 6px;
  padding: 7px 10px;
  font-size: 0.85rem;
  font-family: inherit;
}
/* 2x2 (or fewer) grid of preset preview thumbnails - see
   editorOpenPresetsCompareGrid in app.js. Each cell is clickable to
   apply that preset directly, so this is a "look before you commit"
   step rather than a separate confirmation flow. */
.editor-presets-compare-grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 8px;
  margin-top: 4px;
}
.editor-presets-compare-cell {
  position: relative;
  border: 1px solid #3a3a3a;
  border-radius: 6px;
  overflow: hidden;
  cursor: pointer;
  background: #1a1a1a;
  aspect-ratio: 4 / 3;
}
.editor-presets-compare-cell:hover {
  border-color: #6a6a6a;
}
.editor-presets-compare-cell img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}
.editor-presets-compare-cell .editor-presets-compare-label {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  padding: 4px 6px;
  font-size: 0.72rem;
  font-weight: 600;
  color: #fff;
  background: linear-gradient(to top, rgba(0, 0, 0, 0.75), transparent);
}
.editor-presets-compare-cell .editor-presets-compare-loading {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 0.72rem;
  color: #8a8a8a;
}
.editor-toolbar-row {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
}
.editor-toolbar-row .btn {
  flex: 1 1 auto;
  font-weight: 600;
  font-size: 0.78rem;
  padding: 5px 8px;
}
/* The slim top row now holds just Auto (see the icon rail below for
   everything else that used to live here) - given a bit more room to
   breathe since it's no longer competing with two dozen other buttons
   for space. */
.editor-toolbar-row-top .btn {
  flex: 0 1 auto;
  padding: 7px 16px;
  font-size: 0.85rem;
}
/* Vertical icon-only rail along the right edge of the editor window -
   everything from the old horizontal toolbar EXCEPT Auto (which stays
   as its own labeled button up top). Sticky-positioned the same way
   .editor-preview-wrap already is, so it stays reachable regardless of
   how far the settings panel has scrolled. Icon-only (title attributes
   carry the full descriptive tooltip each button already had) rather
   than a smaller version of the labeled buttons, since ~26 buttons
   with visible text wouldn't fit in a narrow side rail at any
   reasonable width. */
.editor-icon-rail {
  position: sticky;
  top: 0;
  flex: 0 0 auto;
  display: flex;
  flex-direction: column;
  gap: 4px;
  /* Wide enough for a 34px button PLUS a reserved scrollbar gutter
     (scrollbar-gutter below) without the scrollbar overlapping the
     button when the rail's ~27 icons don't all fit in the available
     height and it needs to scroll - confirmed as a real problem at the
     previous, narrower width: a browser's own scrollbar (15-17px in
     most) was landing right on top of the last button in each row
     instead of getting its own space, both covering part of the icon
     and making it unreliable to click. */
  width: 54px;
  max-height: calc(92vh - 48px);
  overflow-y: auto;
  overflow-x: hidden;
  padding: 2px;
  /* Reserves the scrollbar's own space in the layout even before it's
     needed, rather than having it appear on top of content once the
     rail actually overflows - the real fix for the covering-icons
     problem above. Falls back gracefully (no visual regression, just
     no reserved gutter) in a browser old enough not to support this. */
  scrollbar-gutter: stable;
  scrollbar-width: thin; /* Firefox */
}
.editor-icon-rail::-webkit-scrollbar {
  width: 8px;
}
.editor-icon-rail::-webkit-scrollbar-thumb {
  background: #4a4a4e;
  border-radius: 4px;
}
/* Labelled mode. The rail is 25 unlabelled 36px glyphs; knowing which
   one removes an object requires hovering them one at a time, which is
   how a user can have the feature and never find it. Turning names on
   widens the rail and puts the caption beside each icon.

   ::after rather than real text nodes, so the markup stays a plain
   icon button and nothing has to be kept in sync between a label
   element and an aria-label - the caption IS the aria-label's text,
   read from the same data attribute. */
.editor-icon-rail.rail-labels-on {
  width: auto;
  min-width: 168px;
}
.editor-icon-rail.rail-labels-on .btn {
  width: 100%;
  justify-content: flex-start;
  gap: 8px;
  padding: 0 10px;
}
.editor-icon-rail.rail-labels-on .btn[data-rail-label]::after {
  content: attr(data-rail-label);
  font-size: 0.78rem;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.editor-rail-labels-btn {
  font-size: 0.68rem;
  white-space: nowrap;
  padding: 2px 4px;
}

.editor-icon-rail .btn {
  width: 36px;
  height: 36px;
  min-width: 36px;
  padding: 0;
  font-size: 1rem;
  display: flex;
  align-items: center;
  justify-content: center;
  /* Defensive against ANY font/glyph-width edge case (a symbol
     rendering wider than expected on a given OS/browser's font
     fallback, or with an unexpected width due to a variation
     selector) spilling text outside the button's own box and visually
     overlapping the next icon down - confirmed as a real, reported
     problem, not just a theoretical one. Forces single-line, clipped
     rendering no matter what. */
  overflow: hidden;
  white-space: nowrap;
  text-overflow: clip;
  flex: 0 0 auto;
}
.editor-icon-rail-select {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 2px;
  font-size: 0.9rem;
  padding: 4px 0;
}
.editor-icon-rail-select select {
  width: 46px;
  font-size: 0.6rem;
  background: #232323;
  color: var(--text);
  border: 1px solid #3a3a3a;
  border-radius: 4px;
  padding: 2px 0;
}
/* Narrow screens (see the mobile breakpoint elsewhere in this file for
   the exact width) - a wide sticky side rail eats too much of an
   already-tight preview area on a phone, so it drops to flowing
   normally (same as it would with no sticky positioning) instead of
   pinning itself to a scroll position that doesn't make sense at that
   size. */
@media (max-width: 720px) {
  .editor-icon-rail {
    position: static;
    flex-direction: row;
    flex-wrap: wrap;
    width: 100%;
    max-height: none;
  }
}
.editor-colorblind-label {
  display: flex;
  align-items: center;
  gap: 4px;
  font-size: 0.78rem;
  color: #9a9a9a;
  border: 1px solid #3a3a3a;
  border-radius: 6px;
  padding: 5px 8px;
}
.editor-colorblind-label select {
  background: #232323;
  color: var(--text);
  border: 1px solid #3a3a3a;
  border-radius: 4px;
  font-size: 0.76rem;
  font-family: inherit;
}
/* ACR keeps its histogram pinned at the very top of the panel, always
   visible (see editorHistogramVisible's new default of true in app.js)
   - the toggle link is kept for anyone who'd rather reclaim the
   vertical space, but styled small and secondary since it's no longer
   the primary way most people will encounter this control. */
.editor-histogram-row {
  display: flex;
  flex-direction: column;
  gap: 4px;
}
.editor-histogram-row .link-btn {
  align-self: flex-end;
  font-size: 0.72rem;
  opacity: 0.7;
}
.editor-histogram-canvas {
  width: 100%;
  height: 90px;
  background: #1a1a1a;
  border: 1px solid #3a3a3a;
  border-radius: 4px;
}
.editor-tone-curve-canvas {
  width: 100%;
  height: 180px;
  background: #0d0e10;
  border: 1px solid var(--border);
  border-radius: 6px;
  cursor: crosshair;
  touch-action: none;
}
.editor-tone-curve-channel-tabs {
  display: flex;
  gap: 4px;
  margin-bottom: 6px;
}
.editor-tone-curve-channel-tabs .btn {
  flex: 1 1 0;
  font-size: 0.76rem;
  padding: 4px 6px;
}
/* Clipping-warning overlay - sits directly on top of editorPreviewImg,
   positioned/sized identically to editorCropOverlay (see
   editorPositionClippingCanvas in app.js, which reuses the same
   offsetLeft/Top/Width/Height approach). pointer-events:none since this
   is a pure visual indicator, never something to click or drag - it
   should never intercept a click meant for local-adjustment placement
   or the crop box underneath/above it. */
.editor-clipping-canvas {
  position: absolute;
  pointer-events: none;
  z-index: 1;
}
/* GPU (WebGL) instant-feedback layer - sits directly on top of
   editorPreviewImg but BELOW the crop overlay/clipping/magnifier (which
   is why this has no z-index at all, unlike .editor-clipping-canvas
   above: default stacking by DOM order already puts it above the plain
   <img> and below every overlay that follows it in the markup, without
   needing to out-rank .editor-clipping-canvas's z-index:1). Shown only
   while editorGpuEligible() is true and a slider is actively being
   dragged - see editorRequestPreview/editorGpuRender in app.js - and
   hidden again the instant the authoritative server-rendered frame
   lands, so what's on screen between drags is always the exact
   backend-verified pixels, never the GPU approximation.  */
.editor-gpu-canvas {
  position: absolute;
  pointer-events: none;
}
/* Temporary click markers for the two-point straighten tool (see
   editorTwoPointStraightenBtn in app.js) - removed as soon as the
   second point is clicked and the angle is computed, never a
   persistent part of the editor's UI. */
.editor-straighten-point {
  position: absolute;
  width: 10px;
  height: 10px;
  margin-left: -5px;
  margin-top: -5px;
  border-radius: 50%;
  background: #4fd1c5;
  border: 2px solid #0d0e10;
  pointer-events: none;
  z-index: 4;
}
.editor-straighten-line {
  position: absolute;
  height: 2px;
  background: #4fd1c5;
  transform-origin: 0 50%;
  pointer-events: none;
  z-index: 3;
}
/* Clickable dust-spot suggestion markers (see editorDustScanBtn in
   app.js) - a dashed circle rather than a filled dot, since these sit
   ON TOP of the actual candidate spot and shouldn't hide it from view
   while someone's deciding whether to accept it. */
.editor-dust-spot-marker {
  position: absolute;
  transform: translate(-50%, -50%);
  border: 2px dashed #ffcc4f;
  border-radius: 50%;
  background: rgba(255, 204, 79, 0.15);
  cursor: pointer;
  z-index: 4;
}
.editor-dust-spot-marker:hover {
  background: rgba(255, 204, 79, 0.35);
  border-color: #fff;
}
/* Placed content-aware removal markers - solid (not dashed) since these
   represent an ALREADY-APPLIED removal, not a pending suggestion to
   review the way dust-spot markers are. */
.editor-content-aware-marker {
  position: absolute;
  transform: translate(-50%, -50%);
  border: 2px solid #c77dff;
  border-radius: 50%;
  background: rgba(199, 125, 255, 0.15);
  cursor: pointer;
  z-index: 4;
}
.editor-content-aware-marker:hover {
  background: rgba(199, 125, 255, 0.35);
  border-color: #fff;
}
/* Small floating local-histogram readout that follows the cursor while
   the Magnifier tool is active (see editorUpdateCursorHistogram in
   app.js) - same positioning/clamping idea as the magnifier loupe
   itself, just a plain box instead of a circle. */
.editor-cursor-histogram {
  position: absolute;
  pointer-events: none;
  z-index: 6;
  background: rgba(10, 10, 10, 0.85);
  border: 1px solid #3a3a3a;
  border-radius: 4px;
  padding: 4px;
}
.editor-cursor-histogram canvas {
  display: block;
}
.editor-history-panel {
  display: flex;
  flex-direction: column;
  gap: 6px;
  max-height: 220px;
  overflow-y: auto;
  padding: 8px;
  border: 1px solid var(--border);
  border-radius: 6px;
  background: var(--panel-bg, rgba(0, 0, 0, 0.15));
}
.editor-history-entry {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 0.78rem;
  color: #9a9a9a;
}
.editor-history-entry img {
  width: 40px;
  height: 40px;
  object-fit: cover;
  border-radius: 4px;
  flex-shrink: 0;
}
.editor-history-entry .editor-history-meta {
  flex: 1 1 auto;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.editor-history-entry button {
  flex-shrink: 0;
}
.editor-crop-group,
.editor-local-group {
  display: flex;
  flex-direction: column;
  gap: 8px;
  padding: 10px 0;
  border-top: 1px solid var(--border);
}
.editor-crop-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  font-size: 0.82rem;
  color: #e8e8e8;
  font-weight: 600;
}
.editor-crop-summary-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
}
.editor-crop-edit-panel {
  display: flex;
  flex-direction: column;
  gap: 8px;
}
.editor-crop-edit-actions {
  display: flex;
  gap: 6px;
  margin-top: 4px;
}
.editor-crop-edit-actions .link-btn {
  margin-right: auto;
}
.editor-local-toolbar-row {
  display: flex;
  gap: 6px;
}
.editor-local-toolbar-row .btn {
  flex: 1 1 auto;
  font-size: 0.78rem;
  padding: 5px 8px;
}
.editor-local-list {
  display: flex;
  flex-direction: column;
  gap: 8px;
}
.editor-local-spot {
  display: flex;
  flex-direction: column;
  gap: 4px;
  padding: 8px;
  border: 1px solid var(--border);
  border-radius: 6px;
  font-size: 0.78rem;
}
.editor-local-spot-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  color: #9a9a9a;
}
.editor-local-spot-header button {
  background: none;
  border: none;
  color: #9a9a9a;
  cursor: pointer;
  font-size: 0.85rem;
}
.editor-local-spot-header button:hover { color: var(--reject, #e05); }
.editor-local-spot-header button.editor-local-duplicate-btn:hover { color: var(--sharp); }
.editor-local-spot label {
  display: flex;
  align-items: center;
  gap: 6px;
  color: #9a9a9a;
}
.editor-local-spot input[type="range"] {
  flex: 1 1 auto;
}
.editor-denoise-row {
  display: flex;
  flex-direction: column;
  gap: 6px;
  padding: 8px 0;
  border-top: 1px solid var(--border);
  border-bottom: 1px solid var(--border);
}
.editor-species-row {
  display: flex;
  align-items: center;
  gap: 8px;
  flex-wrap: wrap;
  margin: 8px 0;
}
.editor-species-row input[type="text"] {
  flex: 1 1 180px;
  min-width: 140px;
}
.editor-slider-group {
  display: flex;
  flex-direction: column;
  gap: 14px;
}
/* ACR-style layout: label text top-left, numeric value top-right, the
   slider itself spanning the full width on its own row underneath -
   achieved with a 2-column/2-row grid rather than the flex-column
   stack this used to be (which put the label, the value box, AND the
   slider each on their own separate line - readable, but not what "an
   Adobe Camera Raw-style editor panel" was asked for). The label's
   OWN text content is a bare text node, not a wrapped span - CSS Grid
   still auto-places a bare text run as its own anonymous grid item, so
   this doesn't need any HTML restructuring across the ~20 sliders that
   use it. */
.editor-slider-label {
  display: grid;
  grid-template-columns: 1fr auto;
  align-items: center;
  column-gap: 10px;
  row-gap: 6px;
  font-size: 0.8rem;
  font-weight: 500;
  color: #9a9a9a;
  position: relative;
}
.editor-slider-label span {
  color: var(--text);
  font-variant-numeric: tabular-nums;
}
.editor-slider-val-input {
  grid-column: 2;
  grid-row: 1;
  justify-self: end;
  width: 50px;
  background: #232323;
  color: var(--text);
  border: 1px solid #3a3a3a;
  border-radius: 3px;
  padding: 2px 5px;
  font-variant-numeric: tabular-nums;
  font-size: 0.8rem;
  font-family: inherit;
  text-align: right;
}
.editor-slider-label input[type="range"] {
  grid-column: 1 / -1;
  grid-row: 2;
  width: 100%;
}
/* A subtle centered zero-tick under sliders that run -100..100 around
   a true middle (see the editor-slider-bipolar class added alongside
   each of these inputs in index.html) - ACR marks its own bipolar
   sliders' zero point the same way. Approximate rather than pixel-
   exact: a native <input type="range">'s clickable track is inset
   slightly from the element's own edges by half the thumb's width on
   each side, and that inset isn't something CSS can query - so this
   sits at the geometric center of the row, which is extremely close
   but not mathematically guaranteed identical to where 0 actually
   lands. Purely decorative (pointer-events:none), never affects
   interaction. */
.editor-slider-bipolar::after {
  content: "";
  position: absolute;
  left: 50%;
  bottom: 1px;
  width: 1px;
  height: 6px;
  background: #55555a;
  pointer-events: none;
}
/* Reset to a thin, neutral ACR-style track instead of the browser's
   default thick, colored-fill progress-bar look - no left/right fill
   distinction, just an even track and a small flat handle, matching
   every slider in the reference screenshot this whole section is
   based on. */
.editor-slider-label input[type="range"] {
  -webkit-appearance: none;
  appearance: none;
  background: transparent;
  height: 14px;
  cursor: pointer;
}
.editor-slider-label input[type="range"]::-webkit-slider-runnable-track {
  height: 3px;
  background: #4a4a4e;
  border-radius: 2px;
}
.editor-slider-label input[type="range"]::-webkit-slider-thumb {
  -webkit-appearance: none;
  margin-top: -5px;
  width: 12px;
  height: 12px;
  border-radius: 50%;
  background: #e8e8e8;
  border: none;
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
}
.editor-slider-label input[type="range"]::-moz-range-track {
  height: 3px;
  background: #4a4a4e;
  border-radius: 2px;
}
.editor-slider-label input[type="range"]::-moz-range-thumb {
  width: 12px;
  height: 12px;
  border-radius: 50%;
  background: #e8e8e8;
  border: none;
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
}
.editor-slider-label input[type="range"]:hover::-webkit-slider-thumb,
.editor-slider-label input[type="range"]:focus::-webkit-slider-thumb {
  background: #fff;
}
.editor-export-row {
  display: flex;
  flex-direction: column;
  gap: 8px;
  padding-top: 8px;
  border-top: 1px solid var(--border);
}
.editor-format-label {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 0.82rem;
  color: #9a9a9a;
}
.editor-export-checkbox-label {
  display: flex;
  align-items: center;
  gap: 6px;
  font-size: 0.82rem;
  color: #9a9a9a;
  cursor: pointer;
}
.editor-extra-sizes-fieldset {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  border: 1px solid #3a3a3a;
  border-radius: 6px;
  padding: 8px 10px;
  margin: 0;
}
.editor-extra-sizes-fieldset legend {
  padding: 0 4px;
  font-size: 0.76rem;
}
.editor-export-row input[type="text"] {
  width: 100%;
}

/* ---------- health panel ---------- */

.health-content { font-size: 0.85rem; }
.health-section-title {
  margin: 18px 0 6px;
  font-size: 0.8rem;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--text-dim);
  border-bottom: 1px solid var(--border);
  padding-bottom: 4px;
}
.health-section-title:first-child { margin-top: 0; }
.health-row {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 3px 0;
  font-family: var(--font-mono);
}
.health-dot {
  width: 7px;
  height: 7px;
  border-radius: 50%;
  flex-shrink: 0;
}
.health-dot-ok { background: var(--keep); }
.health-dot-warn { background: var(--soft); }
.health-label { color: var(--text-dim); }
.health-value { color: var(--text); text-align: right; margin-left: auto; }
.health-row.warn .health-value { color: var(--soft); }

/* ---------- activity log timeline ---------- */

.activity-timeline {
  position: relative;
  padding-left: 4px;
}
.activity-item {
  position: relative;
  display: flex;
  gap: 12px;
  padding: 8px 0 8px 20px;
}
/* The connecting vertical line, drawn per-item rather than once on the
   container - each segment only needs to span this row's own height,
   and the first/last item's segment is shortened to start/end at its
   own dot instead of overshooting past the first and last markers into
   empty space above/below the whole list. */
.activity-item::before {
  content: "";
  position: absolute;
  left: 4px;
  top: 0;
  bottom: 0;
  width: 1px;
  background: var(--border);
}
.activity-item:first-child::before { top: 50%; }
.activity-item:last-child::before { bottom: 50%; }
.activity-dot {
  position: absolute;
  left: 0;
  top: 18px;
  width: 9px;
  height: 9px;
  border-radius: 50%;
  background: var(--sharp);
  /* A ring in the panel's own background color around the dot - visually
     "breaks" the connecting line where it passes behind the dot, so the
     line reads as passing THROUGH markers rather than the dot floating
     disconnected on top of it. */
  box-shadow: 0 0 0 3px var(--panel);
  z-index: 1;
  flex-shrink: 0;
}
.activity-item-content { padding-top: 0; }
.activity-when {
  font-family: var(--font-mono);
  font-size: 0.72rem;
  color: var(--text-dim);
  margin-bottom: 2px;
}
.activity-detail {
  font-size: 0.85rem;
  color: var(--text);
}

/* ---------- calendar view ---------- */

.calendar-nav {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 16px;
  margin-bottom: 12px;
}
.calendar-month-label {
  font-family: var(--font-mono);
  font-weight: 600;
  min-width: 160px;
  text-align: center;
}
.calendar-grid {
  display: grid;
  grid-template-columns: repeat(7, 1fr);
  gap: 3px;
  margin-bottom: 12px;
}
.calendar-day-head {
  text-align: center;
  font-size: 0.68rem;
  color: var(--text-dim);
  font-family: var(--font-mono);
  padding: 4px 0;
}
.calendar-day-cell {
  aspect-ratio: 1;
  border-radius: 6px;
  background: var(--wash-1);
  padding: 4px;
  display: flex;
  flex-direction: column;
}
.calendar-day-cell.today { box-shadow: inset 0 0 0 1px var(--sharp); }
.calendar-day-num {
  font-size: 0.72rem;
  font-family: var(--font-mono);
  color: var(--text-dim);
}
.calendar-day-dots {
  display: flex;
  gap: 2px;
  flex-wrap: wrap;
  margin-top: auto;
}
.calendar-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
.calendar-dot-shoot { background: var(--sharp); }
.calendar-dot-deadline { background: var(--soft); }
.calendar-legend {
  display: flex;
  gap: 16px;
  font-size: 0.75rem;
  color: var(--text-dim);
  margin-bottom: 10px;
}
.calendar-legend-item { display: flex; align-items: center; gap: 5px; }
.calendar-legend-item .calendar-dot { width: 8px; height: 8px; }
.health-email-log {
  margin-top: 6px;
  max-height: 180px;
  overflow-y: auto;
  font-family: var(--font-mono);
  font-size: 0.75rem;
  color: var(--text-dim);
}
.health-email-row { padding: 3px 0; border-bottom: 1px solid var(--border); }
.health-ok { color: var(--keep); }
.health-fail { color: var(--soft); }

.health-error-row {
  font-family: var(--font-mono);
  font-size: 0.75rem;
  color: var(--reject, #e05);
  border-bottom: 1px solid var(--border);
  padding: 4px 0;
}
.health-error-row summary {
  cursor: pointer;
  color: var(--text-dim);
}
.health-error-row summary:hover { color: var(--text); }
.health-error-traceback {
  margin: 6px 0 2px;
  padding: 8px;
  background: var(--panel);
  border-radius: 6px;
  font-size: 0.72rem;
  color: var(--text-dim);
  white-space: pre-wrap;
  word-break: break-word;
  max-height: 240px;
  overflow-y: auto;
}

.users-modal-inner {
  padding: 24px;
  max-width: min(560px, 92vw);
  overflow-y: auto;
  overflow-x: auto;
}
.users-modal-title { margin: 0 0 8px; font-size: 1.1rem; }

/* ---------- keyboard shortcuts panel ---------- */

.shortcuts-list {
  display: flex;
  flex-direction: column;
  gap: 14px;
}
.shortcuts-group .folder-heading {
  margin: 0 0 6px;
  padding: 0;
  border-bottom: none;
  font-size: 0.85rem;
  color: var(--sharp);
}
.shortcut-row {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 4px 0;
  font-size: 0.85rem;
}
.shortcut-row span {
  color: var(--text-dim);
}
kbd {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 22px;
  height: 22px;
  padding: 0 6px;
  border-radius: 5px;
  border: 1px solid var(--border);
  border-bottom-width: 2px;
  background: var(--panel-hover);
  font-family: var(--font-mono);
  font-size: 0.78rem;
  color: var(--text);
  box-shadow: 0 1px 0 rgba(0, 0, 0, 0.3);
}
/* Every admin data table (Manage users, Clients, Print sizes/orders,
   Storage, Portfolios, gear stats...) shares the .users-table class and
   can run wider than its modal on a phone. The modal itself already
   scrolls horizontally, but nothing on screen hinted there was more to
   the right - the action-buttons column could end up entirely
   off-screen with zero indication a user needed to scroll for it. Each
   table gets wrapped in this at load time (see app.js) so a soft fade
   appears on whichever edge still has more content, and disappears on
   its own once you've scrolled all the way - the classic pure-CSS
   "scroll shadow" trick (two background gradients that scroll with the
   content to hide the shadow, two that don't, to draw it), no JS
   scroll-position tracking needed. */
.table-scroll {
  overflow-x: auto;
  /* Every modal that wraps a table this way lives inside .lightbox-inner,
     a flex COLUMN. Giving a flex item any overflow other than `visible`
     (overflow-x: auto counts) silently resets its browser-computed
     minimum height to 0 per the flexbox spec - normally a flex item's
     floor is its content's natural size, but an overflow container's
     floor becomes 0, making it fair game to shrink toward nothing.
     On a short modal that never matters (there's room for everything),
     but on a long one (Clients, with its big multi-fieldset create-
     client form and upload sections below the table), the browser will
     happily crush this one shrinkable item down to near-zero height to
     fit everything else in, since nothing tells it not to - the table
     ends up with real rows in the DOM that are simply never visible.
     flex-shrink: 0 pins this wrapper to its natural content height
     always, so a data table never silently disappears regardless of
     how much other content shares its modal - it's the LIGHTBOX that's
     meant to scroll (see .lightbox-inner's own overflow-y: auto) when
     everything together doesn't fit, not this. */
  flex-shrink: 0;
  background:
    linear-gradient(to right, var(--panel) 30%, rgba(255, 255, 255, 0)),
    linear-gradient(to right, rgba(255, 255, 255, 0), var(--panel) 70%) 100% 0,
    linear-gradient(to right, rgba(0, 0, 0, 0.35), rgba(0, 0, 0, 0)),
    linear-gradient(to left, rgba(0, 0, 0, 0.35), rgba(0, 0, 0, 0)) 100% 0;
  background-repeat: no-repeat;
  background-color: var(--panel);
  background-size: 24px 100%, 24px 100%, 10px 100%, 10px 100%;
  background-attachment: local, local, scroll, scroll;
}

.users-table {
  width: 100%;
  border-collapse: collapse;
  margin: 14px 0;
  font-size: 0.85rem;
}
.users-table th {
  text-align: left;
  color: var(--text-dim, #9aa0a6);
  font-weight: 500;
  padding: 6px 8px;
  border-bottom: 1px solid var(--border);
}
.users-table td {
  padding: 6px 8px;
  border-bottom: 1px solid var(--border);
  font-family: var(--font-mono);
}
/* Zebra striping + row hover - a cheap, generic win applied once here
   rather than per-table, same reasoning as .table-scroll above: all 11
   admin tables share this class, so one rule covers Manage users,
   Clients, Print sizes/orders, Storage, Portfolios, gear stats, etc. at
   once. Uses --wash-1 (the same faint tint already used for the app's
   ambient background washes) rather than a new gray, so a dense data
   table reads as "this app's chrome" instead of a bootstrap-default
   table dropped in on top of it. Hover uses --panel-hover, which every
   other hoverable surface in the app already draws from.
   :nth-child on tr rather than on td keeps zebra rows from also
   double-striping any table that later grows an internal nested table
   or a colspan'd expando row - it targets whole rows structurally,
   not every cell independently. */
.users-table tbody tr:nth-child(even) { background: var(--wash-1); }
.users-table tbody tr:hover { background: var(--panel-hover); }
.users-table .role-admin { color: var(--sharp); }

/* ---------- gear stats inline bars ---------- */

.gear-bar-cell {
  position: relative;
  display: flex;
  align-items: center;
  min-width: 70px;
  height: 18px;
}
.gear-bar-fill {
  position: absolute;
  left: 0;
  top: 0;
  bottom: 0;
  background: var(--wash-1);
  border-right: 2px solid var(--sharp);
  border-radius: 2px;
  transition: width 0.4s ease;
}
.gear-bar-cell span {
  position: relative;
  z-index: 1;
  padding-left: 6px;
  font-family: var(--font-mono);
}
.users-table td:last-child {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  align-items: center;
}
.users-table .delete-user-btn {
  background: none;
  border: none;
  color: var(--soft);
  cursor: pointer;
  font-size: 0.85rem;
  margin-left: 10px;
}
.users-table .delete-user-btn:first-child { margin-left: 0; }
.users-table .delete-user-btn:disabled { opacity: 0.35; cursor: default; }
.users-table .reset-password-btn {
  background: none;
  border: none;
  color: var(--mid);
  cursor: pointer;
  font-size: 0.85rem;
}

/* ---------- print cart drag-to-reorder (client gallery) ---------- */

.cart-drag-handle {
  cursor: grab;
  text-align: center;
  color: var(--text-dim);
  width: 24px;
  user-select: none;
  font-size: 1rem;
}
.cart-row {
  transition: opacity 0.15s ease, background 0.15s ease;
}
.cart-row-dragging { opacity: 0.4; }
.cart-row-dragover {
  /* A visible insertion line at whichever row the dragged item is
     currently hovering over - without this, dropping felt like a
     guessing game (no feedback at all about where the row would land
     until after releasing). */
  box-shadow: inset 0 2px 0 var(--sharp);
}

.create-user-form {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  align-items: center;
  margin-top: 10px;
}
.create-user-form input[type="text"],
.create-user-form input[type="password"],
.create-user-form input[type="email"] {
  font-family: var(--font-mono);
  padding: 8px 10px;
  border-radius: 6px;
  border: 1px solid var(--border);
  background: var(--panel);
  color: var(--text);
  flex: 1 1 140px;
}
.client-welcome-input {
  font-family: var(--font-mono);
  font-size: 0.82rem;
  padding: 8px 10px;
  border-radius: 6px;
  border: 1px solid var(--border);
  background: var(--panel);
  color: var(--text);
  width: 100%;
  flex: 1 1 100%;
  resize: vertical;
}
.upload-section select {
  font-family: var(--font-mono);
  font-size: 0.82rem;
  padding: 8px 10px;
  border-radius: 6px;
  border: 1px solid var(--border);
  background: var(--panel);
  color: var(--text);
}
.admin-checkbox:not([hidden]) {
  display: flex;
  align-items: center;
  gap: 5px;
  font-size: 0.82rem;
  color: var(--text-dim, #9aa0a6);
}

/* ---------- upload modal ---------- */

.upload-section {
  margin-top: 18px;
  padding-top: 16px;
  border-top: 1px solid var(--border);
  display: flex;
  flex-direction: column;
  gap: 8px;
}
.upload-section:first-of-type { margin-top: 8px; }
.upload-section-title { margin: 0; font-size: 0.95rem; }
.upload-section input[type="file"],
.upload-section input[type="text"] {
  font-family: var(--font-mono);
  font-size: 0.82rem;
  padding: 8px 10px;
  border-radius: 6px;
  border: 1px solid var(--border);
  background: var(--panel);
  color: var(--text);
}
.upload-progress {
  font-family: var(--font-mono);
  font-size: 0.8rem;
  color: var(--text-dim, #9aa0a6);
}
.upload-progress[hidden] { display: none; }
.upload-progress-text { margin-bottom: 6px; }
.upload-progress-bar {
  height: 5px;
  border-radius: 999px;
  background: var(--border);
  overflow: hidden;
}
.upload-progress-fill {
  height: 100%;
  background: var(--sharp);
  border-radius: 999px;
  /* Width is set inline per-file (see doUpload in app.js) - this just
     animates the jump between one file's progress and the next, rather
     than the fill snapping instantly from e.g. 20% to 30%. */
  transition: width 0.3s ease;
}

/* ---------- metadata panel ---------- */

.metadata-panel {
  margin-top: 10px;
  padding: 12px 14px;
  background: rgba(12, 13, 15, 0.6);
  border: 1px solid var(--border);
  border-radius: 8px;
  font-family: var(--font-mono);
  font-size: 0.8rem;
  max-height: 220px;
  overflow-y: auto;
}
.metadata-panel[hidden] { display: none; }
.metadata-panel dl {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: 4px 14px;
  margin: 0;
}
.metadata-panel dt { color: var(--text-dim, #9aa0a6); }
.metadata-panel dd { margin: 0; color: var(--text); }
.metadata-panel p { margin: 0; color: var(--text-dim, #9aa0a6); }

/* ---------- lightbox GPS map panel ---------- */
/* Same shell (border/radius/spacing) as .metadata-panel above so the two
   toggleable panels feel like one family, just with a fixed-height map
   instead of a metadata list. */

.gps-map-panel {
  margin-top: 10px;
  padding: 10px;
  background: rgba(12, 13, 15, 0.6);
  border: 1px solid var(--border);
  border-radius: 8px;
}
.gps-map-panel[hidden] { display: none; }
.gps-map-el {
  height: 220px;
  border-radius: 6px;
  overflow: hidden;
  /* Leaflet's own default background, so the panel doesn't flash the
     app's dark background through the gaps while tiles are still
     loading in. */
  background: #ddd;
}
.gps-map-el[hidden] { display: none; }
.gps-map-empty { padding: 8px 2px; }
.gps-map-coords {
  margin-top: 8px;
  font-family: var(--font-mono);
  font-size: 0.78rem;
  color: var(--text-dim);
  text-align: center;
}
.gps-map-coords:empty { display: none; }
.gps-edit-row {
  display: flex;
  gap: 12px;
  margin-top: 6px;
}
.gps-edit-form {
  display: flex;
  flex-direction: column;
  gap: 6px;
  margin-top: 6px;
}
.gps-edit-form input[type="text"] {
  width: 100%;
}

/* ---------- custom Leaflet map pin (both the lightbox GPS map and the
   client shoot-location map use this - see createMapPinIcon in app.js) ---------- */

/* Leaflet's own stylesheet puts a background/border box on every
   divIcon by default (it's meant for arbitrary custom marker HTML in
   general, so it can't assume the content fills/replaces that box
   itself) - left alone, that's just a different box around the pin
   than the original broken-image one, not a fix. Our own marker
   content below fully replaces it, so the box itself is switched off
   here. */
.map-pin-icon-wrap {
  background: transparent !important;
  border: none !important;
}
.map-pin {
  position: relative;
  display: block;
  width: 20px;
  height: 20px;
}
.map-pin-dot {
  position: absolute;
  top: 50%; left: 50%;
  transform: translate(-50%, -50%);
  width: 14px; height: 14px;
  border-radius: 50%;
  background: var(--map-pin-color, var(--sharp));
  border: 2px solid var(--panel);
  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.5);
}
/* Only used on the lightbox's single-photo GPS map, never on the client
   shoot-location map (which can carry up to ~400 pins at once - that
   many independently-animating rings would be noise, not polish; see
   the two createMapPinIcon() call sites in app.js). */
.map-pin-pulse {
  position: absolute;
  inset: 0;
  border-radius: 50%;
  background: var(--map-pin-color, var(--sharp));
  animation: map-pin-pulse-out 2s ease-out infinite;
}
@keyframes map-pin-pulse-out {
  from { transform: scale(0.5); opacity: 0.55; }
  to { transform: scale(1.9); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
  .map-pin-pulse { animation: none; opacity: 0; }
}

/* ---------- back to top ---------- */

.back-to-top {
  position: fixed;
  right: 22px;
  bottom: 22px;
  width: 44px;
  height: 44px;
  border-radius: 50%;
  background: var(--panel);
  border: 1px solid var(--border);
  color: var(--text);
  font-size: 1.2rem;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.4);
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.15s ease, transform 0.15s ease, border-color 0.15s ease;
  z-index: 40;
}
.back-to-top.visible { opacity: 1; pointer-events: auto; }
.back-to-top:hover { border-color: var(--sharp); transform: translateY(-2px); }

/* ---------- admin: print order full-resolution preview/print ---------- */
.print-full-modal-inner {
  max-width: min(94vw, 1200px);
  width: 100%;
  display: flex;
  flex-direction: column;
  gap: 10px;
  padding: 18px;
}
.print-full-meta { text-align: center; }
.print-full-img-wrap {
  max-height: 76vh;
  overflow: auto;
  display: flex;
  align-items: center;
  justify-content: center;
  background: #0d0e10;
  border: 1px solid var(--border);
  border-radius: 6px;
}
.print-full-img-wrap img { max-width: 100%; max-height: 76vh; display: block; }
.print-full-actions { display: flex; gap: 10px; justify-content: center; }

.order-item-crop-badge {
  font-family: var(--font-mono);
  font-size: 10px;
  color: var(--sharp);
  border: 1px solid var(--sharp);
  border-radius: 3px;
  padding: 1px 5px;
  margin-left: 6px;
}

/* When the browser's native print (Ctrl+P or the Print button below,
   which calls window.print()) fires, hide everything except the
   full-res image itself - the point is a clean print straight from the
   admin panel, not a screenshot of the whole app chrome. */
@media print {
  body * { visibility: hidden; }
  #printFullModal, #printFullModal *, #fulfillmentQueueModal, #fulfillmentQueueModal * { visibility: visible; }
  #printFullModal, #fulfillmentQueueModal {
    position: fixed;
    inset: 0;
    background: #fff;
  }
  #printFullModal .lightbox-close,
  #printFullModal .print-full-meta,
  #printFullModal .print-full-actions,
  #printFullModal .print-crop-editor,
  #fulfillmentQueueModal .lightbox-close,
  #fulfillmentQueueModal .users-modal-title,
  #fulfillmentQueueModal .fulfillment-queue-nav,
  #fulfillmentQueueModal .fulfillment-queue-meta,
  #fulfillmentQueueModal .print-full-actions {
    display: none !important;
  }
  .print-full-img-wrap, .fulfillment-queue-img-wrap {
    max-height: none;
    overflow: visible;
    border: none;
    background: #fff;
  }
  .print-full-img-wrap img, .fulfillment-queue-img-wrap img {
    max-width: 100%;
    max-height: 100vh;
    width: 100%;
    height: auto;
  }
}

/* ---------- client: bulk print-order selection ---------- */
.card.select-mode { cursor: default; }
.card.select-mode img { outline: 2px solid transparent; transition: outline-color 0.12s ease; }
.card.selected-for-print img { outline: 3px solid var(--sharp); }
.card-select-check {
  position: absolute;
  top: 8px;
  left: 8px;
  width: 26px;
  height: 26px;
  border-radius: 50%;
  border: 2px solid #fff;
  background: rgba(0, 0, 0, 0.45);
  color: #fff;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 14px;
  z-index: 2;
}
.card.selected-for-print .card-select-check { background: var(--sharp); border-color: var(--sharp); }

.bulk-print-bar:not([hidden]) {
  position: fixed;
  bottom: 22px;
  left: 50%;
  transform: translateX(-50%);
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 10px 16px;
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: 10px;
  box-shadow: var(--shadow-lift);
  z-index: 50;
}

/* ---------- client: crop re-edit on My Orders ---------- */
.order-item-line { display: flex; align-items: center; justify-content: space-between; gap: 8px; }

/* ---------- admin: crop editor inside the full-res preview modal ---------- */
.print-crop-editor:not([hidden]) { display: flex; flex-direction: column; align-items: center; gap: 10px; margin-top: 8px; }
.print-crop-editor-box { max-width: 320px; aspect-ratio: 1 / 1; cursor: grab; }
.print-crop-editor-box:active { cursor: grabbing; }
.print-crop-editor-box img { width: 100%; height: 100%; object-fit: cover; display: block; user-select: none; -webkit-user-drag: none; }

/* ---------- admin: fulfillment queue ---------- */
.fulfillment-queue-nav { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 10px; }
.fulfillment-queue-img-wrap {
  max-height: 60vh;
  display: flex;
  align-items: center;
  justify-content: center;
  background: #0d0e10;
  border: 1px solid var(--border);
  border-radius: 6px;
  overflow: hidden;
}
.fulfillment-queue-img-wrap img { max-width: 100%; max-height: 60vh; }
.fulfillment-queue-meta { text-align: center; margin: 8px 0; }

/* ---------- admin: known-face auto-suggestions ---------- */
.face-label-suggested { border-color: var(--mid) !important; color: var(--mid); font-style: italic; }
.face-suggested-badge { color: var(--mid); border-color: var(--mid); }

/* ---------- client: gallery cover banner ---------- */
.cover-banner {
  width: 100%;
  max-height: 320px;
  overflow: hidden;
  border-radius: 8px;
  margin-bottom: 16px;
  border: 1px solid var(--border);
}
.cover-banner img { width: 100%; height: 320px; object-fit: cover; display: block; }
.card-cover-btn { position: absolute; top: 8px; right: 46px; z-index: 5; font-size: 1rem; }
.card-cover-btn.active { background: var(--sharp); color: #0d0e10; }

/* ---------- client: download-all progress + resume banners ---------- */
/* :not([hidden]) matters here - a bare `display: flex` outranks the
   browser's built-in `[hidden] { display: none }`, so the banner stays
   on screen even after JS sets .hidden = true. That's what made the
   "Download all may not have finished" banner impossible to dismiss:
   the Dismiss handler was firing and clearing the pending flag
   correctly, but the banner itself never went away. Same class of bug
   previously fixed on .grid and .controls/.sort-group. */
.download-resume-banner:not([hidden]),
.download-progress-banner:not([hidden]) {
  display: flex;
  align-items: center;
  gap: 12px;
  flex-wrap: wrap;
}
.download-progress-track {
  flex: 1 1 160px;
  height: 8px;
  border-radius: 4px;
  background: var(--border);
  overflow: hidden;
  min-width: 120px;
}
.download-progress-fill {
  height: 100%;
  background: var(--sharp);
  width: 0%;
  transition: width 0.15s ease;
}

/* ---------- admin: quick search ---------- */
.admin-search-wrap {
  position: relative;
  flex: 0 0 auto;
  margin: 0 10px;
  display: flex;
  align-items: center;
}
/* Collapsed by default (see adminSearchToggleBtn in app.js) - a
   magnifying-glass icon rather than a permanently-open text field
   taking up ~320px of topbar space whether or not anyone's using it.
   Expanding is a width transition on the wrap itself (not just
   swapping which child is visible) so it reads as the icon "opening
   up" into the field, matching how this was actually asked for,
   rather than an instant swap. */
.admin-search-toggle-btn {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 34px;
  height: 34px;
  padding: 0;
  border-radius: 6px;
  border: 1px solid var(--border);
  background: var(--panel);
  color: var(--text-dim);
  cursor: pointer;
  font-size: 0.95rem;
  flex-shrink: 0;
}
.admin-search-toggle-btn:hover { background: var(--panel-hover); color: var(--text); }
.admin-search-wrap.expanded .admin-search-toggle-btn { display: none; }
.admin-search-input {
  width: 0;
  min-width: 0;
  opacity: 0;
  padding: 7px 0;
  border: 1px solid transparent;
  background: var(--panel);
  border-radius: 6px;
  color: var(--text);
  font-size: 0.85rem;
  overflow: hidden;
  transition: width 0.18s ease, opacity 0.12s ease, padding 0.18s ease;
}
.admin-search-wrap.expanded .admin-search-input {
  width: 280px;
  opacity: 1;
  padding: 7px 10px;
  border-color: var(--border);
}
.admin-search-input:focus { outline: none; border-color: var(--sharp); }
.admin-search-results {
  position: absolute;
  top: calc(100% + 4px);
  left: 0;
  right: 0;
  max-height: 60vh;
  overflow-y: auto;
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: 8px;
  box-shadow: var(--shadow-lift);
  z-index: 60;
}
.admin-search-section-title {
  font-family: var(--font-mono);
  font-size: 0.7rem;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--text-dim);
  padding: 8px 12px 4px;
}
.admin-search-result {
  display: flex;
  flex-direction: column;
  gap: 2px;
  padding: 8px 12px;
  cursor: pointer;
  border-top: 1px solid var(--border);
}
.admin-search-result:hover { background: var(--panel-hover); }
.admin-search-empty { padding: 12px; }

@media (max-width: 900px) {
  /* Only forces a full-width row while actually expanded - the
     collapsed icon button is small enough to just sit inline with the
     rest of the topbar without needing its own row, unlike the old
     permanently-open 320px input this replaced. */
  .admin-search-wrap.expanded { flex-basis: 100%; margin: 8px 0; order: 10; }
  .admin-search-wrap.expanded .admin-search-input { width: 100%; }
}

/* ---------- admin: duplicate client warning ---------- */
.duplicate-client-warning {
  border: 1px solid var(--mid);
  background: color-mix(in srgb, var(--mid) 12%, transparent);
  border-radius: 6px;
  padding: 8px 12px;
  font-size: 0.85rem;
  color: var(--mid);
}
.duplicate-client-warning ul { margin: 6px 0 6px 18px; padding: 0; }
.duplicate-client-warning li { margin-bottom: 2px; }

/* ---------- admin: shoot location map ---------- */
.location-map-el {
  width: 100%;
  height: 60vh;
  border-radius: 6px;
  border: 1px solid var(--border);
}

/* ---------- Ironman tab (race bib-number sorting) ---------- */
.ironman-subtabs {
  padding: 10px 28px 0;
}
.ironman-bib-noimg {
  display: flex;
  align-items: center;
  justify-content: center;
  aspect-ratio: 1 / 1;
  background: var(--panel);
  color: var(--text-dim);
  font-family: var(--font-mono);
  font-size: 0.8rem;
}
.ironman-bib-number {
  font-family: var(--font-mono);
  font-weight: 700;
  font-size: 1rem;
  color: var(--sharp);
}
/* Review-queue crop thumbnails are already a tight crop around just the
   bib-number text (see bib_pipeline._save_review_crop) - the generic
   `.card img { aspect-ratio: 3/2; object-fit: cover }` rule then forced
   that into a wider box and cropped it further, cutting off digits at
   the edges. Show the whole saved crop, letterboxed, instead. Cursor
   signals it's clickable to open the full photo for more context/zoom
   (see ironman.js's review-queue click handler). */
.ironman-review-card img {
  aspect-ratio: auto;
  height: 160px;
  object-fit: contain;
  background: #0d0e10;
  cursor: zoom-in;
}
.ironman-review-actions {
  display: flex;
  gap: 6px;
  align-items: center;
  margin-top: 4px;
  flex-wrap: wrap;
}
.ironman-review-input {
  width: 70px;
  font-family: var(--font-mono);
  font-size: 0.85rem;
  padding: 5px 8px;
  border-radius: 6px;
  border: 1px solid var(--border);
  background: var(--panel);
  color: var(--text);
}
.ironman-photo-bib-row {
  padding: 4px 0;
  border-bottom: 1px solid var(--border);
  font-size: 0.85rem;
  display: flex;
  align-items: center;
  gap: 6px;
  flex-wrap: wrap;
}
.ironman-bib-edit-btn, .ironman-bib-remove-btn {
  padding: 2px 8px;
  font-size: 0.8rem;
}
.ironman-bib-edit-row {
  display: flex;
  align-items: center;
  gap: 6px;
  width: 100%;
}

/* ---------- mobile swipe-review mode ---------- */
.ironman-swipe-inner {
  max-width: 420px;
  width: 92vw;
  display: flex;
  flex-direction: column;
  gap: 10px;
}
#ironmanSwipeCardWrap {
  position: relative;
  overflow: hidden;
  border-radius: 12px;
}
#ironmanSwipeCard {
  background: #0d0e10;
  border-radius: 12px;
  border: 1px solid var(--border);
  overflow: hidden;
  touch-action: pan-y;
  cursor: grab;
  user-select: none;
  -webkit-user-drag: none;
  transition: transform 0.2s ease, opacity 0.2s ease;
}
#ironmanSwipeCard.dragging { transition: none; }
#ironmanSwipeImg {
  width: 100%;
  max-height: 50vh;
  object-fit: contain;
  display: block;
  background: #0d0e10;
  pointer-events: none;
}
#ironmanSwipeMeta {
  padding: 10px 12px;
  border-top: 1px solid var(--border);
}
.ironman-swipe-stamp {
  position: absolute;
  top: 16px;
  padding: 6px 14px;
  font-weight: 800;
  font-size: 1.1rem;
  letter-spacing: 0.04em;
  border-radius: 8px;
  border: 3px solid;
  opacity: 0;
  transform: rotate(-12deg);
  pointer-events: none;
  transition: opacity 0.1s ease;
}
.ironman-swipe-stamp-confirm { left: 16px; color: var(--good, #3fb950); border-color: var(--good, #3fb950); }
.ironman-swipe-stamp-reject { right: 16px; color: var(--reject, #f85149); border-color: var(--reject, #f85149); }
.ironman-swipe-actions {
  display: flex;
  gap: 10px;
}
.ironman-swipe-actions .btn { flex: 1; font-size: 1rem; padding: 12px; }
.ironman-photo-bib-row:last-child { border-bottom: none; }

/* ---------- public race-photo purchase modals (static/race.html) ---------- */
.purchase-download-choice:not([hidden]) {
  display: flex;
  flex-wrap: wrap;
  gap: 12px;
  margin-top: 14px;
}
.purchase-saved-link-row { margin-top: 18px; padding-top: 18px; border-top: 1px solid var(--border); }
.purchase-saved-link-controls { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin-top: 8px; }
.purchase-saved-link-controls .client-welcome-input { flex: 1 1 260px; width: auto; }

/* ---------- client gallery "pay to unlock" banner (client.html) ---------- */
.unlock-banner:not([hidden]) {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  flex-wrap: wrap;
}

/* ---------- Ironman public-page QR code modal ---------- */
.ironman-qr-container {
  display: flex;
  justify-content: center;
  align-items: center;
  background: #ffffff;
  border-radius: 8px;
  padding: 16px;
  margin: 14px 0;
}
.ironman-qr-container svg { width: 220px; height: 220px; }
.ironman-qr-url { word-break: break-all; text-align: center; margin-bottom: 14px; }

/* ---------- race.html: hero search ----------
   Signature element: the search field reads as a race bib — a pill
   shape with two small pinned dots above it, echoing how a bib is
   safety-pinned to a jersey. Kept to this one element (search is
   quite literally the page's job) rather than spreading the motif
   around, per "spend your boldness in one place." */

.race-hero {
  position: relative;
  overflow: hidden;
  /* Deliberately shallow top padding: someone who just crossed the line
     is on their phone looking for one thing, so the search bib starts
     near the top of the viewport instead of below a full-height hero. */
  padding: 26px 24px 34px;
  text-align: center;
  border-bottom: 1px solid var(--border);
  background:
    radial-gradient(760px 380px at 50% -20%, rgba(79, 209, 197, 0.16), transparent 60%),
    radial-gradient(520px 300px at 85% 10%, rgba(232, 163, 61, 0.10), transparent 55%);
}

/* One-shot "photo finish" flash on load - fades out and never repeats,
   never loops. Skipped entirely for reduced-motion. */
.race-hero::before {
  content: "";
  position: absolute;
  inset: 0;
  background: radial-gradient(900px 480px at 50% 0%, rgba(255, 255, 255, 0.45), transparent 65%);
  animation: race-hero-flash 1.1s ease-out forwards;
  pointer-events: none;
  z-index: 2;
}
@keyframes race-hero-flash {
  0% { opacity: 1; }
  100% { opacity: 0; }
}

/* Ambient motion-blur streaks drifting behind the bib - the only looping
   animation on the page, kept near-invisible so it reads as atmosphere
   rather than decoration. */
.race-speedlines {
  position: absolute;
  inset: -30% -20%;
  pointer-events: none;
  background: repeating-linear-gradient(
    100deg,
    transparent 0 38px,
    rgba(79, 209, 197, 0.055) 38px 40px,
    transparent 40px 76px
  );
  animation: race-speedlines-drift 16s linear infinite;
}
@keyframes race-speedlines-drift {
  from { transform: translateX(0); }
  to { transform: translateX(76px); }
}

@media (prefers-reduced-motion: reduce) {
  .race-hero::before { animation: none; opacity: 0; }
  .race-speedlines { animation: none; }
  .race-live-dot { animation: none; }
}

/* ---- the bib itself ---- */

.race-bib {
  position: relative;
  z-index: 1;
  max-width: 620px;
  margin: 0 auto;
  padding: 30px 30px 28px;
  border-radius: 16px;
  border: 1px solid var(--border);
  background: var(--panel);
  box-shadow: var(--shadow-lift);
}

/* safety pins at the top corners, where a real bib gets pinned */
.race-bib::before,
.race-bib::after {
  content: "";
  position: absolute;
  top: 13px;
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: var(--mid);
  box-shadow: 0 0 8px rgba(232, 163, 61, 0.55);
}
.race-bib::before { left: 14px; }
.race-bib::after { right: 14px; }

/* checkered flag tab taped to the top edge */
.race-bib-tape {
  position: absolute;
  top: 0;
  left: 50%;
  transform: translateX(-50%);
  width: 128px;
  height: 9px;
  border-radius: 0 0 5px 5px;
  background-color: var(--text-dim);
  background-image:
    linear-gradient(45deg, var(--text) 25%, transparent 25%, transparent 75%, var(--text) 75%),
    linear-gradient(45deg, var(--text) 25%, transparent 25%, transparent 75%, var(--text) 75%);
  background-size: 9px 9px;
  background-position: 0 0, 4.5px 4.5px;
  opacity: 0.7;
}

.race-eyebrow {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  font-family: var(--font-mono);
  font-size: 0.72rem;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--sharp);
  margin: 0 0 10px;
}

.race-live-dot {
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--keep);
  animation: race-live-pulse 2.6s ease-out infinite;
}
@keyframes race-live-pulse {
  0%   { box-shadow: 0 0 0 0 rgba(111, 207, 135, 0.55); }
  70%  { box-shadow: 0 0 0 7px rgba(111, 207, 135, 0); }
  100% { box-shadow: 0 0 0 0 rgba(111, 207, 135, 0); }
}

.race-headline {
  position: relative;
  font-family: var(--font-display);
  font-weight: 700;
  font-size: clamp(1.55rem, 3.6vw, 2.4rem);
  letter-spacing: -0.02em;
  line-height: 1.1;
  margin: 0 0 20px;
  color: var(--text);
}
.race-headline .accent { color: var(--sharp); filter: drop-shadow(0 0 10px var(--glow-sharp)); }

.race-search-stage {
  position: relative;
  margin: 0 auto;
}

.race-event-select {
  width: 100%;
  font-family: var(--font-mono);
  font-size: 0.95rem;
  padding: 15px 22px;
  border-radius: 999px;
  border: 1px solid var(--border);
  background: var(--bg);
  color: var(--text);
  cursor: pointer;
}
.race-event-select:focus-visible {
  outline: none;
  border-color: var(--sharp);
  box-shadow: 0 0 0 4px var(--glow-sharp);
}

/* Once an event is picked the select shrinks back to a quiet label and
   the search field becomes the focal point - two equally loud pill
   inputs stacked would fight each other for attention. */
.race-search-input-wrap {
  position: relative;
  margin-top: 12px;
}
/* With only one event, race.js hides the picker entirely - drop the gap
   so the search field doesn't float with dead space above it. */
#eventPickerWrap[hidden] + .race-search-input-wrap { margin-top: 0; }

.race-search-input {
  width: 100%;
  font-family: var(--font-display);
  font-size: 1.25rem;
  font-weight: 500;
  padding: 20px 26px 20px 56px;
  border-radius: 999px;
  border: 1px solid var(--border);
  background: var(--bg);
  color: var(--text);
  transition: border-color 0.15s, box-shadow 0.15s;
}
.race-search-input::placeholder { color: var(--text-dim); font-weight: 400; }
.race-search-input:focus {
  outline: none;
  border-color: var(--sharp);
  box-shadow: 0 0 0 4px var(--glow-sharp);
}
.race-search-icon {
  position: absolute;
  left: 24px;
  top: 50%;
  transform: translateY(-50%);
  color: var(--text-dim);
  font-size: 1.05rem;
  pointer-events: none;
}

.race-hero .empty-state { padding: 16px 20px 0; }
#suggestionsHeading { text-align: left; }

/* Race results are text-only cards (no thumbnail), so .card-filename is
   carrying the actual answer - "Ana Ruiz - Bib #482" - rather than a
   photo caption. Its default 0.72rem dimmed-and-truncated treatment is
   right on the Cull grid and wrong here: names get long and must not
   ellipsis away, and the bib number is what people scan for. Only the
   base .card hover (already defined above) is reused - no need to
   restate it. */
#resultsList .card-meta {
  padding: 14px 16px;
  gap: 6px;
  text-align: left;
}
#resultsList .card-filename {
  font-size: 1rem;
  font-weight: 500;
  color: var(--text);
  white-space: normal;
  overflow: visible;
  font-variant-numeric: tabular-nums;
  letter-spacing: 0.01em;
}
#resultsList .card-meta > div:last-child {
  font-family: var(--font-mono);
  font-size: 0.74rem;
  color: var(--sharp);
  letter-spacing: 0.04em;
}

@media (max-width: 640px) {
  .race-hero { padding: 18px 14px 24px; }
  .race-bib { padding: 26px 18px 22px; border-radius: 14px; }
  .race-headline { margin-bottom: 18px; }
  .race-search-input { padding: 16px 20px 16px 48px; font-size: 1.08rem; }
  .race-search-icon { left: 20px; }
  .race-event-select { padding: 13px 18px; font-size: 0.9rem; }
}


/* ==========================================================================
   Gallery style (event type) - client.html
   --------------------------------------------------------------------------
   Each event type restates the palette and display face as custom
   properties on <body>. Everything downstream already draws from these
   variables, so a whole reskin costs one block per theme rather than a
   parallel set of rules.

   These come AFTER .theme-light on purpose: picking an event style is a
   deliberate choice about how the gallery should feel for a particular
   client, so it wins over the generic light/dark toggle. Only the
   default "Classic" style leaves the toggle in charge, which is why it
   has no class of its own - it's just :root.
   ========================================================================== */

/* Wedding - warm ivory paper, eucalyptus accent, high-contrast serif.
   Sage rather than the usual terracotta: it sits beside skin tones and
   greenery in real wedding work without tinting the photographs. */
body.evt-wedding {
  --bg: #f2efe9;
  --panel: #fbf9f5;
  --panel-hover: #efeae1;
  --border: #ded7ca;
  --text: #2b2721;
  --text-dim: #7a7266;
  --sharp: #6e7f66;
  --sharp-bright: #869a7c;
  --mid: #b08d57;
  --soft: #a8564c;
  --keep: #6e7f66;
  --glow-sharp: rgba(110, 127, 102, 0.20);
  --shadow-lift: 0 10px 30px rgba(64, 54, 40, 0.10);
  --wash-1: rgba(110, 127, 102, 0.06);
  --wash-2: rgba(176, 141, 87, 0.05);
  --topbar-bg: rgba(251, 249, 245, 0.78);
  --font-display: "Fraunces", Georgia, "Times New Roman", serif;
}

/* Studio - near-black room, brass accent. Built so nothing on the page
   competes with the print: the chrome recedes and the photo is the only
   bright object on screen. */
body.evt-studio {
  --bg: #131211;
  --panel: #1c1a17;
  --panel-hover: #24211d;
  --border: #302c26;
  --text: #ece6db;
  --text-dim: #8d857a;
  --sharp: #c9a227;
  --sharp-bright: #e0b93c;
  --mid: #b08d57;
  --soft: #c2604f;
  --keep: #8a9a6b;
  --glow-sharp: rgba(201, 162, 39, 0.25);
  --shadow-lift: 0 10px 30px rgba(0, 0, 0, 0.55);
  --wash-1: rgba(201, 162, 39, 0.06);
  --wash-2: rgba(176, 141, 87, 0.04);
  --topbar-bg: rgba(28, 26, 23, 0.62);
  --font-display: "Fraunces", Georgia, "Times New Roman", serif;
}

/* Corporate - cool neutral, single navy accent, no warmth and no
   flourish. Reads correctly on a conference-room projector and in a
   forwarded screenshot, which is where these galleries actually land. */
body.evt-corporate {
  --bg: #f4f6f8;
  --panel: #ffffff;
  --panel-hover: #e9eef3;
  --border: #d7dde5;
  --text: #16202c;
  --text-dim: #63707f;
  --sharp: #2f6394;
  --sharp-bright: #3b7cb8;
  --mid: #9a6b1f;
  --soft: #b8453a;
  --keep: #2d7a53;
  --glow-sharp: rgba(47, 99, 148, 0.16);
  --shadow-lift: 0 8px 24px rgba(22, 32, 44, 0.10);
  --wash-1: rgba(47, 99, 148, 0.05);
  --wash-2: rgba(99, 112, 127, 0.04);
  --topbar-bg: rgba(255, 255, 255, 0.80);
}

/* Family - soft warm light with a muted plum accent. Plum rather than
   blush pink so it stays neutral across newborn, maternity and family
   sessions instead of reading as gendered. */
body.evt-family {
  --bg: #f6f1ed;
  --panel: #fffbf9;
  --panel-hover: #f0e8e2;
  --border: #e3d6cd;
  --text: #322722;
  --text-dim: #85736a;
  --sharp: #8d6b84;
  --sharp-bright: #a67f9c;
  --mid: #c2925a;
  --soft: #b5564c;
  --keep: #6f8f6a;
  --glow-sharp: rgba(141, 107, 132, 0.20);
  --shadow-lift: 0 10px 30px rgba(70, 52, 44, 0.10);
  --wash-1: rgba(141, 107, 132, 0.06);
  --wash-2: rgba(194, 146, 90, 0.05);
  --topbar-bg: rgba(255, 251, 249, 0.80);
  --font-display: "Fraunces", Georgia, "Times New Roman", serif;
}

/* The serif themes need slightly tighter display type - Fraunces runs
   larger on the body than Space Grotesk at the same px size. */
body.evt-wedding .brand,
body.evt-studio .brand,
body.evt-family .brand { letter-spacing: -0.01em; }

/* ==========================================================================
   Three additional gallery styles - full redesigns, not palette swaps.
   Each changes the actual typeface used for display text (--font-display)
   and/or the grid mechanics, not just color, so they read as genuinely
   different pages rather than the same layout recolored. Grounded in a
   real part of a photographer's own world: a darkroom contact sheet, a
   magazine spread, a gallery's white cube.
   ========================================================================== */

/* Contact Sheet - darkroom proof sheet. Monospace becomes the DISPLAY
   face (not just captions), frame numbers count up in the corner of
   each photo like negative numbers, and sprocket-hole dots run along
   the top/bottom of the cover banner like a film strip. */
body.evt-contactsheet {
  --bg: #1a1714;
  --panel: #211d19;
  --panel-hover: #2a2521;
  --border: #3a332c;
  --text: #f2ece2;
  --text-dim: #9c9184;
  --sharp: #ff7a1a;
  --sharp-bright: #ff9645;
  --mid: #d9a441;
  --soft: #e2574c;
  --keep: #8fae6a;
  --glow-sharp: rgba(255, 122, 26, 0.28);
  --shadow-lift: 0 10px 30px rgba(0, 0, 0, 0.55);
  --wash-1: rgba(255, 122, 26, 0.05);
  --wash-2: rgba(217, 164, 65, 0.04);
  --topbar-bg: rgba(26, 23, 20, 0.6);
  --font-display: "IBM Plex Mono", ui-monospace, monospace;
}
body.evt-contactsheet .card { border-radius: 3px; }
/* Frame numbers - reset per folder group, so each folder reads as its
   own roll rather than one continuously numbered set. */
body.evt-contactsheet .grid { counter-reset: frame; }
body.evt-contactsheet .card { counter-increment: frame; position: relative; }
body.evt-contactsheet .card::before {
  content: counter(frame, decimal-leading-zero);
  position: absolute;
  top: 6px;
  right: 8px;
  z-index: 4;
  font-family: var(--font-mono);
  font-size: 0.62rem;
  letter-spacing: 0.05em;
  color: var(--sharp);
  background: rgba(0, 0, 0, 0.55);
  padding: 1px 5px;
  border-radius: 3px;
  pointer-events: none;
}

/* Editorial - magazine spread. True masonry (CSS columns, not a forced
   3:2 crop) so portrait and landscape shots sit at their real
   proportions like a laid-out feature, plus a drop-cap on the client's
   name and a single rule instead of a full frame. */
body.evt-editorial {
  --bg: #f6f0e6;
  --panel: #fffdf9;
  --panel-hover: #efe6d6;
  --border: #ddd0b8;
  --text: #221c15;
  --text-dim: #7c6f5b;
  --sharp: #8a2e2e;
  --sharp-bright: #a83a3a;
  --mid: #b98a3e;
  --soft: #a8564c;
  --keep: #5c7a52;
  --glow-sharp: rgba(138, 46, 46, 0.16);
  --shadow-lift: 0 10px 30px rgba(50, 34, 20, 0.10);
  --wash-1: rgba(138, 46, 46, 0.05);
  --wash-2: rgba(185, 138, 62, 0.05);
  --topbar-bg: rgba(255, 253, 249, 0.80);
  --font-display: "Fraunces", Georgia, "Times New Roman", serif;
}
body.evt-editorial .brand { letter-spacing: -0.01em; }
body.evt-editorial #galleryGrid .grid:not([hidden]) {
  display: block;
  column-count: 3;
  column-gap: 14px;
  padding: 24px 28px;
}
body.evt-editorial #galleryGrid .card {
  display: inline-block;
  width: 100%;
  margin: 0 0 14px;
  break-inside: avoid;
}
body.evt-editorial #galleryGrid .card img {
  aspect-ratio: auto;
  height: auto;
}
@media (max-width: 900px) {
  body.evt-editorial #galleryGrid .grid:not([hidden]) { column-count: 2; }
}
@media (max-width: 560px) {
  body.evt-editorial #galleryGrid .grid:not([hidden]) { column-count: 1; }
}
body.evt-editorial .cover-caption-title::first-letter {
  font-size: 1.35em;
  color: var(--sharp);
}

/* Gallery Wall - white cube. Quiet near-monochrome palette (the accent
   is a warm graphite, not a color, so nothing competes with the
   photos), generous mat space, and every print mounted on its own
   bordered white card instead of bleeding to the edge. */
body.evt-gallerywall {
  --bg: #fafaf8;
  --panel: #ffffff;
  --panel-hover: #f0f0ec;
  --border: #dedcd4;
  --text: #1a1a18;
  --text-dim: #78766c;
  --sharp: #3d3a33;
  --sharp-bright: #555146;
  --mid: #a08a5c;
  --soft: #a24a3f;
  --keep: #4f7a5c;
  --glow-sharp: rgba(61, 58, 51, 0.12);
  --shadow-lift: 0 8px 24px rgba(0, 0, 0, 0.08);
  --wash-1: rgba(0, 0, 0, 0.02);
  --wash-2: rgba(0, 0, 0, 0.015);
  --topbar-bg: rgba(255, 255, 255, 0.85);
}
body.evt-gallerywall #galleryGrid .grid:not([hidden]) { gap: 28px; padding: 28px 32px; }
body.evt-gallerywall .card {
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: 2px;
  padding: 10px;
  box-shadow: none;
}
body.evt-gallerywall .card:hover {
  transform: none;
  border-color: var(--sharp);
  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06);
}
body.evt-gallerywall .card img { border-radius: 1px; }

/* First Look - a full-bleed cover reveal, ghost-quiet chrome, and an
   edge-to-edge grid. Named for the private pre-ceremony reveal, which
   is the same move this style makes with the whole gallery: one big
   photo first, everything else held back until you scroll past it. */
body.evt-firstlook {
  --bg: #ffffff;
  --panel: #ffffff;
  --panel-hover: #f5f5f3;
  --border: #ececea;
  --text: #111110;
  --text-dim: #8a887f;
  --sharp: #111110;
  --sharp-bright: #333330;
  --mid: #b08d57;
  --soft: #a24a3f;
  --keep: #4f7a5c;
  --glow-sharp: rgba(17, 17, 16, 0.08);
  --shadow-lift: 0 8px 24px rgba(0, 0, 0, 0.06);
  --wash-1: transparent;
  --wash-2: transparent;
  --topbar-bg: rgba(255, 255, 255, 0.9);
  letter-spacing: 0.005em;
}

/* Ghost chrome: every button loses its filled background except the
   one true call to action, which goes solid ink-on-white instead of
   the usual teal - "the accent is black" is the whole point here. */
body.evt-firstlook .topbar { border-bottom: none; padding: 22px 32px; }
body.evt-firstlook .btn {
  background: transparent;
  border-color: transparent;
  font-weight: 500;
}
body.evt-firstlook .btn:hover { background: var(--panel-hover); }
body.evt-firstlook .btn-accent {
  background: var(--text);
  color: #fff;
  border-color: var(--text);
}
body.evt-firstlook .btn-accent:hover { filter: none; background: var(--sharp-bright); }

/* The cover fills almost the whole viewport on arrival, with the
   client's name centered over it rather than tucked in a corner - the
   gallery's one big statement before anything else competes for
   attention. */
body.evt-firstlook .cover-banner {
  max-height: none;
  height: 88vh;
  border-radius: 0;
  border: none;
  margin-bottom: 0;
}
body.evt-firstlook .cover-banner img { height: 88vh; }
body.evt-firstlook .cover-banner::after {
  background: linear-gradient(180deg, rgba(0, 0, 0, 0.18), rgba(0, 0, 0, 0.12) 45%, rgba(0, 0, 0, 0.42));
}
body.evt-firstlook .cover-caption {
  left: 50%;
  bottom: auto;
  top: 50%;
  transform: translate(-50%, -50%);
  text-align: center;
  width: 90%;
}
body.evt-firstlook .cover-caption::before { display: none; }
body.evt-firstlook .cover-caption-title {
  font-size: clamp(2rem, 6vw, 3.6rem);
  font-weight: 600;
}
body.evt-firstlook .cover-caption-sub { margin-top: 10px; }
/* A quiet scroll cue standing in for Pixieset's "View Gallery" link -
   there's no separate landing step here, so this tells the visitor
   there's more below without adding a click they don't need. */
body.evt-firstlook .cover-banner::before {
  content: "Scroll to view gallery";
  position: absolute;
  inset: auto auto 30px 50%;
  transform: translateX(-50%);
  border: none;
  border-radius: 0;
  background: none;
  font-family: var(--font-mono);
  font-size: 0.65rem;
  letter-spacing: 0.22em;
  text-transform: uppercase;
  color: rgba(255, 255, 255, 0.85);
}

/* Nav becomes a centered row of quiet uppercase labels instead of an
   underlined tab strip. */
body.evt-firstlook .gallery-tabs {
  border-bottom: 1px solid var(--border);
  justify-content: center;
  gap: 32px;
  padding: 40px 32px 22px;
}
body.evt-firstlook .gallery-tabs .tab {
  text-transform: uppercase;
  font-size: 0.72rem;
  font-weight: 500;
  letter-spacing: 0.14em;
  padding: 6px 2px;
  border-radius: 0;
  border-bottom: 1px solid transparent;
  background: none;
}
body.evt-firstlook .gallery-tabs .tab:hover { background: none; color: var(--text); }
body.evt-firstlook .gallery-tabs .tab.active {
  background: none;
  box-shadow: none;
  border-bottom-color: var(--text);
}

/* Edge-to-edge grid - no gaps, no borders, no hover lift. The photos
   are the only texture on the page. */
body.evt-firstlook #galleryGrid { padding: 0; gap: 0; }
body.evt-firstlook #galleryGrid .grid:not([hidden]) { gap: 2px; padding: 0 2px; }
body.evt-firstlook .folder-heading { margin: 0; padding: 18px 32px 12px; border-bottom: none; }
body.evt-firstlook .card { border-radius: 0; border: none; }
body.evt-firstlook .card:hover { transform: none; box-shadow: none; border-color: transparent; }

/* ---- cover banner: title sits on the image instead of below it ---- */

.cover-banner:not([hidden]) { position: relative; display: block; }
.cover-banner::after {
  content: "";
  position: absolute;
  inset: 0;
  pointer-events: none;
  /* Three stops instead of one: a touch of scrim at the very top too
     (not just the bottom) so a bright sky/window in the source photo
     doesn't wash out the frame motif living in the corners. */
  background: linear-gradient(to top, rgba(0, 0, 0, 0.58), transparent 42%, transparent 78%, rgba(0, 0, 0, 0.18));
}
.cover-caption {
  position: absolute;
  left: 24px;
  bottom: 18px;
  z-index: 1;
  color: #fff;
  text-shadow: 0 2px 12px rgba(0, 0, 0, 0.5);
}

/* This page's equivalent of the race-hero eyebrow: names the kind of
   session this gallery is, in the theme's own accent. "classic" (no
   evt-* class) gets a neutral fallback since it isn't tied to a
   specific event type. */
.cover-caption::before {
  content: "Your gallery";
  display: block;
  margin-bottom: 7px;
  font-family: var(--font-mono);
  font-size: 0.7rem;
  font-weight: 600;
  letter-spacing: 0.15em;
  text-transform: uppercase;
  color: var(--sharp);
  opacity: 0.95;
}
body.evt-wedding .cover-caption::before { content: "Wedding gallery"; }
body.evt-studio .cover-caption::before { content: "Studio session"; }
body.evt-corporate .cover-caption::before { content: "Delivered gallery"; }
body.evt-family .cover-caption::before { content: "Family session"; }
body.evt-contactsheet .cover-caption::before { content: "Proof sheet"; }
body.evt-editorial .cover-caption::before { content: "Featured story"; }
body.evt-gallerywall .cover-caption::before { content: "On view"; }

.cover-caption-title {
  font-family: var(--font-display);
  font-size: clamp(1.3rem, 3vw, 2rem);
  font-weight: 700;
  letter-spacing: -0.01em;
  line-height: 1.15;
}
.cover-caption-sub {
  font-family: var(--font-mono);
  font-size: 0.75rem;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  opacity: 0.85;
  margin-top: 4px;
}

/* ---- per-theme frame, mounted just inside the banner's edge ----
   Each theme gets a different frame *shape*, not just a different
   color, so the motif itself is grounded in that event's own world
   rather than being the same rectangle repainted five times:
     - classic:   a plain mat line - the neutral default
     - wedding:   a delicate double hairline, like a deckle-edged
                  invitation mat
     - studio:    brass crop-mark corners - a real print-production
                  mark, not a decorative flourish
     - corporate: a single top rule and nothing else - the "no
                  flourish" instruction taken literally
     - family:    the same mat line as classic, but softened to a
                  generous rounded corner - warmth via geometry
   All are drawn from --sharp / neutral white, so no new colors were
   introduced anywhere in this block. */
.cover-banner::before {
  content: "";
  position: absolute;
  inset: 10px;
  border: 1px solid rgba(255, 255, 255, 0.18);
  border-radius: 6px;
  pointer-events: none;
}
body.evt-wedding .cover-banner::before {
  inset: 12px;
  border: none;
  border-radius: 3px;
  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.7), inset 0 0 0 5px rgba(255, 255, 255, 0.2);
}
body.evt-studio .cover-banner::before {
  inset: 16px;
  border: none;
  border-radius: 0;
  opacity: 0.85;
  background:
    linear-gradient(var(--sharp), var(--sharp)) 0 0 / 16px 1px no-repeat,
    linear-gradient(var(--sharp), var(--sharp)) 0 0 / 1px 16px no-repeat,
    linear-gradient(var(--sharp), var(--sharp)) 100% 0 / 16px 1px no-repeat,
    linear-gradient(var(--sharp), var(--sharp)) 100% 0 / 1px 16px no-repeat,
    linear-gradient(var(--sharp), var(--sharp)) 0 100% / 16px 1px no-repeat,
    linear-gradient(var(--sharp), var(--sharp)) 0 100% / 1px 16px no-repeat,
    linear-gradient(var(--sharp), var(--sharp)) 100% 100% / 16px 1px no-repeat,
    linear-gradient(var(--sharp), var(--sharp)) 100% 100% / 1px 16px no-repeat;
}
body.evt-corporate .cover-banner::before {
  inset: 0;
  border: none;
  border-radius: 0;
  border-top: 3px solid var(--sharp);
}
body.evt-family .cover-banner::before {
  inset: 14px;
  border: 1px solid rgba(255, 255, 255, 0.32);
  border-radius: 22px;
}
body.evt-contactsheet .cover-banner::before {
  inset: 0;
  border: none;
  border-radius: 0;
  background:
    radial-gradient(circle, var(--sharp) 2px, transparent 2.5px) repeat-x,
    radial-gradient(circle, var(--sharp) 2px, transparent 2.5px) repeat-x;
  background-size: 22px 100%, 22px 100%;
  background-position: 0 8px, 0 calc(100% - 8px);
  opacity: 0.55;
}
body.evt-editorial .cover-banner::before {
  inset: auto 20px 20px 20px;
  top: auto;
  height: 2px;
  border: none;
  border-radius: 0;
  background: var(--sharp);
  opacity: 0.85;
}
body.evt-gallerywall .cover-banner::before {
  inset: 16px;
  border: 1px solid rgba(255, 255, 255, 0.5);
  border-radius: 0;
}

@media (max-width: 640px) {
  .cover-caption { left: 16px; bottom: 14px; }
}

/* ---------- Clients modal: grouped create-client form ---------- */

.create-client-form {
  display: flex;
  flex-direction: column;
  gap: 14px;
}

.client-form-group {
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 12px 14px 14px;
  margin: 0;
  background: rgba(255, 255, 255, 0.02);
}
.client-form-group > legend {
  font-family: var(--font-mono);
  font-size: 0.7rem;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--text-dim);
  padding: 0 6px;
}
.client-form-row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 10px;
}
.client-form-row + .client-form-row { margin-top: 10px; }
.client-form-row > input[type="text"],
.client-form-row > input[type="email"] { flex: 1 1 220px; }
.client-form-group > .client-welcome-input { margin-top: 10px; width: 100%; }

.client-form-actions {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  align-items: center;
}

/* Live palette preview beside the gallery-style picker, so the choice
   is verifiable here rather than only by logging into the client
   gallery as that client. Swatches are painted from the same CSS
   variables the real theme uses (see the gallery-style block above),
   so they can't drift out of sync with it. */
.style-preview {
  display: inline-flex;
  align-items: center;
  gap: 6px;
}
.style-preview-swatch {
  width: 16px;
  height: 16px;
  border-radius: 50%;
  border: 1px solid var(--border);
  background: var(--preview-accent, var(--sharp));
}
.style-preview-swatch.alt { background: var(--preview-bg, var(--panel)); }
.style-preview-label {
  font-family: var(--font-mono);
  font-size: 0.7rem;
  color: var(--text-dim);
}

/* Per-style preview values. Kept beside the theme definitions they
   mirror so the two get updated together. */
.style-preview[data-style="classic"]   { --preview-accent: #4fd1c5; --preview-bg: #15171a; }
.style-preview[data-style="wedding"]   { --preview-accent: #6e7f66; --preview-bg: #f2efe9; }
.style-preview[data-style="studio"]    { --preview-accent: #c9a227; --preview-bg: #131211; }
.style-preview[data-style="corporate"] { --preview-accent: #2f6394; --preview-bg: #f4f6f8; }
.style-preview[data-style="family"]    { --preview-accent: #8d6b84; --preview-bg: #f6f1ed; }
.style-preview[data-style="contactsheet"] { --preview-accent: #ff7a1a; --preview-bg: #1a1714; }
.style-preview[data-style="editorial"]    { --preview-accent: #8a2e2e; --preview-bg: #f6f0e6; }
.style-preview[data-style="gallerywall"]  { --preview-accent: #3d3a33; --preview-bg: #fafaf8; }
.style-preview[data-style="firstlook"]    { --preview-accent: #111110; --preview-bg: #ffffff; }

/* ---------- reduced motion ---------- */
/* This session added several purely-decorative animations (card
   entrance stagger, the score chip's indefinite glow pulse, the modal
   pop-in, the sliding tab underline, button press feedback). None of
   them carry information you'd lose by seeing them happen instantly
   instead of animated - so anyone with prefers-reduced-motion set (an
   OS-level accessibility preference, not something this app has its
   own toggle for) gets all the same states with none of the motion.
   The infinite score-chip-hot pulse is the one most worth guarding
   here specifically - a looping animation is the case this media
   query exists for. */
@media (prefers-reduced-motion: reduce) {
  .card,
  .lightbox:not([hidden]) .lightbox-inner {
    animation: none;
  }
  .score-chip-hot {
    animation: none;
    box-shadow: 0 0 6px 1px var(--glow-sharp);
  }
  .btn,
  .btn:hover,
  .btn:active,
  .tab-indicator,
  .undo-toast {
    transition-duration: 0.001ms;
  }
}

/* ---------- keyboard focus floor ----------
   Several components set `outline: none` on focus and replace it with a
   border-colour change. That reads clearly on a text field, where the
   border is the control, but it leaves buttons, tabs and links with no
   visible focus at all - and the app is keyboard-driven enough to ship
   a shortcuts dialog, so someone is tabbing through it.

   :focus-visible rather than :focus, so a mouse click does not draw a
   ring; it appears only when the browser judges the interaction to be
   keyboard-driven. Placed last in the file deliberately: it must win
   over the per-component `outline: none` rules, and it is an addition
   to them rather than a replacement, so their border treatment stays. */
.btn:focus-visible,
.link-btn:focus-visible,
.tab:focus-visible,
a:focus-visible,
button:focus-visible,
summary:focus-visible,
[role="tab"]:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible {
  outline: 2px solid var(--sharp);
  outline-offset: 2px;
  border-radius: 6px;
}

/* ---------- icon sprite ----------
   The sprite itself is a <symbol> library, never drawn. It has to stay
   in the document (a <use> cannot reference a removed node), so it is
   hidden by size rather than by `display: none`, which in some browsers
   stops referenced symbols resolving. */
.icon-sprite { position: absolute; width: 0; height: 0; overflow: hidden; }

/* Sized in `em` so an icon tracks whatever text it sits beside instead
   of needing a variant per context, and `currentColor` is inherited
   from the stroke on each <symbol> - which is the whole point of the
   change: these follow the accent, the disabled state and the light
   theme automatically, where an emoji could follow none of them. */
.ico {
  width: 1.15em;
  height: 1.15em;
  flex: none;
  vertical-align: -0.18em;
}
/* Buttons already gap their children (see .btn), so an icon needs no
   margin there. Anywhere else - a heading, a summary, a table cell -
   there is no flex gap to rely on. */
:not(.btn) > .ico { margin-right: 0.4em; }
.btn > .ico { margin-right: 0; }

/* ---------- grouped toolbars ----------
   A flat row of eight equally-weighted buttons makes the reader compare
   all eight every time, because nothing in the layout says which ones
   belong together. Grouping is information: "Scan" and "Stop & clear"
   are one decision about the queue, "Roster / Pricing / Reports" are
   event administration, and "Copy link / QR code" are two ways to hand
   the same URL to someone.

   The separator is a hairline between groups rather than a box around
   each, so the row still reads as one toolbar - boxes would imply four
   panels where there is one. On a narrow window the groups wrap as
   units and the rule is dropped, since a divider that has wrapped onto
   its own line separates nothing. */
.toolbar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 10px 18px;
  margin: 10px 0;
}
.toolbar-group {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
  position: relative;
}
.toolbar-group + .toolbar-group::before {
  content: "";
  position: absolute;
  left: -9px;
  top: 15%;
  height: 62%;
  /* --border is tuned for panel edges against a panel fill; a divider
     floating in a gap over the page background needs more contrast than
     that or it reads as nothing at all. Mixed toward the dim text
     colour so it stays a hairline rather than becoming a rule. */
  border-left: 1px solid color-mix(in srgb, var(--text-dim) 45%, transparent);
}
@media (max-width: 900px) {
  .toolbar-group + .toolbar-group::before { display: none; }
}
/* The toolbar's own status text sits at the end, not in a group: it is
   output, not an action, and grouping it with buttons would say it was
   one. */
.toolbar > .queue-status { margin-left: auto; }

/* A grouped toolbar inside the mobile filter drawer has to give up its
   row layout, or the drawer's full-width stack (see
   body.mobile-mode .controls-filters) would be undone one level down:
   the groups themselves would still lay their children out in a row,
   which is exactly the row of differently-sized inline controls the
   drawer exists to replace. The dividers go too - between stacked
   blocks a vertical hairline separates nothing. */
body.mobile-mode .controls-filters .toolbar-group {
  flex-direction: column;
  align-items: stretch;
  width: 100%;
  gap: 12px;
}
body.mobile-mode .controls-filters .toolbar-group + .toolbar-group::before {
  display: none;
}

/* ---------- burst top pick ---------- */
/* Sits with the other corner badges rather than in the card's meta row,
   because the decision is made by comparing FRAMES - the button needs to
   be next to the image it is about, at the moment several are side by
   side. */
.burst-pick-btn {
  font-size: 0.72rem;
  padding: 3px 8px;
  border-radius: 5px;
  background: rgba(0, 0, 0, 0.55);
  border: 1px solid var(--border);
  backdrop-filter: blur(3px);
}
.burst-pick-btn:hover { border-color: var(--sharp); }
.burst-pick-on {
  color: var(--sharp);
  border-color: var(--sharp);
  background: rgba(0, 0, 0, 0.65);
}
/* An explicit pick is worth distinguishing from a frame that merely
   scored highest - otherwise a curated burst and an uncurated one look
   identical from the grid. */
.group-badge-picked { color: var(--sharp); border-color: var(--sharp); }

/* ---------- verified card import ---------- */
.ingest-progress-wrap {
  height: 6px;
  border-radius: 3px;
  background: var(--panel);
  border: 1px solid var(--border);
  overflow: hidden;
  margin: 10px 0;
}
.ingest-progress-bar {
  height: 100%;
  width: 0;
  background: var(--sharp);
  transition: width 0.3s ease;
}
.ingest-history-head { font-size: 0.95rem; margin: 20px 0 4px; }
.ingest-batches { display: flex; flex-direction: column; gap: 10px; }
.ingest-batch-row {
  padding: 10px 12px;
  border-radius: 7px;
  border: 1px solid var(--border);
  background: var(--panel);
}
.ingest-batch-head { font-size: 0.85rem; font-weight: 600; }
/* A failed import is the whole reason the history is kept, so it is
   marked rather than left to be spotted in a count. */
.ingest-batch-failed { border-color: var(--soft); }
.ingest-failure-list {
  margin-top: 8px;
  padding-top: 8px;
  border-top: 1px solid var(--border);
  display: flex;
  flex-direction: column;
  gap: 3px;
}

/* ---------- `hidden` must actually hide ----------
   The browser hides [hidden] with `display: none` from its own default
   stylesheet, which ANY author rule setting `display` outranks. That is
   easy to do by accident and silent when you do: giving .btn
   `display: inline-flex` for icon alignment un-hid thirteen buttons
   across the app at once - Stop scan, the ingest cancel, Apply denoise,
   the editor's auto-crop - each of which then sat there permanently,
   offering an action that was not available.

   !important is deliberate and is the narrow correct use of it: `hidden`
   is not a style preference, it is the element declaring it should not
   be rendered, and a layout rule that overrides that is a bug in every
   case rather than an intentional override worth preserving. Last in
   the file so it also covers rules added after it in future. */
[hidden] { display: none !important; }
