/* Modern Design System for Key Recognition App */

/* ── ON REPAINTING THIS IN THE SHOPFRONT'S COLOURS (asked 2026-09-04) ──────
   The landing page (static/mainkey/site.css) is cool grey-green with a single
   warm brass accent; this is blue. The seam is real — you sign up off a brass
   page and land in a blue app — but a straight swap is the wrong fix, for one
   hard reason and two soft ones:

   HARD: --query-color and --match-color below are not only CSS. The same two
   hex values are written into key_script.py (plot_overlay_profiles, ~line 104)
   so the blue and yellow lines in a generated overlay plot match the blue and
   yellow borders around the photos. Change them here and every plot ALREADY ON
   DISK keeps the old colours while the UI describes the new ones. Reprocessing
   every enrolled key is the only way back, so treat these two as pinned.

   SOFT: brass IS yellow, and yellow already means "the key you had saved".
   An accent that close to --match-color takes the meaning out of it. And the
   two stylesheets are deliberately separate (see the header of site.css and
   the pages.py notes in CLAUDE.md) because they are read by different people
   for different reasons.

   SETTLED 2026-09-04: the palette stays as it is. The four semantic hexes
   (#3b82f6 / #10b981 / #f59e0b / #ef4444 — the stock Tailwind blue-500,
   emerald-500, amber-500, red-500) each MEAN something here: action, success,
   caution, danger. A colour that is doing a job is not a colour worth changing
   to look less familiar, and green-means-success is the convention the modals
   lean on. The card hover-lift stays too.

   So don't reopen this as "the app looks generic". If it is ever reopened, the
   thing to change is the NEUTRALS (bg/text/border below) toward site.css's cool
   grey-greens — that is most of what is on screen, it shifts the feel, and it
   touches no meaning and no stored plot. The semantic four and the two pinned
   plot colours are not the lever.
   ──────────────────────────────────────────────────────────────────────── */

:root {
  /* Light theme colors. The page bg sits a step below white and borders a
     step darker than the classic slate values, so cards visibly separate
     from the page — light mode used to wash together (white on #f8fafc). */
  --bg-primary: #ffffff;
  --bg-secondary: #eef2f7;
  --bg-tertiary: #e3e9f1;
  --text-primary: #1e293b;
  --text-secondary: #55657c;
  --text-muted: #8494ab;
  --border-light: #d7dfe9;
  --border-medium: #b9c5d4;
  --accent-primary: #3b82f6;
  --accent-hover: #2563eb;
  /* The accent as a bare triple, for the tints and focus rings that need
     it at an opacity. Without this they were written out as literal blue
     and stayed blue whatever palette was chosen. */
  --accent-rgb: 59 130 246;
  --accent-text: #3b82f6;
  --success: #10b981;
  --success-hover: #059669;
  --warning: #f59e0b;
  --danger: #ef4444;
  --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
  --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
  --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
  --shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
  /* Single source of truth for navbar height — used by the sidebar, its
     overlay, and the body wrapper so they can never drift out of sync
     (previously each hardcoded its own "73px" guess). */
  --navbar-h: 64px;
  /* Query vs matched-key colour coding, shared by the recognition result
     image borders AND the blade-profile overlay plot itself
     (key_script.py plot_overlay_profiles uses these same two hex values) —
     so a user can tell which line is which key at a glance. */
  --query-color: #3b82f6;
  --match-color: #eab308;
}

/* Dark theme colors */
.dark-mode {
  --bg-primary: #0f172a;
  --bg-secondary: #1e293b;
  --bg-tertiary: #334155;
  --text-primary: #f1f5f9;
  --text-secondary: #cbd5e1;
  --text-muted: #94a3b8;
  --border-light: #334155;
  --border-medium: #475569;
  --accent-primary: #60a5fa;
  --accent-hover: #3b82f6;
  --accent-rgb: 96 165 250;
  --accent-text: #60a5fa;
  --success: #34d399;
  --success-hover: #10b981;
  --warning: #fbbf24;
  --danger: #f87171;
  --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.2);
  --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.2), 0 2px 4px -2px rgb(0 0 0 / 0.2);
  --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.2), 0 4px 6px -4px rgb(0 0 0 / 0.2);
  --shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.2), 0 8px 10px -6px rgb(0 0 0 / 0.2);
}

/* ── The brass palette (opt-in) ─────────────────────────────────────────────
   The shopfront's colours, applied to the app. Chosen per browser on the
   account page; absent this attribute nothing below applies and the blue
   scheme above is exactly as it was.

   Only TOKENS are redefined — no component is restyled to suit one palette,
   except the handful at the bottom that had a colour written into them rather
   than taken from a token. That is the whole reason this is switchable at all.

   :not(.dark-mode) / .dark-mode rather than bare [data-palette], because
   `:root[data-palette]` (0,2,0) would otherwise outrank `.dark-mode` (0,1,0)
   and the light values would win inside dark mode.

   What deliberately does NOT change:
   --query-color and --match-color are written into key_script.py as well
   (plot_overlay_profiles), so the plot PNGs already on disk carry them.
   --query-color is untouched. --match-color is brightened here — asked for,
   and safe because it stays the same yellow: the border and the plotted line
   read as the same signal at slightly different shades rather than as two
   different things. Moving it out of yellow would break that, so don't.
   The semantic three (success / warning / danger) are untouched: they mean
   saved, duplicate and failed, and the upload grid says what happened to a
   photo with nothing but those three colours. */

:root[data-palette="brass"]:not(.dark-mode) {
  /* Cool grey-green neutrals — a machinist's surface plate, not paper. */
  --bg-primary: #ffffff;      /* cards, navbar, sidebar */
  --bg-secondary: #eef1f0;    /* the page itself */
  --bg-tertiary: #e4e9e8;     /* hovers and steps */
  --text-primary: #131c24;
  --text-secondary: #4e5f6c;
  /* Darker than the blue scheme's muted, which sat at 2.7:1 on the page and
     was never really readable. This is 3.8:1. */
  --text-muted: #6b7c87;
  --border-light: #cfd8db;
  --border-medium: #b3c1c8;

  /* Chosen off the ladder: brighter and purer than the shopfront's #a1701f,
     which read flat once it was spread over an app's worth of small controls.

     Bright enough that it can no longer do both of the accent's jobs with one
     value, so there are two — the same split, for the same reason, that
     site.css makes between --brass and --accent:

       --accent-primary  the FILL. #bb840f carries dark ink at 5.3:1. It cannot
                         carry white (3.3:1), so the components that fill with
                         it take ink labels; see the rules below.
       --accent-text     the same gold as TEXT and icons, which needs to work
                         against the page rather than under a label. #bb840f on
                         the light page is 2.9:1 and unreadable, so light mode
                         drops to #8a610b (4.9:1). Dark mode keeps #bb840f,
                         which reads at 5.7:1 there.

     Hover LIFTS rather than deepens, the opposite of every earlier version
     here: with a dark label on the fill, a lighter fill means a more readable
     label, so hover improves contrast instead of spending it. */
  --accent-primary: #bb840f;
  --accent-hover: #c9900f;
  --accent-text: #8a610b;
  --accent-rgb: 187 132 15;

  /* Brighter, so it stays distinct from the brass now sharing the screen. */
  --match-color: #facc15;

  /* Bootstrap's own tokens, or every component this app doesn't restyle —
     modals, breadcrumbs, form controls, the folder tree — would stay blue-grey
     inside a grey-green app. */
  --bs-body-bg: #eef1f0;
  --bs-body-color: #131c24;
  --bs-emphasis-color: #131c24;
  --bs-secondary-color: #4e5f6c;
  --bs-secondary-bg: #e4e9e8;
  --bs-tertiary-bg: #e4e9e8;
  --bs-border-color: #cfd8db;
  --bs-primary: #bb840f;
  --bs-primary-rgb: 187, 132, 15;
  --bs-link-color: #8a610b;
  --bs-link-hover-color: #6f4d09;
  --bs-info: #4e7f95;
}

:root[data-palette="brass"].dark-mode {
  /* Cards sit a step ABOVE the page here, where the blue scheme recesses them.
     That is the shopfront's dark arrangement and the reason its panels read as
     raised; keeping the blue scheme's inversion would have looked like a bug. */
  --bg-primary: #161f25;      /* cards, navbar, sidebar */
  --bg-secondary: #0e1418;    /* the page itself */
  --bg-tertiary: #1f2a31;
  --text-primary: #e9eef0;
  --text-secondary: #9aabb5;
  --text-muted: #7d8e99;
  --border-light: #26333a;
  --border-medium: #46585f;

  /* Same fill as the light theme. The text token does not need to drop here —
     on a near-black page #bb840f reads at 5.7:1, so the accent is one value in
     dark mode and two in light. */
  --accent-primary: #bb840f;
  --accent-hover: #c9900f;
  --accent-text: #bb840f;
  --accent-rgb: 187 132 15;

  --match-color: #facc15;

  --bs-body-bg: #0e1418;
  --bs-body-color: #e9eef0;
  --bs-emphasis-color: #e9eef0;
  --bs-secondary-color: #9aabb5;
  --bs-secondary-bg: #1f2a31;
  --bs-tertiary-bg: #1f2a31;
  --bs-border-color: #26333a;
  --bs-primary: #bb840f;
  --bs-primary-rgb: 187, 132, 15;
  --bs-link-color: #bb840f;
  --bs-link-hover-color: #c9900f;
  --bs-info: #7cc0dd;
}

/* Bootstrap composes its outline variants from per-variant values, not from
   --bs-primary, so this one stays blue unless it is said again. */
:root[data-palette="brass"] .btn-outline-primary {
  /* Text takes the readable token, the fill it gains on hover takes the bright
     one — and its label flips to ink along with it. */
  --bs-btn-color: var(--accent-text);
  --bs-btn-border-color: var(--accent-primary);
  --bs-btn-hover-bg: var(--accent-primary);
  --bs-btn-hover-border-color: var(--accent-primary);
  --bs-btn-hover-color: #131c24;
  --bs-btn-active-bg: var(--accent-hover);
  --bs-btn-active-border-color: var(--accent-hover);
  --bs-btn-active-color: #131c24;
}

/* ── Corners, for the brass palette ────────────────────────────────────────
   Tighter corners read as precise, and this is software for measuring keys:
   the shopfront already uses 2px on its buttons, and the blue scheme's 8px
   radii and capsule chips are the vocabulary of consumer apps.

   These tokens are defined HERE AND NOWHERE ELSE, and that is the design.
   Every rule in the app is written `border-radius: var(--radius-role, <its
   original value>)`, so in the blue scheme — where no radius token exists —
   the fallback applies and every corner is exactly what it was, value for
   value. `RadiusTokenTests` holds that line: a token defined anywhere else, a
   token without its fallback, or a new literal corner each fail it.

   Five roles rather than a size scale, because what a corner should be depends
   on what the thing is, not how big:
     control  things you press or type into — buttons, inputs, rows, toggles
     surface  things that hold things — cards, panels, banners, photos
     tile     the cards you tap in a grid or list of many — upload results,
              search results, batch cards, tag tiles, the home page's tiles.
              Softer than a surface: at 3px a grid of them read as a
              spreadsheet, and a tile is the one place a corner says "pick me"
     inner    small parts inside a surface — thumbnails, bar fills, badges
     pill     capsule chips and counts; in this palette, small rectangles
   Circles (50%) and deliberately square corners (0) are never tokenised: a
   dot, a spinner, a round icon button and a radio stay round in both. */
:root[data-palette="brass"] {
  --radius-control: 2px;   /* the shopfront's own button radius */
  --radius-surface: 3px;   /* a step more, so large areas don't read as harsh */
  --radius-tile:    6px;   /* between that and blue's 10-14px */
  --radius-inner:   2px;
  --radius-pill:    2px;

  /* Bootstrap's components read these rather than ours: modals, alerts,
     dropdowns, pagination, list groups, form controls, tooltips and the
     `rounded` utility. -lg and up are its surfaces, the rest its controls. */
  --bs-border-radius:      2px;
  --bs-border-radius-sm:   2px;
  --bs-border-radius-lg:   3px;
  --bs-border-radius-xl:   3px;
  --bs-border-radius-xxl:  3px;
  --bs-border-radius-2xl:  3px;
  --bs-border-radius-pill: 2px;
}

/* Bootstrap writes these two corners as literals, so the tokens above don't
   reach them — found by reading the shipped 5.3.0 stylesheet, not by eye.
   Its toggle switch (2em) is deliberately left alone: a sliding switch drawn
   square reads as broken, not as precise. Radios stay circles for the same
   reason. */
:root[data-palette="brass"] .form-check-input[type="checkbox"],
:root[data-palette="brass"] .btn-close {
  border-radius: 2px;
}

/* The five places that fill with the accent and hardcode white on top. This
   gold carries white at 3.3:1 and dark ink at 5.3:1, so the labels flip. Both
   themes, because the fill is the same in both — unlike every earlier version
   of this palette, where only dark mode needed the exception. */
:root[data-palette="brass"] .btn-primary,
:root[data-palette="brass"] .sidebar-toggle,
:root[data-palette="brass"] .sidebar-custom .sidebar-nav-btn--primary,
:root[data-palette="brass"] .sidebar-custom .sidebar-nav-btn--primary:hover,
:root[data-palette="brass"] .home-tile--primary .home-tile-icon,
:root[data-palette="brass"] .btn-primary:hover {
  color: #131c24;
}

/* Bootstrap compiles its blue in as a literal in twenty-one places, not as
   --bs-primary, so overriding that token misses every one of them. These are
   the ones this app actually renders — found by grepping the shipped 5.3.0
   stylesheet for #0d6efd rather than by hunting for blue on screen, which is
   how the first pass missed them. */
:root[data-palette="brass"] .form-check-input:checked {
  background-color: var(--accent-primary);
  border-color: var(--accent-primary);
}

:root[data-palette="brass"] .form-check-input:focus {
  border-color: var(--accent-primary);
  box-shadow: 0 0 0 0.25rem rgb(var(--accent-rgb) / 0.25);
}

:root[data-palette="brass"] .form-select:focus {
  border-color: var(--accent-primary);
  box-shadow: 0 0 0 0.25rem rgb(var(--accent-rgb) / 0.25);
}

:root[data-palette="brass"] .pagination {
  --bs-pagination-active-bg: var(--accent-primary);
  --bs-pagination-active-border-color: var(--accent-primary);
  --bs-pagination-color: var(--accent-text);
  --bs-pagination-hover-color: var(--accent-hover);
  --bs-pagination-focus-color: var(--accent-hover);
  --bs-pagination-focus-box-shadow: 0 0 0 0.25rem rgb(var(--accent-rgb) / 0.25);
}

:root[data-palette="brass"] .list-group {
  --bs-list-group-active-bg: var(--accent-primary);
  --bs-list-group-active-border-color: var(--accent-primary);
  --bs-list-group-active-color: #131c24;
}

:root[data-palette="brass"] .pagination {
  --bs-pagination-active-color: #131c24;
}

/* Bootstrap's tick is a white stroke baked into a data URI, so it has to be
   redrawn rather than recoloured — and white does not read on this gold. */
:root[data-palette="brass"] .form-check-input:checked[type="checkbox"] {
  background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23131c24' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='m6 10 3 3 6-6'/%3e%3c/svg%3e");
}

:root[data-palette="brass"] .progress {
  --bs-progress-bar-bg: var(--accent-primary);
}

:root[data-palette="brass"] .nav-link:focus-visible,
:root[data-palette="brass"] .btn:focus-visible {
  box-shadow: 0 0 0 0.25rem rgb(var(--accent-rgb) / 0.25);
}

/* Likewise .alert-info, which is a blue panel from Bootstrap's own scale. */
:root[data-palette="brass"] .alert-info {
  --bs-alert-color: var(--text-primary);
  --bs-alert-bg: var(--bg-tertiary);
  --bs-alert-border-color: var(--border-medium);
}

/* Image optimization */
.image-container img {
  max-width: 100%;
  height: auto;
  object-fit: contain;
  border-radius: var(--radius-surface, 0.5rem);
}

.image-container img[data-bs-toggle="modal"] {
  cursor: pointer;
  transition: transform 0.2s ease, box-shadow 0.2s ease;
}

.image-container img[data-bs-toggle="modal"]:hover {
  transform: scale(1.02);
  box-shadow: var(--shadow-lg);
}

/* Responsive image sizing */
@media (max-width: 768px) {
  .image-container img {
    max-height: 250px;
  }
}

@media (max-width: 576px) {
  .image-container img {
    max-height: 200px;
  }
}

/* Modal image styling */
#modalImage {
  transition: transform 0.1s ease;
}

/* Base styles */
* {
  box-sizing: border-box;
}

/* Anything the browser scrolls to itself — an in-page anchor, a focused field
   brought into view — has to clear the fixed navbar, or it lands underneath it. */
html {
  scroll-padding-top: var(--navbar-h);
}

