/* ═══════════════════════════════════════════════════════════════════════
   Anna's On The Go Studio — Client Portal + Admin
   ═══════════════════════════════════════════════════════════════════════ */
[hidden] { display: none !important; }
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
input:not([type="checkbox"]):not([type="radio"]), select, button, textarea { -webkit-appearance: none; appearance: none; }
input[type="checkbox"] {
  -webkit-appearance: none; appearance: none;
  width: 18px; height: 18px; border: 2px solid oklch(0.4 0.005 260);
  border-radius: 3px; background: oklch(0.17 0.005 260);
  cursor: pointer; position: relative; flex-shrink: 0;
}
input[type="checkbox"]:checked {
  background: oklch(0.65 0.1 70); border-color: oklch(0.65 0.1 70);
}
input[type="checkbox"]:checked::after {
  content: ''; position: absolute; left: 5px; top: 1px;
  width: 5px; height: 10px; border: solid oklch(0.15 0.01 70);
  border-width: 0 2px 2px 0; transform: rotate(45deg);
}

:root {
  --ease: cubic-bezier(0.22, 1, 0.36, 1);
  --r: 6px;
  --r-lg: 10px;
  --font: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
  --bg: oklch(0.13 0.005 260);
  --surface: oklch(0.17 0.005 260);
  --border: oklch(0.24 0.005 260);
  --text: oklch(0.92 0.01 80);
  --dim: oklch(0.55 0.01 80);
  /* Literally the same gold as the site, not an oklch approximation of it. The nearest oklch
     landed on #e0b659 — close, and close is exactly what makes two yellows look wrong side by
     side. The hover is main.css's own .mbtn-solid:hover value. */
  --accent: #e0b84e;
  --accent-h: #efce6a;
  --danger: oklch(0.6 0.18 25);
  --ok: oklch(0.65 0.15 145);
}

body { font-family: var(--font); -webkit-font-smoothing: antialiased; }
.admin-body { background: var(--bg); color: var(--text); min-height: 100dvh; }

/* ── Login ─────────────────────────────────────────────────────────── */
.admin-login { display: grid; place-items: center; min-height: 100dvh; padding: 24px; }
.login-card { width: 100%; max-width: 340px; text-align: center; }
.login-title { font-size: 1.4rem; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; }
.login-sub { font-size: 0.75rem; letter-spacing: 0.12em; text-transform: uppercase; color: var(--dim); margin: 4px 0 28px; }
.login-input {
  display: block; width: 100%; padding: 13px 15px; background: var(--surface);
  border: 1px solid var(--border); border-radius: var(--r); color: var(--text);
  font: 1rem var(--font); outline: none; transition: border-color 0.2s var(--ease);
}
.login-input:focus { border-color: var(--accent); }
.login-btn {
  display: block; width: 100%; margin-top: 14px; padding: 13px; background: var(--accent);
  color: oklch(0.15 0.01 70); border: none; border-radius: var(--r); font: 600 0.95rem var(--font);
  cursor: pointer; transition: background 0.2s var(--ease);
}
.login-btn:hover { background: var(--accent-h); }
.login-error { margin-top: 12px; color: var(--danger); font-size: 0.85rem; }

/* ── Dashboard ─────────────────────────────────────────────────────── */
.dash-header {
  display: flex; align-items: center; justify-content: space-between;
  padding: 14px 24px; border-bottom: 1px solid var(--border); gap: 12px; flex-wrap: wrap;
}
.dash-brand { display: flex; align-items: baseline; gap: 10px; }
.dash-logo { font-size: 1rem; font-weight: 600; letter-spacing: 0.03em; }
.dash-tag { font-size: 0.65rem; letter-spacing: 0.1em; text-transform: uppercase; color: var(--dim); }
.dash-actions { display: flex; gap: 8px; }
.dash-btn {
  padding: 9px 18px; border-radius: var(--r); font: 600 0.82rem var(--font);
  cursor: pointer; border: none; transition: background 0.15s var(--ease);
}
.dash-btn-primary { background: var(--accent); color: oklch(0.15 0.01 70); }
.dash-btn-primary:hover { background: var(--accent-h); }
.dash-btn-ghost { background: transparent; color: var(--dim); border: 1px solid var(--border); }
.dash-btn-ghost:hover { border-color: var(--dim); color: var(--text); }
.dash-btn-danger { background: transparent; color: var(--danger); border: 1px solid oklch(0.32 0.08 25); }
.dash-btn-danger:hover { background: oklch(0.18 0.05 25); }
.dash-main { padding: 24px; }

.gallery-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; }
.gallery-card {
  background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-lg);
  padding: 18px; cursor: pointer; transition: border-color 0.2s var(--ease), transform 0.15s var(--ease);
}
.gallery-card:hover { border-color: var(--accent); transform: translateY(-1px); }
.gc-client { font-weight: 600; font-size: 1rem; margin-bottom: 2px; }
.gc-title { font-size: 0.82rem; color: var(--dim); margin-bottom: 10px; }
.gc-meta { display: flex; gap: 14px; font-size: 0.75rem; color: var(--dim); align-items: center; }
.gc-status { font-size: 0.65rem; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; }
.gc-status-on { color: var(--ok); }
.gc-status-off { color: var(--danger); }
.empty-state { text-align: center; padding: 80px 24px; }
.empty-msg { font-size: 1.1rem; color: var(--dim); }
.empty-sub { font-size: 0.82rem; color: oklch(0.4 0.01 80); margin-top: 6px; }

/* ── Editor ────────────────────────────────────────────────────────── */
.editor-header {
  display: flex; align-items: center; gap: 12px; padding: 12px 20px;
  border-bottom: 1px solid var(--border); flex-wrap: wrap;
}
.editor-back {
  background: none; border: none; color: var(--dim); font: 0.82rem var(--font);
  cursor: pointer; white-space: nowrap;
}
.editor-back:hover { color: var(--text); }
.editor-title-row { display: flex; gap: 10px; flex: 1; min-width: 180px; }
.editor-input {
  background: var(--surface); border: 1px solid var(--border); border-radius: var(--r);
  color: var(--text); font: 0.9rem var(--font); padding: 7px 11px; outline: none;
  transition: border-color 0.2s var(--ease); flex: 1;
}
.editor-input:focus { border-color: var(--accent); }
.editor-actions { display: flex; gap: 8px; }

/* Theme picker */
.theme-picker {
  display: flex; align-items: center; gap: 12px; padding: 12px 20px;
  border-bottom: 1px solid var(--border); overflow-x: auto;
}
.theme-label { font-size: 0.68rem; letter-spacing: 0.08em; text-transform: uppercase; color: var(--dim); white-space: nowrap; }
.theme-options { display: flex; gap: 6px; }
.theme-swatch {
  display: flex; align-items: center; gap: 6px; padding: 5px 12px; background: none;
  border: 1px solid var(--border); border-radius: 100px; cursor: pointer;
  font: 0.75rem var(--font); color: var(--dim); transition: border-color 0.15s var(--ease); white-space: nowrap;
}
.theme-swatch:hover { border-color: var(--dim); }
.theme-swatch.active { border-color: var(--accent); color: var(--text); }
.swatch-dot { width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; }

/* Upload zone */
.upload-zone {
  margin: 16px 20px; padding: 32px; border: 2px dashed var(--border); border-radius: var(--r-lg);
  text-align: center; transition: border-color 0.2s var(--ease), background 0.2s var(--ease);
}
.upload-zone.dragover { border-color: var(--accent); background: oklch(0.15 0.02 70 / 0.2); }
.upload-text { color: var(--dim); font-size: 0.9rem; }
.upload-link { color: var(--accent); text-decoration: underline; cursor: pointer; }
.upload-sub { color: oklch(0.4 0.01 80); font-size: 0.75rem; margin-top: 4px; }

/* Upload queue */
.upload-queue { padding: 0 20px 12px; display: flex; flex-direction: column; gap: 6px; }
.uq-row {
  display: grid; grid-template-columns: 1fr auto 120px auto; gap: 10px; align-items: center;
  padding: 8px 12px; background: var(--surface); border-radius: var(--r); font-size: 0.8rem;
}
.uq-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.uq-size { color: var(--dim); font-size: 0.72rem; }
.uq-bar-track { height: 4px; background: var(--border); border-radius: 2px; overflow: hidden; }
.uq-bar-fill { height: 100%; background: var(--accent); border-radius: 2px; transition: width 0.2s linear; }
.uq-status { font-size: 0.72rem; color: var(--dim); text-align: right; min-width: 60px; }
.uq-done .uq-bar-fill { background: var(--ok); }
.uq-done .uq-status { color: var(--ok); }
.uq-error .uq-bar-fill { background: var(--danger); }
.uq-error .uq-status { color: var(--danger); }

/* Filter bar */
.filter-bar {
  display: flex; align-items: center; gap: 12px; padding: 10px 20px;
  border-bottom: 1px solid oklch(0.24 0.005 260);
}
.filter-tabs { display: flex; gap: 2px; }
.filter-tab {
  -webkit-appearance: none; appearance: none;
  padding: 8px 16px; background: transparent; border: none;
  color: oklch(0.55 0.01 80); font: 600 0.82rem var(--font);
  cursor: pointer; border-radius: var(--r);
}
.filter-tab:hover { color: oklch(0.92 0.01 80); }
.filter-tab.active { background: oklch(0.17 0.005 260); color: oklch(0.92 0.01 80); }
.filter-search {
  -webkit-appearance: none; appearance: none;
  flex: 1; min-width: 120px; padding: 8px 12px;
  background: oklch(0.17 0.005 260); border: 1px solid oklch(0.24 0.005 260);
  border-radius: var(--r); color: oklch(0.92 0.01 80);
  font: 0.85rem var(--font); outline: none;
}
.filter-search:focus { border-color: oklch(0.72 0.12 70); }
.filter-tag-select {
  -webkit-appearance: none; appearance: none;
  padding: 8px 12px; background: oklch(0.17 0.005 260);
  border: 1px solid oklch(0.24 0.005 260);
  border-radius: var(--r); color: oklch(0.92 0.01 80);
  font: 0.82rem var(--font); outline: none;
  max-width: 200px;
}

/* Editor content area (grid + detail side-by-side) */
.editor-content { display: flex; flex: 1; min-height: 300px; }

/* Media grid */
.media-grid {
  /* Mirrors .gv-grid exactly — the admin view is supposed to be what the client sees, and it
     was four across while the client was two. */
  flex: 1; column-count: 3; column-gap: 6px; padding: 16px 20px;
}
.media-empty { grid-column: 1 / -1; text-align: center; padding: 40px; color: var(--dim); font-size: 0.85rem; }
.media-section-header {
  column-span: all; font-size: 0.72rem; font-weight: 600; letter-spacing: 0.06em;
  text-transform: uppercase; color: var(--dim); padding: 12px 0 4px;
  border-bottom: 1px solid var(--border); margin-bottom: 4px; line-height: 1.4;
}
.media-item {
  position: relative; break-inside: avoid; margin-bottom: 8px; border-radius: var(--r); overflow: hidden;
  cursor: pointer; border: 2px solid transparent; transition: border-color 0.15s var(--ease);
  background: var(--surface); line-height: 0;
}
.media-item:hover { border-color: oklch(0.4 0.01 80); }
.media-item.selected { border-color: var(--accent); }
.media-item.dragging { opacity: 0.3; }
.media-item img, .media-item video { width: 100%; height: auto; max-height: 85vh; object-fit: cover; display: block; }
.media-type-badge {
  position: absolute; top: 6px; left: 6px; font-size: 0.6rem; font-weight: 600;
  text-transform: uppercase; letter-spacing: 0.04em; padding: 2px 6px;
  background: oklch(0 0 0 / 0.5); color: #fff; border-radius: 3px;
}
.media-item-info {
  position: absolute; bottom: 0; left: 0; right: 0; padding: 24px 8px 6px;
  background: linear-gradient(to top, oklch(0 0 0 / 0.7), transparent);
  display: flex; flex-direction: column; gap: 1px;
}
.media-item-name { font-size: 0.65rem; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.media-item-tags { font-size: 0.58rem; color: oklch(0.7 0 0); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.media-item-del {
  position: absolute; top: 6px; right: 6px; background: oklch(0 0 0 / 0.5); border: none;
  color: #fff; width: 22px; height: 22px; border-radius: 50%; cursor: pointer;
  font-size: 0.85rem; display: none; align-items: center; justify-content: center;
}
.media-item:hover .media-item-del { display: flex; }

/* Folder card (group in main view) */
.media-folder {
  break-inside: avoid; margin-bottom: 8px; border-radius: var(--r);
  background: oklch(0.19 0.008 260); border: 1px solid oklch(0.26 0.005 260);
  cursor: pointer; overflow: hidden; transition: border-color 0.15s;
}
.media-folder:hover { border-color: oklch(0.72 0.12 70); }
.media-folder.dragging { opacity: 0.3; }
.media-folder.drop-reorder { border-top: 3px solid oklch(0.72 0.12 70); }
.folder-preview { position: relative; line-height: 0; }
.folder-preview img { width: 100%; height: auto; display: block; }
.folder-stack-line {
  position: absolute; bottom: -3px; left: 6px; right: 6px; height: 3px;
  background: oklch(0.22 0.005 260); border-radius: 0 0 3px 3px; border: 1px solid oklch(0.26 0.005 260); border-top: none;
}
.folder-stack-line-2 { bottom: -6px; left: 12px; right: 12px; }
.folder-info { padding: 10px 10px 14px; line-height: 1.4; }
.folder-count { font-size: 0.7rem; font-weight: 600; color: oklch(0.72 0.12 70); display: block; }
.folder-name { font-size: 0.68rem; color: oklch(0.5 0.01 80); display: block; margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }

/* Group open bar */
.group-open-bar {
  display: flex; align-items: center; gap: 12px; padding: 10px 20px;
  background: oklch(0.15 0.008 260); border-bottom: 1px solid var(--border);
}
.group-open-label { font-size: 0.82rem; font-weight: 600; }
.group-open-hint { font-size: 0.65rem; color: oklch(0.45 0.01 80); margin-left: auto; }

/* In-group items */
.in-group { border-color: oklch(0.3 0.04 70); }
.group-primary-badge {
  position: absolute; top: 6px; left: 6px; padding: 3px 8px; z-index: 2;
  font-size: 0.62rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em;
  color: oklch(0.15 0.01 70); background: oklch(0.72 0.12 70); border-radius: 3px;
}
.group-item-actions {
  position: absolute; bottom: 4px; left: 4px; right: 4px; display: none; gap: 4px; z-index: 3;
}
.in-group:hover .group-item-actions { display: flex; }
.group-action-btn {
  flex: 1; padding: 5px 6px; font: 500 0.6rem var(--font);
  color: oklch(1 0 0 / 0.85); background: oklch(0 0 0 / 0.5); backdrop-filter: blur(8px);
  -webkit-backdrop-filter: blur(8px); border: 1px solid oklch(1 0 0 / 0.1);
  border-radius: 3px; cursor: pointer; text-align: center;
}
.group-action-btn:hover { background: oklch(0 0 0 / 0.7); }
.group-action-danger { color: oklch(0.7 0.15 25); }

/* Selection mode */
.select-bar {
  display: flex; align-items: center; gap: 12px; padding: 10px 20px;
  background: oklch(0.18 0.02 70); border-bottom: 1px solid oklch(0.3 0.04 70);
}
.select-bar span { font-size: 0.82rem; font-weight: 600; color: oklch(0.9 0.02 70); }
.select-check {
  position: absolute; top: 6px; right: 6px; width: 24px; height: 24px; z-index: 3;
  background: oklch(0 0 0 / 0.4); border: 2px solid oklch(1 0 0 / 0.3);
  border-radius: 50%; display: flex; align-items: center; justify-content: center;
  font-size: 0.75rem; color: #fff;
}
.media-item.select-checked .select-check {
  background: oklch(0.65 0.1 70); border-color: oklch(0.65 0.1 70);
}

/* Drop indicators */
.media-item.drop-reorder { border-top: 3px solid oklch(0.72 0.12 70); }

/* Detail panel */
.detail-panel {
  width: 300px; flex-shrink: 0; border-left: 1px solid var(--border);
  overflow-y: auto; max-height: calc(100dvh - 240px);
}
.detail-header {
  display: flex; justify-content: space-between; align-items: center;
  padding: 12px 16px; border-bottom: 1px solid var(--border);
}
.detail-header-title { font-size: 0.75rem; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--dim); }
.detail-close { background: none; border: none; color: var(--dim); font-size: 1.2rem; cursor: pointer; }
.detail-preview { padding: 12px 16px; }
.detail-preview img, .detail-preview video { border-radius: 4px; }
.detail-meta { padding: 0 16px 12px; }
.detail-row { display: flex; justify-content: space-between; padding: 4px 0; font-size: 0.78rem; }
.detail-key { color: var(--dim); }
.detail-val { color: var(--text); max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.detail-section-row {
  display: flex; align-items: center; gap: 8px; padding: 8px 16px;
  border-top: 1px solid var(--border);
}
.detail-section-input {
  flex: 1; padding: 5px 8px; background: var(--surface); border: 1px solid var(--border);
  border-radius: var(--r); color: var(--text); font: 0.78rem var(--font); outline: none;
}
.detail-section-input:focus { border-color: var(--accent); }
.detail-tags-header {
  font-size: 0.7rem; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase;
  color: var(--dim); padding: 12px 16px 6px; border-top: 1px solid var(--border);
}
.detail-tags { padding: 0 16px 16px; }
.dtag-category { margin-bottom: 10px; }
.dtag-cat-name { font-size: 0.68rem; font-weight: 600; color: var(--dim); margin-bottom: 4px; letter-spacing: 0.03em; }
.dtag-list { display: flex; flex-direction: column; gap: 2px; }
.dtag-label {
  display: flex; align-items: center; gap: 6px; font-size: 0.76rem; color: var(--text);
  cursor: pointer; padding: 2px 0;
}
.dtag-label input[type="checkbox"] { accent-color: oklch(0.65 0.1 70); }

/* Editor footer */
.editor-footer {
  display: flex; align-items: center; justify-content: space-between;
  padding: 12px 20px; border-top: 1px solid var(--border);
}
.toggle-row { display: flex; align-items: center; gap: 8px; font-size: 0.82rem; color: var(--dim); cursor: pointer; }

/* Preview overlay */
.preview-overlay { position: fixed; inset: 0; z-index: 1000; background: var(--bg); display: flex; flex-direction: column; }
.preview-bar {
  display: flex; align-items: center; justify-content: space-between;
  padding: 10px 16px; background: oklch(0.11 0.005 260); border-bottom: 1px solid var(--border);
}
.preview-label { font-size: 0.75rem; letter-spacing: 0.08em; text-transform: uppercase; color: var(--dim); }
.preview-close { background: none; border: none; color: var(--text); font-size: 1.3rem; cursor: pointer; }
.preview-frame { flex: 1; border: none; width: 100%; }

/* Modal */
.modal-overlay { position: fixed; inset: 0; z-index: 900; background: oklch(0 0 0 / 0.6); display: grid; place-items: center; padding: 24px; }
.modal-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-lg); padding: 24px; width: 100%; max-width: 400px; }
.modal-title { font-size: 1.05rem; font-weight: 600; margin-bottom: 16px; }
.modal-input {
  display: block; width: 100%; padding: 10px 12px; margin-bottom: 10px; background: var(--bg);
  border: 1px solid var(--border); border-radius: var(--r); color: var(--text);
  font: 0.9rem var(--font); outline: none;
}
.modal-input:focus { border-color: var(--accent); }
.modal-hint { font-size: 0.72rem; color: var(--dim); margin-bottom: 12px; }
.modal-label { display: block; font-size: 0.72rem; letter-spacing: 0.06em; text-transform: uppercase; color: var(--dim); margin-bottom: 4px; }
.modal-select {
  display: block; width: 100%; padding: 9px 10px; margin-bottom: 18px; background: var(--bg);
  border: 1px solid var(--border); border-radius: var(--r); color: var(--text); font: 0.85rem var(--font); outline: none;
}
.modal-btns { display: flex; gap: 8px; justify-content: flex-end; }