body {
  font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
  background-color: var(--bg-secondary);
  color: var(--text-primary);
  line-height: 1.6;
  margin: 0;
  /* The navbar is position: fixed and out of flow, so the page has to leave
     its height behind. One variable, so this can't drift from the navbar's own
     height or from the offsets the drawer and its backdrop use. */
  padding: var(--navbar-h) 0 0;
  transition: none; /* Remove transition to prevent flash */
  font-size: 1rem; /* Increased from default 14px */
}

/* Typography */
h1, h2, h3, h4, h5, h6 {
  color: var(--text-primary);
  font-weight: 600;
  line-height: 1.2;
  margin-bottom: 1rem;
}

h1 { font-size: 2.5rem; }
h2 { font-size: 2rem; }
h3 { font-size: 1.625rem; }
h4 { font-size: 1.375rem; }
h5 { font-size: 1.25rem; }
h6 { font-size: 1.125rem; }

/* Navigation. Fixed height (not just padding) + nowrap so it can never grow
   taller than --navbar-h and silently desync from the sidebar/overlay offset
   that assumes this exact height.

   POSITION: FIXED, NOT STICKY, and the difference matters here. The mobile
   drawer locks the page behind it with `position: fixed` on <body> (the only
   lock iOS Safari honours — see body.sidebar-open). A fixed body doesn't
   scroll, so a sticky child has nothing to stick to: it would render at its
   natural place in a document that has been shifted up by the scroll offset,
   i.e. off the top of the screen, exactly when the drawer needs it visible.
   A fixed element is positioned against the viewport regardless, so the two
   work together. It is out of flow, hence the padding-top on body. */
.navbar {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 1030;   /* over the page and the drawer, under Bootstrap's modals */
  background-color: var(--bg-primary) !important;
  border-bottom: 1px solid var(--border-light);
  box-shadow: var(--shadow-sm);
  height: var(--navbar-h);
  padding: 0;
  transition: all 0.3s ease;
}

.navbar .container-fluid {
  height: 100%;
  flex-wrap: nowrap;
}

/* The header has three slots in the markup — hamburger, brand, actions — but
   only ever shows two of them side by side on a desktop. Hiding the start slot
   here leaves the container with exactly two flex children, which is what
   Bootstrap's justify-content:space-between needs to put the brand hard left
   and the actions hard right. The phone layout below turns all three on. */
.navbar-side--start {
  display: none;
}

.navbar-side--end {
  min-width: 0;
}

.navbar-brand {
  font-weight: 600;
  font-size: 1.25rem;
  color: var(--text-primary) !important;
  white-space: nowrap;
}

/* The witch-on-a-key mark. Two PNGs are in the DOM and one is hidden, so the
   right colour is painted on first frame — the theme class is already on <html>
   by then (inline script in base.html's head), which a JS src swap couldn't
   match without flashing.

   The art is black-on-transparent with white detail lines cut out of it, so it
   cannot be recoloured with a filter or a mask; the inverse file is a real
   second asset. */
.brand-mark {
  display: inline-block;
  /* As large as the header can carry: the navbar is a fixed 64px (--navbar-h)
     with no padding of its own, and Bootstrap's .navbar-brand takes 5px above
     and below, so 46px leaves a comfortable margin and nothing can push the bar
     taller — which would silently desync the sidebar offset that assumes this
     exact height. One size at every width now: the mark carries the brand alone
     on a phone, and 30px was a size picked for sitting beside a word. */
  width: 46px;
  height: 46px;
  flex: 0 0 auto;
  line-height: 0;
}

.brand-mark img {
  width: 100%;
  height: 100%;
  object-fit: contain;
}

.brand-mark-inverse,
.dark-mode .brand-mark-ink {
  display: none;
}

.dark-mode .brand-mark-inverse {
  display: block;
}

/* Icon-only brand on phones — full lettering plus the hamburger and theme
   toggle was crowding narrow screens. The mark is what stays, which is the
   whole reason it has to read on its own at any width. */
@media (max-width: 767px) {
  .navbar-brand-text {
    display: none;
  }

  /* Brand dead centre, menu and actions pinned to the edges. A grid rather
     than flex because centring the middle child of a flex row only works when
     the two outside it happen to be the same width, and they never are — a
     lone hamburger on one side, "Sign Out" on the other.

     minmax(0, 1fr) rather than 1fr: an fr column won't shrink below its
     content by default, so a wide enough action group would quietly push the
     brand off centre — the exact failure this replaces. */
  .navbar .container-fluid {
    display: grid;
    grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
    align-items: center;
  }

  .navbar-side--start {
    display: flex;
    align-items: center;
    justify-self: start;
  }

  .navbar-brand {
    justify-self: center;
    margin-right: 0;
  }


  .navbar-side--end {
    justify-self: end;
  }
}

/* ── The signed-out header ──
   Brand on the left, one button on the right. Not a variant for its own sake:
   the landing, sign-in and sign-up pages each carry a large centred mark as
   the first thing on the page, and a second one centred in the bar directly
   above it read as a duplicate rather than as a header. On the left it is a
   masthead again.

   The word comes back with it at every width. The mark is hidden on a phone in
   the app because the header is crowded there — a hamburger, a name, a sign-out
   button — but this header holds two things, and a stranger who has never seen
   the mark needs to be told the site's name in words.

   After the media query, not inside it: the flex declaration has to beat
   `.navbar .container-fluid`'s grid at equal specificity, which only source
   order decides. */
.navbar--public .navbar-brand-text {
  display: inline;
}