/* ═══════════════════════════════════════════════════════════════════════
   PORTAL — Client-facing
   ═══════════════════════════════════════════════════════════════════════ */
.portal-body { background: oklch(0.12 0.005 260); color: oklch(0.9 0.01 80); min-height: 100dvh; font-family: var(--font); }

/* Landing */
.portal-landing { display: grid; place-items: center; min-height: 100dvh; padding: 24px; }
.portal-landing-card { text-align: center; max-width: 440px; width: 100%; }
.portal-brand { font-size: 1.8rem; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; }
/* The signature, at the size a landing page can give it — this is the one screen where it is the
   whole point rather than a corner mark. Colour comes from .gv-sig's mask, so it needs only ink. */
.portal-brand-sig {
  width: min(196px, 62vw); height: min(100px, 32vw);
  margin: 0 auto; color: var(--text);
}
.portal-tagline { font-size: 0.75rem; letter-spacing: 0.14em; text-transform: uppercase; color: oklch(0.5 0.01 80); margin-top: 4px; }
.portal-divider { width: 36px; height: 1px; background: oklch(0.28 0.005 80); margin: 24px auto; }
.portal-msg { font-size: 1rem; line-height: 1.6; color: oklch(0.72 0.01 80); }
.portal-msg-sub { font-size: 0.82rem; color: oklch(0.48 0.01 80); margin: 6px 0 20px; }
.portal-link-form { display: flex; gap: 8px; }
.portal-link-input {
  flex: 1; padding: 13px 14px; background: oklch(0.16 0.005 260);
  border: 1px solid oklch(0.26 0.005 260); border-radius: var(--r);
  color: oklch(0.9 0.01 80); font: 0.9rem var(--font); outline: none;
}
.portal-link-input:focus { border-color: oklch(0.62 0.005 260); }
.portal-link-btn {
  /* Same inversion as the audio transport: the page's ink as the fill, the page's ground as the
     label. Nothing here is gold any more. */
  padding: 13px 26px; background: var(--text); color: var(--bg);
  border: none; border-radius: var(--r); font: 600 0.9rem var(--font); cursor: pointer;
  transition: filter 0.15s var(--ease);
}
.portal-link-btn:hover { filter: brightness(0.88); }
.portal-link-error { margin-top: 10px; color: oklch(0.62 0.18 25); font-size: 0.82rem; }
/* A client who cannot find their link has nowhere else to go from this page — without this the
   only options were to guess or to give up. */
.portal-help { margin-top: 22px; font-size: 0.8rem; color: oklch(0.48 0.01 80); }
.portal-help a {
  color: oklch(0.82 0.01 80); text-decoration: none;
  border-bottom: 1px solid oklch(0.82 0.01 80 / 0.35);
  padding-bottom: 1px; transition: border-color 0.15s var(--ease);
}
.portal-help a:hover { border-bottom-color: oklch(0.82 0.01 80 / 0.9); }
.portal-footer {
  position: fixed; bottom: 14px; left: 0; right: 0; text-align: center; font-size: 0.72rem;
  display: flex; align-items: center; justify-content: center; gap: 10px; flex-wrap: wrap;
}
.portal-footer a { color: oklch(0.42 0.01 80); text-decoration: none; }
.portal-copy { color: var(--gv-dim, oklch(0.42 0.01 80)); }
/* The old footer was one line reading "© JJ Rhymes" that happened to be clickable — which is
   not something anyone tries. This says where it goes. */
.portal-home-link {
  display: inline-flex; align-items: center; gap: 5px;
  padding: 5px 11px; border-radius: var(--r);
  border: 1px solid oklch(from var(--gv-dim, oklch(0.5 0 0)) l c h / 0.32);
  color: var(--gv-text, oklch(0.8 0 0)) !important;
  transition: border-color 0.15s var(--ease), color 0.15s var(--ease);
}
.portal-home-link:hover {
  border-color: oklch(from var(--gv-text, oklch(0.9 0 0)) l c h / 0.55);
}
.portal-home-link .portal-home-arrow { font-size: 0.85em; opacity: 0.7; }
.portal-footer-gallery { position: static; padding: 36px 0 20px; }

/* ═══ Gallery Viewer — Themed ═══ */
.gallery-viewer[data-theme="cinema"]    { --gv-bg: oklch(0.08 0.005 40); --gv-text: oklch(0.88 0.02 70); --gv-accent: oklch(0.72 0.1 60); --gv-surface: oklch(0.12 0.01 40); --gv-dim: oklch(0.5 0.02 60); --gv-font: 'Georgia', serif; }
.gallery-viewer[data-theme="dark"]      { --gv-bg: oklch(0.1 0.005 260); --gv-text: oklch(0.92 0.005 260); --gv-accent: oklch(0.7 0.005 260); --gv-surface: oklch(0.15 0.005 260); --gv-dim: oklch(0.5 0.005 260); --gv-font: var(--font); }
.gallery-viewer[data-theme="wedding"]   { --gv-bg: oklch(0.96 0.01 80); --gv-text: oklch(0.3 0.02 60); --gv-accent: oklch(0.65 0.08 70); --gv-surface: oklch(0.99 0.005 80); --gv-dim: oklch(0.6 0.02 60); --gv-font: 'Georgia', serif; }
.gallery-viewer[data-theme="rustic"]    { --gv-bg: oklch(0.93 0.02 75); --gv-text: oklch(0.28 0.03 60); --gv-accent: oklch(0.55 0.1 70); --gv-surface: oklch(0.96 0.015 75); --gv-dim: oklch(0.55 0.03 60); --gv-font: 'Georgia', serif; }
.gallery-viewer[data-theme="editorial"] { --gv-bg: oklch(0.99 0 0); --gv-text: oklch(0.15 0 0); --gv-accent: oklch(0.15 0 0); --gv-surface: oklch(0.96 0 0); --gv-dim: oklch(0.5 0 0); --gv-font: var(--font); }
.gallery-viewer[data-theme="moody"]     { --gv-bg: oklch(0.12 0.008 280); --gv-text: oklch(0.72 0.01 60); --gv-accent: oklch(0.5 0.06 50); --gv-surface: oklch(0.16 0.008 280); --gv-dim: oklch(0.4 0.01 60); --gv-font: var(--font); }
.gallery-viewer[data-theme="film"]      { --gv-bg: oklch(0.94 0.015 70); --gv-text: oklch(0.25 0.02 55); --gv-accent: oklch(0.55 0.08 55); --gv-surface: oklch(0.9 0.02 70); --gv-dim: oklch(0.55 0.02 55); --gv-font: 'Georgia', serif; }
.gallery-viewer[data-theme="clean"]     { --gv-bg: oklch(0.99 0 0); --gv-text: oklch(0.2 0 0); --gv-accent: oklch(0.5 0.15 250); --gv-surface: oklch(0.99 0 0); --gv-dim: oklch(0.55 0 0); --gv-font: var(--font); }

.gallery-viewer { background: var(--gv-bg, oklch(0.1 0.005 260)); color: var(--gv-text); font-family: var(--gv-font, var(--font)); min-height: 100dvh; }

.gv-header { display: flex; align-items: center; gap: 16px; padding: 16px 28px; border-bottom: 1px solid oklch(from var(--gv-dim) l c h / 0.15); flex-wrap: wrap; }
.gv-logo { font-size: 0.8rem; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--gv-dim); }
/* The wordmark was 0.8rem bold uppercase, which carries at --gv-dim. A signature is thin
   strokes, and the same colour left it barely there — so it takes the text colour held back by
   opacity instead: present, without competing with the gallery title beside it. */
.gv-logo.gv-sig {
  width: 92px; height: 47px; flex: 0 0 auto;
  color: var(--gv-text, oklch(.92 0 0)); opacity: .78;
}

/**
 * The signature, drawn as a mask rather than an <img>.
 *
 * A gallery can carry any of eight themes, five of them light and three dark, so a fixed white
 * or black signature would vanish on half of them. Masking paints the shape in currentColor, so
 * it takes the ink of whatever theme it lands in — one file, correct everywhere.
 */
.gv-sig {
  display: block;
  background-color: currentColor;
  -webkit-mask: url(/assets/img/signature.svg) no-repeat center / contain;
          mask: url(/assets/img/signature.svg) no-repeat center / contain;
}
.gv-info { flex: 1; min-width: 120px; }
.gv-title { font-size: 1.2rem; font-weight: 600; }
.gv-client { font-size: 0.78rem; color: var(--gv-dim); margin-top: 1px; }
.gv-downloads { display: flex; gap: 6px; flex-wrap: wrap; }
.gv-dl-btn {
  padding: 8px 16px; background: transparent; color: var(--gv-dim); border: 1px solid oklch(from var(--gv-dim) l c h / 0.25);
  border-radius: var(--r); font: 500 0.75rem inherit; cursor: pointer; display: inline-flex; align-items: center; gap: 6px;
  transition: color 0.15s, border-color 0.15s;
}
.gv-dl-btn:hover { color: var(--gv-text); border-color: var(--gv-dim); }
.gv-dl-svg { flex-shrink: 0; }