.navbar--public .container-fluid {
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.navbar--public .navbar-brand {
  justify-self: start;
  margin-right: 0;
}

.dark-mode-toggle {
  background: var(--bg-tertiary);
  border: 1px solid var(--border-light);
  color: var(--text-primary);
  border-radius: var(--radius-control, 0.5rem);
  padding: 0.5rem 1rem;
  font-size: 0.875rem;
  font-weight: 500;
  transition: all 0.2s ease;
  cursor: pointer;
}

.dark-mode-toggle:hover {
  background: var(--border-light);
  transform: translateY(-1px);
}

/* Sidebar (widths live here, not inline in base.html: 300px desktop,
   240px tablet, off-canvas drawer under 768px) */
.sidebar-custom {
  background-color: var(--bg-primary) !important;
  border-right: 1px solid var(--border-light) !important;
  box-shadow: var(--shadow-md);
  transition: all 0.3s ease;
  width: 300px;
  min-width: 250px;
  max-width: 350px;
  flex-shrink: 0;
}

.sidebar-custom {
  padding: 1.5rem 1rem;
}

@media (min-width: 769px) and (max-width: 991px) {
  .sidebar-custom {
    width: 240px;
    min-width: 240px;
  }
}

/* Tap-outside-to-close backdrop for the mobile drawer */
.sidebar-overlay {
  display: none;
}

@media (max-width: 768px) {
  /* Stops below the navbar on purpose: the hamburger stays uncovered, so the
     control that opened the drawer is also the one that closes it. */
  .sidebar-overlay.show {
    display: block;
    position: fixed;
    inset: 0;
    top: var(--navbar-h);
    background: rgb(0 0 0 / 0.4);
    z-index: 999;
    touch-action: none;             /* no scrolling the page through the scrim */
  }
}

.sidebar-section {
  margin-bottom: 2rem;
}

.sidebar-title {
  font-size: 0.875rem;
  font-weight: 600;
  color: var(--text-secondary);
  text-transform: uppercase;
  letter-spacing: 0.05em;
  margin-bottom: 0.75rem;
  display: flex;
  align-items: center;
  justify-content: space-between;
}

/* Folder tree */
.explorer-folder {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0.5rem;
  border-radius: var(--radius-control, 0.375rem);
  cursor: pointer;
  transition: all 0.2s ease;
  font-size: 0.875rem;
  color: var(--text-primary);
}

.explorer-folder:hover {
  background-color: var(--bg-tertiary);
}

.explorer-arrow {
  width: 1rem;
  height: 1rem;
  color: var(--text-muted);
  transition: transform 0.2s ease;
}

.folder-icon {
  width: 1rem;
  height: 1rem;
  color: var(--accent-text);
}

.folder-add {
  background: none;
  border: none;
  color: var(--text-muted);
  padding: 0.25rem;
  border-radius: var(--radius-control, 0.25rem);
  cursor: pointer;
  transition: all 0.2s ease;
  opacity: 0;
  font-size: 0.875rem;
}

.explorer-folder:hover .folder-add {
  opacity: 1;
}

.folder-add:hover {
  color: var(--accent-text);
  background-color: var(--bg-tertiary);
}

/* Key items */
.key-item {
  display: block;
  padding: 0.5rem 0.75rem;
  border-radius: var(--radius-control, 0.375rem);
  color: var(--text-primary);
  text-decoration: none;
  transition: all 0.2s ease;
  font-size: 0.875rem;
  border-left: 2px solid transparent;
}

.key-item:hover {
  background-color: var(--bg-tertiary);
  border-left-color: var(--accent-primary);
  color: var(--text-primary);
  text-decoration: none;
  transform: translateX(2px);
}

/* Row below the navbar (sidebar + main content) */
.app-body {
  min-height: calc(100vh - var(--navbar-h));
}

/* Main content — the ONLY place a page's outer gutter is set.
   Pages must not add their own wrapper padding or top margin on top of this
   (a .container-fluid.mt-4 on the folder explorer is what made that page sit
   lower and narrower than every other one). Side padding grows with the
   viewport; the top stays put, because the gap above the first heading should
   look the same on a laptop as on a wide monitor.

   Top is deliberately smaller than the bottom: above the heading it's dead
   space before the page starts, below the last card it's just room to breathe
   at the end of a scroll. */
.main-content {
  padding: 1.25rem 1.5rem 2rem;
  max-width: 1400px;
  margin: 0 auto;
}

@media (min-width: 1200px) {
  .main-content {
    padding: 1.25rem 2.5rem 2rem;
  }
}

@media (min-width: 1400px) {
  .main-content {
    padding: 1.25rem 3rem 2rem;
  }
}

/* Cards */
.modern-card {
  background-color: var(--bg-primary);
  border-radius: var(--radius-surface, 0.75rem);
  border: 1px solid var(--border-light);
  box-shadow: var(--shadow-md);
  padding: 1.5rem;
  margin-bottom: 1.5rem;
  transition: all 0.3s ease;
}

.modern-card:hover {
  box-shadow: var(--shadow-lg);
  transform: translateY(-2px);
}

.card-header {
  border-bottom: 1px solid var(--border-light);
  padding-bottom: 1rem;
  margin-bottom: 1.5rem;
}

.card-title {
  font-size: 1.25rem;
  font-weight: 600;
  color: var(--text-primary);
  margin: 0;
}

/* Forms */
.form-group {
  margin-bottom: 1.5rem;
}

.form-label {
  display: block;
  margin-bottom: 0.5rem;
  font-weight: 500;
  color: var(--text-primary);
  font-size: 0.875rem;
}

.form-control {
  width: 100%;
  padding: 0.75rem;
  border: 1px solid var(--border-medium);
  border-radius: var(--radius-control, 0.5rem);
  background-color: var(--bg-primary);
  color: var(--text-primary);
  font-size: 1rem;
  transition: all 0.2s ease;
}

.form-control:focus {
  outline: none;
  border-color: var(--accent-primary);
  box-shadow: 0 0 0 3px rgb(var(--accent-rgb) / 0.1);
}

.form-control:disabled {
  background-color: var(--bg-tertiary);
  color: var(--text-muted);
}

/* The actual <input type=file> is visually hidden; it's triggered by the
   native <label for=...> (bulletproof cross-browser/touch trigger) or set
   programmatically by the camera capture below. Never use display:none on a
   file input some browsers stop firing events on a fully undisplayed input;
   the standard "clipped to nothing" technique keeps it functional. */
.visually-hidden-file-input {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

/* Photo picker: two big obvious buttons (Take Photo / Choose Photo) side by
   side. A real-user test showed the previous single dashed-box-with-overlay
   pattern wasn't reliably tappable on mobile. */
.photo-picker {
  display: flex;
  gap: 0.75rem;
}

.photo-picker-btn {
  flex: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 0.4rem;
  padding: 1.1rem 0.75rem;
  border: 2px solid var(--border-medium);
  border-radius: var(--radius-control, 0.75rem);
  background-color: var(--bg-primary);
  color: var(--text-primary);
  font-weight: 600;
  font-size: 0.9rem;
  text-align: center;
  cursor: pointer;
  transition: all 0.2s ease;
}

.photo-picker-btn:hover {
  border-color: var(--accent-primary);
  background-color: rgb(var(--accent-rgb) / 0.06);
}

.photo-picker-btn:active {
  transform: scale(0.98);
}

.photo-picker-btn--primary {
  border-color: var(--accent-primary);
  background-color: rgb(var(--accent-rgb) / 0.08);
  color: var(--accent-text);
}

@media (max-width: 480px) {
  .photo-picker {
    flex-direction: column;
  }
}

/* Live camera capture panel */
.camera-panel {
  margin-top: 0.5rem;
}

/* Height is capped in viewport units, not just by the 3:4 ratio: on a phone a
   ratio-sized preview pushed the zoom row and shutter below the fold, and the
   whole point of a live preview is seeing it and the shutter at the same time.
   The video is object-fit:cover, so a shorter box just crops the view. */
.camera-video-wrap {
  position: relative;
  width: 100%;
  max-width: 420px;
  margin: 0 auto;
  aspect-ratio: 3 / 4;
  max-height: 50vh;
  overflow: hidden;
  border-radius: var(--radius-surface, 0.75rem);
  background: #000;
}

.camera-video-wrap video {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* Dashed framing guide. This IS the crop — the shutter captures exactly the
   region inside it (key_upload.html reads this element's box), so what you
   framed is what the CV pipeline sees. It sits flush with the top of the
   preview on purpose: a key photographed head-up has its ring above the head,
   and anything above the guide's top edge is now discarded rather than being
   silently included in the "whole photo" the pipeline used to get. */
.camera-guide {
  position: absolute;
  top: 0;
  left: 50%;
  transform: translateX(-50%);
  width: 62%;
  height: 82%;
  border: 3px dashed rgb(255 255 255 / 0.9);
  border-radius: 0 0 var(--radius-surface, 0.75rem) var(--radius-surface, 0.75rem);
  box-shadow: 0 0 0 999px rgb(0 0 0 / 0.45);
  pointer-events: none;
}

.camera-guide-caption {
  position: absolute;
  bottom: 0.75rem;
  left: 0;
  right: 0;
  text-align: center;
  color: #fff;
  font-size: 0.8rem;
  font-weight: 500;
  text-shadow: 0 1px 3px rgb(0 0 0 / 0.8);
  pointer-events: none;
}

/* Stepped zoom (−/+). Uses the camera's optical zoom where the browser exposes
   it, otherwise digital zoom (crop into the full-res capture) — see
   key_upload.html. A slider was too hard to place precisely with a thumb. */
.camera-zoom {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 0.5rem;
  max-width: 420px;
  margin: 0.6rem auto 0;
}

.camera-zoom-label {
  font-size: 0.8rem;
  color: var(--text-secondary);
  flex-shrink: 0;
  margin-right: 0.25rem;
}

.camera-zoom-btn {
  width: 2.5rem;
  height: 2.5rem;
  flex-shrink: 0;
  border-radius: 50%;
  border: 1px solid var(--border-medium);
  background: var(--bg-primary);
  color: var(--text-primary);
  font-size: 1.25rem;
  font-weight: 600;
  line-height: 1;
  cursor: pointer;
  transition: all 0.15s ease;
}

.camera-zoom-btn:active {
  transform: scale(0.94);
  background: var(--bg-tertiary);
}

.camera-zoom-btn:disabled {
  opacity: 0.4;
  cursor: not-allowed;
}

.camera-zoom-value {
  font-size: 0.95rem;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  color: var(--text-primary);
  width: 3.2rem;
  text-align: center;
  flex-shrink: 0;
}

.camera-hint {
  max-width: 420px;
  margin: 0.5rem auto 0;
  font-size: 0.78rem;
  color: var(--warning);
  text-align: center;
}

/* Brief flash to acknowledge a tap-to-focus */
.camera-video-wrap.focus-ping .camera-guide {
  border-color: var(--accent-primary);
}

/* Capture confirmation: a bright white flash (mimics a physical camera
   shutter) plus a checkmark badge — fired together on every shutter press so
   it's unmistakable a photo was taken, even when firing several shots in a
   row. Both are inert until camera_capture.js adds the trigger class. */
.camera-flash {
  position: absolute;
  inset: 0;
  background: #fff;
  opacity: 0;
  pointer-events: none;
  z-index: 3;
}

.camera-flash.flash {
  animation: cameraFlashPulse 320ms ease-out;
}

@keyframes cameraFlashPulse {
  0%   { opacity: 0.85; }
  100% { opacity: 0; }
}

.camera-capture-badge {
  position: absolute;
  top: 50%;
  left: 50%;
  display: flex;
  align-items: center;
  gap: 0.4rem;
  transform: translate(-50%, -50%) scale(0.7);
  background: rgba(16, 185, 129, 0.95);
  color: #fff;
  font-weight: 700;
  font-size: 0.95rem;
  padding: 0.5rem 1rem;
  border-radius: var(--radius-pill, 2rem);
  opacity: 0;
  pointer-events: none;
  z-index: 4;
  box-shadow: 0 8px 20px rgb(0 0 0 / 0.35);
}

.camera-capture-badge.show {
  animation: cameraBadgePop 900ms cubic-bezier(0.34, 1.56, 0.64, 1);
}

@keyframes cameraBadgePop {
  0%   { opacity: 0; transform: translate(-50%, -50%) scale(0.6); }
  15%  { opacity: 1; transform: translate(-50%, -50%) scale(1.08); }
  25%  { opacity: 1; transform: translate(-50%, -50%) scale(1); }
  75%  { opacity: 1; transform: translate(-50%, -50%) scale(1); }
  100% { opacity: 0; transform: translate(-50%, -50%) scale(1); }
}

.camera-controls {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 1.5rem;
  margin-top: 0.6rem;
}

.camera-controls-spacer {
  width: 4.25rem; /* balances the Cancel button so the shutter stays centered */
}

.camera-shutter {
  width: 60px;
  height: 60px;
  border-radius: 50%;
  border: 4px solid var(--border-medium);
  background: #fff;
  cursor: pointer;
  padding: 0;
  flex-shrink: 0;
}

.camera-shutter:active {
  transform: scale(0.94);
}

/* Briefly dead after a shot — see SHUTTER_COOLDOWN_MS in camera_capture.js.
   A button that ignores a press while still looking live reads as a missed
   press, and the natural response to that is to press it again, which is the
   double-shot this exists to prevent. The ring drains back over the cooldown
   so the wait is visibly finite. */
.camera-shutter--cooling {
  cursor: default;
  opacity: 0.55;
  transform: scale(0.94);
  border-color: var(--accent-primary);
  animation: shutter-cool 0.9s linear;
}

.camera-shutter--cooling:active {
  transform: scale(0.94);
}

@keyframes shutter-cool {
  from { opacity: 0.35; }
  to   { opacity: 0.75; }
}

@media (prefers-reduced-motion: reduce) {
  .camera-shutter--cooling { animation: none; }
}

/* Preview + "use a different photo" once a file is present */
.photo-status {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 0.6rem;
}

.upload-preview {
  display: block;
  max-width: 100%;
  max-height: 220px;
  border-radius: var(--radius-surface, 0.5rem);
  border: 1px solid var(--border-light);
  box-shadow: var(--shadow-sm);
}

/* The photo rules above the camera button (mainkey/partials/photo_tips.html).
   It was one line of prose, on the theory that the camera guide teaches framing
   visually; the guide does teach framing, but not "one key", "off the blade" or
   "matte background", and a sentence of grey text under a big button reads as a
   disclaimer and goes unread.

   A bordered panel, not loose text. On a phone it sits between the ways of
   getting a photo in and the batch options, and unframed it read as debris
   between two controls rather than as one block of advice.

   It is still redundant while the live preview is up, and hiding it there buys
   the room the preview needs — but the pages hide it themselves (the bulk
   page's CameraCapture onOpen/onClose), because the camera panel isn't a
   sibling of this block on either of them. */
.photo-tips {
  margin-top: 0.9rem;
  padding: 0.75rem 0.9rem;
  border: 1px solid var(--border-light);
  border-radius: var(--radius-surface, 0.7rem);
  background-color: var(--bg-secondary);
  font-size: 0.8rem;
  color: var(--text-secondary);
}

.photo-tips-heading {
  margin: 0 0 0.5rem;
  font-size: 0.7rem;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--text-muted);
}

/* One per line on a phone. Two columns looked worse than it sounds: a grid row
   is as tall as its taller item, so a one-line point beside a two-line one left
   a visible hole, and these six can't all be cut to one line without losing the
   half that says what to do. Columns come back once there's width for every
   point to fit on its own line. */
.photo-tips-list {
  display: grid;
  grid-template-columns: 1fr;
  gap: 0.3rem 1.2rem;
  list-style: none;
  padding: 0;
  margin: 0;
}

@media (min-width: 576px) {
  .photo-tips-list {
    grid-template-columns: repeat(2, 1fr);
  }
}

@media (min-width: 992px) {
  .photo-tips-list {
    grid-template-columns: repeat(3, 1fr);
  }
}

.photo-tips-list li {
  position: relative;
  padding-left: 0.85rem;
  line-height: 1.45;
}

.photo-tips-list li::before {
  content: "";
  position: absolute;
  left: 0;
  top: 0.5rem;
  width: 0.3rem;
  height: 0.3rem;
  border-radius: 50%;
  background-color: var(--accent-primary);
}

.photo-tips-list strong {
  color: var(--text-primary);
  font-weight: 700;
  letter-spacing: 0.02em;
}

/* Folded away: this is for the first visit and the failed one, not the
   hundredth. */
.photo-examples {
  margin-top: 0.7rem;
  padding-top: 0.6rem;
  border-top: 1px solid var(--border-light);
}

/* Not underlined. An underline in this app means "this navigates somewhere";
   this opens a drawer on the page it is already on, so it gets the shape of a
   control instead.

   A full-width centred strip rather than a pill: the panel it sits in already
   draws a border, so a second small outlined shape inside it was one box too
   many, and centred it reads as the foot of the panel — which is what it is —
   instead of as a stray tag pinned to the left. The caret follows the words and
   turns when it opens, the same language as the batch-options group. */
.photo-examples > summary {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 0.4rem;
  width: 100%;
  cursor: pointer;
  padding: 0.15rem 0;
  font-size: 0.8rem;
  font-weight: 600;
  color: var(--text-secondary);
  list-style: none;
  transition: color 0.15s ease;
}

.photo-examples > summary::-webkit-details-marker {
  display: none;
}

/* Down when shut, up when open — a caret drawn from two borders, the same one
   the batch-options group uses, rather than a second kind of arrow. */
.photo-examples > summary::after {
  content: "";
  width: 0.4rem;
  height: 0.4rem;
  border-right: 2px solid var(--text-muted);
  border-bottom: 2px solid var(--text-muted);
  transform: rotate(45deg);
  margin-top: -0.2rem;
  transition: transform 0.15s ease;
}

.photo-examples[open] > summary::after {
  transform: rotate(-135deg);
  margin-top: 0.15rem;
}

/* Only where a pointer can actually hover: on a phone :hover sticks after the
   tap and clears only when something else is touched, so a control that just
   collapsed sits there still lit. */
@media (hover: hover) and (pointer: fine) {
  .photo-examples > summary:hover {
    color: var(--accent-text);
  }
  .photo-examples > summary:hover::after {
    border-color: var(--accent-primary);
  }
}

@media (prefers-reduced-motion: reduce) {
  .photo-examples > summary::after { transition: none; }
}

.photo-examples-defs {
  position: absolute;
  width: 0;
  height: 0;
}

.photo-examples-grid {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: 0.7rem;
  margin-top: 0.7rem;
}

@media (min-width: 576px) {
  .photo-examples-grid {
    grid-template-columns: repeat(3, 1fr);
  }
}

@media (min-width: 992px) {
  .photo-examples-grid {
    grid-template-columns: repeat(6, 1fr);
  }
}

.photo-example {
  margin: 0;
}

.photo-example svg {
  display: block;
  width: 100%;
  height: auto;
  border-radius: var(--radius-surface, 0.5rem);
  background-color: var(--bg-primary);
}

/* The frame is the photo's edge, so "head cropped off" has something to be
   cropped by. */
.pe-frame {
  fill: none;
  stroke: var(--border-medium);
  stroke-width: 2;
}

.pe-key {
  color: var(--text-secondary);
}

.photo-example--good .pe-key {
  color: var(--accent-text);
}

/* Everything that shouldn't be lying across the blade, drawn back so the key
   still reads as the subject. */
.pe-clutter {
  stroke: var(--text-muted);
  fill: none;
  opacity: 0.75;
}

.pe-shadow {
  fill: var(--text-primary);
  opacity: 0.28;
}

.photo-example figcaption {
  margin-top: 0.3rem;
  font-size: 0.72rem;
  line-height: 1.35;
  color: var(--text-secondary);
}

.pe-mark {
  font-weight: 700;
  color: var(--danger);
}

.pe-mark--good {
  color: var(--success);
}

.photo-examples-note {
  margin: 0.7rem 0 0;
  font-size: 0.75rem;
  color: var(--text-muted);
}

/* Plain-language verdict banner on the recognition result pages.
   The border carries the verdict on its own, before any of the words are read:
   GREEN whenever the app is telling you it found the key among your saved ones
   (a recognition match, or a duplicate on enroll), RED whenever it isn't,
   AMBER when the photo couldn't be read and there is no verdict at all. */
.verdict-banner {
  display: flex;
  align-items: flex-start;
  gap: 0.75rem;
  padding: 0.9rem 1.1rem;
  border-radius: var(--radius-surface, 0.6rem);
  margin-bottom: 1.25rem;
}

.verdict-match,
.verdict-duplicate {
  background-color: rgb(16 185 129 / 0.1);
  border: 2px solid var(--success);
  color: var(--success);
}

.verdict-notfound {
  background-color: rgb(239 68 68 / 0.08);
  border: 2px solid var(--danger);
  color: var(--danger);
}

/* Neither of the above: the photo couldn't be read, so there is no answer
   about the key at all. Amber so it can't be mistaken for either. */
.verdict-unreadable {
  background-color: rgb(245 158 11 / 0.1);
  border: 2px dashed var(--warning);
  color: var(--warning);
}

.verdict-banner .verdict-title {
  font-weight: 600;
  font-size: 1.05rem;
  color: var(--text-primary);
}

/* Two shapes of verdict, and they want opposite treatments:

   On /identify/ the title IS the answer — "This is 'Front door'" — so it stays
   bold, and there is no subject span.
   In the duplicate modal the title is only a lead-in to a name that follows it
   in a `.verdict-title-subject`. There, 600 on the sentence against 700 on the
   name is one step apart, which is no step at all: the banner read as one long
   bold line with nothing leading it. `--lead` hands the weight to the name.

   A modifier rather than `:has(.verdict-title-subject)` so the intent is
   readable in the template, where the choice actually belongs. */
.verdict-banner .verdict-title--lead {
  font-weight: 400;
  font-size: 1rem;
  color: var(--text-secondary);
}

/* The thing the verdict is ABOUT — a key's name — on its own line, bigger than
   the sentence introducing it, and the only bold thing in the banner. */
.verdict-banner .verdict-title-subject {
  display: block;
  font-weight: 700;
  font-size: 1.3rem;
  line-height: 1.25;
  color: var(--text-primary);
  margin-top: 0.2rem;
}

.verdict-banner .verdict-sub {
  font-size: 0.875rem;
  color: var(--text-secondary);
  margin-top: 0.1rem;
}

/* Query vs matched-key photo comparison (recognition result page). Side by
   side at EVERY width including mobile (nowrap) — the ask was "not stacked,
   but not cramped either" — both photos the same size (the matched key used
   to be bigger, but equal weight reads better). */
.key-compare-row {
  display: flex;
  flex-wrap: nowrap;
  gap: 0.75rem;
  justify-content: center;
  align-items: flex-start;
}

.key-compare-card {
  min-width: 0; /* let a flex child shrink below its image's natural size */
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
}

.key-compare-card--query,
.key-compare-card--match {
  flex: 1;
}

.key-compare-label {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 0.4rem;
  font-weight: 600;
  font-size: 0.95rem;
  margin-bottom: 0.6rem;
}

.key-compare-dot {
  width: 10px;
  height: 10px;
  border-radius: 50%;
  flex-shrink: 0;
}

.key-compare-dot--query { background: var(--query-color); }
.key-compare-dot--match { background: var(--match-color); }

/* The arrow between the two photos. It says the same thing the labels say —
   this photo IS that saved key — but says it in the gap, where the eye is
   already travelling from one image to the other.

   Neutral, not coloured: blue and yellow are spoken for either side of it, and
   a third colour in a 40px gap would read as a third thing rather than as the
   relation between the two. Aligned to centre rather than inheriting the row's
   flex-start, so it sits against the middle of the photos and not up against
   the labels. `flex: 0 0 auto` keeps it out of the two cards' flex share, so
   adding it doesn't shrink the images. */
.key-compare-arrow {
  flex: 0 0 auto;
  align-self: center;
  display: flex;
  align-items: center;
  /* Muted, not secondary: it is a connector, not a third label. Quiet enough
     that the two photos and their coloured borders stay the thing being
     looked at, present enough that the relation between them is never in
     doubt. */
  color: var(--text-muted);
}

/* Sized here rather than by the SVG's own width/height attributes, so the two
   compare rows can't drift apart. Large enough to read as the thing joining
   the photos rather than as punctuation between them, and no larger — it is
   the relation, not the subject. */
.key-compare-arrow svg {
  width: 40px;
  height: 40px;
}

/* The coloured border IS the labelling here — query blue, match yellow — so it
   has to trace the visible photo exactly and be the same thickness the whole
   way round. Three things fight that, and all three need handling together:

   1. A later `.image-container img { width: 100% }` rule (further down this
      file) doesn't compete on specificity — it wins per-property, since
      nothing here declared `width`. Forcing the img to full container width
      then letting object-fit:contain shrink the actual photo inside it left
      the border tracing an oversized invisible box, with the photo (and the
      illusion of the border) floating off-centre inside it. `width: auto`
      restores intrinsic sizing so the border hugs the photo.
   2. `.image-container` carries its own border-radius + overflow: hidden for
      the plain images elsewhere, which reshapes this element's corners no
      matter what radius is set on the <img>. So the two radii have to be the
      SAME or the corners disagree — hence `image-container--flush`, which
      takes the parent out of the way entirely.
   3. The parent also carries `box-shadow: var(--shadow-md)` and a hover
      `scale(1.02)`, both drawn on a full-width block sitting behind a photo
      that is usually narrower. That put a rounded shadow rectangle around a
      square-cornered photo — two boxes of different widths and different
      corners, which is what actually read as unfinished. --flush drops both.

   `--flush` is not optional decoration: every `.key-compare-image` wrapper
   must carry it, or the corners and the shadow come back. */
.image-container img.key-compare-image {
  width: auto;
  max-width: 100%;
  object-fit: contain;
  border-radius: var(--radius-surface, 0.5rem);
  cursor: pointer;
  border: 3px solid transparent;
}

.image-container.image-container--flush {
  border-radius: 0;
  overflow: visible;
  box-shadow: none;
}

/* The parent's hover-scale is on the container, so --flush has to cancel it
   there too — otherwise the photo still jumps under the cursor while its
   shadow is gone, which reads as a glitch rather than as feedback. */
.image-container.image-container--flush:hover {
  box-shadow: none;
  transform: none;
}

/* The query border is the photo just taken, and the question on this screen
   is about the OTHER one — which saved key it matched. At full strength the two
   borders competed, and the blue one, being the colder and more saturated,
   won. It stays blue so it still ties to the blue line in the overlay plot
   (and --query-color itself stays pinned, see the note at the top of this
   file), but at 40% it steps back and lets the match lead. The legend dot
   beside the label stays solid: that is the part doing the plot-matching. */
.image-container img.key-compare-image--query {
  border-color: color-mix(in srgb, var(--query-color) 40%, transparent);
  max-height: 260px;
}

.image-container img.key-compare-image--match {
  border-color: var(--match-color);
  max-height: 260px;
}

/* No-match page: the closest key is a candidate, not a result, so its border
   is dashed. Same high specificity as the base rule above, or the shorthand's
   implicit `border-style: solid` wins and the dashes never show (this is why
   it looked solid on the deployed build). */
.image-container img.key-compare-image--closest {
  border-style: dashed;
}

.key-compare-view-btn {
  margin-top: 0.75rem;
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
  background: var(--match-color);
  color: #1e1300;
  font-weight: 700;
  font-size: 0.9rem;
  border: none;
  border-radius: var(--radius-control, 0.5rem);
  padding: 0.6rem 1.1rem;
  text-decoration: none;
  box-shadow: var(--shadow-sm);
  transition: all 0.15s ease;
}

.key-compare-view-btn:hover {
  background: #ca9a04;
  color: #1e1300;
  transform: translateY(-1px);
  box-shadow: var(--shadow-md);
}

/* Blade profile overlay on the match result page. Deliberately compact on
   desktop so the About card below pokes above the fold — at full size the
   overlay filled the viewport and nothing hinted there was more to scroll to. */
.image-container img.key-overlay-image {
  max-height: 350px;
  border-radius: var(--radius-surface, 0.5rem);
  cursor: pointer;
}

@media (min-width: 768px) {
  .image-container img.key-overlay-image {
    max-height: 230px;
  }
}

/* Emphasised block: the thing on the page worth looking at first. Strength
   comes from a much darker/lighter (theme-adaptive) border colour rather than
   extra thickness — var(--text-primary) gives a strong outline in both light
   and dark mode without pulling in an accent colour that would clash with the
   query/match colour coding used elsewhere.

   It wraps the Key Information list on the key detail page, not that page's
   card. Around the whole card it outlined everything at once, which singles
   out nothing; around the list it points at the details themselves. */
.card-emphasis {
  border: 2px solid var(--text-primary);
  box-shadow: var(--shadow-lg);
}

/* Bottom-of-card CTA to the matched key's detail page. The label is a full
   sentence, so let it wrap centred instead of shrinking, and make it a
   full-width tap target on phones. */
.key-compare-view-btn--cta {
  justify-content: center;
  text-align: center;
}

@media (max-width: 576px) {
  .key-compare-view-btn--cta {
    width: 100%;
  }
}

/* No-match page: the closest key gets a dashed, unfilled View button to match
   its dashed photo border — solid yellow is reserved for an actual match. */
.key-compare-view-btn--ghost {
  background: transparent;
  border: 2px dashed var(--match-color);
  color: var(--text-primary);
  font-weight: 600;
  box-shadow: none;
}

.key-compare-view-btn--ghost:hover {
  background: rgb(234 179 8 / 0.12);
  color: var(--text-primary);
  transform: none;
  box-shadow: none;
}

/* Inline name + enroll button on the "Enroll New Key" card; wraps on mobile */
.enroll-new-row {
  display: flex;
  gap: 0.6rem;
  flex-wrap: wrap;
}

.enroll-new-row .form-control {
  flex: 1 1 220px;
}

.enroll-new-row .btn {
  flex-shrink: 0;
}

@media (max-width: 576px) {
  /* When the row wraps on phones, a full-width button beats a stubby
     left-aligned one. */
  .enroll-new-row .btn {
    flex: 1 1 100%;
  }
}

@media (max-width: 576px) {
  .key-compare-row { gap: 0.35rem; }
  .key-compare-arrow svg { width: 26px; height: 26px; }
  .image-container img.key-compare-image--query,
  .image-container img.key-compare-image--match { max-height: 170px; }
  .key-compare-label { font-size: 0.8rem; }
  .key-compare-view-btn { font-size: 0.8rem; padding: 0.5rem 0.8rem; }
}

@media (min-width: 577px) and (max-width: 767px) {
  .image-container img.key-compare-image--query,
  .image-container img.key-compare-image--match { max-height: 220px; }
}

/* Radio buttons */
.radio-group {
  display: flex;
  gap: 1rem;
  margin-top: 0.5rem;
}

.radio-option {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  padding: 0.75rem 1rem;
  border: 1px solid var(--border-medium);
  border-radius: var(--radius-control, 0.5rem);
  cursor: pointer;
  transition: all 0.2s ease;
  background-color: var(--bg-primary);
  flex: 1;
}

.radio-option input[type="radio"] {
  margin: 0;
  accent-color: var(--accent-primary);
  flex-shrink: 0;
}

.radio-option-body {
  font-size: 0.9rem;
  color: var(--text-secondary);
}

.radio-option-body strong {
  color: var(--text-primary);
}

@media (max-width: 576px) {
  .radio-group {
    flex-direction: column;
    gap: 0.5rem;
  }
}

.radio-option:hover {
  border-color: var(--accent-primary);
  background-color: var(--bg-tertiary);
}

.radio-option.selected {
  border-color: var(--accent-primary);
  background-color: rgb(var(--accent-rgb) / 0.05);
}

/* Buttons */
.btn {
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0.75rem 1.5rem;
  border-radius: var(--radius-control, 0.5rem);
  font-weight: 500;
  font-size: 1rem;
  text-decoration: none;
  cursor: pointer;
  transition: all 0.2s ease;
  border: 1px solid transparent;
}

/* Give the outline variants their outline back. Bootstrap's own `.btn` draws
   it with `border: … solid var(--bs-btn-border-color)`, and the rule above —
   same specificity, later in the cascade — was replacing that with
   `transparent` for every button on the site. The outline variants set only
   the custom property, so they had no visible border at all and read as bare
   coloured text; .key-manage-btn's shadow exists because someone noticed
   "Manage Tags" looking like a label rather than something you press. */
[class*="btn-outline-"] {
  border-color: var(--bs-btn-border-color);
}

/* Center content only for specific form buttons, not navigation buttons */
.btn[type="submit"],
input[type="submit"].btn,
.modal .btn,
form .btn {
  justify-content: center;
  text-align: center;
}

/* Mobile menu button. Filled in the accent colour, because on a phone it's the
   only route to Search, Folders, Tags and the previous batch — an outlined icon
   is easy to miss entirely if you aren't already hunting for a hamburger. It
   used to carry the word "Menu" too; the fill does that job alone now, and the
   room went to centring the brand.

   Sized by padding rather than a fixed width/height: the button sits inside a
   flex slot inside a grid column, and a fixed box in that chain came out as a
   dot on a real phone. Padding around an explicitly-sized icon can't collapse
   the same way. line-height:0 because there is no text left to leave room for,
   and the radius is stated so nothing upstream can round it into a circle. */
.sidebar-toggle {
  background-color: var(--accent-primary);
  border: 1px solid var(--accent-primary);
  color: #ffffff;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex: 0 0 auto;
  padding: 0.45rem 0.55rem;
  border-radius: var(--radius-control, 0.5rem);
  line-height: 0;
}

/* In CSS, not just the element's width/height attributes, so no flex or grid
   ancestor can shrink the three lines away to nothing. */
.sidebar-toggle svg {
  display: block;
  width: 22px;
  height: 22px;
  flex: 0 0 auto;
}
.sidebar-toggle:hover,
.sidebar-toggle:focus {
  background-color: var(--accent-hover);
  border-color: var(--accent-hover);
  color: #ffffff;
}

/* ── Help tips ─────────────────────────────────────────────────────────────
   A small "?" beside a label that explains what the thing does. Hover on a
   pointer device, tap on a touch one — it's a real <button>, so a tap focuses
   it and Bootstrap's tooltip fires on focus as well as hover. Never the only
   place an explanation lives; it's for the detail that would clutter the page
   if it were always on screen. */
.help-tip {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex: 0 0 auto;
  width: 1.05rem;
  height: 1.05rem;
  padding: 0;
  margin-left: 0.35rem;
  border: 1px solid var(--text-muted);
  border-radius: 50%;
  background: transparent;
  color: var(--text-muted);
  font-size: 0.7rem;
  font-weight: 700;
  line-height: 1;
  cursor: help;
  vertical-align: middle;
}
.help-tip:hover,
.help-tip:focus-visible {
  border-color: var(--accent-primary);
  color: var(--accent-text);
  outline: none;
}

/* Sidebar nav buttons: one accent (Upload, the primary action) + neutrals.
   Replaces four different outline colours (yellow/blue/cyan/green) that read
   as scattered on a light background. */
.sidebar-custom .sidebar-nav-btn {
  font-size: 1rem;
  padding: 0.6rem 1rem;
  color: var(--text-primary);
  background-color: var(--bg-primary);
  border: 1px solid var(--border-medium);
}

.sidebar-custom .sidebar-nav-btn:hover {
  background-color: var(--bg-tertiary);
  border-color: var(--accent-primary);
  color: var(--text-primary);
}

.sidebar-custom .sidebar-nav-btn--primary {
  background-color: var(--accent-primary);
  border-color: var(--accent-primary);
  color: #ffffff;
}

.sidebar-custom .sidebar-nav-btn--primary:hover {
  background-color: var(--accent-hover);
  border-color: var(--accent-hover);
  color: #ffffff;
}

/* The page you're already on. On a phone the sidebar is a drawer you opened
   deliberately, so the first question on opening it is "where am I?" — and
   until now nothing in it answered. A left accent bar plus a filled row, so
   it survives being read in greyscale and doesn't rely on colour alone;
   aria-current="page" in sidebar.html says the same thing to a screen reader. */
.sidebar-custom .sidebar-nav-btn--active {
  background-color: var(--bg-tertiary);
  border-color: var(--accent-primary);
  font-weight: 600;
}

/* Upload is already accent-filled, so the outline can't do the work there.
   Deepen the fill instead. */
.sidebar-custom .sidebar-nav-btn--primary.sidebar-nav-btn--active {
  background-color: var(--accent-hover);
  border-color: var(--accent-hover);
  color: #ffffff;
}

/* Ensure sidebar navigation buttons stay left-aligned */
.sidebar-custom .btn {
  justify-content: flex-start;
  text-align: left;
}

.btn-primary {
  background-color: var(--accent-primary);
  color: white;
  border-color: var(--accent-primary);
}

.btn-primary:hover {
  background-color: var(--accent-hover);
  border-color: var(--accent-hover);
  transform: translateY(-1px);
  box-shadow: var(--shadow-md);
}

.btn-secondary {
  background-color: var(--bg-tertiary);
  color: var(--text-primary);
  border-color: var(--border-medium);
}

.btn-secondary:hover {
  background-color: var(--border-light);
  transform: translateY(-1px);
}

.btn-success {
  background-color: var(--success);
  color: white;
  border-color: var(--success);
}

.btn-success:hover {
  background-color: var(--success-hover);
  border-color: var(--success-hover);
}

.btn-sm {
  padding: 0.5rem 1rem;
  font-size: 0.875rem;
}

.btn-lg {
  padding: 1rem 2rem;
  font-size: 1.125rem;
}

/* Images */
.image-container {
  position: relative;
  border-radius: var(--radius-surface, 0.75rem);
  overflow: hidden;
  box-shadow: var(--shadow-md);
  transition: all 0.3s ease;
}

.image-container:hover {
  box-shadow: var(--shadow-lg);
  transform: scale(1.02);
}

.image-container img {
  width: 100%;
  height: auto;
  display: block;
}

/* List groups */
.list-group {
  border-radius: var(--radius-surface, 0.5rem);
  overflow: hidden;
  border: 1px solid var(--border-light);
}

.list-group-item {
  background-color: var(--bg-primary);
  border-color: var(--border-light);
  color: var(--text-primary);
  padding: 1rem;
  border-bottom: 1px solid var(--border-light);
}

.list-group-item:last-child {
  border-bottom: none;
}

.list-group-item strong {
  color: var(--text-primary);
  font-weight: 600;
}

/* Alerts */
.alert {
  padding: 1rem 1.25rem;
  border-radius: var(--radius-surface, 0.5rem);
  border: 1px solid transparent;
  margin-bottom: 1.5rem;
}

.alert-success {
  background-color: rgb(16 185 129 / 0.1);
  border-color: var(--success);
  color: var(--success);
}

.alert-warning {
  background-color: rgb(245 158 11 / 0.1);
  border-color: var(--warning);
  color: var(--warning);
}

.alert-danger {
  background-color: rgb(239 68 68 / 0.1);
  border-color: var(--danger);
  color: var(--danger);
}

/* Accordion */
.accordion {
  border-radius: var(--radius-surface, 0.5rem);
  overflow: hidden;
  border: 1px solid var(--border-light);
}

.accordion-item {
  border: none;
  background-color: var(--bg-primary);
}

.accordion-header {
  margin: 0;
}

.accordion-button {
  background-color: var(--bg-primary);
  color: var(--text-primary);
  border: none;
  padding: 1rem 1.25rem;
  font-weight: 500;
  border-bottom: 1px solid var(--border-light);
}

.accordion-button:not(.collapsed) {
  background-color: var(--bg-tertiary);
  color: var(--accent-text);
}

.accordion-button:focus {
  box-shadow: 0 0 0 3px rgb(var(--accent-rgb) / 0.1);
}

.accordion-body {
  padding: 1.25rem;
  background-color: var(--bg-primary);
}

/* Utilities */
.text-center { text-align: center; }
.text-muted { color: var(--text-muted); }
.text-muted-dark {
  color: var(--text-muted);
  opacity: 0.85;
}
.dark-mode .text-muted-dark {
  /* From the token, not a literal slate — this was the one piece of muted text
     that stayed blue-grey whatever palette was chosen. */
  color: var(--text-secondary);
  opacity: 1;
}
.mb-3 { margin-bottom: 1rem; }
.mb-4 { margin-bottom: 1.5rem; }
.mt-3 { margin-top: 1rem; }
.mt-4 { margin-top: 1.5rem; }
.d-flex { display: flex; }
.align-items-center { align-items: center; }
.justify-content-between { justify-content: space-between; }
.gap-2 { gap: 0.5rem; }
.gap-3 { gap: 0.75rem; }

/* Responsive design */
@media (max-width: 768px) {
  /* 1rem read as edge-to-edge/cramped on real phones ("looks amateur") —
     a bit more breathing room on the sides the content sits in. This is the
     only mobile rule for it: a second `.main-content { padding: 1rem }` in the
     block further down used to override this one silently, so the 1.25rem
     sides written here never actually reached a phone. */
  .main-content {
    padding: 1rem 1.25rem 1.5rem;
  }

  .modern-card {
    padding: 1rem;
  }

  .radio-group {
    flex-direction: column;
  }

  /* Full-width only for full-size buttons (the page's primary actions).
     A blanket rule here stretched every small/inline button — tag filter
     pills, "Manage Tags", Delete Key — edge to edge on phones. */
  .btn:not(.btn-sm) {
    width: 100%;
    justify-content: center;
  }

  /* ...but not the sidebar's, which are nav items and read as a list. The
     rule above matches them at the same specificity as `.sidebar-custom .btn`
     and sits later in the file, so it was quietly centring every item in the
     hamburger drawer while the desktop sidebar stayed left-aligned. */
  .sidebar-custom .btn {
    justify-content: flex-start;
    text-align: left;
  }
}

/* Additional utilities and fixes */
.bg-tertiary {
  background-color: var(--bg-tertiary) !important;
}

/* Sidebar styling improvements */
.sidebar-custom {
  padding: 1.5rem 1rem;
}

/* Form control improvements */
.form-control {
  background-color: var(--bg-primary);
  border: 1px solid var(--border-medium);
  color: var(--text-primary);
}

.form-control:focus {
  background-color: var(--bg-primary);
  border-color: var(--accent-primary);
  color: var(--text-primary);
  box-shadow: 0 0 0 0.2rem rgb(var(--accent-rgb) / 0.25);
}

/* Pre/code styling */
pre {
  background-color: var(--bg-tertiary);
  color: var(--text-primary);
  border: 1px solid var(--border-light);
  font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
  font-size: 0.875rem;
  line-height: 1.4;
}

/* Dark mode improvements for Bootstrap overrides */
.dark-mode .list-group-item {
  background-color: var(--bg-primary);
  border-color: var(--border-light);
  color: var(--text-primary);
}

.dark-mode .accordion-button {
  background-color: var(--bg-primary);
  color: var(--text-primary);
  border-color: var(--border-light);
}

.dark-mode .accordion-button:not(.collapsed) {
  background-color: var(--bg-tertiary);
  color: var(--accent-text);
  border-color: var(--border-light);
  box-shadow: none;
}

.dark-mode .accordion-button:focus {
  border-color: var(--accent-primary);
  box-shadow: 0 0 0 0.2rem rgb(var(--accent-rgb) / 0.25);
}

.dark-mode .accordion-body {
  background-color: var(--bg-primary);
  border-color: var(--border-light);
}

/* Custom scrollbar for dark mode */
.dark-mode ::-webkit-scrollbar {
  width: 8px;
  height: 8px;
}

.dark-mode ::-webkit-scrollbar-track {
  background: var(--bg-secondary);
}

.dark-mode ::-webkit-scrollbar-thumb {
  background: var(--border-medium);
  border-radius: var(--radius-inner, 4px);
}

.dark-mode ::-webkit-scrollbar-thumb:hover {
  background: var(--text-muted);
}

/* Loading states */
.btn:disabled {
  opacity: 0.6;
  cursor: not-allowed;
  transform: none !important;
}

/* A disabled button has to look the same however it got that way.
   Bootstrap's `.btn-secondary:hover` and `.btn:disabled` have equal
   specificity and hover is declared later, so it won — and a button disabled
   by the very tap that used it keeps the browser's sticky :hover afterwards.
   The result was a third appearance: faded like a disabled button, but still
   carrying the pressed background, sitting next to a button that had been
   disabled all along. Tapping elsewhere cleared it, which is the tell.
   The extra pseudo-class lifts these over Bootstrap's variant rules; the
   values are Bootstrap's own disabled tokens, so every variant stays correct. */
.btn:disabled,
.btn:disabled:hover,
.btn:disabled:focus,
.btn:disabled:focus-visible,
.btn:disabled:active {
  color: var(--bs-btn-disabled-color, var(--bs-btn-color));
  background-color: var(--bs-btn-disabled-bg, var(--bs-btn-bg));
  border-color: var(--bs-btn-disabled-border-color, var(--bs-btn-border-color));
  box-shadow: none;
}

/* Focus states. :focus-visible, not :focus — a tapped button on a phone keeps
   :focus afterwards, so the ring stayed on and the button read as "still
   applied" long after the tap. */
.btn:focus-visible,
.form-control:focus,
.radio-option:focus-within {
  outline: 2px solid var(--accent-primary);
  outline-offset: 2px;
}

/* Same problem for :hover — a touch device leaves the last-tapped element in
   the hover state indefinitely. Outline buttons fill with colour on hover, so
   e.g. "Clear Filters" looked permanently active after being pressed. Repaint
   them back to their resting skin where there's no real pointer. */
@media (hover: none) {
  [class*="btn-outline-"]:hover {
    color: var(--bs-btn-color);
    background-color: transparent;
    border-color: var(--bs-btn-border-color);
  }
}

/* Animation improvements */
@keyframes fadeIn {
  from { opacity: 0; transform: translateY(10px); }
  to { opacity: 1; transform: translateY(0); }
}

.modern-card {
  animation: fadeIn 0.3s ease-out;
}

/* Mobile improvements */
@media (max-width: 768px) {
  /* Sits under the navbar, which is now fixed and therefore always there —
     that is what makes this offset safe. It was not safe before: the navbar
     was in normal flow, so it scrolled away, and opening the drawer after
     scrolling down left it starting 64px down the screen with the page showing
     through the gap above it.

     The bottom was the same bug in the other direction. 100vh on a phone is
     the viewport with the browser's chrome retracted, so subtracting the
     navbar from it gave a height that overshot or came up short as the URL bar
     moved. dvh is the unit that tracks the chrome; the vh line above it is the
     fallback for engines that don't know dvh, and has to stay first. */
  .sidebar-custom {
    position: fixed;
    top: var(--navbar-h);
    left: -320px;
    width: min(300px, 85vw);        /* always leave a strip of backdrop to tap */
    height: calc(100vh - var(--navbar-h));
    height: calc(100dvh - var(--navbar-h));
    overflow-y: auto;
    overscroll-behavior: contain;   /* don't hand the scroll back to the page */
    transition: left 0.3s ease;
    z-index: 1000;
    box-shadow: var(--shadow-xl);
  }

  .navbar-text {
    display: none;
  }
  
  .sidebar-custom.show {
    left: 0;
  }

  /* (.main-content padding is set once, in the mobile block above.) */

  .row > .col-md-6,
  .row > .col-md-7,
  .row > .col-md-5 {
    margin-bottom: 1.5rem;
  }
  
  .d-flex.justify-content-between {
    flex-direction: column;
    gap: 1rem;
    align-items: stretch;
  }
  
  .card-title {
    font-size: 1.125rem;
  }
}

/* Scrolling the page behind an open drawer moved the thing you were reading
   while you weren't looking at it, and on iOS the drawer's own scroll handed
   over to the page as soon as it hit its end. The class goes on <body> while
   the drawer is open.

   position: fixed rather than overflow: hidden, because overflow: hidden on
   body is quietly ignored by iOS Safari. The cost is that fixing the body
   scrolls it to the top, so base.html stores the offset in `top` on the way in
   and restores it on the way out — see the drawer JS there. */
body.sidebar-open {
  position: fixed;
  left: 0;
  right: 0;
  width: 100%;
  overflow: hidden;
}

/* Print styles */
@media print {
  .sidebar-custom,
  .btn,
  .accordion-button {
    display: none !important;
  }
  
  .main-content {
    padding: 0;
  }
  
  .modern-card {
    box-shadow: none;
    border: 1px solid #000;
  }
}

/* Dark mode improvements and folder hierarchy fixes */
.folder-add-root {
  background: var(--bg-primary) !important;
  border: 1px solid var(--border-medium) !important;
  color: var(--text-secondary) !important;
  padding: 0.375rem;
  border-radius: var(--radius-control, 0.375rem);
  cursor: pointer;
  transition: all 0.2s ease;
  opacity: 1;
}

.folder-add-root:hover,
.folder-add-root:focus {
  color: var(--accent-text) !important;
  background-color: var(--bg-tertiary) !important;
  border-color: var(--accent-primary) !important;
  transform: scale(1.05);
}

.folder-add {
  background: var(--bg-primary) !important;
  border: 1px solid var(--border-light) !important;
  color: var(--text-secondary) !important;
}

.folder-add:hover,
.folder-add:focus {
  color: var(--accent-text) !important;
  background-color: var(--bg-tertiary) !important;
  border-color: var(--accent-primary) !important;
}

/* Improved folder alignment */
.explorer-folder {
  display: flex;
  align-items: center;
  min-height: 2rem;
}

.explorer-arrow {
  width: 16px;
  height: 16px;
  margin-right: 0.5rem;
  flex-shrink: 0;
  display: flex;
  align-items: center;
  justify-content: center;
}

.folder-spacer {
  width: 16px;
  height: 16px;
  margin-right: 0.5rem;
  flex-shrink: 0;
}

.folder-icon {
  width: 16px !important;
  height: 16px !important;
  margin-right: 0.5rem;
  flex-shrink: 0;
}

.folder-label {
  flex-grow: 1;
  color: var(--text-primary);
  font-size: 0.875rem;
}

/* Ensure dark mode visibility for all interactive elements */
.dark-mode .btn-close {
  filter: invert(1);
}

.dark-mode .modal-content {
  background-color: var(--bg-primary);
  border-color: var(--border-medium);
}

.dark-mode .modal-header {
  border-bottom-color: var(--border-medium);
}

/* Image modal improvements - proper initial sizing */
.modal-body {
  background: var(--bg-primary);
}

#zoomContainer {
  background: var(--bg-primary) !important;
  display: flex;
  align-items: center;
  justify-content: center;
}

#modalImage {
  background: var(--bg-primary);
  max-width: 100%;
  max-height: 100%;
  width: auto;
  height: auto;
  object-fit: contain;
}

.dark-mode .sidebar-custom {
  background-color: var(--bg-secondary) !important;
  border-right: 1px solid var(--border-medium) !important;
}
.dark-mode .key-item {
  background-color: var(--bg-secondary);
  color: var(--text-primary);
}
.dark-mode .key-item:hover {
  background-color: var(--bg-tertiary);
}
.dark-mode .folder-header {
  background-color: var(--bg-secondary) !important;
  color: var(--text-primary) !important;
}
.dark-mode .folder-item {
  background-color: transparent !important;
  color: var(--text-primary) !important;
}
.dark-mode .folder-header:hover {
  background-color: rgba(255, 255, 255, 0.12) !important;
  border-color: rgba(108, 117, 125, 0.5) !important;
}
.dark-mode .modern-card,
.dark-mode .card {
  background-color: var(--bg-primary) !important;
  color: var(--text-primary) !important;
}
.dark-mode .breadcrumb-item.active {
  color: var(--accent-text) !important;
  opacity: 1;
}

/* Removed custom #deleteKeyModal styles for stability */

#keyList {
  background: var(--bg-secondary);
  border-radius: var(--radius-surface, 0.5rem);
  padding: 0.5rem 0;
}
.dark-mode #keyList {
  background: var(--bg-secondary);
}

/* ══════════════════════════════════════════════════════════════════
   Shared tag-picker + folder-picker modals
   (partials/tag_picker_modal.html, partials/folder_picker_modal.html —
   used by the key detail page and the bulk upload page)
   ══════════════════════════════════════════════════════════════════ */

/* A picked tag, shown as a filled chip in the colour of the tag itself. Lives
   here rather than in one page's <style> block because three pages render it:
   the upload page's batch options, the duplicate modal, and the previous-batch
   list. It used to be defined only inside bulk_upload.html, so the batch list
   rendered its tags as bare text. */
.bulk-tag-chip {
    display: inline-flex; align-items: center; gap: 0.3rem;
    border: 1px solid var(--tag-color);
    background: var(--tag-color);
    color: #fff;
    border-radius: var(--radius-pill, 1rem);
    font-size: 0.8rem;
    padding: 0.15rem 0.6rem;
}
.bulk-tag-chip .btn-close {
    font-size: 0.55em;
    filter: invert(1) grayscale(100%) brightness(200%);
}

/* ── Tag picker modal ───────────────────────────────────────────────────────
   The dialog and the search field are both back to what they were: modal-lg,
   field the full width of the body. Two goes at making the search "not so
   long" — first by shrinking the whole dialog, then by capping the field — and
   both times what actually arrived was a modal with a short thing on the left
   and a close button stranded far away on the right. It was fine as it was;
   the icon is all that's left of the idea. */