.gv-loading { display: flex; flex-direction: column; align-items: center; padding: 100px 24px; color: var(--gv-dim); gap: 14px; }
.gv-spinner { width: 28px; height: 28px; border: 2px solid oklch(from var(--gv-dim) l c h / 0.2); border-top-color: var(--gv-accent); border-radius: 50%; animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.gv-error { text-align: center; padding: 100px 24px; }
.gv-error-msg { font-size: 1rem; color: var(--gv-dim); margin-bottom: 14px; }
.gv-error-link { color: var(--gv-accent); text-decoration: none; font-size: 0.85rem; }

/* Was a fixed 3-column masonry that the cinema theme dropped to 2, which is where "still 2x2"
   came from. Columns now follow the viewport, so a wide screen gets four and a laptop three. */
.gv-grid { column-count: 3; column-gap: 6px; padding: 24px 28px; }
@media (min-width: 1500px) { .gv-grid, .media-grid { column-count: 4; } }
@media (max-width: 1100px)  { .gv-grid, .media-grid { column-count: 3; } }
@media (max-width: 820px)   { .gv-grid, .media-grid { column-count: 2; } }
.gv-section-header {
  column-span: all; font-size: 0.85rem; font-weight: 600; letter-spacing: 0.04em;
  color: var(--gv-dim); padding: 16px 0 6px; border-bottom: 1px solid oklch(from var(--gv-dim) l c h / 0.12);
  line-height: 1.4;
}
.gv-item { position: relative; break-inside: avoid; margin-bottom: 6px; overflow: hidden;
  cursor: pointer; border-radius: var(--r); line-height: 0;
  /* No surface colour behind the photo. The tile used to paint --gv-surface, so any pixel the
     image did not cover showed as a band — which is what the gradient looked like it was
     floating on. The tile is now exactly the image. */
  background: none; display: block; font-size: 0; }
.gv-item > img, .gv-item > .gv-video-thumb { display: block; width: 100%; }
.gv-item.gv-stack { overflow: visible; margin-bottom: 16px; }
/* max-height with object-fit:cover CROPPED tall portraits — the photo was cut off rather than
   fitted. In a masonry column the tile should simply be as tall as the image is; the column
   width already bounds it. */
.gv-item img, .gv-item video { display: block; width: 100%; height: auto; object-fit: contain; transition: transform 0.4s var(--ease); }
.gv-item:hover img, .gv-item:hover video { transform: scale(1.02); }
.gv-item-bar {
  position: absolute; bottom: 0; left: 0; right: 0; display: flex; align-items: center;
  justify-content: flex-end; padding: 10px; gap: 6px;
  /* No gradient. It never sat flush against a masonry tile's sub-pixel height, and the button
     carries its own contrast, so the fade was cost without benefit. */
  background: none;
  opacity: 0; transition: opacity 0.2s var(--ease);
}
.gv-item:hover .gv-item-bar { opacity: 1; }

/* Tags on preview — glassmorphism top-left */
.gv-item-tags {
  position: absolute; top: 6px; left: 6px; display: flex; flex-wrap: wrap; gap: 3px;
  pointer-events: none; max-width: calc(100% - 12px);
}
.gv-tag {
  padding: 4px 10px; font-size: 0.72rem; font-weight: 500; letter-spacing: 0.02em;
  color: oklch(1 0 0 / 0.9); background: oklch(0 0 0 / 0.35); backdrop-filter: blur(14px);
  -webkit-backdrop-filter: blur(14px); border: 1px solid oklch(1 0 0 / 0.1);
  border-radius: 4px; white-space: nowrap;
}

/* Client filter bar */
.gv-filter-bar {
  padding: 10px 28px; border-bottom: 1px solid oklch(from var(--gv-dim, #888) l c h / 0.12);
  /* wrap: on mobile the media tabs take the full width, and without this they push the
     filter selects off to the right instead of taking their own row. */
  display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
}
.gv-filter-select {
  -webkit-appearance: none; appearance: none;
  padding: 9px 14px; background: oklch(0 0 0 / 0.3); backdrop-filter: blur(8px);
  -webkit-backdrop-filter: blur(8px);
  border: 1px solid oklch(0.5 0 0 / 0.15); border-radius: var(--r);
  color: inherit; font: 0.82rem var(--font); outline: none; min-width: 160px;
}
.gv-item-name { font-size: 0.68rem; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.gv-item-actions { display: flex; gap: 5px; }
.gv-item-dl {
  padding: 6px 12px; background: oklch(0 0 0 / 0.35); backdrop-filter: blur(12px);
  -webkit-backdrop-filter: blur(12px); border: 1px solid oklch(1 0 0 / 0.1);
  border-radius: 4px; color: oklch(1 0 0 / 0.85); font: 500 0.75rem var(--font);
  cursor: pointer; text-decoration: none; display: inline-flex; align-items: center; gap: 4px;
  transition: background 0.15s;
}
.gv-item-dl:hover { background: oklch(0 0 0 / 0.5); color: #fff; }
.gv-item-dl svg { flex-shrink: 0; width: 13px; height: 13px; }

/* Lightbox */
.gv-lightbox { position: fixed; inset: 0; z-index: 2000; background: oklch(0 0 0 / 0.92); display: flex; flex-direction: column; align-items: center; justify-content: center; }
.gv-lb-close, .gv-lb-prev, .gv-lb-next { position: absolute; background: none; border: none; color: oklch(0.8 0 0); cursor: pointer; z-index: 10; }
.gv-lb-close:hover, .gv-lb-prev:hover, .gv-lb-next:hover { color: #fff; }
.gv-lb-close { top: 16px; right: 20px; font-size: 1.8rem; }
.gv-lb-prev { left: 16px; top: 50%; transform: translateY(-50%); font-size: 2.2rem; }
.gv-lb-next { right: 16px; top: 50%; transform: translateY(-50%); font-size: 2.2rem; }
.gv-lb-content { max-width: 90vw; max-height: 80vh; }
.gv-lb-content img, .gv-lb-content video { max-width: 90vw; max-height: 80vh; object-fit: contain; border-radius: var(--r); }
.gv-lb-bar { position: absolute; bottom: 0; left: 0; right: 0; display: flex; align-items: center; justify-content: space-between; padding: 14px 24px; background: linear-gradient(to top, oklch(0 0 0 / 0.5), transparent); }
.gv-lb-name { font-size: 0.82rem; color: oklch(0.7 0 0); }
.gv-lb-actions { display: flex; gap: 8px; }
.gv-lb-dl {
  padding: 8px 16px; border-radius: var(--r); font: 500 0.82rem var(--font);
  text-decoration: none; color: oklch(1 0 0 / 0.8); background: oklch(1 0 0 / 0.08);
  border: 1px solid oklch(1 0 0 / 0.15); cursor: pointer;
  display: inline-flex; align-items: center; gap: 5px;
  transition: background 0.15s, color 0.15s;
}
.gv-lb-dl:hover { background: oklch(1 0 0 / 0.18); color: #fff; }
.gv-lb-dl svg { flex-shrink: 0; width: 14px; height: 14px; }

/* Version viewer overlay */
.gv-versions { padding: 0 28px 40px; }
/* The body is frozen while this is open (so the gallery behind it cannot scroll), which means
   this panel has to own the scrolling itself. Without this it inherited a locked body and
   nothing moved at all. */
.gv-versions:not([hidden]) {
  position: fixed; inset: 0; z-index: 50; overflow-y: auto; overscroll-behavior: contain;
  -webkit-overflow-scrolling: touch; background: var(--gv-bg, #14161a); padding-top: 0;
}
.gv-versions-header {
  display: flex; align-items: center; gap: 14px; padding: 20px 0 16px;
  border-bottom: 1px solid oklch(from var(--gv-dim, #888) l c h / 0.15); margin-bottom: 16px;
  flex-wrap: wrap;
}
.gv-versions-back {
  background: none; border: none; color: var(--gv-dim); font: 0.82rem var(--gv-font, var(--font));
  cursor: pointer;
}
.gv-versions-back:hover { color: var(--gv-text); }
.gv-versions-title { font-size: 1.1rem; font-weight: 600; flex: 1; }
.gv-versions-dl-all {
  padding: 8px 16px; background: oklch(0 0 0 / 0.3); backdrop-filter: blur(8px);
  -webkit-backdrop-filter: blur(8px); border: 1px solid oklch(0.5 0 0 / 0.15);
  border-radius: var(--r); color: var(--gv-dim); font: 500 0.78rem var(--font);
  cursor: pointer; display: inline-flex; align-items: center; gap: 6px;
}
.gv-versions-dl-all:hover { color: var(--gv-text); }
.gv-versions-tabs {
  display: flex; gap: 2px; margin-bottom: 20px;
}
.gv-vtab {
  padding: 8px 18px; background: transparent; border: none;
  color: var(--gv-dim); font: 600 0.82rem var(--gv-font, var(--font));
  cursor: pointer; border-radius: var(--r);
}
.gv-vtab.active { background: oklch(from var(--gv-surface, #222) l c h / 0.8); color: var(--gv-text); }

/* Version grid (all at once) */
.gv-versions-grid {
  display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px;
}
/* No card, no border, no label row. The photo IS the card; the version number and the save
   sit on top of it and only appear on hover, so the grid is images and nothing else. */
.gv-vcard { position: relative; background: none; border: 0; border-radius: 0; overflow: hidden; }
.gv-vcard img { width: 100%; height: auto; display: block; cursor: pointer; border-radius: 0; }
.gv-vcard img:hover { opacity: 0.94; }
.gv-vcard-num {
  position: absolute; top: 8px; left: 8px; padding: 3px 9px; border-radius: 0;
  font: 600 0.68rem var(--font); letter-spacing: .03em; color: #fff;
  background: oklch(0 0 0 / 0.5); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
  pointer-events: none;
}
.gv-vcard-save {
  position: absolute; bottom: 8px; right: 8px; width: 32px; height: 32px; padding: 0;
  display: inline-flex; align-items: center; justify-content: center;
  background: oklch(0 0 0 / 0.5); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
  border: 1px solid oklch(1 0 0 / 0.16); border-radius: 8px; color: #fff; cursor: pointer;
  opacity: 0; transition: opacity .15s;
}
.gv-vcard:hover .gv-vcard-save, .gv-vcard-save:focus-visible { opacity: 1; }
@media (hover: none) { .gv-vcard-save { opacity: 1; } }
.gv-vcard-save:hover { background: oklch(0 0 0 / 0.72); }

/* Version single (one at a time) */
.gv-versions-single { text-align: center; }
.gv-vsingle-nav {
  display: flex; align-items: center; justify-content: center; gap: 20px; margin-bottom: 16px;
}
.gv-vsingle-prev, .gv-vsingle-next {
  padding: 8px 18px; background: transparent; border: 1px solid oklch(from var(--gv-dim, #888) l c h / 0.2);
  border-radius: var(--r); color: var(--gv-text); font: 0.82rem var(--font); cursor: pointer;
}
.gv-vsingle-prev:disabled, .gv-vsingle-next:disabled { opacity: 0.3; cursor: not-allowed; }
.gv-vsingle-prev:not(:disabled):hover, .gv-vsingle-next:not(:disabled):hover { border-color: var(--gv-dim); }
.gv-vsingle-counter { font-size: 0.78rem; color: var(--gv-dim); }
.gv-vsingle-img { max-width: 100%; max-height: 70vh; border-radius: var(--r); cursor: pointer; }
.gv-vsingle-bar {
  display: flex; align-items: center; justify-content: center; gap: 16px;
  padding: 14px 0; font-size: 0.82rem; color: var(--gv-dim);
}

/* Media type tabs (Photo/Video) */
.gv-media-tabs { display: flex; gap: 2px; margin-right: 12px; }
.gv-media-tab {
  padding: 8px 20px; background: transparent; border: none;
  color: var(--gv-dim); font: 600 0.85rem var(--gv-font, var(--font));
  cursor: pointer; border-radius: var(--r); transition: background 0.15s, color 0.15s;
}
.gv-media-tab.active { background: oklch(from var(--gv-surface, #222) l c h / 0.8); color: var(--gv-text); }
.gv-media-tab:not(.active):hover { color: var(--gv-text); }

/* Video thumbnail in grid */
.gv-video-thumb { position: relative; line-height: 0; }
.gv-video-thumb video { width: 100%; height: auto; display: block; }
.gv-play-icon {
  position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
  width: 48px; height: 48px; background: oklch(0 0 0 / 0.5); backdrop-filter: blur(6px);
  -webkit-backdrop-filter: blur(6px); border-radius: 50%;
  display: flex; align-items: center; justify-content: center;
  color: #fff; font-size: 1.2rem; pointer-events: none;
  border: 1px solid oklch(1 0 0 / 0.15);
}

/* Video in lightbox */
.gv-video-wrap { position: relative; max-width: 90vw; max-height: 80vh; }
.gv-video-wrap video { max-width: 90vw; max-height: 80vh; border-radius: 6px; background: #000; }
.gv-video-loading {
  position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
  font: 500 0.72rem var(--font); letter-spacing: 0.14em; text-transform: uppercase;
  color: oklch(1 0 0 / 0.7); animation: lb-breathe 2.4s cubic-bezier(0.4, 0, 0.6, 1) infinite;
  pointer-events: none; z-index: 2;
}
.gv-video-quality {
  position: absolute; top: 10px; right: 10px; display: flex; gap: 2px; z-index: 3;
}
.gv-vq-btn {
  padding: 5px 12px; background: oklch(0 0 0 / 0.5); backdrop-filter: blur(8px);
  -webkit-backdrop-filter: blur(8px); border: 1px solid oklch(1 0 0 / 0.1);
  border-radius: 3px; color: oklch(1 0 0 / 0.6); font: 500 0.68rem var(--font);
  cursor: pointer;
}
.gv-vq-btn.gv-vq-active { color: #fff; border-color: oklch(1 0 0 / 0.3); }

/* Client version groups */
.gv-stack { position: relative; }
.gv-stack::before, .gv-stack::after {
  content: ''; position: absolute; border-radius: var(--r);
  background: oklch(from var(--gv-surface) l c h / 0.6);
}
.gv-stack::before { inset: 3px -3px -3px 3px; z-index: -1; border: 1px solid oklch(0.5 0 0 / 0.1); }
.gv-stack::after { inset: 6px -6px -6px 6px; z-index: -2; border: 1px solid oklch(0.5 0 0 / 0.06); }
.gv-version-badge {
  position: absolute; top: 8px; right: 8px; padding: 5px 12px; z-index: 3;
  font-size: 0.75rem; font-weight: 600; color: #fff;
  background: oklch(0 0 0 / 0.45); backdrop-filter: blur(14px);
  -webkit-backdrop-filter: blur(14px); border: 1px solid oklch(1 0 0 / 0.12);
  border-radius: 4px; cursor: pointer;
}

/* Lightbox zoom + pan + full-res loading */
.gv-lb-wrap { position: relative; display: flex; align-items: center; justify-content: center; max-width: 90vw; max-height: 80vh; cursor: zoom-in; overflow: hidden; }
.gv-lb-wrap.is-zoomed { cursor: grab; max-width: none; max-height: none; width: 90vw; height: 80vh; }
.gv-lb-wrap.is-zoomed.is-dragging { cursor: grabbing; }
.gv-lb-wrap img, .gv-lb-wrap video {
  max-width: 90vw; max-height: 80vh; object-fit: contain; border-radius: var(--r);
  transition: none; image-rendering: auto;
}
.gv-lb-wrap.is-zoomed img {
  max-width: none; max-height: none; width: 100%; height: 100%;
  object-fit: contain; will-change: transform;
  image-rendering: -webkit-optimize-contrast; image-rendering: high-quality;
}
.gv-lb-wrap.is-loading img { filter: blur(20px) saturate(0.7); transform: scale(1.04); }
.gv-lb-wrap.is-loading::after {
  content: "loading full resolution preview"; position: absolute; z-index: 5;
  font: 500 0.72rem var(--font); letter-spacing: 0.16em; text-transform: uppercase;
  color: oklch(1 0 0 / 0.8); animation: lb-breathe 2.4s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
@keyframes lb-breathe { 0%, 100% { opacity: 0.5; } 50% { opacity: 1; } }
.gv-lb-counter { font-size: 0.78rem; color: oklch(0.6 0 0); }
.gv-lb-hint { position: absolute; bottom: 52px; left: 0; right: 0; text-align: center; font-size: 0.72rem; color: oklch(0.45 0 0); pointer-events: none; }
.gv-lb-minimap {
  position: absolute; bottom: 60px; right: 20px; width: 120px; height: 80px;
  border: 1px solid oklch(1 0 0 / 0.15); border-radius: 4px; overflow: hidden;
  background: oklch(0 0 0 / 0.4); opacity: 0; pointer-events: none; transition: opacity 0.2s;
}
.gv-lb-wrap.is-zoomed ~ .gv-lb-minimap { opacity: 1; }
.gv-lb-minimap img { width: 100%; height: 100%; object-fit: contain; }
.gv-lb-minimap-view {
  position: absolute; border: 1px solid oklch(1 0 0 / 0.5); background: oklch(1 0 0 / 0.08);
}

/* Theme-specific tweaks */
/* Cinema used to force two columns at every width, overriding the responsive rule above. It
   now just goes one step wider-spaced rather than halving the grid. */
.gallery-viewer[data-theme="cinema"] .gv-grid { column-gap: 14px; }
.gallery-viewer[data-theme="editorial"] .gv-title { font-size: 1.8rem; font-weight: 800; text-transform: uppercase; letter-spacing: 0.02em; }
.gallery-viewer[data-theme="editorial"] .gv-item { border-radius: 0; }
.gallery-viewer[data-theme="film"] .gv-item { border-radius: 6px; box-shadow: 0 2px 10px oklch(0 0 0 / 0.06); }
.gallery-viewer[data-theme="clean"] .gv-item { border: 1px solid oklch(0.86 0 0); border-radius: 3px; }

/* ═══ Responsive ═══ */
@media (max-width: 768px) {
  .dash-header { padding: 12px 16px; }
  .dash-main { padding: 16px; }
  .gallery-grid { grid-template-columns: 1fr; }

  .editor-header { padding: 10px 14px; }
  .editor-title-row { flex-direction: column; gap: 6px; }
  .editor-actions { width: 100%; }
  .editor-actions .dash-btn { flex: 1; text-align: center; }

  .theme-picker { padding: 10px 14px; }
  .upload-zone { margin: 12px 14px; padding: 24px 14px; }
  .upload-queue { padding: 0 14px 10px; }
  .uq-row { grid-template-columns: 1fr 80px auto; }
  .uq-size { display: none; }

  /* Admin filter bar mobile */
  .filter-bar { padding: 10px 14px; flex-wrap: wrap; gap: 8px; }
  .filter-tabs { width: 100%; }
  .filter-tab { flex: 1; text-align: center; }
  .filter-search { min-width: 100%; order: 10; }
  .filter-tag-select { max-width: none; flex: 1; }

  /* Admin detail panel — fullscreen modal on mobile */
  .editor-content { flex-direction: column; }
  .media-grid { column-count: 2; padding: 12px 14px; }
  .detail-panel {
    position: fixed; inset: 0; z-index: 800;
    width: 100% !important; max-height: none;
    background: oklch(0.13 0.005 260);
    border-left: none; border-top: none;
    overflow-y: auto; -webkit-overflow-scrolling: touch;
    padding-bottom: 40px;
  }
  .detail-header {
    position: sticky; top: 0; z-index: 5;
    background: oklch(0.13 0.005 260);
    border-bottom: 1px solid oklch(0.24 0.005 260);
    padding: 14px 16px;
  }
  .detail-close { font-size: 1.6rem; padding: 4px 8px; }
  .detail-preview { padding: 12px 16px; }
  .detail-preview img, .detail-preview video { max-height: 280px; width: auto; max-width: 100%; margin: 0 auto; display: block; border-radius: 6px; }
  .detail-meta { padding: 10px 16px; }
  .detail-row { padding: 8px 0; font-size: 0.9rem; border-bottom: 1px solid oklch(0.2 0.005 260); }
  .detail-section-row { padding: 12px 16px; }
  .detail-section-input { padding: 12px 14px; font-size: 0.9rem; }
  .detail-tags-header { font-size: 0.78rem; padding: 16px 16px 8px; }
  .detail-tags { padding: 0 16px 24px; }
  .dtag-category { margin-bottom: 18px; padding-bottom: 12px; border-bottom: 1px solid oklch(0.2 0.005 260); }
  .dtag-cat-name { font-size: 0.8rem; margin-bottom: 10px; color: oklch(0.72 0.12 70); }
  .dtag-label { font-size: 0.9rem; padding: 8px 0; min-height: 36px; display: flex; align-items: center; gap: 10px; }
  .dtag-label input[type="checkbox"] {
    width: 22px; height: 22px; flex-shrink: 0;
    border: 2px solid oklch(0.4 0.005 260); border-radius: 4px;
    background: oklch(0.17 0.005 260);
  }
  .dtag-label input[type="checkbox"]:checked {
    background: oklch(0.65 0.1 70); border-color: oklch(0.65 0.1 70);
  }
  .editor-footer { padding: 10px 14px; flex-direction: column; gap: 10px; align-items: stretch; }
  .editor-footer .dash-btn { text-align: center; }

  /* Client gallery mobile.
     One photo at a time only works if the photo actually gets the width. The header, filter bar
     and 16px of side padding were taking a third of a phone screen before the first image. */
  .gv-header { padding: 10px 14px; gap: 6px; flex-direction: column; align-items: flex-start; }
  .gv-grid { padding: 0; column-count: 1; column-gap: 0; }
  .gv-item { margin-bottom: 6px; border-radius: 0; }
  .gv-item img, .gv-item video { border-radius: 0; }
  .gv-downloads { width: auto; align-self: flex-start; }
  .gv-dl-btn { padding: 8px 16px; font-size: 0.75rem; }
  .gv-item-bar { opacity: 1; padding: 10px 12px; }
  .gv-item-name { font-size: 0.75rem; }
  .gv-item-dl { padding: 7px 14px; font-size: 0.75rem; }
  .gv-item-dl svg { width: 14px; height: 14px; }
  .gv-title { font-size: 1rem; }
  .gv-tag { font-size: 0.65rem; padding: 3px 8px; }
  .gallery-viewer[data-theme="cinema"] .gv-grid { column-count: 1; }

  /* Lightbox mobile */
  .gv-lb-close { top: 10px; right: 14px; font-size: 2rem; }
  .gv-lb-prev { left: 4px; font-size: 1.8rem; }
  .gv-lb-next { right: 4px; font-size: 1.8rem; }
  .gv-lb-wrap { max-width: 100vw; max-height: 70vh; width: 100vw; }
  .gv-lb-wrap img, .gv-lb-wrap video { max-width: 100vw; max-height: 70vh; width: auto; height: auto; object-fit: contain; }

  .gv-lb-bar { padding: 12px 16px; gap: 8px; }
  .gv-lb-name { font-size: 0.75rem; }
  .gv-lb-counter { font-size: 0.72rem; }
  .gv-lb-dl { padding: 10px 18px; font-size: 0.82rem; }
  .gv-lb-dl svg { width: 14px; height: 14px; }
  .gv-lb-hint { display: none; }
  .gv-lb-minimap { display: none; }

  /* Client filter bar mobile */
  .gv-filter-bar { padding: 8px 12px; gap: 8px; }
  /* flex-basis 0 with a floor keeps two selects side by side and stops one long option label
     from starving the other. */
  .gv-filter-select { flex: 1 1 0; min-width: 7.5rem; padding: 9px 10px; font-size: 0.82rem; }

  /* Media tabs mobile — their own full-width row above the selects. */
  .gv-media-tabs { flex: 1 0 100%; width: 100%; margin-right: 0; margin-bottom: 2px; }
  .gv-media-tab { flex: 1; text-align: center; padding: 10px; font-size: 0.85rem; }
  .gv-play-icon { width: 40px; height: 40px; font-size: 1rem; }
  .gv-video-wrap video { max-width: 100vw; max-height: 70vh; }

  /* Version viewer mobile */
  .gv-versions { padding: 0 16px 30px; }
  .gv-versions-header { padding: 16px 0 12px; gap: 10px; }
  .gv-versions-title { font-size: 0.95rem; }
  .gv-versions-grid { grid-template-columns: 1fr; gap: 14px; }
  .gv-vtab { padding: 8px 14px; font-size: 0.78rem; }
  .gv-vsingle-img { max-height: 50vh; }
  .gv-versions-dl-all { width: 100%; justify-content: center; }

  /* Select bar mobile */
  .select-bar { flex-wrap: wrap; padding: 10px 14px; gap: 6px; }
  .select-bar .dash-btn { font-size: 0.72rem; padding: 8px 12px; }
}

@media (max-width: 400px) {
  .media-grid { column-count: 2; }
  .gv-grid { column-count: 1; }
  .portal-link-form { flex-direction: column; }
  .portal-link-btn { width: 100%; }
}

/* ---- download dialog + video posters ---- */
.gv-dialog-back { position: fixed; inset: 0; z-index: 3000; background: rgba(0,0,0,.72);
  display: flex; align-items: center; justify-content: center; padding: 24px;
  backdrop-filter: blur(6px); }
.gv-dialog { max-width: 30rem; background: #16181d; border: 1px solid rgba(255,255,255,.1);
  border-radius: 14px; padding: 22px 24px; }
.gv-dialog h3 { margin: 0 0 10px; font-size: 1.05rem; color: #f2f3f5; }
.gv-dialog p { margin: 0; font-size: .9rem; line-height: 1.6; color: #b6bac2; }
.gv-dialog-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 20px; }
.gv-dialog-actions button { padding: 9px 18px; border-radius: 8px; font-size: .875rem;
  cursor: pointer; border: 1px solid rgba(255,255,255,.16); background: transparent; color: #e6e8ec; }
.gv-dialog-ok { background: #e0b84e !important; border-color: #e0b84e !important; color: #1a1812 !important;
  font-weight: 600; }
/* A poster that has not been captured yet: a neutral block, never a spinner — the tile should
   not look broken while a frame is being grabbed in the background. */
.gv-poster-pending { width: 100%; aspect-ratio: 16 / 9; background:
  linear-gradient(135deg, #1b1e24, #23262d); }
.gv-video-thumb img { display: block; width: 100%; height: auto; }

/* Version grid: bigger photos, less gutter. */
.gv-versions-grid { grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)) !important; gap: 8px !important; }

/* Single version: controls overlay the photo instead of stacking bars above and below it. */
.gv-vsingle-stage { position: relative; display: inline-block; max-width: 100%; }
.gv-vsingle-stage .gv-vsingle-img { display: block; max-width: 100%; max-height: 82vh; width: auto; border-radius: var(--r); }
.gv-vsingle-stage .gv-vsingle-prev,
.gv-vsingle-stage .gv-vsingle-next {
  position: absolute; top: 50%; transform: translateY(-50%); width: 42px; height: 42px;
  display: flex; align-items: center; justify-content: center; font-size: 1.6rem; line-height: 1;
  background: oklch(0 0 0 / 0.45); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
  border: 1px solid oklch(1 0 0 / 0.14); border-radius: 0; color: #fff; cursor: pointer;
}
.gv-vsingle-stage .gv-vsingle-prev { left: 10px; }
.gv-vsingle-stage .gv-vsingle-next { right: 10px; }
.gv-vsingle-stage .gv-vsingle-prev[disabled],
.gv-vsingle-stage .gv-vsingle-next[disabled] { opacity: .25; cursor: default; }
.gv-vsingle-stage .gv-vsingle-counter {
  position: absolute; top: 10px; left: 10px; padding: 4px 10px; border-radius: 0;
  font: 600 0.7rem var(--font); color: #fff; background: oklch(0 0 0 / 0.5);
  backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
}
.gv-vsingle-stage .gv-vsingle-save {
  position: absolute; bottom: 10px; right: 10px; padding: 8px 14px; border-radius: 8px;
  display: inline-flex; align-items: center; gap: 6px; font: 500 0.78rem var(--font);
  color: #fff; background: oklch(0 0 0 / 0.55); border: 1px solid oklch(1 0 0 / 0.16);
  backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); cursor: pointer;
}
@media (max-width: 768px) {
  .gv-versions-grid { grid-template-columns: 1fr !important; gap: 6px !important; }
  .gv-vsingle-stage .gv-vsingle-img { max-height: 74vh; }
}

/* ---- Download All overlay ---- */
.gv-zip-back { position: fixed; inset: 0; z-index: 4000; background: oklch(0 0 0 / 0.74);
  backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
  display: flex; align-items: center; justify-content: center; padding: 24px;
  animation: gvzipfade .22s ease; }
@keyframes gvzipfade { from { opacity: 0 } to { opacity: 1 } }
.gv-zip { width: 100%; max-width: 30rem; background: #14161a;
  border: 1px solid oklch(1 0 0 / 0.09); border-radius: 18px; padding: 26px 26px 22px;
  box-shadow: 0 24px 60px oklch(0 0 0 / 0.5); animation: gvzipin .28s cubic-bezier(.2,.9,.3,1); }
@keyframes gvzipin { from { opacity: 0; transform: translateY(10px) scale(.985) } to { opacity: 1; transform: none } }
.gv-zip-head { text-align: center; }
.gv-zip h3 { margin: 0; font-size: 1.15rem; font-weight: 600; color: #f4f5f7; letter-spacing: -0.01em; }
.gv-zip-sub { margin: 6px 0 0; font-size: .8rem; color: #8d929b; }

/* Stages. Each one dims until it is reached, pulses while active, and ticks when done — so the
   dialog reads as progress through named work rather than an anonymous wait. */
.gv-zip-stages { list-style: none; margin: 20px 0 18px; padding: 0;
  display: flex; flex-direction: column; gap: 11px; }
.gv-zip-stages li { display: flex; align-items: center; gap: 11px;
  font-size: .875rem; color: #61656d; transition: color .35s ease; }
.gv-zip-dot { flex: 0 0 auto; width: 16px; height: 16px; border-radius: 50%; position: relative;
  border: 1.5px solid oklch(1 0 0 / 0.18); transition: border-color .35s ease, background .35s ease; }
.gv-zip-stages li.is-active { color: #f0f1f3; }
.gv-zip-stages li.is-active .gv-zip-dot { border-color: #e0b84e; }
.gv-zip-stages li.is-active .gv-zip-dot::after {
  content: ''; position: absolute; inset: 3px; border-radius: 50%; background: #e0b84e;
  animation: gvzippulse 1.1s ease-in-out infinite; }
@keyframes gvzippulse { 0%,100% { opacity: 1; transform: scale(1) } 50% { opacity: .45; transform: scale(.72) } }
.gv-zip-stages li.is-done { color: #9aa0aa; }
.gv-zip-stages li.is-done .gv-zip-dot { border-color: oklch(0.72 0.17 145); background: oklch(0.72 0.17 145); }
.gv-zip-stages li.is-done .gv-zip-dot::after {
  content: ''; position: absolute; left: 4.5px; top: 1.5px; width: 4px; height: 8px;
  border: solid #14161a; border-width: 0 1.6px 1.6px 0; transform: rotate(45deg);
  animation: gvziptick .25s ease; }
@keyframes gvziptick { from { opacity: 0; transform: rotate(45deg) scale(.5) } to { opacity: 1; transform: rotate(45deg) scale(1) } }

/* Indeterminate: the browser reports nothing about a navigation download, so a percentage
   would be invented. This shows work without claiming progress. */
.gv-zip-bar { position: relative; height: 4px; border-radius: 0;
  background: oklch(1 0 0 / 0.08); overflow: hidden; }
.gv-zip-bar span { position: absolute; top: 0; bottom: 0; width: 38%; border-radius: 0;
  background: linear-gradient(90deg, transparent, #e0b84e, transparent);
  animation: gvzipbar 1.5s cubic-bezier(.45,0,.55,1) infinite; }
@keyframes gvzipbar { 0% { left: -40% } 100% { left: 100% } }
.gv-zip-bar.is-done span { animation: none; left: 0; width: 100%;
  background: oklch(0.72 0.17 145); }
.gv-zip-hint { margin: 10px 0 20px; text-align: center; font-size: .78rem; color: #7d828b;
  transition: color .3s ease; }

.gv-zip-steps { list-style: none; margin: 0; padding: 18px 0 0;
  border-top: 1px solid oklch(1 0 0 / 0.07); display: flex; flex-direction: column; gap: 15px; }
.gv-zip-steps li { display: flex; gap: 13px; align-items: flex-start; }
.gv-zip-num { flex: 0 0 auto; width: 23px; height: 23px; border-radius: 0;
  display: flex; align-items: center; justify-content: center; margin-top: 1px;
  font: 600 .72rem var(--font); color: #1a1812; background: #e0b84e; }
.gv-zip-steps strong { display: block; font-size: .875rem; font-weight: 600; color: #e9eaee; }
.gv-zip-steps span:not(.gv-zip-num) { display: block; margin-top: 3px; font-size: .8rem;
  line-height: 1.55; color: #8d929b; }
.gv-zip-steps code { font-family: var(--font-mono, ui-monospace, monospace); font-size: .78rem;
  color: #e0b84e; background: oklch(1 0 0 / 0.06); padding: 1px 5px; border-radius: 4px; }

.gv-zip-close { width: 100%; margin-top: 22px; padding: 11px; border-radius: 10px; cursor: pointer;
  background: oklch(1 0 0 / 0.06); border: 1px solid oklch(1 0 0 / 0.12); color: #e6e8ec;
  font: 500 .85rem var(--font); transition: background .15s; }
.gv-zip-close:hover { background: oklch(1 0 0 / 0.11); }
@media (prefers-reduced-motion: reduce) {
  .gv-zip-back, .gv-zip { animation: none; }
  .gv-zip-bar span { animation: none; left: 0; width: 100%; opacity: .5; }
  .gv-zip-stages li.is-active .gv-zip-dot::after { animation: none; }
}
@media (max-width: 520px) {
  .gv-zip { padding: 22px 20px 18px; border-radius: 14px; }
  .gv-zip-steps, .gv-zip-stages { gap: 12px; }
}

/* ---- paired Full / Medium buttons ---- */
.gv-vcard-saves { position: absolute; bottom: 8px; right: 8px; display: flex; gap: 5px;
  opacity: 0; transition: opacity .15s; }
.gv-vcard:hover .gv-vcard-saves { opacity: 1; }
@media (hover: none) { .gv-vcard-saves { opacity: 1; } }
.gv-vcard-saves .gv-vcard-save { position: static; width: auto; height: auto;
  padding: 5px 10px; font: 500 .72rem var(--font); border-radius: 7px; }
.gv-vsingle-saves { position: absolute; bottom: 10px; right: 10px; display: flex; gap: 6px; }
.gv-vsingle-saves .gv-save-btn { position: static; padding: 8px 13px; border-radius: 8px;
  font: 500 .78rem var(--font); color: #fff; background: oklch(0 0 0 / 0.55);
  border: 1px solid oklch(1 0 0 / 0.16); backdrop-filter: blur(6px);
  -webkit-backdrop-filter: blur(6px); cursor: pointer; }
.gv-vsingle-saves .gv-save-btn:hover { background: oklch(0 0 0 / 0.75); }

/* ---- mobile: lock horizontal scrolling ---- */
/* A gallery scrolls one way. Sideways drift on a phone is always an overflowing child, and it
   makes every vertical swipe feel loose. */
html, body { overflow-x: hidden; max-width: 100%; }
.gallery-viewer, .gv-grid, .gv-header, .gv-filter-bar { max-width: 100%; }
@media (max-width: 768px) {
  .gv-item, .gv-item img, .gv-item video { max-width: 100%; }
  .gv-versions, .gv-versions-grid { max-width: 100%; overflow-x: hidden; }
}

/* ---- version viewer: only the foreground scrolls ---- */
/* Two scrollbars, and the page moving underneath, is the classic overlay-without-a-scroll-lock.
   The body is frozen while the version viewer is open and the panel owns the scrolling. */
body.gv-locked { overflow: hidden; position: fixed; width: 100%; }
.gv-versions { overscroll-behavior: contain; -webkit-overflow-scrolling: touch; }

/* ---- Back out of a version group ---- */
/* It was a plain text link at the top of a scrolling panel — off screen the moment you moved,
   and indistinguishable from a caption. Now it is a solid button, and on mobile it is pinned so
   it never scrolls away. */
.gv-versions-back {
  display: inline-flex; align-items: center; gap: 8px;
  /* Squared, 2px border, letterspaced — the same shape as the buttons on jjrhymes.com,
     drawn in whichever theme the gallery uses. */
  padding: .62rem 1.1rem; border-radius: 0; cursor: pointer;
  font: 400 .78rem/1 var(--gv-font, var(--font)); letter-spacing: .06em; text-transform: uppercase;
  color: var(--gv-bg, #14161a); background: var(--gv-accent, #e0b84e);
  border: 2px solid var(--gv-accent, #e0b84e); transition: background .14s, color .14s;
}
.gv-versions-back:hover { background: transparent; color: var(--gv-accent, #e0b84e); }
.gv-versions-back svg { flex: 0 0 auto; }

@media (max-width: 768px) {
  /* Pinned bottom-left, thumb height, above everything in the panel. */
  .gv-versions-back {
    position: fixed; left: 14px; bottom: calc(14px + env(safe-area-inset-bottom));
    z-index: 60; padding: .8rem 1.3rem; font-size: .8rem;
    box-shadow: 0 8px 28px oklch(0 0 0 / 0.5);
  }
  /* Room so the last row is not sitting under the button. */
  .gv-versions { padding-bottom: 84px; }

  /* The zoom minimap belongs on mobile too — it is the only cue showing where you are in a
     photo once you have zoomed in. Top-left, clear of the close control on the right. */
  .gv-lb-minimap {
    display: block !important; top: 12px; left: 12px; right: auto;
    width: 78px; height: 78px; border-radius: 8px;
    border: 1px solid oklch(1 0 0 / 0.35); background: oklch(0 0 0 / 0.55);
    backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
  }
}

/* ---- Video play badge ---- */
/* Was the ▶ character, which renders as a different glyph in every font and as a coloured
   emoji on iOS. An inline SVG looks the same everywhere. */
.gv-play-icon { display: flex; align-items: center; justify-content: center; font-size: 0 !important; }
.gv-play-icon::before {
  content: ''; width: 38%; height: 38%; background: currentColor;
  clip-path: polygon(22% 12%, 22% 88%, 86% 50%);
}

/* ---- copy link on each gallery card ---- */
.gallery-card { position: relative; }
.gc-copy, .gc-open {
  position: absolute; right: 12px; display: inline-flex; align-items: center; gap: 6px;
  padding: 6px 11px; border-radius: 8px; cursor: pointer;
  font: 500 .74rem var(--font); color: var(--text-secondary, #9aa0aa);
  background: oklch(1 0 0 / 0.05); border: 1px solid oklch(1 0 0 / 0.1);
  opacity: 0; transition: opacity .15s, color .15s, border-color .15s;
}
.gc-copy { top: 12px; }
.gc-open { top: 48px; }
.gallery-card:hover .gc-copy, .gc-copy:focus-visible,
.gallery-card:hover .gc-open, .gc-open:focus-visible { opacity: 1; }
@media (hover: none) { .gc-copy, .gc-open { opacity: 1; } }
.gc-copy:hover, .gc-open:hover { color: #e9eaee; border-color: oklch(1 0 0 / 0.22); }
.gc-copy.is-copied { color: oklch(0.72 0.17 145); border-color: oklch(0.72 0.17 145 / 0.5); }

/* ---- one sticky header for the whole client view ---- */
/* The title bar and the filter bar were two separate strips that both scrolled away, so the
   controls were only reachable from the top of the gallery. They are one unit now, pinned. */
.gallery-viewer .gv-header,
.gallery-viewer .gv-filter-bar {
  position: sticky; z-index: 40; background: var(--gv-bg);
  backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
}
.gallery-viewer .gv-header { top: 0; }
.gallery-viewer .gv-filter-bar { top: var(--gv-header-h, 64px); }
@media (max-width: 768px) {
  .gallery-viewer .gv-filter-bar { top: var(--gv-header-h, 92px); }
  /* Compact once pinned so the controls do not eat the screen on a phone. */
  .gallery-viewer .gv-header { padding: 8px 14px; }
  .gv-title { font-size: .95rem; }
  .gv-client { font-size: .72rem; }
}

/* ---- lightbox: position, then actions ---- */
.gv-lb-top {
  position: absolute; top: 0; left: 0; right: 0; z-index: 12;
  display: flex; flex-direction: column; align-items: center; gap: 4px;
  padding: 16px 64px 26px; pointer-events: none;
  background: linear-gradient(to bottom, oklch(0 0 0 / 0.6), transparent);
}
.gv-lb-top .gv-lb-counter {
  font: 600 .95rem var(--gv-font, var(--font)); color: #fff; letter-spacing: .04em;
  text-shadow: 0 1px 8px oklch(0 0 0 / 0.6);
}
.gv-lb-top .gv-lb-hint {
  margin: 0; font-size: .74rem; letter-spacing: .05em; text-transform: uppercase;
  color: oklch(1 0 0 / 0.62);
}

/* Downloads centred and solid — they were a dim pair pushed to one end of a bar. */
.gv-lb-bar {
  position: absolute; bottom: 0; left: 0; right: 0; z-index: 12;
  display: flex; justify-content: center; align-items: center;
  padding: 26px 20px calc(22px + env(safe-area-inset-bottom));
  background: linear-gradient(to top, oklch(0 0 0 / 0.72), transparent);
}
.gv-lb-actions { display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; }
.gv-lb-dl {
  display: inline-flex; align-items: center; justify-content: center; gap: 7px;
  padding: .68rem 1.25rem; border-radius: 0; cursor: pointer;
  font: 400 .78rem/1 var(--gv-font, var(--font)); letter-spacing: .06em; text-transform: uppercase;
  color: var(--gv-bg, #14161a); background: var(--gv-accent, #e0b84e);
  border: 2px solid var(--gv-accent, #e0b84e); transition: background .14s, color .14s;
}
.gv-lb-dl:hover { background: transparent; color: var(--gv-accent, #e0b84e); }
/* The second option reads as the alternative, not the equal. */
.gv-lb-dl#gv-lb-dl-comp { background: transparent; color: var(--gv-accent, #e0b84e); }
.gv-lb-dl#gv-lb-dl-comp:hover { background: var(--gv-accent, #e0b84e); color: var(--gv-bg, #14161a); }

@media (max-width: 768px) {
  .gv-lb-top { padding: 14px 54px 24px; }
  .gv-lb-actions { width: 100%; gap: 8px; }
  .gv-lb-dl { flex: 1 1 0; min-width: 0; padding: .8rem .6rem; font-size: .72rem; }

  /* Video on mobile: fill the width, never overflow, controls always reachable. */
  .gv-video-wrap { width: 100vw; max-width: 100vw; }
  .gv-video-wrap video { width: 100%; max-width: 100vw; max-height: 62vh; height: auto;
    background: #000; border-radius: 0; }
  .gv-video-quality { position: static; display: flex; gap: 8px; justify-content: center;
    margin: 10px auto 0; padding: 0 14px; }
  .gv-vq-btn { flex: 1 1 0; padding: .6rem .4rem; border-radius: 0; font-size: .72rem;
    letter-spacing: .05em; text-transform: uppercase; }
  .gv-video-loading { font-size: .78rem; letter-spacing: .05em; text-transform: uppercase; }
}

/* ---- controls now live in the header ---- */
.gv-controls { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.gv-controls .gv-media-tabs { margin: 0; }
.gv-controls .gv-filter-select { min-width: 9rem; }
/* The old strip is empty now; collapse it so it leaves no gap. */
.gv-filter-bar:empty { display: none; }
@media (max-width: 768px) {
  .gv-header { flex-direction: column; align-items: stretch; gap: 8px; }
  .gv-controls { width: 100%; }
  .gv-controls .gv-media-tabs { flex: 1 1 100%; }
  .gv-controls .gv-filter-select { flex: 1 1 0; min-width: 0; }
  /* NOT full width, and not stretched across the header.
     A 347px-wide "Download All" inside a sticky header sat over the top of the grid, so a tap
     meant for a photo's own Save button landed on it — which is why an individual download
     came back as the entire 220 MB gallery. It is a modest button on its own row now. */
  .gv-downloads { width: auto; align-self: flex-start; }
  .gv-downloads .gv-dl-btn { width: auto; justify-content: center; padding: .55rem 1rem; font-size: .74rem; }
}

/* ---- version badge: readable on any photo ---- */
/* A translucent chip vanished into a bright or busy image. Solid accent, dark text, squared —
   it has to be legible on a white dress and a night stage alike. */
.gv-version-badge {
  position: absolute; top: 8px; left: 8px; z-index: 3;
  padding: 4px 9px; border-radius: 0;
  font: 600 .7rem/1 var(--gv-font, var(--font)); letter-spacing: .06em; text-transform: uppercase;
  color: var(--gv-bg, #14161a); background: var(--gv-accent, #e0b84e);
  box-shadow: 0 2px 10px oklch(0 0 0 / 0.45);
}
/* Every download control carries the same icon and shape, everywhere in the app. */
.gv-item-dl, .gv-lb-dl, .gv-vcard-save, .gv-vsingle-save, .gv-dl-btn {
  display: inline-flex; align-items: center; justify-content: center; gap: 7px;
  border-radius: 0; letter-spacing: .05em;
}

/* The header is sticky, so anything inside it must stay inside it — a child that overflows
   becomes an invisible tap target floating over the gallery. */
.gallery-viewer .gv-header { overflow: hidden; }
.gallery-viewer .gv-header > * { max-width: 100%; }

/* ---- version badge: top-right, quiet ---- */
/* Top-left collided with the tags, and a solid accent block was shouting. It sits opposite the
   tags now and reads as a label rather than a warning. */
.gv-version-badge {
  top: 8px; right: 8px; left: auto;
  padding: 3px 8px;
  font: 500 .68rem/1 var(--gv-font, var(--font)); letter-spacing: .05em; text-transform: none;
  color: #fff; background: oklch(0 0 0 / 0.55);
  border: 1px solid oklch(1 0 0 / 0.22);
  backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
  box-shadow: none;
}

/* An element you cannot see must not take your tap. The tile bar sits at opacity 0 until hover,
   but it still occupied the hit area — so a tap aimed at a photo could land on an invisible
   download control, and on touch there is no hover to reveal it first. */
.gv-item-bar { pointer-events: none; }
.gv-item-bar > *, .gv-item-bar button { pointer-events: auto; }
.gv-item:hover .gv-item-bar { pointer-events: auto; }
@media (hover: none) {
  /* On touch there is no hover state, so the controls are simply always visible. */
  .gv-item-bar { opacity: 1; pointer-events: auto; }
  .gv-version-badge { pointer-events: none; }
}
/* Badges and labels are decoration — never tap targets. */
.gv-version-badge, .gv-vcard-num, .gv-item-tags, .gv-tag { pointer-events: none; }


/* A download choice — three routes, not a yes/no. */
.gv-dialog-choice .gv-choice-list {
  display: flex; flex-direction: column; gap: 8px; margin: 1.1rem 0 0.4rem;
}
.gv-dialog-choice .gv-choice {
  display: flex; flex-direction: column; gap: 3px; width: 100%;
  padding: 0.7rem 0.9rem; text-align: left; cursor: pointer;
  background: oklch(1 0 0 / 0.04); border: 1px solid oklch(1 0 0 / 0.12);
  border-radius: 6px; color: inherit; font: inherit;
  transition: background 0.15s ease, border-color 0.15s ease;
}
.gv-dialog-choice .gv-choice:hover,
.gv-dialog-choice .gv-choice:focus-visible {
  background: oklch(1 0 0 / 0.09); border-color: oklch(1 0 0 / 0.3);
}
.gv-dialog-choice .gv-choice-label { font-size: 0.86rem; font-weight: 600; }
.gv-dialog-choice .gv-choice-note {
  font-size: 0.72rem; opacity: 0.6; letter-spacing: 0.02em;
}
.gv-dialog-choice .gv-dialog-actions { justify-content: flex-end; }

[data-theme="light"] .gv-dialog-choice .gv-choice {
  background: oklch(0 0 0 / 0.04); border-color: oklch(0 0 0 / 0.14);
}
[data-theme="light"] .gv-dialog-choice .gv-choice:hover {
  background: oklch(0 0 0 / 0.08); border-color: oklch(0 0 0 / 0.3);
}


/* ── The gradient has to touch the photo's bottom edge ─────────────────────────
   A masonry column gives images fractional heights (224.24px, not 224px). The tile rounds, the
   image does not, and the leftover fraction shows as a hairline of page background between the
   gradient and the picture. Overshooting the bar past the bottom edge closes the seam; the
   tile's own overflow clip stops it from ever hanging over the photo below. */
.gv-item { overflow: hidden; }
.gv-item-bar { bottom: -2px; padding-bottom: 12px; }
/* Stacks show their offset cards outside the tile, so they cannot clip — a 1px overshoot is
   enough to hide the seam without reaching the card behind. */
.gv-item.gv-stack { overflow: visible; }
.gv-item.gv-stack .gv-item-bar { bottom: -1px; padding-bottom: 11px; }

/* ── Feedback for a download ───────────────────────────────────────────────── */
.gv-toast {
  position: fixed; left: 50%; bottom: 24px; transform: translate(-50%, 16px);
  z-index: 300; max-width: min(520px, calc(100vw - 32px));
  padding: 0.7rem 1.05rem; border-radius: 8px;
  background: oklch(0.18 0.01 260 / 0.96); color: oklch(0.97 0 0);
  border: 1px solid oklch(1 0 0 / 0.12);
  box-shadow: 0 12px 32px oklch(0 0 0 / 0.4);
  font: 500 0.8rem var(--font); letter-spacing: 0.01em; text-align: center;
  opacity: 0; pointer-events: none;
  transition: opacity 0.22s var(--ease), transform 0.22s var(--ease);
}
.gv-toast.is-on { opacity: 1; transform: translate(-50%, 0); }
.gv-toast.is-error {
  background: oklch(0.28 0.09 25 / 0.97); border-color: oklch(0.7 0.16 25 / 0.5);
}
@media (prefers-reduced-motion: reduce) { .gv-toast { transition: opacity 0.01s; } }


/* ── Stack cards must never show BELOW the photo ───────────────────────────────
   The offset cards behind a version group extended 3px and 6px past the bottom edge, so under
   every grouped photo sat two thin bands of card — read as the gradient failing to sit flush,
   because that is exactly where the gradient ends. They now peek to the RIGHT only; the bottom
   edge of a stacked tile is the bottom edge of the photo, same as any other tile. */
.gv-stack::before { inset: 3px -3px 0 3px; }
.gv-stack::after  { inset: 6px -6px 0 6px; }
.gv-item.gv-stack .gv-item-bar { bottom: 0; padding-bottom: 10px; }


/* ═══ Header ═══════════════════════════════════════════════════════════════════
   Sticky on desktop too. It was declared sticky but sat inside a scrolling wrapper, so it had
   nothing to stick to and scrolled away like any static element. */
.gallery-viewer { overflow: visible; }
.gallery-viewer .gv-header {
  position: -webkit-sticky; position: sticky; top: 0; z-index: 40;
  background: var(--gv-bg, oklch(0.14 0.008 60));
  backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
}

/* The media tabs sit in the middle of the bar rather than crowding the tag filter. */
.gv-media-tabs {
  display: flex; gap: 2px; justify-content: center;
  margin: 0 auto; order: 0;
}
.gv-header .gv-controls { display: flex; align-items: center; gap: 10px; }

/* One row: brand, then title, then the tag filter and Download All pinned right — with the media
   tabs optically centred on the page rather than wherever the flow happens to leave them.
   Absolute centring only above 1024px; below that a long client name would run into them. */
@media (min-width: 769px) {
  .gv-header {
    display: flex; flex-wrap: nowrap; align-items: center; gap: 16px; position: sticky;
  }
  .gv-brand { flex: 0 0 auto; }
  .gv-info { flex: 1 1 auto; min-width: 0; }
  .gv-info .gv-title, .gv-info .gv-client { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  .gv-media-tabs { flex: 0 0 auto; order: 0; margin: 0; }
  .gv-header .gv-controls { flex: 0 0 auto; margin-left: auto; }
  .gv-downloads { flex: 0 0 auto; }
}
@media (min-width: 1024px) {
  .gv-media-tabs {
    position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%);
  }
}

/* ═══ Download buttons — these are the point of the page ═══════════════════════ */
.gv-dl-btn, .gv-item-dl {
  background: oklch(1 0 0 / 0.1); color: var(--gv-text);
  border: 1px solid oklch(1 0 0 / 0.28); font-weight: 600;
  backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
}
.gv-dl-btn:hover, .gv-item-dl:hover {
  background: oklch(1 0 0 / 0.2); border-color: oklch(1 0 0 / 0.55); color: #fff;
}
.gv-item-dl { padding: 7px 13px; border-radius: var(--r); }
.gv-item-dl.gv-save-med { background: oklch(1 0 0 / 0.05); font-weight: 500; }

/* ═══ Version picker ═══════════════════════════════════════════════════════════ */
.gv-dialog-versions { max-width: min(560px, calc(100vw - 32px)); }
.gv-vpick-grid {
  display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
  gap: 10px; margin: 1rem 0 0.3rem; max-height: 46vh; overflow-y: auto;
}
.gv-vpick {
  display: flex; flex-direction: column; gap: 6px; padding: 0; cursor: pointer;
  background: none; border: 2px solid transparent; border-radius: 8px; color: inherit;
  transition: border-color 0.15s var(--ease), transform 0.15s var(--ease);
}
.gv-vpick img { width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: 6px; display: block; }
.gv-vpick:hover { border-color: var(--gv-accent, oklch(0.8 0.12 70)); transform: translateY(-2px); }
.gv-vpick-n { font: 600 0.72rem var(--font); opacity: 0.75; padding-bottom: 4px; }

/* ═══ Video player tools ═══════════════════════════════════════════════════════ */
.gv-video-tools {
  position: absolute; top: 10px; right: 10px; z-index: 5;
  display: flex; gap: 6px; align-items: flex-start;
}
.gv-vtool {
  width: 34px; height: 34px; display: inline-flex; align-items: center; justify-content: center;
  background: oklch(0 0 0 / 0.55); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
  border: 1px solid oklch(1 0 0 / 0.18); border-radius: 8px;
  color: oklch(1 0 0 / 0.85); cursor: pointer; transition: background 0.15s, color 0.15s;
}
.gv-vtool:hover { background: oklch(0 0 0 / 0.75); color: #fff; }
.gv-vsettings { position: relative; }
.gv-vq-menu {
  position: absolute; top: 40px; right: 0; min-width: 210px; padding: 8px;
  background: oklch(0.16 0.01 260 / 0.97); border: 1px solid oklch(1 0 0 / 0.14);
  border-radius: 10px; box-shadow: 0 14px 34px oklch(0 0 0 / 0.5);
  display: flex; flex-direction: column; gap: 3px;
}
.gv-vq-head {
  font: 600 0.64rem var(--font); text-transform: uppercase; letter-spacing: 0.1em;
  opacity: 0.5; padding: 4px 8px 6px;
}
.gv-vq-opt {
  display: flex; flex-direction: column; gap: 1px; text-align: left; cursor: pointer;
  padding: 8px 10px; border-radius: 6px; border: none; background: none; color: oklch(0.97 0 0);
  font: inherit;
}
.gv-vq-opt span { font: 600 0.78rem var(--font); }
.gv-vq-opt small { font-size: 0.68rem; opacity: 0.55; }
.gv-vq-opt:hover { background: oklch(1 0 0 / 0.09); }
.gv-vq-opt.gv-vq-active { background: oklch(1 0 0 / 0.13); }
.gv-vq-opt.gv-vq-active span::after { content: ' ✓'; color: var(--gv-accent, oklch(0.8 0.12 70)); }
.gv-video-wrap:fullscreen { display: flex; align-items: center; justify-content: center; background: #000; }
.gv-video-wrap:fullscreen video { max-width: 100vw; max-height: 100vh; width: auto; height: auto; }

@media (max-width: 768px) {
  /* Download All belongs in the top-right corner on a phone, beside the title — not stacked
     under it where it pushes the first photo off screen. */
  .gv-header {
    display: grid; grid-template-columns: 1fr auto; align-items: center;
    row-gap: 8px; column-gap: 10px; padding: 10px 12px;
  }
  /* The signature used to be dropped entirely below 768px. It is the only mark of whose work
     this is, and a phone is where most clients open the link — so it keeps the top-left corner
     and the title moves to its own row underneath. */
  .gv-brand { display: block; grid-column: 1; grid-row: 1; }
  .gv-logo.gv-sig { width: 76px; height: 39px; }
  .gv-info { grid-column: 1 / -1; grid-row: 2; margin-left: 0; min-width: 0; }
  .gv-downloads { grid-column: 2; grid-row: 1; justify-self: end; align-self: center; }
  .gv-media-tabs { grid-column: 1 / -1; grid-row: 3; justify-content: flex-start; }
  .gv-header .gv-controls { grid-column: 1 / -1; grid-row: 4; margin-left: 0; }
  .gv-dl-btn { padding: 8px 14px; font-size: 0.72rem; }
  .gv-vpick-grid { grid-template-columns: repeat(auto-fill, minmax(88px, 1fr)); }
  .gv-video-tools { top: 8px; right: 8px; }
}


/* ── Sticky needs a page that is not itself a scroll container ─────────────────
   `overflow-x: hidden` on html/body makes them scroll containers, and a sticky header then
   sticks to that container instead of the viewport — which is why the header scrolled away on
   desktop no matter what `position: sticky` said. `clip` prevents the same sideways scroll
   without creating a scroll container. */
html, body { overflow-x: clip; max-width: 100%; }
.gallery-viewer, .gv-grid { overflow-x: clip; }


/* ── The hover zoom was pushing the photo out from under its own gradient ──────
   `.gv-item:hover img { transform: scale(1.02) }` grows the image ~6px past the tile on each
   edge. A plain tile clips that (overflow: hidden), but a version stack must keep overflow
   visible for its offset cards — so on grouped photos six pixels of picture spilled out BELOW
   the bar, reading as a gradient that stops short of the bottom edge.
   Scaling the whole tile instead keeps the gradient welded to the photo. */
.gv-item.gv-stack:hover img { transform: none; }
.gv-item.gv-stack { transition: transform 0.4s var(--ease); }
.gv-item.gv-stack:hover { transform: scale(1.01); }

.gv-dialog-actions-3 { flex-wrap: wrap; gap: 8px; }
.gv-dialog-view {
  padding: 0.55rem 1rem; border-radius: 6px; cursor: pointer; font: 600 0.78rem var(--font);
  background: oklch(1 0 0 / 0.07); border: 1px solid oklch(1 0 0 / 0.22); color: inherit;
  transition: background 0.15s var(--ease), border-color 0.15s var(--ease);
}
.gv-dialog-view:hover { background: oklch(1 0 0 / 0.14); border-color: oklch(1 0 0 / 0.4); }


/* ── Download status ───────────────────────────────────────────────────────────
   Top of the screen, not bottom: iOS slides its share sheet up over the lower half, so a status
   message down there sits behind the dialog it is meant to explain. */
.gv-status {
  position: fixed; left: 50%; top: max(14px, env(safe-area-inset-top));
  transform: translate(-50%, -14px);
  z-index: 400; max-width: min(460px, calc(100vw - 24px));
  display: flex; align-items: center; gap: 9px;
  padding: 0.72rem 1.1rem; border-radius: var(--r);
  background: oklch(0.18 0.01 260 / 0.97); color: oklch(0.98 0 0);
  border: 1px solid oklch(1 0 0 / 0.16);
  box-shadow: 0 10px 34px oklch(0 0 0 / 0.5);
  font: 600 0.78rem var(--font); text-align: center; line-height: 1.3;
  opacity: 0; pointer-events: none;
  transition: opacity 0.2s var(--ease), transform 0.2s var(--ease);
}
.gv-status.is-on { opacity: 1; transform: translate(-50%, 0); }
.gv-status-dot {
  width: 9px; height: 9px; flex: 0 0 auto; border-radius: 50%;
  background: var(--gv-accent, oklch(0.82 0.13 75));
  animation: gv-status-pulse 1.1s ease-in-out infinite;
}
@keyframes gv-status-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
@media (prefers-reduced-motion: reduce) {
  .gv-status { transition: opacity 0.01s; }
  .gv-status-dot { animation: none; }
}


/* ── Lightbox arrows need a thumb-sized target ─────────────────────────────────
   The glyph was the whole hit area, so a miss landed on the photo and zoomed instead. The button
   is now a proper touch target with the arrow drawn inside it — same look, far more forgiving. */
.gv-lb-prev, .gv-lb-next {
  display: flex; align-items: center; justify-content: center;
  width: 56px; height: 56px; border-radius: 50%;
  background: oklch(0 0 0 / 0.32);
  backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
  transition: background 0.15s var(--ease), opacity 0.15s var(--ease);
}
.gv-lb-prev:hover, .gv-lb-next:hover { background: oklch(0 0 0 / 0.55); }

@media (max-width: 768px) {
  /* Bigger still on a phone, and pulled clear of the edge so the browser's own back-swipe
     gesture does not fight for the same pixels. */
  .gv-lb-prev, .gv-lb-next {
    width: 68px; height: 68px; font-size: 2rem;
    background: oklch(0 0 0 / 0.38);
  }
  .gv-lb-prev { left: 10px; }
  .gv-lb-next { right: 10px; }
  .gv-lb-close { width: 52px; height: 52px; display: flex; align-items: center; justify-content: center; }
}

/* ── Download status: a bar, not a pill ────────────────────────────────────────
   Sits under the top edge, full width, so it reads as a system message rather than a floating
   bubble — and stays clear of the iOS share sheet, which covers the bottom of the screen. */
.gv-status {
  left: 0; right: 0; top: 0; transform: translateY(-100%);
  max-width: none; width: 100%; border-radius: 0; border: none;
  border-bottom: 1px solid oklch(1 0 0 / 0.12);
  padding: calc(0.7rem + env(safe-area-inset-top)) 1.1rem 0.7rem;
  justify-content: center; box-shadow: 0 6px 24px oklch(0 0 0 / 0.35);
}
.gv-status.is-on { transform: translateY(0); }
.gv-status.is-done .gv-status-dot { animation: none; background: oklch(0.75 0.15 145); }


/* ── No gradient ───────────────────────────────────────────────────────────────
   The fade never sat right against a masonry tile and it was never load-bearing: the buttons
   carry their own contrast. Dropping it removes the whole class of alignment bug with it. */
.gv-item-bar {
  background: none;
  padding: 10px; padding-top: 10px; padding-bottom: 10px;
  bottom: 0;
}
.gv-item.gv-stack .gv-item-bar { bottom: 0; padding-bottom: 10px; }

/* Square, not pill. They read as buttons rather than tags, and they stay legible over a bright
   photo now that there is no gradient underneath. */
.gv-item-dl {
  border-radius: 4px;
  background: oklch(0 0 0 / 0.55);
  border: 1px solid oklch(1 0 0 / 0.28);
  color: oklch(0.98 0 0);
  backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
  padding: 8px 14px; font-weight: 600;
}
.gv-item-dl:hover {
  background: oklch(0 0 0 / 0.75); border-color: oklch(1 0 0 / 0.55); color: #fff;
}
.gv-item-dl.gv-save-med {
  background: oklch(0 0 0 / 0.45); font-weight: 500;
}
.gv-dl-btn { border-radius: 4px; }


/* ── Arrows: the old look, a bigger target ─────────────────────────────────────
   No circle, no plate — just the glyph, exactly as before. The button is padded out to a
   thumb-sized hit area instead, so the extra room is felt and not seen. */
.gv-lb-prev, .gv-lb-next {
  background: none; backdrop-filter: none; -webkit-backdrop-filter: none;
  border-radius: 0; width: auto; height: auto;
  padding: 28px 22px; line-height: 1;
  display: flex; align-items: center; justify-content: center;
  -webkit-tap-highlight-color: transparent;
}
.gv-lb-prev:hover, .gv-lb-next:hover { background: none; color: #fff; }

@media (max-width: 768px) {
  .gv-lb-prev, .gv-lb-next { padding: 40px 26px; font-size: 2rem; background: none; }
  .gv-lb-prev { left: 0; }
  .gv-lb-next { right: 0; }
  .gv-lb-close { width: auto; height: auto; padding: 16px 20px; }
}


/* ── Version dialog actions: one row ───────────────────────────────────────────
   Three buttons were wrapping so "Download all" dropped onto a line of its own. They share the
   row now, with Cancel pushed away from the two that act. */
.gv-dialog-actions-3 {
  display: flex; flex-wrap: nowrap; align-items: center; gap: 8px;
  margin-top: 1rem;
}
.gv-dialog-actions-3 .gv-dialog-cancel { margin-right: auto; }
.gv-dialog-actions-3 button {
  white-space: nowrap; flex: 0 1 auto; min-width: 0;
  padding: 0.55rem 0.9rem; font-size: 0.76rem;
}
@media (max-width: 480px) {
  /* Too narrow for three across: stack them, primary action on top, full width and tappable. */
  .gv-dialog-actions-3 { flex-wrap: wrap; }
  .gv-dialog-actions-3 button { flex: 1 1 100%; padding: 0.7rem 1rem; text-align: center; }
  .gv-dialog-actions-3 .gv-dialog-ok { order: 1; }
  .gv-dialog-actions-3 .gv-dialog-view { order: 2; }
  .gv-dialog-actions-3 .gv-dialog-cancel { order: 3; margin-right: 0; }
}


/* The pooled <video> is moved into this slot when a clip opens, so it keeps its buffer. */
.gv-video-slot { display: contents; }
.gv-video-wrap video { display: block; max-width: 90vw; max-height: 80vh; border-radius: 6px; background: #000; }

/* ── "Save all versions" should look like the action it is ─────────────────────
   It was grey-on-grey and read as disabled. Same treatment as Download All. */
#gv-versions-dl-all {
  display: inline-flex; align-items: center; gap: 7px;
  padding: 9px 18px; border-radius: 4px; cursor: pointer;
  background: oklch(1 0 0 / 0.12); color: var(--gv-text, oklch(0.97 0 0));
  border: 1px solid oklch(1 0 0 / 0.32);
  font: 600 0.78rem var(--font); letter-spacing: 0.01em;
  backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
  transition: background 0.15s var(--ease), border-color 0.15s var(--ease);
}
#gv-versions-dl-all:hover {
  background: oklch(1 0 0 / 0.22); border-color: oklch(1 0 0 / 0.6); color: #fff;
}


/* ── Slideshow arrows sit OFF the picture ──────────────────────────────────────
   Over the image they compete with the photo and swallow taps meant for it. Outside it, the
   frame narrows a little and the arrows get the margin to themselves. */
.gv-lb-wrap { max-width: min(90vw, calc(100vw - 190px)); }
.gv-lb-prev { left: 12px; }
.gv-lb-next { right: 12px; }

@media (max-width: 768px) {
  /* A phone has no margin to spare, so the picture keeps the width and the arrows drop into the
     band beneath it — clear of the photo, still an easy reach for a thumb. */
  .gv-lb-wrap { max-width: 100vw; }
  .gv-lb-prev, .gv-lb-next {
    top: auto; bottom: calc(12px + env(safe-area-inset-bottom)); transform: none;
    padding: 20px 34px; font-size: 1.9rem;
  }
  .gv-lb-prev { left: 6px; }
  .gv-lb-next { right: 6px; }
}

/* ── Download tutorial ─────────────────────────────────────────────────────── */
.gv-dialog-tip { max-width: min(500px, calc(100vw - 32px)); }
.gv-tip-steps { list-style: none; margin: 1.15rem 0 0.5rem; padding: 0; display: grid; gap: 12px; }
.gv-tip-steps li { display: flex; gap: 11px; align-items: flex-start; }
.gv-tip-n {
  flex: 0 0 auto; width: 23px; height: 23px; border-radius: 50%;
  display: flex; align-items: center; justify-content: center;
  background: var(--gv-accent, oklch(0.82 0.13 75)); color: oklch(0.2 0.02 60);
  font: 700 0.72rem var(--font);
}
.gv-tip-steps strong { display: block; font-size: 0.83rem; font-weight: 650; }
.gv-tip-steps span { font-size: 0.76rem; opacity: 0.66; line-height: 1.45; }
.gv-tip-foot { margin-top: 0.9rem; font-size: 0.76rem; opacity: 0.6; }

/* Upload failure report button, admin side. */
.uq-report {
  margin-left: 10px; padding: 5px 11px; border-radius: 4px; cursor: pointer;
  background: oklch(0.3 0.09 25 / 0.35); border: 1px solid oklch(0.65 0.16 25 / 0.5);
  color: inherit; font: 600 0.7rem var(--font);
}
.uq-report:hover { background: oklch(0.35 0.11 25 / 0.5); }


/* ── Slideshow arrows stay vertically centred ──────────────────────────────────
   Dropping them to the bottom band on a phone put them somewhere nobody looks for a next/prev
   control. They belong at the middle of the frame, same as on desktop — just with a hit area
   big enough for a thumb, which was the only thing actually wrong with them. */
@media (max-width: 768px) {
  .gv-lb-prev, .gv-lb-next {
    top: 50%; bottom: auto; transform: translateY(-50%);
    padding: 44px 22px; font-size: 2rem;
  }
  .gv-lb-prev { left: 0; }
  .gv-lb-next { right: 0; }
}


/* ── Arrows beside the photo, not on it ────────────────────────────────────────
   The picture gives up the outer strip on each side so the arrows have somewhere of their own to
   sit. Still vertically centred, still a thumb-sized target — just no longer overlapping the
   image they are there to change. */
@media (max-width: 768px) {
  .gv-lb-wrap { max-width: calc(100vw - 116px); margin-inline: auto; }
  .gv-lb-wrap img, .gv-lb-wrap video { max-width: calc(100vw - 116px); }
  .gv-lb-prev, .gv-lb-next {
    top: 50%; bottom: auto; transform: translateY(-50%);
    padding: 44px 14px; font-size: 1.85rem;
  }
  .gv-lb-prev { left: 2px; }
  .gv-lb-next { right: 2px; }
}


/* ── One download button, bottom-right of the photo ────────────────────────────
   A photo has a single action now, so it does not need a bar spanning the tile — just a button
   in the corner, out of the picture's way and exactly where a thumb lands. */
.gv-item-bar { justify-content: flex-end; }
.gv-item-actions { display: flex; gap: 6px; align-items: center; }
.gv-item-dl.gv-dl-one {
  padding: 9px 15px; border-radius: 4px; font-weight: 650; font-size: 0.76rem;
  background: oklch(0 0 0 / 0.6); border: 1px solid oklch(1 0 0 / 0.34);
  color: oklch(0.99 0 0);
}
.gv-item-dl.gv-dl-one:hover {
  background: oklch(0 0 0 / 0.82); border-color: oklch(1 0 0 / 0.65);
}
@media (max-width: 768px) {
  .gv-item-bar { padding: 12px; }
  .gv-item-dl.gv-dl-one { padding: 11px 18px; font-size: 0.8rem; }
}


/* ── Give the photo its size back ──────────────────────────────────────────────
   Narrowing the frame to make room for the arrows shrank every picture, which is a bad trade —
   the photo is the point. The image goes back to the width it had, and the arrows sit hard
   against the screen edges instead. Most of this gallery is portrait, so at 70vh tall there is
   already empty space either side for them to occupy without covering anything. */
@media (max-width: 768px) {
  .gv-lb-wrap { max-width: 100vw; margin-inline: 0; }
  .gv-lb-wrap img, .gv-lb-wrap video { max-width: 100vw; max-height: 70vh; }
  .gv-lb-prev, .gv-lb-next {
    top: 50%; bottom: auto; transform: translateY(-50%);
    padding: 46px 12px; font-size: 1.9rem;
  }
  .gv-lb-prev { left: 0; }
  .gv-lb-next { right: 0; }
}


/* ── No accidental "Save Image" on a preview copy ──────────────────────────────
   iOS raises its own callout menu on a long press, and that menu saves the DISPLAY copy — a
   web-sized, re-compressed file. A client using it would think they had the real photo. The
   Download button is right there and gives them the original. */
.gv-item img, .gv-item video, .gv-item .gv-video-thumb,
.gv-lb-wrap img, .gv-lb-wrap video,
.gv-vpick img, .gv-vcard img, .gv-vsingle-img {
  -webkit-touch-callout: none;
  -webkit-user-select: none;
  user-select: none;
  -webkit-user-drag: none;
  user-drag: none;
}


/* ── Back to gallery ───────────────────────────────────────────────────────────
   The solid accent-filled uppercase slab shouted louder than anything else on screen, including
   the photos. It is a navigation control, so it now matches the rest of the gallery's chrome —
   the same dark translucent, squared, 1px-bordered treatment as Download All — and reads as part
   of the interface instead of an advert for itself. */
.gv-versions-back {
  display: inline-flex; align-items: center; gap: 8px;
  padding: 9px 16px; border-radius: 4px; cursor: pointer;
  font: 600 0.78rem/1 var(--gv-font, var(--font));
  letter-spacing: 0.01em; text-transform: none;
  color: var(--gv-text, oklch(0.97 0 0));
  background: oklch(1 0 0 / 0.1);
  border: 1px solid oklch(1 0 0 / 0.3);
  backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
  transition: background 0.15s var(--ease), border-color 0.15s var(--ease);
}
.gv-versions-back:hover {
  background: oklch(1 0 0 / 0.2); border-color: oklch(1 0 0 / 0.6);
  color: #fff;
}
.gv-versions-back svg { flex: 0 0 auto; width: 15px; height: 15px; stroke-width: 2; }

/* Light themes need the inverse, or a white-on-white button disappears. */
.gallery-viewer[data-theme="wedding"] .gv-versions-back,
.gallery-viewer[data-theme="rustic"] .gv-versions-back,
.gallery-viewer[data-theme="editorial"] .gv-versions-back,
.gallery-viewer[data-theme="film"] .gv-versions-back,
.gallery-viewer[data-theme="clean"] .gv-versions-back {
  background: oklch(0 0 0 / 0.06); border-color: oklch(0 0 0 / 0.22); color: var(--gv-text);
}
.gallery-viewer[data-theme="wedding"] .gv-versions-back:hover,
.gallery-viewer[data-theme="rustic"] .gv-versions-back:hover,
.gallery-viewer[data-theme="editorial"] .gv-versions-back:hover,
.gallery-viewer[data-theme="film"] .gv-versions-back:hover,
.gallery-viewer[data-theme="clean"] .gv-versions-back:hover {
  background: oklch(0 0 0 / 0.12); border-color: oklch(0 0 0 / 0.45); color: #000;
}

@media (max-width: 768px) {
  /* Still pinned within thumb reach, just no longer shouting. */
  .gv-versions-back {
    position: fixed; left: 14px; bottom: calc(14px + env(safe-area-inset-bottom));
    z-index: 60; padding: 11px 18px; font-size: 0.8rem;
    background: oklch(0 0 0 / 0.72); border-color: oklch(1 0 0 / 0.34);
    box-shadow: 0 8px 28px oklch(0 0 0 / 0.5);
  }
}


/* ── Group download buttons wear the same glass as the "3 versions" tag ────────
   They were a thin, dim pair that all but vanished on a bright frame, and on iOS the blur behind
   them was so small it read as a smudge. Same treatment as the badge: a real blur radius, a
   visible edge, and enough padding for the glass to be seen at all. */
.gv-vcard-save, .gv-vsingle-save, #gv-versions-dl-all, .gv-item-dl {
  padding: 8px 14px;
  font: 600 0.75rem/1 var(--gv-font, var(--font));
  color: #fff;
  background: oklch(0 0 0 / 0.45);
  backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
  border: 1px solid oklch(1 0 0 / 0.22);
  border-radius: 4px; cursor: pointer;
  display: inline-flex; align-items: center; justify-content: center; gap: 7px;
  transition: background 0.15s var(--ease), border-color 0.15s var(--ease);
}
.gv-vcard-save:hover, .gv-vsingle-save:hover, #gv-versions-dl-all:hover, .gv-item-dl:hover {
  background: oklch(0 0 0 / 0.7); border-color: oklch(1 0 0 / 0.5);
}

/* Desktop version grid: give the cards room and put their button where the eye expects it. */
@media (min-width: 769px) {
  .gv-versions-grid {
    display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
    gap: 18px; align-items: start;
  }
  .gv-vcard { position: relative; display: flex; flex-direction: column; gap: 0; }
  .gv-vcard img { width: 100%; height: auto; display: block; border-radius: 4px; }
  .gv-vcard-saves {
    position: absolute; right: 10px; bottom: 10px; display: flex; gap: 6px;
    opacity: 0; transition: opacity 0.18s var(--ease);
  }
  .gv-vcard:hover .gv-vcard-saves { opacity: 1; }
  .gv-versions-head { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
}


/* ── The video frame must not collapse to the default 300x150 ──────────────────
   The pooled <video> only reports an intrinsic size once metadata arrives, and preload is now
   'metadata' — so on open the wrap briefly had nothing to size itself against and rendered a
   postage-stamp player in the middle of the screen. Sizing the frame explicitly means the player
   is the right size from the first frame, whatever the file has told us so far. */
.gv-video-wrap {
  width: min(92vw, 1280px); max-width: 92vw; max-height: 80vh;
  display: flex; align-items: center; justify-content: center;
}
.gv-video-wrap video {
  width: 100%; height: auto; max-height: 80vh; background: #000; display: block;
}
.gv-video-slot { display: contents; }

/* The photo loader belongs to photos. */
.gv-lb-wrap.is-loading:has(video)::after { content: none; }

@media (max-width: 768px) {
  .gv-video-wrap { width: 100vw; max-width: 100vw; }
  .gv-video-wrap video { max-height: 70vh; }
}


/* ═══════════════════════════════════════════════════════════════════════════════
   ONE DOWNLOAD BUTTON, EVERYWHERE

   These controls had been styled six separate times — once for the grid tile, once for the
   version cards, once for the single-version view, once for the lightbox bar, once for "Save all
   versions", once for "Download All" — each written against the view it sat in. The result was a
   button that changed shape, colour and size depending on which view you happened to be in, and
   changed again between mobile and desktop.

   This is the single definition. It is scoped under .gallery-viewer purely so it outranks the
   older per-view rules above without reaching for !important, and it is the last word in the
   file. Anything that needs to differ between views should differ in POSITION, never in style.
   ═══════════════════════════════════════════════════════════════════════════════ */
.gallery-viewer .gv-item-dl,
.gallery-viewer .gv-save-btn,
.gallery-viewer .gv-vcard-save,
.gallery-viewer .gv-vsingle-save,
.gallery-viewer .gv-lb-dl,
.gallery-viewer .gv-dl-btn,
.gallery-viewer #gv-versions-dl-all,
.gallery-viewer #btn-dl-all-full {
  /* Same glass as the "3 versions" tag: a real blur radius, a visible edge, enough padding that
     the glass reads as glass rather than a smudge — which is how it looked on iOS. */
  display: inline-flex; align-items: center; justify-content: center; gap: 7px;
  padding: 9px 15px;
  border-radius: 4px;
  font: 600 0.76rem/1 var(--gv-font, var(--font));
  letter-spacing: 0.01em; text-transform: none; white-space: nowrap;
  color: oklch(0.99 0 0);
  background: oklch(0 0 0 / 0.45);
  border: 1px solid oklch(1 0 0 / 0.22);
  backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
  cursor: pointer;
  width: auto; height: auto; min-width: 0;
  position: static;
  box-shadow: none;
  transition: background 0.15s var(--ease), border-color 0.15s var(--ease), color 0.15s var(--ease);
}

.gallery-viewer .gv-item-dl:hover,
.gallery-viewer .gv-save-btn:hover,
.gallery-viewer .gv-vcard-save:hover,
.gallery-viewer .gv-vsingle-save:hover,
.gallery-viewer .gv-lb-dl:hover,
.gallery-viewer .gv-dl-btn:hover,
.gallery-viewer #gv-versions-dl-all:hover,
.gallery-viewer #btn-dl-all-full:hover {
  background: oklch(0 0 0 / 0.72);
  border-color: oklch(1 0 0 / 0.55);
  color: #fff;
}

/* The icon is part of the button, not a decoration that some views dropped. */
.gallery-viewer .gv-dl-svg { flex: 0 0 auto; width: 13px; height: 13px; }

/* Medium reads as the secondary choice — by weight alone, not by becoming a different button. */
.gallery-viewer .gv-save-med { font-weight: 500; background: oklch(0 0 0 / 0.32); }

/* One size step up for touch, applied to all of them together. */
@media (max-width: 768px) {
  .gallery-viewer .gv-item-dl,
  .gallery-viewer .gv-save-btn,
  .gallery-viewer .gv-vcard-save,
  .gallery-viewer .gv-vsingle-save,
  .gallery-viewer .gv-lb-dl,
  .gallery-viewer .gv-dl-btn,
  .gallery-viewer #gv-versions-dl-all,
  .gallery-viewer #btn-dl-all-full {
    padding: 11px 17px; font-size: 0.8rem;
  }
  .gallery-viewer .gv-dl-svg { width: 14px; height: 14px; }
}

/* Position is the only thing that varies by view. */
.gallery-viewer .gv-vcard-saves,
.gallery-viewer .gv-item-actions { display: flex; gap: 6px; align-items: center; }
.gallery-viewer .gv-lb-actions { display: flex; gap: 10px; justify-content: center; flex-wrap: nowrap; }


/* ── Photos and videos are two sections, not one mixed run ─────────────────────
   A masonry column layout needs the heading to break the flow, or it would be laid out as just
   another item inside a column. */
.gv-kind-header {
  column-span: all; break-after: avoid; break-inside: avoid;
  display: flex; align-items: baseline; gap: 10px;
  margin: 30px 0 14px; padding: 0 2px;
}
.gv-kind-header span {
  font: 600 0.78rem/1 var(--gv-font, var(--font));
  letter-spacing: 0.14em; text-transform: uppercase;
  color: var(--gv-text, oklch(0.95 0 0));
}
.gv-kind-header small {
  font: 500 0.72rem/1 var(--font); color: var(--gv-dim); opacity: 0.7;
}
.gv-kind-header::after {
  content: ''; flex: 1; height: 1px;
  background: oklch(from var(--gv-dim) l c h / 0.22);
}
.gv-kind-header:first-child { margin-top: 4px; }

@media (max-width: 768px) {
  .gv-kind-header { margin: 22px 14px 10px; }
}


/* ── Zoom, matched to the homepage ─────────────────────────────────────────────
   The homepage lightbox is sharp on iOS and this one was not, running near-identical JS. The
   difference was two properties the homepage never had:

     will-change: transform          promotes the image to its own composited layer, and Safari
                                     rasterises that layer ONCE at its pre-scale size. Scaling it
                                     afterwards magnifies those fixed pixels, so a 2.5x zoom shows
                                     2.5x-sized pixels. Pinch-zoom looked better precisely because
                                     the browser re-rasterises from the decoded image instead.

     image-rendering:                asks Safari to sharpen rather than interpolate, which is where
       -webkit-optimize-contrast     the hard edges and lines came from.

   Both are removed, and the zoomed image keeps the same max-width/max-height it has unzoomed —
   exactly as the homepage does it — so the browser re-rasterises at the displayed size. */
.gallery-viewer .gv-lb-wrap.is-zoomed {
  cursor: grab; width: auto; height: auto; max-width: none; max-height: none;
}
.gallery-viewer .gv-lb-wrap.is-zoomed.is-dragging { cursor: grabbing; }
.gallery-viewer .gv-lb-wrap.is-zoomed img {
  max-width: 90vw; max-height: 80vh;
  width: auto; height: auto;
  object-fit: contain;
  will-change: auto;
  image-rendering: auto;
  cursor: grab;
}
@media (max-width: 768px) {
  .gallery-viewer .gv-lb-wrap.is-zoomed img { max-width: 100vw; max-height: 70vh; }
}


/* The encoder's own output, on screen. A failure that only exists in the console is a failure
   nobody reads. */
.uq-diag {
  margin: 0 0 10px; padding: 10px 12px; border-radius: 6px;
  background: oklch(0.2 0.05 25 / 0.35); border: 1px solid oklch(0.55 0.14 25 / 0.4);
  position: relative;
}
.uq-diag pre {
  margin: 0; max-height: 260px; overflow: auto; white-space: pre-wrap; word-break: break-word;
  font: 400 0.7rem/1.45 ui-monospace, SFMono-Regular, Menlo, monospace;
  color: oklch(0.9 0.02 25);
}
.uq-diag-copy {
  position: absolute; top: 8px; right: 8px; z-index: 2;
  padding: 5px 11px; border-radius: 4px; cursor: pointer;
  background: oklch(0.3 0.09 25 / 0.7); border: 1px solid oklch(0.65 0.16 25 / 0.55);
  color: oklch(0.96 0 0); font: 600 0.68rem var(--font);
}
.uq-diag-copy:hover { background: oklch(0.38 0.12 25 / 0.85); }


/* ── The photo gets the room ───────────────────────────────────────────────────
   Both the top strip and the bottom bar were padded as though they were page furniture, and the
   close button sat on its own line above the counter — so the chrome was eating vertical space
   the picture should have had. The counter and the X now share one horizontal line, both floated
   over the image rather than stacked above it, and the bars are only as tall as their contents.
   Everything left over goes to the photo. */
.gallery-viewer .gv-lb-top {
  padding: 10px 16px 14px;
  flex-direction: row; align-items: center; justify-content: center;
  background: linear-gradient(to bottom, oklch(0 0 0 / 0.5), transparent);
}
.gallery-viewer .gv-lb-close {
  /* Vertically centred on the counter, not above it. */
  top: 10px; right: 12px; bottom: auto;
  display: flex; align-items: center; justify-content: center;
  width: 40px; height: 32px; padding: 0; font-size: 1.6rem; line-height: 1;
}
.gallery-viewer .gv-lb-bar {
  padding: 10px 16px calc(10px + env(safe-area-inset-bottom));
  justify-content: center; gap: 10px;
  background: linear-gradient(to top, oklch(0 0 0 / 0.55), transparent);
}
.gallery-viewer .gv-lb-hint { bottom: 62px; }

/* With the chrome trimmed, the frame can take the space back. */
.gallery-viewer .gv-lb-wrap { max-width: 94vw; max-height: 86vh; }
.gallery-viewer .gv-lb-wrap img,
.gallery-viewer .gv-lb-wrap.is-zoomed img { max-width: 94vw; max-height: 86vh; }

@media (max-width: 768px) {
  .gallery-viewer .gv-lb-top { padding: 8px 12px 12px; }
  .gallery-viewer .gv-lb-close { top: 6px; right: 8px; width: 44px; height: 34px; font-size: 1.7rem; }
  .gallery-viewer .gv-lb-bar { padding: 8px 10px calc(8px + env(safe-area-inset-bottom)); }
  .gallery-viewer .gv-lb-wrap { max-width: 100vw; max-height: 82vh; }
  .gallery-viewer .gv-lb-wrap img,
  .gallery-viewer .gv-lb-wrap.is-zoomed img { max-width: 100vw; max-height: 82vh; }
  .gallery-viewer .gv-lb-hint { bottom: 58px; }
}


/* The step list draws its own numbers in the circle, so the list marker must not draw a second
   set — "1. 1" was the browser's marker plus ours. Scoped to beat the earlier rule. */
.gallery-viewer .gv-tip-steps {
  list-style: none; margin: 1.15rem 0 0.5rem; padding: 0;
  display: grid; gap: 12px;
}
.gallery-viewer .gv-tip-steps li {
  display: flex; gap: 11px; align-items: flex-start; list-style: none;
}
.gallery-viewer .gv-tip-steps li::marker { content: none; }


/* ── The status card outranks everything ───────────────────────────────────────
   It sat at z-index 400 while the lightbox is 2000, dialogs 3000 and the zip overlay 4000 — so
   "Preparing your video…" was hidden behind the very slideshow the client started the download
   from. It is a progress message: if it is not visible during the wait it explains, it does no
   work at all. This is deliberately far above every other layer so nothing added later can bury
   it again by accident. */
.gallery-viewer .gv-status,
.gv-status {
  z-index: 2147483000;
}


/* ── Explained download, in sections ───────────────────────────────────────────
   Each answer gets its own heading so it can be found at a glance, and the ones that are a
   caution rather than a recommendation are marked, so skimming still surfaces them. */
.gv-dialog-explain { max-width: min(520px, calc(100vw - 32px)); }
.gv-dialog-explain .gv-x-sub {
  margin: 0.35rem 0 0; font-size: 0.84rem; opacity: 0.72;
}
.gv-dialog-explain .gv-x-sections {
  display: grid; gap: 2px; margin: 1.1rem 0 0.4rem;
  max-height: 52vh; overflow-y: auto;
  border: 1px solid oklch(1 0 0 / 0.1); border-radius: 6px;
  background: oklch(1 0 0 / 0.1);
}
.gv-dialog-explain .gv-x-sec {
  padding: 0.75rem 0.9rem;
  background: var(--gv-bg, oklch(0.14 0.008 60));
}
.gv-dialog-explain .gv-x-sec h4 {
  margin: 0 0 3px; font: 700 0.7rem/1 var(--gv-font, var(--font));
  letter-spacing: 0.09em; text-transform: uppercase;
  color: var(--gv-accent, oklch(0.82 0.13 75));
}
.gv-dialog-explain .gv-x-sec.is-warn h4 { color: oklch(0.78 0.14 55); }
.gv-dialog-explain .gv-x-sec p {
  margin: 0; font-size: 0.8rem; line-height: 1.5; opacity: 0.85;
}
.gv-dialog-explain .gv-x-sec.is-warn {
  background: oklch(0.2 0.04 55 / 0.5);
}

[data-theme="wedding"] .gv-dialog-explain .gv-x-sec,
[data-theme="rustic"] .gv-dialog-explain .gv-x-sec,
[data-theme="editorial"] .gv-dialog-explain .gv-x-sec,
[data-theme="film"] .gv-dialog-explain .gv-x-sec,
[data-theme="clean"] .gv-dialog-explain .gv-x-sec { background: var(--gv-bg, #fff); }

@media (max-width: 768px) {
  .gv-dialog-explain .gv-x-sections { max-height: 46vh; }
  .gv-dialog-explain .gv-x-sec { padding: 0.7rem 0.8rem; }
  .gv-dialog-explain .gv-x-sec p { font-size: 0.78rem; }
}


/* ── Slideshow chrome clears the notch, and nothing scrolls ────────────────────
   The counter was pinned to the very top, so on an iPhone it sat under the notch — and in
   landscape it ended up slightly above the close button rather than level with it. Both now
   start below the safe area, which is where the usable screen actually begins. */
.gallery-viewer .gv-lb-top {
  padding-top: calc(10px + env(safe-area-inset-top));
  padding-left: calc(16px + env(safe-area-inset-left));
  padding-right: calc(16px + env(safe-area-inset-right));
}
.gallery-viewer .gv-lb-close {
  top: calc(8px + env(safe-area-inset-top));
  right: calc(12px + env(safe-area-inset-right));
}
.gallery-viewer .gv-lb-prev { left: calc(0px + env(safe-area-inset-left)); }
.gallery-viewer .gv-lb-next { right: calc(0px + env(safe-area-inset-right)); }

/* The lightbox is a fixed viewport-sized surface, so it should never scroll in any direction.
   .gv-lb-wrap had overflow:auto for the zoomed case, which let a fingertip drag the whole frame
   around; panning is handled by the transform, not by scrollbars. */
.gallery-viewer .gv-lightbox {
  overflow: hidden; overscroll-behavior: contain; touch-action: none;
  height: 100dvh; max-height: 100dvh;
}
.gallery-viewer .gv-lb-wrap { overflow: hidden; overscroll-behavior: contain; }

@media (max-width: 768px) {
  /* Fit the phone exactly: dvh accounts for the browser chrome that vh does not. */
  .gallery-viewer .gv-lb-wrap { max-height: calc(100dvh - 150px); }
  .gallery-viewer .gv-lb-wrap img,
  .gallery-viewer .gv-lb-wrap video,
  .gallery-viewer .gv-lb-wrap.is-zoomed img { max-height: calc(100dvh - 150px); }
}

/* Landscape has almost no vertical room, so the chrome shrinks rather than the photo. */
@media (max-width: 900px) and (orientation: landscape) {
  .gallery-viewer .gv-lb-top { padding-top: calc(4px + env(safe-area-inset-top)); padding-bottom: 6px; }
  .gallery-viewer .gv-lb-close { top: calc(2px + env(safe-area-inset-top)); height: 28px; }
  .gallery-viewer .gv-lb-bar { padding: 6px 10px calc(6px + env(safe-area-inset-bottom)); }
  .gallery-viewer .gv-lb-wrap { max-height: calc(100dvh - 96px); }
  .gallery-viewer .gv-lb-wrap img,
  .gallery-viewer .gv-lb-wrap video,
  .gallery-viewer .gv-lb-wrap.is-zoomed img { max-height: calc(100dvh - 96px); }
}

/* The portal button's second line was being clipped mid-word. */
.ph-portal-text { min-width: 0; }
.ph-portal-text small { white-space: normal; overflow-wrap: anywhere; }
@media (max-width: 720px) {
  .ph-hero-cta .ph-portal-btn { padding-right: 1rem; }
}


/* ── Video fills the frame the same way a photo does ───────────────────────────
   The video wrapper had its own fixed width (min(92vw, 1280px)), so a vertical clip sat in a
   letterboxed column while a photo of the same shape filled the screen. Sizing it from the same
   dvh budget as the photo means a 9:16 video uses the height it has and lands at the width that
   follows, exactly like a portrait still. */
.gallery-viewer .gv-video-wrap {
  width: auto; max-width: 94vw; max-height: 86vh;
  display: flex; align-items: center; justify-content: center;
}
.gallery-viewer .gv-video-wrap video {
  width: auto; height: auto;
  max-width: 94vw; max-height: 86vh;
  object-fit: contain; background: #000; display: block;
}

@media (max-width: 768px) {
  .gallery-viewer .gv-video-wrap {
    max-width: 100vw;
    max-height: calc(100dvh - 150px);
  }
  .gallery-viewer .gv-video-wrap video {
    max-width: 100vw;
    max-height: calc(100dvh - 150px);
  }
}

@media (max-width: 900px) and (orientation: landscape) {
  .gallery-viewer .gv-video-wrap,
  .gallery-viewer .gv-video-wrap video { max-height: calc(100dvh - 96px); }
}


/* ── The explained dialog fits on screen ───────────────────────────────────────
   It had its own inner scroller, so the content the client most needed to read was the part
   below the fold. Three tighter sections fit without one — and if a long filename or a small
   phone ever does overflow, the DIALOG scrolls as a whole rather than a panel inside it, which
   is at least an obvious thing to scroll. */
.gallery-viewer .gv-dialog-explain,
.gv-dialog-explain {
  max-width: min(480px, calc(100vw - 28px));
  max-height: calc(100dvh - 40px);
  display: flex; flex-direction: column;
}
.gv-dialog-explain .gv-x-sections {
  max-height: none; overflow: visible;
  gap: 1px; margin: 0.95rem 0 0.2rem;
}
.gv-dialog-explain .gv-x-sec { padding: 0.68rem 0.85rem; }
.gv-dialog-explain .gv-x-sec h4 {
  margin: 0 0 2px; font-size: 0.66rem; letter-spacing: 0.1em;
}
.gv-dialog-explain .gv-x-sec p { font-size: 0.775rem; line-height: 1.45; }
.gv-dialog-explain h3 { font-size: 1.02rem; }
.gv-dialog-explain .gv-x-sub { font-size: 0.8rem; margin-top: 0.25rem; }
.gv-dialog-explain .gv-dialog-actions { margin-top: 0.9rem; }

@media (max-width: 768px) {
  .gv-dialog-explain .gv-x-sec { padding: 0.6rem 0.75rem; }
  .gv-dialog-explain .gv-x-sec p { font-size: 0.75rem; line-height: 1.42; }
  .gv-dialog-explain h3 { font-size: 0.96rem; }
  .gv-dialog-explain .gv-x-sub { font-size: 0.76rem; }
}

/* Very short screens in landscape genuinely cannot fit it; scroll the dialog, not a panel. */
@media (max-height: 560px) {
  .gv-dialog-explain { overflow-y: auto; }
}


/* ── Loading, without a spinner ────────────────────────────────────────────────
   A spinner says "something is happening" and nothing else. This says whose gallery is opening
   and roughly how far along it is: the mark draws itself in, the rule fills. It reads as a
   deliberate opening rather than a stalled page. */
.gv-loading {
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: 18px; padding: 22vh 24px; color: var(--gv-dim);
}
.gv-load-mark {
  /* Was a 58px square holding the letters JJ. The signature is close to 2:1, so the frame widens
     to hold it at a readable size rather than shrinking the ink to fit a square. */
  width: 138px; height: 70px; display: flex; align-items: center; justify-content: center;
  border: 2px solid oklch(from var(--gv-text, oklch(.92 0 0)) l c h / .30);
  border-radius: 4px;
  animation: gv-mark-in .9s cubic-bezier(.16,1,.3,1) both;
}
.gv-load-mark .gv-sig {
  width: 88px; height: 45px;
  /* The signature's thin strokes need the theme's text colour to read at all — a pale tan lost
     them completely on the light themes. The frame matches it. */
  color: var(--gv-text, oklch(.92 0 0)); opacity: .85;
}
.gv-load-title {
  margin: 0; font: 500 .82rem/1 var(--gv-font, var(--font));
  letter-spacing: .16em; text-transform: uppercase; color: var(--gv-text, oklch(.92 0 0));
  animation: gv-fade-up .7s .15s cubic-bezier(.16,1,.3,1) both;
}
.gv-load-track {
  width: min(210px, 60vw); height: 2px; overflow: hidden;
  background: oklch(from var(--gv-dim) l c h / .22);
  animation: gv-fade-up .7s .25s cubic-bezier(.16,1,.3,1) both;
}
.gv-load-track span {
  display: block; width: 40%; height: 100%;
  background: var(--gv-text, oklch(.92 0 0));
  animation: gv-sweep 1.5s cubic-bezier(.5,0,.3,1) infinite;
}
@keyframes gv-mark-in { from { opacity: 0; transform: scale(.9); } to { opacity: 1; transform: none; } }
@keyframes gv-fade-up { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
@keyframes gv-sweep { 0% { transform: translateX(-110%); } 100% { transform: translateX(310%); } }
@media (prefers-reduced-motion: reduce) {
  .gv-load-mark, .gv-load-title, .gv-load-track { animation: none; opacity: 1; transform: none; }
  .gv-load-track span { animation: none; width: 100%; opacity: .5; }
}

/* The video caption was inheriting the wrapper's alignment instead of centring on the frame. */
/* Centred by layout, not by transform.
   The transform approach (left:50% + translate(-50%,-50%)) kept losing its translate — the
   computed transform came back as `none` — leaving the caption pinned to the left edge on both
   phone and desktop. Stretching the element across the frame and centring its contents with flex
   has nothing to reset: no percentage offsets, no max-content width, no transform at all. */
.gallery-viewer .gv-video-loading {
  inset: 0; left: 0; right: 0; top: 0; bottom: 0;
  transform: none;
  display: flex; align-items: center; justify-content: center;
  width: auto; height: auto; max-width: none;
  text-align: center; white-space: nowrap;
}


/* ── Version view tabs ─────────────────────────────────────────────────────────
   "All at once" / "One at a time" described the behaviour but read as a sentence fragment. A
   quiet "View" label carries that meaning once, so the tabs themselves can be a single word. */
.gallery-viewer .gv-versions-tabs { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.gallery-viewer .gv-vtab-label {
  font: 600 .64rem/1 var(--gv-font, var(--font));
  letter-spacing: .16em; text-transform: uppercase;
  color: var(--gv-dim); margin-right: 2px;
}

/* The hint belongs to the tab that would answer it, and only when it is not already selected. */
.gallery-viewer .gv-vtab-hint {
  display: none; align-items: center; gap: 5px;
  margin-left: 4px; padding: 4px 9px; border-radius: 4px;
  font: 500 .66rem/1 var(--gv-font, var(--font)); letter-spacing: .02em;
  color: var(--gv-accent, oklch(.82 .13 75));
  background: oklch(from var(--gv-accent, oklch(.82 .13 75)) l c h / .1);
  border: 1px solid oklch(from var(--gv-accent, oklch(.82 .13 75)) l c h / .3);
}
.gallery-viewer .gv-vtab-hint svg { animation: gv-nudge 1.8s cubic-bezier(.4,0,.6,1) infinite; }
@keyframes gv-nudge { 0%, 100% { transform: translateY(-1px); } 50% { transform: translateY(2px); } }

@media (max-width: 768px) {
  /* Phones only: on a wide screen the versions are side by side and nothing is hidden. */
  .gallery-viewer .gv-versions-tabs.is-grid .gv-vtab-hint { display: inline-flex; }
}
@media (prefers-reduced-motion: reduce) {
  .gallery-viewer .gv-vtab-hint svg { animation: none; }
}


/* ── Nothing but the loading screen, until it is done ──────────────────────────
   The header, title and filter bar are markup — they painted immediately while the photos were
   still arriving, so the client watched the page assemble itself top-down. While the loader is up
   the chrome is hidden entirely; when it goes, the whole gallery arrives at once. */
.gallery-viewer:has(#gv-loading:not([hidden])) .gv-header,
.gallery-viewer:has(#gv-loading:not([hidden])) .gv-filter-bar,
.gallery-viewer:has(#gv-loading:not([hidden])) .gv-grid,
.gallery-viewer:has(#gv-loading:not([hidden])) .gv-footer { visibility: hidden; }

/* The loading screen owns the viewport while it is showing. */
#gv-loading:not([hidden]) {
  position: fixed; inset: 0; z-index: 500;
  background: var(--gv-bg, oklch(0.14 0.008 60));
  padding: 0;
}

/* One gentle fade for the finished gallery, so the swap does not snap. */
body.gv-ready .gv-header,
body.gv-ready .gv-grid { animation: gv-reveal .45s cubic-bezier(.16,1,.3,1) both; }
@keyframes gv-reveal { from { opacity: 0; } to { opacity: 1; } }
@media (prefers-reduced-motion: reduce) {
  body.gv-ready .gv-header, body.gv-ready .gv-grid { animation: none; }
}

/* ═══════════════════════════════════════════════════════════════════════
   SITE GALLERY  —  editor for the public /photo and /gallery pages
   Reuses the editor header, upload zone and button classes above; only the
   card grid and the hero picker are new.
   ═══════════════════════════════════════════════════════════════════════ */

.admin-site { padding: 0 clamp(16px, 4vw, 40px) 80px; }

.sg-headline { flex: 1; min-width: 0; }
.sg-title { font: 600 1.05rem var(--font); color: var(--text); margin: 0; }
.sg-sub   { font: 400 0.76rem var(--font); color: var(--dim); margin: 2px 0 0; }

.sg-status { font: 500 0.76rem var(--font); color: var(--dim); margin-right: 4px; }
.sg-status--ok   { color: var(--ok); }
.sg-status--warn { color: var(--accent); }
.sg-status--err  { color: var(--danger); }

.sg-bar {
  display: flex; align-items: baseline; justify-content: space-between;
  gap: 16px; flex-wrap: wrap; margin: 18px 0 12px;
}
.sg-count { font: 600 0.8rem var(--font); color: var(--text); }
.sg-hint  { font: 400 0.74rem var(--font); color: var(--dim); }

.sg-grid {
  display: grid; gap: 12px;
  grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
}

.sg-card {
  display: flex; gap: 12px; padding: 10px;
  background: var(--surface); border: 1px solid var(--border); border-radius: var(--r);
  transition: border-color 0.15s var(--ease), opacity 0.15s var(--ease);
}
.sg-card.is-dragging { opacity: 0.4; }
.sg-card.is-over     { border-color: var(--accent); }

.sg-thumb {
  position: relative; flex: 0 0 96px; width: 96px; height: 128px;
  background: oklch(0.1 0 0); border-radius: 4px; overflow: hidden; cursor: grab;
}
.sg-thumb img, .sg-thumb video { width: 100%; height: 100%; object-fit: cover; display: block; }
.sg-grip {
  position: absolute; left: 4px; top: 2px; color: #fff; opacity: 0.6;
  font-size: 0.8rem; line-height: 1; text-shadow: 0 1px 3px oklch(0 0 0 / 0.8);
  pointer-events: none;
}

.sg-fields { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 7px; }
.sg-fields .sg-alt { width: 100%; }
.sg-row { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }

.sg-row select {
  flex: 1; min-width: 84px; padding: 6px 8px;
  background: var(--bg); color: var(--text);
  border: 1px solid var(--border); border-radius: var(--r);
  font: 500 0.74rem var(--font); cursor: pointer;
}

.sg-toggles label {
  display: inline-flex; align-items: center; gap: 5px;
  font: 500 0.73rem var(--font); color: var(--dim); cursor: pointer;
}
.sg-toggles input { accent-color: var(--accent); cursor: pointer; }

.sg-del {
  margin-left: auto; padding: 5px 10px;
  background: transparent; color: var(--dim);
  border: 1px solid var(--border); border-radius: var(--r);
  font: 500 0.72rem var(--font); cursor: pointer;
  transition: color 0.15s var(--ease), border-color 0.15s var(--ease);
}
.sg-del:hover { color: var(--danger); border-color: var(--danger); }

/* ── hero picker ── */
.sg-hero-sec { margin-top: 40px; padding-top: 26px; border-top: 1px solid var(--border); }
.sg-sec-title { font: 600 0.92rem var(--font); color: var(--text); margin: 0 0 4px; }
.sg-sec-sub   { font: 400 0.76rem var(--font); color: var(--dim); margin: 0 0 16px; max-width: 60ch; }

.sg-hero-list { display: flex; flex-wrap: wrap; gap: 10px; }
.sg-hero-opt {
  position: relative; width: 104px; height: 66px; cursor: pointer;
  border: 2px solid transparent; border-radius: var(--r); overflow: hidden;
  transition: border-color 0.15s var(--ease), opacity 0.15s var(--ease);
  opacity: 0.45;
}
.sg-hero-opt img { width: 100%; height: 100%; object-fit: cover; display: block; }
.sg-hero-opt input { position: absolute; inset: 0; opacity: 0; cursor: pointer; margin: 0; }
.sg-hero-opt.is-on { opacity: 1; border-color: var(--accent); }

@media (max-width: 560px) {
  .sg-grid { grid-template-columns: 1fr; }
  .sg-card { flex-direction: column; }
  .sg-thumb { width: 100%; height: 160px; flex: none; }
}

/* ═══════════════════════════════════════════════════════════════════════
   AUDIO  —  admin tiles, admin detail player, client player
   ═══════════════════════════════════════════════════════════════════════ */

/* ── admin: grid tile ── */
.media-audio-tile {
  width: 100%; height: 100%;
  display: flex; align-items: center; justify-content: center;
  background: linear-gradient(160deg, oklch(0.22 0.01 260), oklch(0.15 0.005 260));
}
.media-audio-glyph { font-size: 2rem; color: var(--text); opacity: 0.5; line-height: 1; }

/* ── admin: detail panel player ── */
.detail-audio {
  display: flex; flex-direction: column; align-items: center; gap: 12px;
  padding: 22px 14px;
  background: linear-gradient(160deg, oklch(0.22 0.01 260), oklch(0.15 0.005 260));
  border: 1px solid var(--border); border-radius: var(--r);
}
.detail-audio-glyph { font-size: 2.2rem; color: var(--text); opacity: 0.55; line-height: 1; }
/* The column centres its children, which left the transport at its content width and the seek
   bar as a stub. It should span the panel. */
.detail-audio .jj-ap { align-self: stretch; }

/* ── client: the tile is the player ── */
.gv-item.gv-audio-item {
  /* The grid sizes photo tiles by their image. An audio tile has no intrinsic height, so it
     states its own and opts out of the aspect-ratio the surrounding tiles are held to. */
  aspect-ratio: auto; cursor: default; background: none;
}
.gv-audio {
  display: flex; flex-direction: column; gap: 10px;
  padding: 18px 16px 16px;
  /* Was a fixed dark gradient, which sat on the light themes as a black slab in the middle of a
     cream page. It takes the theme's own surface now, like every other panel here. */
  background: var(--gv-surface, oklch(0.17 0.005 260));
  border: 1px solid oklch(from var(--gv-dim, var(--dim)) l c h / 0.22);
  border-radius: var(--r);
}
.gv-audio-name {
  font: 500 0.82rem var(--gv-font, var(--font)); color: var(--gv-text, var(--text));
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}

/* ── the transport ──────────────────────────────────────────────────────
   One set of rules for all three faces: the tile, the docked mini player and the expanded view.
   Each only overrides its own sizing. */
.jj-ap { display: flex; align-items: center; gap: 10px; }

.jj-ap-play {
  flex: 0 0 auto; width: 34px; height: 34px; padding: 0;
  display: flex; align-items: center; justify-content: center;
  /* The theme's own ink, inverted: white button with a dark glyph on the dark themes, black
     button with a light glyph on the light ones. Deliberately not the gold accent — a coloured
     transport competes with the work, and the work is the only thing here worth colour. */
  background: var(--gv-text, var(--text)); color: var(--gv-bg, var(--bg));
  border: none; border-radius: 3px;        /* squared, like every other control here */
  font-size: 0.7rem; line-height: 1; letter-spacing: 1px; cursor: pointer;
  transition: background 0.15s var(--ease), transform 0.1s var(--ease);
}
.jj-ap-play:hover { filter: brightness(1.12); }
.jj-ap-play:active { transform: scale(0.94); }
.jj-ap.is-error .jj-ap-play { background: oklch(0.4 0.02 260); color: var(--gv-dim, var(--dim)); cursor: default; }

/* The bar carries generous vertical padding so the hit area is finger-sized while the drawn
   track stays thin. A 4px line you can only tap is fine for a 30-second clip and useless on a
   five-minute mix. */
.jj-ap-bar {
  position: relative; flex: 1; min-width: 60px;
  padding: 11px 0; cursor: pointer; touch-action: none;
}
.jj-ap-bar::before {
  content: ''; display: block; height: 6px; border-radius: 2px;
  background: oklch(from var(--gv-dim, var(--dim)) l c h / 0.28);
}
/* How much of the file is actually here, drawn behind the played fill. It is what turns a jump
   into un-downloaded audio from "nothing happened" into "it is on its way". */
.jj-ap-buffer {
  position: absolute; left: 0; top: 11px; height: 6px; width: 0%;
  background: oklch(from var(--gv-text, var(--text)) l c h / 0.22);
  border-radius: 2px; pointer-events: none;
  transition: width 0.25s var(--ease);
}
.jj-ap-fill {
  position: absolute; left: 0; top: 11px; height: 6px; width: 0%;
  background: var(--gv-text, var(--text)); border-radius: 2px;
  transition: width 0.1s linear; pointer-events: none;
}

/* Waiting on the network. The button becomes a spinner rather than sitting on a play glyph that
   has already been pressed, and the bar breathes so the wait reads as progress, not as a hang. */
.jj-ap.is-waiting .jj-ap-play {
  color: transparent; position: relative;
}
.jj-ap.is-waiting .jj-ap-play::after {
  content: ''; position: absolute; inset: 0; margin: auto;
  width: 14px; height: 14px; border-radius: 50%;
  border: 2px solid oklch(from var(--gv-bg, var(--bg)) l c h / 0.35);
  border-top-color: var(--gv-bg, var(--bg));
  animation: jj-spin 0.7s linear infinite;
}
@keyframes jj-spin { to { transform: rotate(360deg); } }
.jj-ap.is-waiting .jj-ap-fill { animation: jj-pulse 1.1s ease-in-out infinite; }
@keyframes jj-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.45; } }
@media (prefers-reduced-motion: reduce) {
  .jj-ap.is-waiting .jj-ap-play::after { animation: none; }
  .jj-ap.is-waiting .jj-ap-fill { animation: none; opacity: 0.6; }
}
.jj-ap-thumb {
  position: absolute; top: 50%; left: 0; width: 2px; height: 16px;
  transform: translate(-1px, -50%); pointer-events: none;
  background: var(--gv-text, var(--text));
  opacity: 0; transition: opacity 0.15s var(--ease);
}
.jj-ap-bar:hover .jj-ap-thumb,
.jj-ap-bar.is-scrubbing .jj-ap-thumb,
.jj-ap-bar:focus-visible .jj-ap-thumb { opacity: 1; }
.jj-ap-bar.is-scrubbing .jj-ap-fill { transition: none; }
.jj-ap-bar:focus-visible { outline: 2px solid var(--gv-text, var(--text)); outline-offset: 2px; }

.jj-ap-time {
  flex: 0 0 auto; min-width: 74px; text-align: right;
  font: 500 0.7rem var(--gv-font, var(--font)); color: var(--gv-dim, var(--dim));
  font-variant-numeric: tabular-nums;
}

/* ── docked mini player ──────────────────────────────────────────────────
   Follows you down the page while a track is sounding, so browsing the rest of the gallery does
   not mean losing the transport for what you are listening to. */
.jj-mini {
  /* Bottom right on a desktop, out of the way of the grid; full width on a phone, where there
     is no corner to tuck into. */
  position: fixed; right: 24px; bottom: 24px; z-index: 260;
  width: min(440px, calc(100vw - 48px));
  padding: 12px 14px; gap: 12px;
  border-radius: var(--r);

  /* Glass: translucent over the gallery with the work blurred behind it, so the bar reads as
     floating above the page rather than as a panel cut into it. The solid colour below is the
     fallback — without backdrop-filter a translucent bar is just a washed-out one. */
  background: var(--gv-surface, oklch(0.17 0.005 260));
  border: 1px solid oklch(from var(--gv-text, var(--text)) l c h / 0.16);
  box-shadow: 0 16px 50px oklch(0 0 0 / 0.42),
              inset 0 1px 0 oklch(from var(--gv-text, var(--text)) l c h / 0.10);

  opacity: 0; transform: translateY(12px);
  transition: opacity 0.25s var(--ease), transform 0.25s var(--ease);
}
@supports ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
  .jj-mini {
    background: oklch(from var(--gv-surface, oklch(0.17 0.005 260)) l c h / 0.55);
    -webkit-backdrop-filter: blur(24px) saturate(1.7);
            backdrop-filter: blur(24px) saturate(1.7);
  }
}
.jj-mini.is-in { opacity: 1; transform: translateY(0); }

/* `.jj-ap { display: flex }` is a class selector, so it beats the browser's own `[hidden]` rule
   and the mini player stayed laid out after being closed — invisible at opacity 0, but still
   running backdrop-filter, which blurred and darkened a strip along the bottom of the page.
   That was the shadow with nothing casting it. Hiding has to be said explicitly here. */
.jj-mini[hidden], .jj-ap[hidden], .jj-exp[hidden] { display: none !important; }
.jj-mini-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 0; }
.jj-mini-name {
  font: 500 0.76rem var(--gv-font, var(--font)); color: var(--gv-text, var(--text));
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.jj-mini .jj-ap-bar { padding: 8px 0 4px; }
.jj-mini .jj-ap-fill, .jj-mini .jj-ap-buffer { top: 8px; }
.jj-mini .jj-ap-time { min-width: 0; text-align: left; font-size: 0.66rem; }
.jj-mini-expand, .jj-mini-close {
  flex: 0 0 auto; width: 24px; height: 24px; padding: 0;
  background: transparent; border: none; cursor: pointer;
  color: var(--gv-dim, var(--dim)); font-size: 0.9rem; line-height: 1;
  transition: color 0.15s var(--ease);
}
.jj-mini-expand:hover, .jj-mini-close:hover { color: var(--gv-text, var(--text)); }
@media (max-width: 560px) {
  .jj-mini { left: 12px; right: 12px; bottom: 12px; width: auto; }
}

/* ── expanded view ─────────────────────────────────────────────────────── */
.jj-exp { position: fixed; inset: 0; z-index: 300; display: flex; align-items: center; justify-content: center; }
.jj-exp-scrim {
  position: absolute; inset: 0; background: oklch(0 0 0 / 0.72);
  opacity: 0; transition: opacity 0.25s var(--ease);
}
.jj-exp.is-in .jj-exp-scrim { opacity: 1; }
.jj-exp-card {
  position: relative; z-index: 1;
  width: min(560px, calc(100vw - 32px));
  display: flex; flex-direction: column; align-items: center; gap: 0;
  padding: 30px 28px 24px;
  background: var(--gv-surface, oklch(0.17 0.005 260));
  border: 1px solid oklch(from var(--gv-dim, var(--dim)) l c h / 0.22);
  border-radius: var(--r);
  opacity: 0; transform: translateY(14px) scale(0.985);
  transition: opacity 0.28s var(--ease), transform 0.28s var(--ease);
}
.jj-exp.is-in .jj-exp-card { opacity: 1; transform: none; }
.jj-exp-close {
  position: absolute; top: 10px; right: 12px;
  width: 30px; height: 30px; padding: 0; background: transparent; border: none;
  color: var(--gv-dim, var(--dim)); font-size: 1.4rem; line-height: 1; cursor: pointer;
}
.jj-exp-close:hover { color: var(--gv-text, var(--text)); }
/* ── album view: cover, title, listing ── */
.jj-exp-top { display: flex; align-items: center; gap: 16px; width: 100%; margin-bottom: 20px; }
.jj-exp-cover {
  flex: 0 0 auto; width: 92px; height: 92px; border-radius: 3px; overflow: hidden;
  display: flex; align-items: center; justify-content: center;
  background: oklch(from var(--gv-dim, var(--dim)) l c h / 0.18);
}
.jj-exp-cover img { width: 100%; height: 100%; object-fit: cover; display: block; }
.jj-exp-glyph { font-size: 1.8rem; color: var(--gv-text, var(--text)); opacity: 0.55; line-height: 1; }
.jj-exp-meta { min-width: 0; text-align: left; }
.jj-exp-name {
  margin: 0; text-align: left;
  font: 600 1.15rem var(--gv-font, var(--font)); color: var(--gv-text, var(--text));
  overflow-wrap: anywhere; line-height: 1.25;
}
.jj-exp-sub {
  margin: 6px 0 0; font: 500 0.74rem var(--gv-font, var(--font));
  color: var(--gv-dim, var(--dim));
}

.jj-exp-list {
  list-style: none; margin: 22px 0 0; padding: 16px 0 0; width: 100%;
  max-height: 40vh; overflow-y: auto;
  border-top: 1px solid oklch(from var(--gv-dim, var(--dim)) l c h / 0.22);
}
.jj-exp-track {
  display: grid; grid-template-columns: 22px 1fr auto; align-items: center; gap: 12px;
  padding: 9px 8px; border-radius: 3px; cursor: pointer;
  color: var(--gv-text, var(--text));
  transition: background 0.12s var(--ease);
}
.jj-exp-track:hover { background: oklch(from var(--gv-text, var(--text)) l c h / 0.07); }
.jj-exp-n  { font: 500 0.72rem var(--gv-font, var(--font)); color: var(--gv-dim, var(--dim)); text-align: right; }
.jj-exp-tname {
  font: 500 0.84rem var(--gv-font, var(--font));
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.jj-exp-tt {
  font: 500 0.72rem var(--gv-font, var(--font)); color: var(--gv-dim, var(--dim));
  font-variant-numeric: tabular-nums;
}
/* The line currently playing. A left rule rather than a fill, so it marks the row without
   turning it into a button that looks pressed. */
.jj-exp-track.is-current {
  background: oklch(from var(--gv-text, var(--text)) l c h / 0.10);
  box-shadow: inset 2px 0 0 var(--gv-text, var(--text));
}
.jj-exp-track.is-current .jj-exp-n { color: var(--gv-text, var(--text)); }

/* Without a listing the card is just a player and should not carry an empty rule. */
.jj-exp:not(.has-list) .jj-exp-list { display: none; }

/* A tile that has a listing says so — otherwise there is nothing to suggest tapping it opens
   anything at all. */
.gv-audio-parts {
  font: 500 0.68rem var(--gv-font, var(--font)); color: var(--gv-dim, var(--dim));
  margin-top: -4px;
}
.jj-exp-bar { width: 100%; flex: none; }
.jj-exp-bar::before { height: 8px; }
.jj-exp-bar .jj-ap-fill, .jj-exp-bar .jj-ap-buffer { height: 8px; }
.jj-exp-bar .jj-ap-thumb { height: 20px; width: 3px; }
.jj-exp-row { display: flex; align-items: center; gap: 18px; margin-top: 14px; }
.jj-exp-play { width: 52px; height: 52px; font-size: 0.95rem; border-radius: 4px; }
.jj-exp-skip {
  background: transparent; border: 1px solid oklch(from var(--gv-dim, var(--dim)) l c h / 0.3);
  border-radius: 3px; color: var(--gv-dim, var(--dim));
  font: 500 0.7rem var(--gv-font, var(--font)); padding: 7px 10px; cursor: pointer;
  transition: color 0.15s var(--ease), border-color 0.15s var(--ease);
}
.jj-exp-skip:hover { color: var(--gv-text, var(--text)); border-color: oklch(from var(--gv-text, var(--text)) l c h / 0.45); }
.jj-exp-time { margin-top: 14px; min-width: 0; text-align: center; font-size: 0.76rem; }
/* Matches the lightbox's lock: position:fixed is the part iOS actually honours, and the body's
   top offset is what stops the page jumping to the top when it closes. */
body.jj-exp-open { overflow: hidden; position: fixed; width: 100%; }

@media (prefers-reduced-motion: reduce) {
  .jj-mini, .jj-exp-card, .jj-exp-scrim { transition: none; }
}

/* .gv-item zeroes line-height and font-size so image tiles have no stray leading. An audio tile
   is actual text and a control, so it has to put both back. */
.gv-audio { line-height: 1.35; font-size: 1rem; }

/* The download bar is absolutely positioned over the bottom of a photo and fades in on hover.
   Over an audio tile that lands squarely on the transport — the scrubber and the Download button
   competing for the same pixels. On audio it joins the flow underneath instead, and stays put:
   there is no photo to keep unobstructed, so nothing is gained by hiding it. */
.gv-audio-item .gv-item-bar {
  position: static; opacity: 1; pointer-events: auto;
  justify-content: flex-start; padding: 0 16px 16px;
  background: var(--gv-surface, oklch(0.17 0.005 260));
  border: 1px solid oklch(from var(--gv-dim, var(--dim)) l c h / 0.22); border-top: none;
  border-radius: 0 0 var(--r) var(--r); margin-top: -1px;
}
.gv-audio { border-radius: var(--r) var(--r) 0 0; border-bottom: none; }


/* ── admin: rename a file in place ─────────────────────────────────────── */
.detail-name-row {
  display: grid; grid-template-columns: auto 1fr; align-items: center;
  gap: 8px 10px; padding: 8px 0;
}
.detail-name-input {
  grid-column: 2; width: 100%; padding: 7px 9px;
  background: var(--bg); color: var(--text);
  border: 1px solid var(--border); border-radius: var(--r);
  font: 500 0.78rem var(--font);
}
.detail-name-input:focus { outline: none; border-color: var(--accent); }
.detail-name-note {
  grid-column: 2; font: 500 0.68rem var(--font); color: var(--dim); min-height: 0.9em;
}


/* ── admin: track listing editor ────────────────────────────────────────── */
.detail-markers { margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--border); }
.detail-markers-head {
  display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 10px;
}
.marker-here-btn, .marker-add-btn {
  background: transparent; color: var(--dim);
  border: 1px solid var(--border); border-radius: var(--r);
  font: 500 0.7rem var(--font); padding: 6px 10px; cursor: pointer;
  transition: color 0.15s var(--ease), border-color 0.15s var(--ease);
}
.marker-here-btn:hover, .marker-add-btn:hover { color: var(--text); border-color: var(--text); }
.marker-add-btn { width: 100%; margin-top: 8px; }

.marker-rows { display: flex; flex-direction: column; gap: 6px; }
.marker-row { display: grid; grid-template-columns: 62px 1fr 26px; gap: 6px; align-items: center; }
.marker-row input {
  padding: 6px 8px; background: var(--bg); color: var(--text);
  border: 1px solid var(--border); border-radius: var(--r);
  font: 500 0.74rem var(--font); min-width: 0;
}
.marker-row input:focus { outline: none; border-color: var(--text); }
.marker-time { font-variant-numeric: tabular-nums; text-align: center; }
.marker-del {
  background: transparent; border: none; color: var(--dim);
  font-size: 1rem; line-height: 1; cursor: pointer; padding: 4px;
}
.marker-del:hover { color: var(--danger); }
.marker-note { margin: 8px 0 0; font: 500 0.68rem var(--font); color: var(--dim); min-height: 0.9em; }
.marker-note.is-error { color: var(--danger); }


/* ── zoomed landscape on a phone ─────────────────────────────────────────
   The photo was never the problem. Zoom is a scale(2.5) transform, and .gv-lb-wrap is the window
   you look through — which sized itself to the UNZOOMED picture. On a portrait phone a landscape
   frame made that window 375x250, so zooming showed a letterbox slot on an 812px screen with
   black above and below it.

   Widening the window to the space actually available means the scaled image's full height fits
   inside it, and panning is left to the one axis a landscape frame has more of. The image rules
   are deliberately untouched: at 2.5x a 375x250 frame becomes 938x625, which already fits the
   taller window — enlarging the picture as well would only overshoot it. */
@media (max-width: 768px) {
  .gallery-viewer .gv-lb-wrap.is-zoomed {
    width: 100vw;
    height: calc(100dvh - 150px);
    max-width: none; max-height: none;
  }
}
@media (max-width: 900px) and (orientation: landscape) {
  .gallery-viewer .gv-lb-wrap.is-zoomed { height: calc(100dvh - 96px); }
}


/* ── inline parts accordion, on the tile ─────────────────────────────────
   Folds the listing open where it sits, so glancing at three part names does not cost the whole
   album view. */
.gv-parts-toggle {
  display: inline-flex; align-items: center; gap: 6px; align-self: flex-start;
  margin-top: 2px; padding: 4px 8px 4px 4px;
  background: transparent; border: none; border-radius: 3px; cursor: pointer;
  font: 500 0.7rem var(--gv-font, var(--font)); color: var(--gv-dim, var(--dim));
  transition: color 0.15s var(--ease);
}
.gv-parts-toggle:hover { color: var(--gv-text, var(--text)); }
.gv-parts-chev { display: inline-block; transition: transform 0.18s var(--ease); font-size: 0.8em; }
.gv-parts-toggle.is-open .gv-parts-chev { transform: rotate(180deg); }

.gv-parts { list-style: none; margin: 4px 0 0; padding: 0; }
.gv-part {
  display: grid; grid-template-columns: 18px 1fr auto; align-items: center; gap: 9px;
  padding: 6px 6px; border-radius: 3px; cursor: pointer;
  color: var(--gv-text, var(--text));
  transition: background 0.12s var(--ease);
}
.gv-part:hover { background: oklch(from var(--gv-text, var(--text)) l c h / 0.07); }
.gv-part.is-current { background: oklch(from var(--gv-text, var(--text)) l c h / 0.10); }
.gv-part-n { font: 500 0.66rem var(--gv-font, var(--font)); color: var(--gv-dim, var(--dim)); text-align: right; }
.gv-part-name {
  font: 500 0.76rem var(--gv-font, var(--font));
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.gv-part-t {
  font: 500 0.66rem var(--gv-font, var(--font)); color: var(--gv-dim, var(--dim));
  font-variant-numeric: tabular-nums;
}

/* ── "Back to gallery" ───────────────────────────────────────────────────
   It was pinned bottom-left on a phone, which is exactly where the mini player now sits — the
   one control that gets you out of a version group was underneath the one that plays music.
   Top-left on mobile, and on desktop the header it lives in sticks rather than scrolling away,
   so the way out is reachable at both sizes without ever moving. */
@media (min-width: 769px) {
  .gallery-viewer .gv-versions-header {
    position: sticky; top: 0; z-index: 40;
    background: var(--gv-bg, oklch(0.1 0.005 260));
  }
}
@media (max-width: 768px) {
  .gallery-viewer .gv-versions-back {
    top: calc(10px + env(safe-area-inset-top));
    bottom: auto; left: 12px;
  }
  /* The panel was padded at the foot to clear the old position; it needs the room at the head. */
  .gallery-viewer .gv-versions { padding-top: 62px; padding-bottom: 28px; }
}

/* ── audio tile: Download to the corner on a phone ───────────────────────
   Underneath the transport it pushed every tile taller, and on a narrow screen that meant one
   track filling the view. In the corner it costs no height at all. */
@media (max-width: 560px) {
  .gv-audio-item .gv-item-bar {
    position: absolute; top: 10px; right: 10px; bottom: auto; left: auto;
    width: auto; margin: 0; padding: 0;
    background: none; border: none; border-radius: 0;
  }
  .gv-audio {
    border-radius: var(--r);
    border-bottom: 1px solid oklch(from var(--gv-dim, var(--dim)) l c h / 0.22);
  }
  /* Keep the title clear of the button rather than letting it slide underneath. */
  .gv-audio-name { padding-right: 108px; }
}

/* ── site gallery: upload queue ──────────────────────────────────────────
   A row per photo with its own bar. Uploading used to be a single line of text that said
   "Uploading…" and then nothing for a minute, which is indistinguishable from a hang. */
.sg-queue { display: flex; flex-direction: column; gap: 6px; margin: 16px 0 4px; }
.sgq-row {
  display: grid; grid-template-columns: 90px 62px 1fr 84px; align-items: center; gap: 12px;
  padding: 9px 12px; border-radius: var(--r);
  background: var(--surface); border: 1px solid var(--border);
  font: 500 0.72rem var(--font); color: var(--text);
  transition: border-color 0.2s var(--ease), opacity 0.3s var(--ease);
}
.sgq-size { color: var(--dim); font-variant-numeric: tabular-nums; }
.sgq-bar { height: 5px; border-radius: 2px; background: oklch(from var(--dim) l c h / 0.25); overflow: hidden; }
.sgq-fill {
  height: 100%; width: 0%; background: var(--accent);
  transition: width 0.18s linear;
}
.sgq-pct {
  text-align: right; color: var(--dim); font-variant-numeric: tabular-nums;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.sgq-row.is-done { border-color: oklch(from var(--ok) l c h / 0.45); }
.sgq-row.is-done .sgq-pct { color: var(--ok); }
.sgq-row.is-done .sgq-fill { background: var(--ok); }
.sgq-row.is-err { border-color: oklch(from var(--danger) l c h / 0.5); }
.sgq-row.is-err .sgq-pct { color: var(--danger); }
.sgq-row.is-err .sgq-fill { background: var(--danger); }
@media (max-width: 620px) {
  .sgq-row { grid-template-columns: 1fr 70px; row-gap: 6px; }
  .sgq-bar { grid-column: 1 / -1; }
}