.tag-picker-search { position: relative; }
.tag-picker-search svg {
    position: absolute;
    left: 0.6rem; top: 50%;
    transform: translateY(-50%);
    color: var(--text-muted);
    pointer-events: none;
}
.tag-picker-search .form-control { padding-left: 2rem; }

/* The field of pickable tags. Capped and scrolling, so a hundred tags can't
   push the create form off the bottom of a phone. */
.tag-picker-list {
    display: flex; flex-wrap: wrap; gap: 0.35rem;
    max-height: 11rem;
    overflow-y: auto;
}

/* One chip, used by every list in the modal.

   Tinted rather than filled: a filled chip puts text on the tag's own colour,
   and the palette runs from #1B3B6F to #FFF6B7 — white is unreadable on half
   of it and black on the other half. A tint behind the page's normal text
   colour is legible whatever was picked, and the solid dot still carries the
   colour at full strength. */
.tag-chip {
    display: inline-flex; align-items: center; gap: 0.4rem;
    border: 1px solid color-mix(in srgb, var(--tag-color) 55%, transparent);
    background: color-mix(in srgb, var(--tag-color) 14%, var(--bg-primary));
    color: var(--text-primary);
    border-radius: var(--radius-pill, 1rem);
    font-size: 0.82rem;
    line-height: 1.35;
    padding: 0.2rem 0.7rem;
    cursor: pointer;
    transition: border-color 0.12s ease, background-color 0.12s ease;
}
.tag-chip:hover {
    border-color: var(--tag-color);
    background: color-mix(in srgb, var(--tag-color) 26%, var(--bg-primary));
}
.tag-chip-dot {
    width: 0.5rem; height: 0.5rem;
    border-radius: 50%;
    background: var(--tag-color);
    flex: 0 0 auto;
}
.tag-chip-x {
    font-size: 1.05em;
    line-height: 1;
    color: var(--text-muted);
    margin-left: 0.05rem;
}
.tag-chip--picked:hover .tag-chip-x { color: var(--danger, #dc3545); }

/* Creating a tag is occasional; the form is a name, thirty-five swatches and a
   button. Folded away it stops being the biggest thing in a modal that exists
   to pick an existing tag. TagPicker opens it for a user who owns none. */
.tag-create { border-top: 1px solid var(--border-light); padding-top: 0.6rem; }
.tag-create > summary {
    cursor: pointer;
    list-style: none;
    font-size: 0.85rem;
    font-weight: 600;
    display: inline-flex; align-items: center; gap: 0.35rem;
}
.tag-create > summary::-webkit-details-marker { display: none; }
.tag-create > summary::before {
    content: '+';
    font-size: 1.05em;
    line-height: 1;
}
.tag-create[open] > summary::before { content: '\2212'; }   /* minus */
.tag-create > summary:hover { color: var(--accent-text); }

/* Colour swatch grid picker. A fixed seven across, so the palette is always
   the same shape and a colour stays where the user last found it — auto-fill
   re-flowed it at every width, which is exactly what a palette must not do.
   Seven columns and five rows for the thirty-five colours. */
.color-picker-grid {
    display: grid;
    grid-template-columns: repeat(7, 22px);
    gap: 5px;
}

.hex-color-small {
    width: 22px;
    height: 22px;
    border-radius: var(--radius-inner, 3px);
    cursor: pointer;
    position: relative;
    transition: transform 0.1s ease;
}

.hex-color-small:hover {
    transform: scale(1.2);
    z-index: 10;
}

.hex-color-small.active {
    box-shadow: 0 0 0 2px var(--bs-body-bg), 0 0 0 4px var(--bs-body-color);
    z-index: 20;
}

/* ── Folder-picker modal tree ── */
.fm-tree-item { user-select: none; }
.fm-tree-children { padding-left: 1.1rem; }
.fm-tree-row {
    display: flex; align-items: center; gap: 0.35rem;
    padding: 0.28rem 0.6rem; border-radius: var(--radius-control, 0.375rem);
    cursor: pointer; position: relative;
    font-size: 0.875rem; white-space: nowrap; overflow: hidden;
}
.fm-tree-row:hover { background: var(--bs-tertiary-bg); }
.fm-tree-row.selected { background: rgba(var(--bs-primary-rgb), 0.12); font-weight: 500; }
.fm-tree-row.selected .fm-tree-folder-icon { color: var(--bs-primary); }
.fm-tree-chevron {
    width: 1rem; flex-shrink: 0; font-size: 0.6rem;
    color: var(--bs-secondary-color);
    display: flex; align-items: center; justify-content: center;
}
.fm-tree-chevron svg { transition: transform 0.15s ease; }
.fm-tree-chevron.open svg { transform: rotate(90deg); }
.fm-tree-folder-icon { color: var(--accent-text); flex-shrink: 0; }
.fm-inline-icon { color: var(--accent-text); vertical-align: -2px; }
.fm-tree-name { flex-grow: 1; overflow: hidden; text-overflow: ellipsis; }
.fm-section-label {
    font-size: 0.7rem; font-weight: 600; text-transform: uppercase;
    letter-spacing: 0.06em; color: var(--bs-secondary-color);
    margin-bottom: 0.6rem; padding-bottom: 0.3rem;
    border-bottom: 1px solid var(--bs-border-color);
}
/* A responsive grid, not a wrapped flex row of fixed 88px boxes: at 88px the
   cards left a ragged margin down the right of the pane at most widths, and a
   folder name longer than about ten characters was ellipsised into nothing
   useful. Auto-fill gives even columns at every width and lets the name have
   two lines before it gives up. */
.fm-folder-card-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(6.5rem, 1fr));
    gap: 0.5rem;
    margin-bottom: 1rem;
}
/* No box. A folder is already a recognisable shape, and drawing a bordered
   card around every one of them made a grid of boxes with a small icon rattling
   inside each — the border was doing the work the icon should do. The icon gets
   the space instead and the tap target stays exactly the same size, just
   invisible until you touch it. */
.fm-folder-card {
    display: flex; flex-direction: column; align-items: center; justify-content: flex-start;
    gap: 0.3rem;
    padding: 0.7rem 0.4rem;
    border: 1px solid transparent;
    border-radius: var(--radius-control, 0.6rem); cursor: pointer;
    text-align: center; background: transparent;
    transition: background-color 0.12s ease;
}
.fm-folder-card:hover,
.fm-folder-card:focus-visible {
    background: var(--bs-tertiary-bg);
}
.fm-folder-card svg { color: var(--accent-text); flex: 0 0 auto; }
.fm-folder-card-name {
    font-size: 0.76rem; line-height: 1.25;
    width: 100%;
    overflow-wrap: anywhere;
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
    overflow: hidden;
    color: var(--bs-body-color);
}
.fm-key-list-item {
    display: flex; align-items: center; gap: 0.7rem;
    padding: 0.45rem 0.7rem;
    border: 1px solid var(--bs-border-color);
    border-radius: var(--radius-surface, 0.375rem); background: var(--bs-body-bg);
    margin-bottom: 0.4rem;
    transition: border-color 0.12s ease;
}
.fm-key-list-item:hover { border-color: var(--bs-primary); }
.fm-key-thumb {
    width: 42px; height: 42px;
    object-fit: cover; border-radius: var(--radius-inner, 4px); flex-shrink: 0;
    border: 1px solid var(--bs-border-color);
}
.fm-key-thumb-placeholder {
    width: 42px; height: 42px; flex-shrink: 0;
    background: var(--bs-tertiary-bg); border-radius: var(--radius-inner, 4px);
    border: 1px solid var(--bs-border-color);
}
.fm-key-list-name {
    flex-grow: 1; font-size: 0.875rem;
    overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}

.fm-modal-content {
    height: 560px;
    display: flex;
    flex-direction: column;
}

/* Sits under the cards now, so the rule that separates it from them goes on
   top. No fill either: a grey band is how you mark a toolbar, and this is a
   path plus one button — it reads better as part of the pane than as a bar
   bolted to it. */
.fm-content-header {
    display: flex; align-items: center; justify-content: space-between; gap: 0.5rem;
    padding: 0.5rem 1rem;
    border-top: 1px solid var(--bs-border-color);
    background: transparent;
    flex-shrink: 0;
}
.fm-breadcrumb-nav { min-width: 0; overflow: hidden; }
/* The path is how you know where you are, and it was set two steps smaller
   than the folder names it describes. */
#fmBreadcrumb { font-size: 0.9rem; }
#fmSelectionLabel { font-size: 0.9rem; }

.fm-new-folder-btn { flex-shrink: 0; white-space: nowrap; }
.fm-new-folder-row {
    display: flex; gap: 0.4rem; align-items: center;
    padding: 0 1rem 0.5rem;
    flex-shrink: 0;
}
.fm-new-folder-row .btn { flex-shrink: 0; }
/* White, not accent-blue. It sits on the tinted content header next to the
   breadcrumb, and a filled blue button there read as the main thing to do in
   the modal — which it isn't; picking a folder is. */
.fm-new-folder-btn,
.fm-new-folder-btn:hover {
    background: var(--bg-primary);
    color: var(--text-primary);
    border-color: var(--border-medium);
}
.fm-new-folder-btn:hover { border-color: var(--accent-primary); }

/* Bootstrap's .btn-sm is inert in this app: modern.css's own .btn sets an
   explicit padding and font-size, is loaded after Bootstrap and wins the tie,
   so every "small" button here is full size. Rather than change that
   everywhere at once, the buttons in this modal are sized here — its footer
   held three of them at full size across the bottom of a dialog. */
#folderManagementModal .btn-sm {
    padding: 0.4rem 0.8rem;
    font-size: 0.85rem;
}

/* The magnifier sits beside the close button; both stay on the title's line. */
.fm-header-actions { display: flex; align-items: center; gap: 0.5rem; }
.fm-header-actions .btn-close { margin: 0; }

/* Search, shown only when the magnifier is pressed. A field permanently across
   the top of a modal is a row of a phone screen spent on something used
   occasionally. */
.fm-search-bar {
    padding: 0.5rem 1rem;
    border-bottom: 1px solid var(--bs-border-color);
    background: var(--bs-tertiary-bg);
    flex-shrink: 0;
}
.fm-search-results {
    max-height: 11rem;
    overflow-y: auto;
    margin-top: 0.4rem;
}

/* ── Folder-picker modal on mobile ──
   The desktop layout is a 260px tree beside a content pane. Inside a 95vw
   dialog on a phone that leaves two unusable ~150px columns, so stack them —
   and put the folder CARDS first, the tree under them, matching the explorer
   page. Expanding a tree with a fingertip is far more work than tapping an
   icon, and the tree is the thing you can do without. */
@media (max-width: 768px) {
    #folderManagementModal .modal-dialog { margin: 0.5rem auto; width: 96vw; }
    .fm-modal-content { height: 88vh; }
    #folderManagementModal .modal-body { flex-direction: column; }

    #fmContentPanel { order: 1; }

    #fmTreePanel {
        order: 2;
        width: 100% !important;
        max-height: 30%;
        border-right: none !important;
        border-top: 1px solid var(--bs-border-color);
    }

    #folderManagementModal .modal-footer {
        flex-direction: column;
        align-items: stretch !important;
        gap: 0.5rem;
    }
    #folderManagementModal .modal-footer .d-flex { justify-content: space-between; }
    #folderManagementModal .modal-footer .btn { flex: 1; }

    /* Finger-sized rows and chevrons (the chevron is the only way to expand —
       there's no double-click on touch). */
    .fm-tree-row { padding: 0.55rem 0.6rem; font-size: 0.95rem; }
    .fm-tree-chevron { width: 1.75rem; height: 1.75rem; font-size: 0.8rem; margin: -0.3rem 0; }
    .fm-tree-folder-icon { font-size: 1rem; }
    .fm-folder-card { padding: 0.9rem 0.4rem; }
    .fm-folder-card-name { font-size: 0.8rem; }
    .fm-key-list-item { padding: 0.55rem; }
}

/* ── Shoulder-line overlay (see static/mainkey/shoulder_overlay.js) ──────────
   Marks where the app judged the blade to stop and the shoulder to start. Grey
   and thin on purpose: it's a reference mark the customer can sanity-check, not
   a result, and it must not compete with the photo underneath. The wrapper
   shrink-wraps the <img> so the absolutely-positioned SVG covers exactly the
   displayed photo and nothing else. */
.shoulder-wrap {
  position: relative;
  display: inline-block;
  line-height: 0;
  max-width: 100%;
}

.shoulder-wrap > svg.shoulder-line {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
}

/* Thin on purpose. This line marks where the blade was judged to start — it
   has to be readable over the photo without becoming the subject of it. At
   2.5px with round caps each dash bulged into a lozenge and the whole thing
   read as a heavy grey stripe laid across the key. */
.shoulder-wrap > svg.shoulder-line line {
  stroke: rgb(148 163 184 / 0.95);
  stroke-width: 1.25;
  stroke-dasharray: 6 4;
  stroke-linecap: butt;
}

.shoulder-note {
  font-size: 0.8rem;
  color: var(--text-secondary);
  margin: 0.4rem 0 0;
}

/* The explanation of that line is a question people ask once and then never
   again, so it hides behind the same "?" every other one-time explanation on
   this app uses. It was a <details> the reader had to expand; that made it a
   third thing on the page to work out, and the answer it hid is one sentence
   long. Four screens carry this note, so consistency with the "?" pattern is
   worth more here than a bespoke disclosure. */

/* UNDERLINES MEAN NAVIGATION. A line of text that opens a drawer, a picker or
   a modal on the page you are already on gets the shape of a control instead —
   a caret, a centred strip, a bordered row (see .photo-examples > summary and
   .batch-options-disclosure in bulk_upload.html). Underlining those made them
   read as links to somewhere else, and they aren't.

   This one is the deliberate exception. It is the secondary route to the file
   picker: an existing photo has no framing guide and fails far more often, so
   it has to stay available without inviting itself, and a quiet underlined
   line is the least inviting thing that is still obviously pressable.
   .photo-fallback--promoted turns it into a real button when the camera didn't
   open — no camera on the device, or a permission that was refused. It is NOT
   "the only way in" any more, which is why it isn't called --only: a refusal is
   usually a misclick, so the camera button stays put next to it and the promoted
   picker needs a gap from it rather than the flush margin the old name assumed. */
.photo-fallback {
  text-align: center;
  margin-top: 0.6rem;
}

.photo-fallback label {
  font-size: 0.85rem;
  color: var(--text-secondary);
  text-decoration: underline;
  cursor: pointer;
}

.photo-fallback label:hover {
  color: var(--accent-text);
}

.photo-fallback--promoted {
  /* Matches .photo-picker's own gap, so the two big buttons sit as one group
     rather than looking stuck together. */
  margin-top: 0.75rem;
}

/* Same size and shape as the camera button, deliberately NOT the same colour.
   Both wore the accent, which made two primary buttons stacked on top of each
   other with nothing to choose between them — and there is something to
   choose: an existing photo has no framing guide and fails far more often, so
   the guided camera is still the one worth trying, even after a refusal.
   Neutral here, accent there, and the pair reads as first-then-second. */
.photo-fallback--promoted label {
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 1.1rem 0.75rem;
  border: 2px solid var(--border-medium);
  border-radius: var(--radius-control, 0.75rem);
  background-color: var(--bg-primary);
  color: var(--text-primary);
  font-size: 1rem;
  font-weight: 600;
  text-decoration: none;
}

.photo-fallback--promoted label:hover {
  border-color: var(--accent-primary);
  background-color: rgb(var(--accent-rgb) / 0.06);
  color: var(--text-primary);
}

/* ==========================================================================
   Public pages: landing, privacy, terms, and the footer on every page.
   Uses the same custom properties as the app so light/dark come for free.
   ========================================================================== */

.site-footer {
  display: flex;
  flex-wrap: wrap;
  gap: 1.25rem;
  align-items: center;
  justify-content: center;
  padding: 2rem 1rem 1.5rem;
  margin-top: 3rem;
  border-top: 1px solid var(--border-light);
  font-size: 0.85rem;
  color: var(--text-muted);
}

.site-footer a {
  color: var(--text-secondary);
  text-decoration: none;
}

.site-footer a:hover {
  color: var(--accent-text);
  text-decoration: underline;
}

/* A page title with an icon in front of it. Folder Explorer has had one for a
   while; this is the shared version so the next page that wants one doesn't
   invent its own alignment. flex + baseline-ish centring rather than a margin
   on the <svg>, which drifts with the heading's line-height. */
.page-title-with-icon {
  display: flex;
  align-items: center;
  gap: 0.5rem;
}

/* No colour of its own: the icon takes the heading's, which is what Folder
   Explorer's has always done. Tinting it accent was a change to a page that was
   already right. */
.page-title-with-icon svg,
.page-title-with-icon i {
  flex: 0 0 auto;
}

/* Sized in em, not pixels, so an inline-SVG icon tracks the heading exactly as
   a Font Awesome glyph does. A fixed 20px matched Folder Explorer's icon at
   neither size: too small beside a 2rem heading on a desktop, too big beside a
   1.5rem one on a phone. 0.95em rather than the 0.8em first guessed — a solid
   FA glyph fills more of its em box than that. */
.page-title-with-icon svg {
  width: 0.95em;
  height: 0.95em;
}

/* The size lives here, not on either page. Folder Explorer set its own 1.5rem
   on a phone while Manage Tags kept the global h2's 2rem, so the two sat at
   different sizes on exactly the screen where they look most alike. */
@media (max-width: 768px) {
  .page-title-with-icon { font-size: 1.5rem; }
}

/* --- Bulk apply: one folder-and-tags group, used in two places ------------
   The upload page offers it before a batch runs; the previous-batch page offers
   it afterwards, over the keys that batch saved. It lived in bulk_upload.html's
   own <style> while previous_batch.html kept a hand-made lookalike, and the two
   drifted — .bulk-chip-row was among the classes /batch/ used without ever
   loading the rule for it. One definition, here, so they can't. */

/* ── Batch options ────────────────────────────────────────────────────────
   Two full-width rows that look and behave like the settings rows they are:
   icon, what it does, what's currently set, chevron. Previously a label and a
   small outline button per line, which read as a form and gave no hint that
   the choice covered the whole batch. */
.bulk-options { display: flex; flex-direction: column; gap: 0.5rem; }

/* Under the options, so it reads as a footnote to a control someone has
   already found rather than the paragraph standing between them and it. */
.bulk-options-intro {
    font-size: 0.78rem;
    color: var(--text-muted);
    margin: 0;
    padding: 0.5rem 0.85rem 0.6rem;
    border-top: 1px solid var(--border-light);
}

.bulk-option {
    display: flex; align-items: center; gap: 0.75rem;
    width: 100%;
    text-align: left;
    border: 1px solid var(--border-medium);
    border-radius: var(--radius-control, 0.6rem);
    background: var(--bg-primary);
    padding: 0.65rem 0.85rem;
    cursor: pointer;
    transition: border-color 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease;
}
@media (hover: hover) and (pointer: fine) {
    .bulk-option:hover {
        border-color: var(--accent-primary);
        background: var(--bg-tertiary);
        box-shadow: var(--shadow-sm);
    }
}

.bulk-option-icon {
    flex: 0 0 auto;
    display: flex; align-items: center; justify-content: center;
    width: 2.1rem; height: 2.1rem;
    border-radius: var(--radius-inner, 0.5rem);
    background: var(--bg-tertiary);
    color: var(--accent-text);
}
@media (hover: hover) and (pointer: fine) {
    .bulk-option:hover .bulk-option-icon { background: var(--bg-primary); }
}

.bulk-option-text { flex: 1 1 auto; min-width: 0; }
.bulk-option-label {
    display: block;
    font-size: 0.92rem; font-weight: 600; color: var(--text-primary);
}
.bulk-option-value {
    display: block;
    font-size: 0.82rem;
    overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* A set value stops being muted, so "chosen" is visible at a glance. */
.bulk-option-value:not(.text-muted) { color: var(--accent-text); font-weight: 600; }

.bulk-option-chevron {
    flex: 0 0 auto;
    width: 0.5rem; height: 0.5rem;
    border-top: 2px solid var(--text-muted);
    border-right: 2px solid var(--text-muted);
    transform: rotate(45deg);
    margin-right: 0.2rem;
}

.bulk-chip-row {
    display: flex; flex-wrap: wrap; gap: 0.35rem;
    padding: 0.15rem 0.1rem 0.1rem;
}

/* (.bulk-tag-chip — the chip for a picked tag — is shared; see modern.css.) */

/* -- Batch options -------------------------------------------------------
   A <details>. The page's job is to get someone to the camera button, so
   anything that isn't that starts closed and takes one line.

   The whole thing is ONE bordered group now: a header row shaped like the
   option rows it opens onto, with those rows flattened inside it so they read
   as its contents rather than as boxes in a box. It used to be a coloured line
   of text with a chevron in front of it, which is the shape of a link -- and
   what it actually does is open two pickers. */
.batch-options-disclosure {
    margin-top: 1rem;
    border: 1px solid var(--border-medium);
    border-radius: var(--radius-surface, 0.6rem);
    background: var(--bg-primary);
    overflow: hidden;
}

/* A tinted bar, not another row. The indent that used to carry this job made
   the group read as a folder tree — three things of one kind at two depths —
   because the header wore the same icon badge and the same shape as the rows
   under it. Ranking them by depth was the wrong axis: they are different KINDS
   of thing, so the header is coloured like a heading and the rows sit flush and
   white beneath it, which is a difference you can see without measuring. */
.batch-options-disclosure > summary {
    display: flex;
    align-items: center;
    gap: 0.6rem;
    padding: 0.65rem 0.85rem;
    cursor: pointer;
    list-style: none;                 /* Firefox's default triangle */
    background: var(--bg-secondary);
    transition: background-color 0.15s ease;
}
/* Plain, small and muted — deliberately NOT the .bulk-option-icon badge the two
   rows wear, which was the loudest thing saying "I am one of these too". */
.batch-options-icon {
    flex: 0 0 auto;
    display: flex;
    color: var(--text-muted);
}
.batch-options-disclosure > summary::-webkit-details-marker { display: none; }
/* Only where a pointer can actually hover. On a phone there is no such thing as
   leaving an element, so a plain :hover rule stayed lit after the tap that shut
   the group and only cleared when something else was touched — which reads as
   "still open" on the one control whose whole job is to say whether it is. */
@media (hover: hover) and (pointer: fine) {
    .batch-options-disclosure > summary:hover { background: var(--bg-tertiary); }
}

.batch-options-heading {
    flex: 1 1 auto;
    min-width: 0;
}
.batch-options-title {
    display: block;
    font-size: 0.92rem; font-weight: 600;
    color: var(--text-primary);
}
/* What's currently chosen, under the title the way an option row carries its
   value, so a set folder or tag stays visible while the group is shut. On a
   phone it used to fight the title for one line and win, ellipsising away the
   half that says this is a batch-wide action. */
.batch-options-summary {
    display: block;
    font-size: 0.82rem;
    color: var(--text-secondary);
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}
.batch-options-summary.is-set { color: var(--accent-text); font-weight: 600; }

/* Down when shut, up when open, on the right where this page's other rows put
   their chevron. */
.batch-options-caret {
    flex: 0 0 auto;
    width: 0.5rem; height: 0.5rem;
    border-right: 2px solid var(--text-muted);
    border-bottom: 2px solid var(--text-muted);
    transform: rotate(45deg);
    margin: -0.2rem 0.2rem 0 0;
    transition: transform 0.15s ease;
}
.batch-options-disclosure[open] .batch-options-caret {
    transform: rotate(-135deg);
    margin: 0.15rem 0.2rem 0 0;
}

/* Inside the group the rows lose their own borders: one hairline between them
   is enough, and nested boxes read as clutter on a phone. The line under the
   header is the full-weight one — it separates two kinds of thing, where the
   line between the rows only separates two of a kind. */
.batch-options-disclosure .bulk-options {
    gap: 0;
    border-top: 1px solid var(--border-medium);
    background: var(--bg-primary);
}
.batch-options-disclosure .bulk-option {
    border: 0;
    border-radius: 0;
    background: transparent;
    box-shadow: none;
    padding: 0.6rem 0.85rem;
}
.batch-options-disclosure .bulk-option + .bulk-option {
    border-top: 1px solid var(--border-light);
}
@media (hover: hover) and (pointer: fine) {
    .batch-options-disclosure .bulk-option:hover {
        background: var(--bg-tertiary);
        box-shadow: none;
    }
}
.batch-options-disclosure .bulk-chip-row { padding: 0.15rem 0.85rem 0.35rem 3.6rem; }

@media (prefers-reduced-motion: reduce) {
    .batch-options-caret { transition: none; }
}

/* --- Landing ------------------------------------------------------------ */

.landing {
  max-width: 62rem;
  margin: 0 auto;
}

.landing section {
  margin-bottom: 3.5rem;
}

.landing-hero {
  text-align: center;
  padding: 2.5rem 0 1rem;
}

/* Bigger than the navbar copy of the same mark, and sized off the viewport so
   it doesn't eat a phone screen above the headline. */
.landing-mark {
  width: clamp(88px, 22vw, 132px);
  height: clamp(88px, 22vw, 132px);
  margin-bottom: 1.25rem;
}

/* Sign-in and register: big enough to register as branding, small enough that
   the form stays the point of the page. */
.auth-mark {
  width: 88px;
  height: 88px;
  margin-bottom: 1rem;
}

.landing-hero h1 {
  font-size: clamp(1.9rem, 5vw, 3rem);
  font-weight: 700;
  letter-spacing: -0.02em;
  color: var(--text-primary);
  margin-bottom: 1rem;
}

.landing-lead {
  font-size: clamp(1rem, 2.4vw, 1.2rem);
  color: var(--text-secondary);
  max-width: 40rem;
  margin: 0 auto 2rem;
  line-height: 1.65;
}

.landing-cta,
.landing-cta-end {
  display: flex;
  flex-wrap: wrap;
  gap: 0.75rem;
  justify-content: center;
}

/* The three capability cards, side by side on a desktop and stacked on a
   phone — where most of these photos get taken, so the stacked order is the
   real order. auto-fit rather than a fixed three columns: at a middling width
   two-and-a-bit narrow columns are worse than two wide ones. */
.landing-features {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
  gap: 1.5rem;
}

/* Where a plan name and a price will go. Small and quiet: it labels the card,
   it isn't the heading. */
.landing-card-eyebrow {
  font-size: 0.75rem;
  font-weight: 600;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--accent-text);
  margin: 0 0 0.4rem;
}

.landing-card {
  padding: 1.75rem;
  border: 1px solid var(--border-light);
  border-radius: var(--radius-surface, 0.9rem);
  background-color: var(--bg-primary);
  box-shadow: var(--shadow-sm);
}

.landing-card h2 {
  font-size: 1.2rem;
  font-weight: 650;
  color: var(--text-primary);
  margin-bottom: 0.75rem;
}

.landing-card p,
.landing-card li {
  color: var(--text-secondary);
  line-height: 1.6;
}

.landing-card ul {
  margin: 0;
  padding-left: 1.15rem;
}

.landing-card li {
  margin-bottom: 0.4rem;
  font-size: 0.94rem;
}

.landing-how h2,
.landing-privacy h2 {
  font-size: 1.35rem;
  font-weight: 650;
  color: var(--text-primary);
  margin-bottom: 1rem;
}

.landing-how ol {
  padding-left: 1.25rem;
  color: var(--text-secondary);
  line-height: 1.7;
}

.landing-how li {
  margin-bottom: 0.85rem;
}

.landing-how strong {
  color: var(--text-primary);
}

/* Not styled as a warning: it's part of the pitch, not a disclaimer bolted to
   the bottom of it. */
.landing-privacy {
  padding: 1.5rem 1.75rem;
  border-left: 3px solid var(--border-medium);
  background-color: var(--bg-secondary);
  border-radius: 0 var(--radius-surface, 0.75rem) var(--radius-surface, 0.75rem) 0;
}

.landing-privacy p {
  color: var(--text-secondary);
  line-height: 1.7;
  margin-bottom: 0;
}

.landing-cta-end {
  padding-bottom: 1rem;
}

/* --- Long-form prose pages (legal, help) --------------------------------
   Same shape for both: a single readable column of headed sections. The help
   page joins these selectors rather than restating them, so the two can't
   drift into looking like pages from different apps. */

.legal-page,
.help-page {
  max-width: 44rem;
  margin: 0 auto;
  padding: 1rem 0 2rem;
  color: var(--text-secondary);
  line-height: 1.7;
}

.legal-page h1,
.help-page h1 {
  font-size: 1.9rem;
  font-weight: 700;
  color: var(--text-primary);
  margin-bottom: 0.35rem;
}

.legal-page h2,
.help-page h2 {
  font-size: 1.2rem;
  font-weight: 650;
  color: var(--text-primary);
  margin: 2.25rem 0 0.75rem;
}

.legal-page h3,
.help-page h3 {
  font-size: 1rem;
  font-weight: 650;
  color: var(--text-primary);
  margin: 1.5rem 0 0.5rem;
}

.legal-meta {
  font-size: 0.85rem;
  color: var(--text-muted);
  margin-bottom: 1.5rem;
}

.legal-page ul,
.legal-page ol,
.help-page ul,
.help-page ol {
  padding-left: 1.25rem;
}

.legal-page li,
.help-page li {
  margin-bottom: 0.5rem;
}

.legal-page strong,
.help-page strong {
  color: var(--text-primary);
}

.legal-table {
  width: 100%;
  border-collapse: collapse;
  margin: 1rem 0 1.5rem;
  font-size: 0.92rem;
}

.legal-table th,
.legal-table td {
  padding: 0.6rem 0.75rem;
  border: 1px solid var(--border-light);
  text-align: left;
  vertical-align: top;
}

.legal-table th {
  background-color: var(--bg-secondary);
  color: var(--text-primary);
  font-weight: 600;
}

/* --- Member home -------------------------------------------------------- */

.home {
  /* Wider than the old 52rem: the page is sections now, and the three
     how-it-works steps want to read as a row on a desktop rather than as a
     cramped column beside empty space. */
  max-width: 60rem;
  margin: 0 auto;
}

.home-head {
  padding: 0.5rem 0 1.25rem;
}

.home-head h1 {
  font-size: clamp(1.5rem, 4vw, 2rem);
  font-weight: 700;
  letter-spacing: -0.02em;
  color: var(--text-primary);
  margin-bottom: 0.6rem;
}

.home-lead {
  color: var(--text-secondary);
  line-height: 1.6;
  margin-bottom: 0;
  max-width: 38rem;
}

/* Two tiles now, not eight: the only two things on the page that DO something.
   One column on a phone, so each is a full-width tap target rather than a pair
   of small ones — this page exists to be obvious. */
.home-actions {
  display: grid;
  grid-template-columns: 1fr;
  gap: 0.75rem;
}

@media (min-width: 576px) {
  .home-actions {
    grid-template-columns: repeat(2, 1fr);
  }
}

.home-tile {
  display: flex;
  align-items: flex-start;
  gap: 0.9rem;
  padding: 1rem 1.1rem;
  border: 1px solid var(--border-light);
  border-radius: var(--radius-tile, 0.9rem);
  background-color: var(--bg-primary);
  box-shadow: var(--shadow-sm);
  text-decoration: none;
  transition: border-color 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease;
}

.home-tile:hover,
.home-tile:focus-visible {
  border-color: var(--accent-primary);
  background-color: var(--bg-tertiary);
  box-shadow: var(--shadow-md);
  text-decoration: none;
}

/* The one thing a new account has to do first. */
.home-tile--primary {
  border-color: var(--accent-primary);
}

.home-tile-icon {
  flex: 0 0 auto;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 2.6rem;
  height: 2.6rem;
  border-radius: var(--radius-inner, 0.65rem);
  background-color: var(--bg-tertiary);
  color: var(--accent-text);
}

.home-tile--primary .home-tile-icon {
  background-color: var(--accent-primary);
  color: #fff;
}

.home-tile-text {
  min-width: 0;
}

.home-tile-title {
  display: block;
  font-size: 1.05rem;
  font-weight: 650;
  color: var(--text-primary);
  margin-bottom: 0.15rem;
}

.home-tile-desc {
  display: block;
  font-size: 0.88rem;
  line-height: 1.5;
  color: var(--text-secondary);
}

/* --- The sections below the two actions --------------------------------- */

.home-section {
  padding-top: 1.75rem;
}

/* Each group gets a real heading and a sentence, not a small-caps label: the
   sidebar already names these places, so what this page adds is saying what
   they are for. */
.home-section-h {
  font-size: 1.05rem;
  font-weight: 650;
  color: var(--text-primary);
  margin-bottom: 0.35rem;
}

.home-section-note {
  font-size: 0.88rem;
  line-height: 1.6;
  color: var(--text-secondary);
  margin-bottom: 0.85rem;
  max-width: 42rem;
}

.home-section-note:last-child {
  margin-bottom: 0;
}

.home-section-note--after {
  margin: 0.85rem 0 0;
  font-size: 0.85rem;
}

/* The last batch, as one tappable row rather than a paragraph with a link
   hidden in it. Same language as the option rows elsewhere: icon, what it says,
   chevron. */
.home-strip {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  margin-top: 0.75rem;
  padding: 0.7rem 0.85rem;
  border: 1px solid var(--border-light);
  border-radius: var(--radius-tile, 0.7rem);
  background-color: var(--bg-primary);
  text-decoration: none;
  transition: border-color 0.15s ease, background-color 0.15s ease;
}

@media (hover: hover) and (pointer: fine) {
  .home-strip:hover {
    border-color: var(--accent-primary);
    background-color: var(--bg-tertiary);
    text-decoration: none;
  }
}

.home-strip-icon {
  flex: 0 0 auto;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 2.1rem;
  height: 2.1rem;
  border-radius: var(--radius-inner, 0.5rem);
  background-color: var(--bg-tertiary);
  color: var(--accent-text);
}

.home-strip-text {
  flex: 1 1 auto;
  min-width: 0;
  font-size: 0.88rem;
  line-height: 1.4;
  color: var(--text-primary);
}

.home-strip-caret {
  flex: 0 0 auto;
  width: 0.5rem;
  height: 0.5rem;
  border-top: 2px solid var(--text-muted);
  border-right: 2px solid var(--text-muted);
  transform: rotate(45deg);
  margin-right: 0.2rem;
}

/* The rest of the app: compact buttons under the sentence that introduces
   them. Two across on a phone, so a group of three or four is one glance
   rather than a column to scroll past. */
.home-nav {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: 0.6rem;
}

@media (min-width: 576px) {
  .home-nav {
    grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
  }
}

.home-nav-tile {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  min-width: 0;
  padding: 0.7rem 0.8rem;
  border: 1px solid var(--border-light);
  border-radius: var(--radius-control, 0.7rem);
  background-color: var(--bg-primary);
  text-decoration: none;
  transition: border-color 0.15s ease, background-color 0.15s ease;
}

@media (hover: hover) and (pointer: fine) {
  .home-nav-tile:hover {
    border-color: var(--accent-primary);
    background-color: var(--bg-tertiary);
    text-decoration: none;
  }
}

.home-nav-icon {
  flex: 0 0 auto;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 1.9rem;
  height: 1.9rem;
  border-radius: var(--radius-inner, 0.5rem);
  background-color: var(--bg-tertiary);
  color: var(--accent-text);
}

.home-nav-label {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-size: 0.9rem;
  font-weight: 600;
  color: var(--text-primary);
}

/* Discard: grey at rest, red only once it's meant. Shared by the batch page's
   modals and the one-photo page.

   It used to be a red dashed border from the start, and red is a claim —
   "this is dangerous, look at it". Sitting under the button people are meant
   to press, the one they're not meant to press was the loudest thing in the
   modal. Now it is the quietest until touched: hover turns it red, and the
   saved-key version, which deletes a real key and so arms on the first tap,
   stays red once armed. The colour arrives with the intent, not before it. */
.modal-discard-btn {
  background: transparent;
  border: 1px solid var(--border-medium);
  color: var(--text-muted);
}

.modal-discard-btn:hover {
  background: transparent;
  border-color: var(--danger);
  color: var(--danger);
}

/* Armed — one tap in, so now it means it. */
.modal-discard-btn--armed {
  border-color: var(--danger);
  color: var(--danger);
  font-weight: 600;
}

/* --- "Verify your email" banner ----------------------------------------
   Shown above every page until the address is verified, so its height is a
   permanent tax on every other page — worst on a phone, where it was costing
   three lines: two of sentence plus a full-width button. Flex with wrapping
   keeps the button beside the text while it fits, and the phone rules below
   shrink everything that made it tall. */

/* "Verify email" chip in the navbar (base.html). Amber like the alert it
   replaced, sized like the theme toggle beside it, and never taller than the
   fixed --navbar-h row. */
.verify-chip {
  display: inline-flex;
  align-items: center;
  gap: 0.375rem;
  background: rgb(245 158 11 / 0.12);
  border: 1px solid var(--warning);
  color: var(--warning);
  border-radius: var(--radius-control, 0.5rem);
  padding: 0.375rem 0.7rem;
  font-size: 0.8125rem;
  font-weight: 500;
  line-height: 1.2;
  white-space: nowrap;
  cursor: pointer;
  transition: background-color 0.2s ease;
}

.verify-chip:hover {
  background: rgb(245 158 11 / 0.22);
}

/* Icon-only on phones, same as the theme toggle next to it — the navbar can't
   wrap, and the accessible name still carries the whole sentence. */
@media (max-width: 768px) {
  .verify-chip-text {
    display: none;
  }

  .verify-chip {
    padding: 0.4rem 0.5rem;
  }
}

/* --- Image lightbox -----------------------------------------------------
   Tap-to-enlarge for one image (see static/mainkey/image_lightbox.js). Sits
   above Bootstrap's modal layer (1055) because everything it's used on so far
   is inside a modal. */

.image-lightbox {
  position: fixed;
  inset: 0;
  /* A rotated image's layout box is wider than the window (see below); this
     keeps that from producing a scrollbar or nudging the centring. */
  overflow: hidden;
  z-index: 1080;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  /* Top clears the close button; the rest is a thin margin, since the image
     is now the only thing in here and every spare pixel goes to it. */
  padding: 3.25rem 0.75rem 0.75rem;
  background: rgb(2 6 23 / 0.92);
}

.image-lightbox[hidden] { display: none; }

/* The picture gets the whole overlay. object-fit:contain rather than a max
   height in px: a blade profile is a wide, short image and a key photo is a
   tall one, and both have to fill what they can of the same box. */
.image-lightbox img {
  max-width: 100%;
  max-height: 100%;
  object-fit: contain;
  border-radius: var(--radius-surface, 0.5rem);
  background: #fff;
}

/* Sideways, for a wide image in a tall window — see applyRotation() in
   image_lightbox.js. Absolutely positioned rather than left in the flex flow:
   a rotated element keeps its ORIGINAL box for layout, so in flow it would
   reserve the wrong dimensions and be centred against them. The two max-
   values are swapped on purpose — they cap the image before it turns, so
   max-width limits its eventual height and max-height its eventual width. */
.image-lightbox--rotated img {
  position: absolute;
  top: 50%;
  left: 50%;
  /* 6.5rem is 3.25rem at each end, because the image is centred and so grows
     both ways at once. That's what it takes to clear the close button, which
     is the only other thing in the overlay now. */
  max-width: calc(100vh - 6.5rem);
  max-height: calc(100vw - 1.5rem);
  transform: translate(-50%, -50%) rotate(90deg);
}

.image-lightbox-close {
  position: absolute;
  top: 0.5rem;
  right: 0.75rem;
  width: 2.5rem;
  height: 2.5rem;
  border: none;
  border-radius: 50%;
  background: rgb(148 163 184 / 0.25);
  color: #f8fafc;
  font-size: 1.6rem;
  line-height: 1;
  cursor: pointer;
}

.image-lightbox-close:hover { background: rgb(148 163 184 / 0.4); }

/* An image that opens the lightbox. Says so before it's tapped — an image
   that silently happens to be a button gets tapped by nobody. */
.image-expandable {
  cursor: zoom-in;
}

.image-expandable:focus-visible {
  outline: 2px solid var(--accent-primary);
  outline-offset: 2px;
}

/* ── First-run guide (guide.js) ──
   Three pulses on the next thing to press, then a steady ring that stays until
   the page is left. Not the upload page's .btn--attention, which plays twice
   and leaves nothing behind: that one marks a button that has just become
   usable while someone watches; this one points somebody who has just arrived
   at where to go, and a glance back should still find it. */
@keyframes guide-pulse {
    0%   { transform: scale(1);    box-shadow: 0 0 0 0    rgb(var(--accent-rgb) / 0.55); }
    45%  { transform: scale(1.03); box-shadow: 0 0 0 14px rgb(var(--accent-rgb) / 0); }
    100% { transform: scale(1);    box-shadow: 0 0 0 3px  rgb(var(--accent-rgb) / 0.45); }
}
.guide-pulse {
    position: relative;
    z-index: 1;
    animation: guide-pulse 1.1s ease-out 3 both;
}
@media (prefers-reduced-motion: reduce) {
    .guide-pulse {
        animation: none;
        box-shadow: 0 0 0 3px rgb(var(--accent-rgb) / 0.45);
    }
}

/* ────────────────────────────────────────────────────────────────────────────
   Welcome tour  (partials/welcome_modal.html + welcome_modal.js)

   Five full-width panes on a flex track, moved with translateX. The panes are
   never `hidden` — that collapses them and the track's geometry with them — so
   everything here assumes all five are laid out and only one is in view.

   SIZE IS THE POINT. An earlier version was 30rem wide with a 16:10 image on
   every slide, which on a phone was the whole screen — and something occupying
   the whole screen reads as the app rather than as a message from it, so Skip
   starts to look like leaving. The width below is deliberately under a phone's,
   so there is always a margin of app on both sides.

   The card's height follows the slide you are on — see The track, below. So
   every picture has a fixed height: a height that changed as a photograph
   loaded would change the card after it had been measured.
   ──────────────────────────────────────────────────────────────────────────── */

.welcome-dialog { max-width: 26rem; }

.welcome-content {
    /* Its own padding throughout — no Bootstrap modal-header/body/footer, so
       none of their borders and none of their spacing to override. */
    overflow: hidden;
    background: var(--bg-primary);
}

/* ── The count, and the one button in the corner ── */
.welcome-top {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 0.75rem;
    padding: 0.7rem 0.85rem 0.1rem;
}

/* "2 of 5". Small and muted: it answers "how much is left?" for anyone who
   looks, and asks nothing of anyone who doesn't. */
.welcome-count {
    margin: 0;
    font-size: 0.78rem;
    font-weight: 500;
    color: var(--text-muted);
    font-variant-numeric: tabular-nums;
}

/* Quiet, but present from the first slide: someone who doesn't want a tour
   should be able to find the way out without reading the tour to find it. On
   the last slide the word is replaced by a cross — there is nothing left to
   skip by then, and a word that says there is reads as though it continues. */
.welcome-skip {
    appearance: none;
    border: none;
    background: none;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-width: 2rem;
    height: 2rem;
    padding: 0 0.35rem;
    font-size: 0.8rem;
    color: var(--text-muted);
    cursor: pointer;
    border-radius: var(--radius-control, 0.4rem);
}
.welcome-skip:hover { color: var(--text-primary); background: var(--bg-secondary); }
.welcome-skip-cross { display: none; }
.welcome-skip.is-close .welcome-skip-word { display: none; }
.welcome-skip.is-close .welcome-skip-cross { display: block; }

/* ── Stage: the track with a hit zone down each side ──
   The chevron is the visible part of a column that is entirely clickable, so
   the target is the whole edge of the card rather than a 22px glyph. The
   slides carry matching horizontal padding, so nothing readable is ever
   underneath one. */
.welcome-stage { position: relative; }

.welcome-arrow {
    position: absolute;
    top: 0;
    bottom: 0;
    z-index: 2;
    width: 3.25rem;
    display: flex;
    align-items: center;
    justify-content: center;
    appearance: none;
    border: none;
    background: none;
    color: var(--text-muted);
    cursor: pointer;
    transition: color 0.15s ease, opacity 0.15s ease;
}
.welcome-arrow:hover { color: var(--text-primary); }
.welcome-arrow--prev { left: 0; }
.welcome-arrow--next { right: 0; }

/* Hidden at the ends rather than disabled: a greyed arrow at the edge of a
   five-pane deck is a control to wonder about, and `hidden` also takes it out
   of the tab order and stops the dead half of the card swallowing taps. */
.welcome-arrow[hidden] { display: none; }

/* ── The track ── */
/* The height follows the slide you are on, set from JS (see sizeToSlide) and
   eased here, so moving between a short slide and a tall one is a resize you
   watch happen rather than a jump. A flex row is otherwise as tall as its
   tallest child, which left the short slides floating in a card half empty.
   min-height is the floor for the closing card, so it doesn't shrink to a
   strip. The first height is set before the card opens — see welcome_modal.js — so
   the opening itself never resizes. `is-sizing` switches the easing off for
   that first one. */
.welcome-viewport {
    overflow: hidden;
    min-height: 13rem;
    transition: height 0.28s ease;
}
.welcome-viewport.is-sizing { transition: none; }

/* flex-start, not stretch: a stretched pane reports the track's height rather
   than its own, and the measurement depends on each slide being its own size. */
.welcome-track {
    display: flex;
    align-items: flex-start;
    transition: transform 0.28s ease;
}

/* Flex needs all three parts: `0 0 100%` alone still lets a long slide push
   the pane wider than the viewport in some engines, so the width is pinned.
   The horizontal padding is the arrow columns' width — see .welcome-arrow. */
.welcome-slide {
    flex: 0 0 100%;
    width: 100%;
    padding: 0.5rem 3.25rem 1.1rem;
}

@media (prefers-reduced-motion: reduce) {
    .welcome-track,
    .welcome-viewport { transition: none; }
}

/* ── Copy ──
   Sized to be read at arm's length on a phone without making the card tall.
   If a slide ever needs more words than this comfortably holds, the slide is
   wrong — help.html is where the long version belongs. */
.welcome-title {
    font-size: 1.3rem;
    font-weight: 700;
    letter-spacing: -0.01em;
    color: var(--text-primary);
    margin: 0 0 0.1rem;
}

/* Slide 1 only. "Welcome" is the one word on the deck that should be a
   greeting rather than a heading, so it is set like one. */
.welcome-hello {
    font-size: 1.75rem;
    font-weight: 700;
    letter-spacing: -0.02em;
    color: var(--text-primary);
    margin: 0.15rem 0 0.5rem;
}

.welcome-lead {
    font-size: 0.875rem;
    line-height: 1.5;
    color: var(--text-secondary);
    margin: 0;
}

/* The "that part's yours" note on slide 4. Set apart by a rule and a size,
   not by a warning colour or an icon — it divides the labour between the app
   and the person, and dressing it as an alert would make it a confession. */
.welcome-note {
    font-size: 0.76rem;
    line-height: 1.45;
    color: var(--text-muted);
    margin: 0.7rem 0 0;
    padding-top: 0.6rem;
    border-top: 1px solid var(--border-light);
}

/* ── The panel ──
   Every slide's picture sits on one of these, in the same place: under the
   heading, above the words. The shapes inside change from slide to slide; the
   panel is what stays put. */
.welcome-panel {
    margin: 0.7rem 0 0.75rem;
    padding: 0.75rem;
    border-radius: var(--radius-surface, 10px);
    background: var(--bg-secondary);
}

/* Slide 1's drawing fills its panel edge to edge. A fixed shape, because the
   card's height is measured before it opens. */
.welcome-panel--art {
    padding: 0;
    aspect-ratio: 16 / 10;
    overflow: hidden;
}

/* ── Slides 2 and 3: the photographs, as prints on cards ──
   Each photograph at one shared HEIGHT, whole and uncropped, centred on a card
   the grid's width, with its caption under it and its verdict on the card's
   corner. The card's margin absorbs the few pixels by which the photographs
   differ in width — which a grey letterbox slot, the version before this, put
   on show instead. Two across on slide 2, where the photos are the ones to
   copy and worth seeing large; three across two down on slide 3. */
.welcome-examples {
    display: grid;
    gap: 0.55rem;
}
.welcome-examples--good { grid-template-columns: repeat(2, 1fr); }
.welcome-examples--bad  { grid-template-columns: repeat(3, 1fr); gap: 0.55rem 0.45rem; }

.welcome-example {
    position: relative;
    margin: 0;
    display: flex;
    flex-direction: column;
    align-items: center;
    padding: 0.35rem 0.35rem 0.4rem;
    background: var(--bg-primary);
    border: 1px solid var(--border-light);
    border-radius: var(--radius-surface, 8px);
    box-shadow: var(--shadow-sm);
    min-width: 0;
}
.welcome-example img {
    display: block;
    width: auto;
    max-width: 100%;
    height: 6.5rem;
    object-fit: contain;
    border-radius: var(--radius-inner, 4px);
    /* Tap for a proper look — these are small, and the fault in a bad one is
       sometimes a detail. The shared ImageLightbox, a plain overlay, because
       Bootstrap cannot stack modals and this is one. */
    cursor: zoom-in;
}
.welcome-examples--good .welcome-example img { height: 10rem; }

/* The caption carries the lesson: six pictures of a key wrong in six ways
   look much the same at this size. So it is not the smallest type on the
   card, and it is text-secondary rather than muted. */
.welcome-example figcaption {
    margin-top: 0.35rem;
    font-size: 0.76rem;
    line-height: 1.25;
    color: var(--text-secondary);
    text-align: center;
    text-wrap: balance;
}

/* The verdict, on the card's corner — the same place slide 4's tiles carry
   theirs, so the deck has one badge, not two. */
.welcome-badge {
    position: absolute;
    top: -0.45rem;
    right: -0.45rem;
    width: 1.35rem;
    height: 1.35rem;
    display: flex;
    align-items: center;
    justify-content: center;
    border-radius: 50%;
    font-size: 0.8rem;
    font-weight: 700;
    line-height: 1;
    color: #fff;
    box-shadow: 0 0 0 2px var(--bg-secondary);
    pointer-events: none;
}
.welcome-badge--yes { background: var(--success); }
.welcome-badge--no { background: var(--danger); }

/* ── The drawings (slides 1, 4 and 5) ──
   Inline SVG in one style: the shared #welcomeKey symbol, flat fills, no
   gradients. Every colour is a token, so the drawings follow dark mode and the
   palette like the rest of the card. The mat and the metal are the only two
   colours of their own — a photo of a key is a light key on a dark surface in
   either theme, and that is what the thumbnails are drawings of. */
.welcome-defs { position: absolute; width: 0; height: 0; overflow: hidden; }

.welcome-content {
    --art-mat: #27313a;
    --art-metal: #d9dfe3;
    --art-groove: #9aa7b0;
    --art-phone: #141a1f;
}
.dark-mode .welcome-content {
    --art-mat: #26323a;
    --art-phone: #05080a;
}

.welcome-art-mat { fill: var(--art-mat); }
.welcome-art-metal { fill: var(--art-metal); }
.welcome-art-groove { fill: none; stroke: var(--art-groove); stroke-width: 1.6; stroke-linecap: round; }
.welcome-art-phone { fill: var(--art-phone); stroke: var(--border-medium); stroke-width: 1; }
.welcome-art-shadow { fill: #000; opacity: 0.18; }
.welcome-art-shutter { fill: none; stroke: var(--border-medium); stroke-width: 1.6; }
/* The camera guide, in the accent: it is the one thing on the phone that is
   the app rather than the photograph. */
.welcome-art-guide {
    fill: none;
    stroke: var(--accent-primary);
    stroke-width: 2.4;
    stroke-linecap: round;
    stroke-linejoin: round;
}

.welcome-panel--art > svg { display: block; width: 100%; height: 100%; }

/* Slide 4's two captions sit above their panels, left-aligned like the title,
   in the same size as the example captions on slides 2 and 3. */
.welcome-art-caption {
    font-size: 0.8rem;
    font-weight: 600;
    color: var(--text-secondary);
    margin: 0.85rem 0 0.45rem;
}
.welcome-art-caption:first-child { margin-top: 0; }

.welcome-art-thumb { display: block; width: 100%; height: auto; border-radius: var(--radius-inner, 4px); }

/* Three tiles across: the upload grid's tile, reduced to its border colour,
   its badge, its state line and its detail. */
.welcome-art-tiles {
    display: grid;
    grid-template-columns: repeat(3, 1fr);
    gap: 0.45rem;
}
.welcome-art-tile {
    position: relative;
    display: flex;
    flex-direction: column;
    gap: 0.1rem;
    padding: 0.3rem 0.3rem 0.4rem;
    border: 2px solid var(--tile-colour);
    border-radius: var(--radius-surface, 7px);
    background: var(--bg-primary);
    min-width: 0;
}
.welcome-art-tile--saved     { --tile-colour: var(--success); }
.welcome-art-tile--duplicate { --tile-colour: var(--warning); }
.welcome-art-tile--failed    { --tile-colour: var(--danger); }
.welcome-art-tile .welcome-art-thumb { margin-bottom: 0.25rem; }
.welcome-art-badge {
    position: absolute;
    top: -0.45rem;
    right: -0.45rem;
    width: 1.15rem;
    height: 1.15rem;
    display: flex;
    align-items: center;
    justify-content: center;
    border-radius: 50%;
    background: var(--tile-colour);
    color: #fff;
    font-size: 0.68rem;
    font-weight: 700;
    line-height: 1;
    box-shadow: 0 0 0 2px var(--bg-secondary);
}
.welcome-art-state {
    font-size: 0.66rem;
    font-weight: 700;
    line-height: 1.2;
    color: var(--tile-colour);
}
.welcome-art-detail {
    font-size: 0.62rem;
    line-height: 1.25;
    color: var(--text-muted);
}

/* The match: the two photos stacked on the left, their profiles on the right,
   each pair in the comparison screen's own two colours. */
.welcome-art-match {
    display: grid;
    grid-template-columns: 4.6rem 1fr;
    gap: 0.6rem;
    align-items: stretch;
}
.welcome-art-pair { display: flex; flex-direction: column; gap: 0.35rem; }
.welcome-art-photo { display: flex; flex-direction: column; gap: 0.12rem; }
.welcome-art-photo .welcome-art-thumb {
    border: 2px solid var(--photo-colour);
    border-radius: var(--radius-inner, 5px);
}
.welcome-art-photo span {
    font-size: 0.6rem;
    line-height: 1.1;
    color: var(--text-muted);
}
.welcome-art-photo--query { --photo-colour: var(--query-color); }
.welcome-art-photo--match { --photo-colour: var(--match-color); }

.welcome-art-profile {
    display: block;
    width: 100%;
    height: 100%;
    min-height: 6rem;
    border-radius: var(--radius-surface, 7px);
    background: var(--bg-primary);
}
.welcome-art-axis { stroke: var(--border-medium); stroke-width: 1; fill: none; }
.welcome-art-line {
    fill: none;
    stroke-width: 3;
    stroke-linejoin: round;
    stroke-linecap: round;
    vector-effect: non-scaling-stroke;
}
.welcome-art-line--query { stroke: var(--query-color); }
.welcome-art-line--match { stroke: var(--match-color); }

/* Slide 5: the key details page on a desktop, drawn. Same two columns, same
   rows, same labels as key_detail.html, at a size where the words are marks
   more than reading — it is there to be recognised later, not read now. */
.welcome-kd-card {
    text-align: left;
    padding: 0.5rem 0.55rem 0.55rem;
    background: var(--bg-primary);
    border: 1px solid var(--border-light);
    border-radius: var(--radius-surface, 8px);
    box-shadow: var(--shadow-sm);
}
.welcome-kd-heading {
    margin: 0 0 0.4rem;
    font-size: 0.72rem;
    font-weight: 700;
    color: var(--text-primary);
}
.welcome-kd-cols {
    display: grid;
    grid-template-columns: 40% 1fr;
    gap: 0.5rem;
}
.welcome-kd-label {
    display: block;
    margin-bottom: 0.25rem;
    font-size: 0.56rem;
    font-weight: 600;
    color: var(--text-secondary);
}
.welcome-kd-image svg { display: block; width: 100%; height: auto; }
.welcome-kd-list {
    list-style: none;
    margin: 0;
    padding: 0;
    border: 1px solid var(--border-light);
    border-radius: var(--radius-inner, 5px);
}
.welcome-kd-list li {
    padding: 0.2rem 0.3rem;
    font-size: 0.55rem;
    line-height: 1.35;
    color: var(--text-secondary);
}
.welcome-kd-list li + li { border-top: 1px solid var(--border-light); }
.welcome-kd-list b { color: var(--text-primary); font-weight: 650; }
.welcome-kd-name {
    display: flex;
    align-items: center;
    justify-content: space-between;
    font-size: 0.66rem !important;
    font-weight: 700;
    color: var(--text-primary) !important;
}
.welcome-kd-name svg { color: var(--text-muted); }
.welcome-kd-profile {
    display: block;
    width: 100%;
    height: 1.1rem;
    margin-top: 0.1rem;
}
.welcome-kd-profile .welcome-art-line { stroke-width: 1.6; }
.welcome-kd-tag {
    display: inline-block;
    padding: 0.02rem 0.28rem;
    border-radius: var(--radius-inner, 3px);
    font-size: 0.52rem;
    font-weight: 600;
    color: #fff;
}
.welcome-kd-tag--house { background: #2563eb; }
.welcome-kd-tag--spare { background: #0d9488; }
.welcome-kd-crumb { color: var(--accent-text); }
.welcome-kd-crumb i { font-style: normal; color: var(--text-muted); }

/* ── Slides 1 and 5: centred cards ──
   The first carries the brand mark above its heading, the last a button under
   its words. Their panels are full width like everyone else's — a centred
   column would otherwise shrink them to their content. */
.welcome-slide--centred {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    text-align: center;
}
.welcome-mark { width: 76px; height: 76px; }
.welcome-slide--centred .welcome-panel { align-self: stretch; }
.welcome-slide--centred .welcome-lead { max-width: 17rem; }

.welcome-cta {
    margin-top: 0.9rem;
    padding: 0.45rem 1.1rem;
    font-size: 0.9rem;
}

/* The card is already narrow enough to sit inside a phone with room either
   side, so the phone only needs the margin restored — Bootstrap's flat
   `margin: .5rem` would otherwise pin a max-width card to the left. */
@media (max-width: 575.98px) {
    .welcome-dialog { margin: 0.75rem auto; }
}

/* ── "Wrong match?" (partials/match_report.html) ─────────────────────────
   A text link under a match's answers, deliberately quieter than any of
   them: it is for the exception, and must not read as a fourth answer. */
.match-report {
  width: 100%;
  margin-top: 0.75rem;
  text-align: center;
  font-size: 0.9rem;
}
.match-report-toggle {
  background: none;
  border: 0;
  padding: 0.35rem 0.5rem;
  color: var(--text-secondary);
  text-decoration: underline;
  text-underline-offset: 0.2em;
  cursor: pointer;
}
.match-report-toggle:hover { color: var(--text-primary, inherit); }
.match-report-form {
  margin-top: 0.5rem;
  text-align: left;
}
.match-report-fine {
  margin: 0.4rem 0 0.6rem;
  font-size: 0.8rem;
  color: var(--text-muted);
}
.match-report-buttons { display: flex; gap: 0.5rem; }
.match-report-done { margin: 0.35rem 0 0; color: var(--text-secondary); }
.match-report-error { margin: 0.35rem 0 0; color: var(--danger); }
