:root {
  --bg: #fafafa;
  --surface: #ffffff;
  --border: #e4e4e4;
  --ink: #191919;
  --ink-soft: #7a7a7a;
  --ink-faint: #ababab;
  --accent: #2f5d50;
  --warn: #a8452f;
  --radius: 6px;
}

* { box-sizing: border-box; }

html, body {
  margin: 0;
  padding: 0;
  background: var(--bg);
  color: var(--ink);
  font-family: 'Inter', system-ui, sans-serif;
  -webkit-font-smoothing: antialiased;
}

body {
  min-height: 100dvh;
  display: flex;
  flex-direction: column;
  padding-bottom: env(safe-area-inset-bottom);
}

a { color: inherit; text-decoration: none; }
button, input, textarea { font-family: inherit; }
p { color: var(--ink-soft); line-height: 1.5; }

/* --- Top bar --- */
.topbar {
  padding: 18px 20px;
  border-bottom: 1px solid var(--border);
  background: var(--bg);
  position: sticky;
  top: 0;
  z-index: 10;
}
.brand {
  font-weight: 700;
  font-size: 16px;
  letter-spacing: -0.01em;
}

/* --- App shell --- */
.app {
  flex: 1;
  width: 100%;
  max-width: 640px;
  margin: 0 auto;
  padding: 20px 16px 100px;
}

/* --- Tab bar --- */
.tabbar {
  position: fixed;
  bottom: 0;
  left: 0;
  right: 0;
  display: flex;
  background: var(--surface);
  border-top: 1px solid var(--border);
  padding-bottom: env(safe-area-inset-bottom);
  z-index: 20;
}
.tabbar a {
  flex: 1;
  text-align: center;
  padding: 14px 0 12px;
  font-size: 12.5px;
  color: var(--ink-soft);
  border-top: 2px solid transparent;
  margin-top: -1px;
}
.tabbar a.active { color: var(--ink); border-top-color: var(--ink); font-weight: 600; }

/* --- Search bar --- */
.search-bar {
  display: flex;
  gap: 8px;
  margin-bottom: 20px;
}
.search-bar input {
  flex: 1;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 11px 12px;
  font-size: 16px;
  background: var(--surface);
  color: var(--ink);
}
.search-bar input:focus { outline: none; border-color: var(--ink); }

.btn {
  border: 1px solid var(--ink);
  background: var(--ink);
  color: var(--surface);
  border-radius: var(--radius);
  padding: 10px 15px;
  font-weight: 600;
  font-size: 13.5px;
  cursor: pointer;
}
.btn:active { opacity: 0.85; }
.btn.secondary { background: var(--surface); color: var(--ink); border-color: var(--border); }
.btn.subtle { background: transparent; color: var(--ink-soft); border-color: var(--border); font-weight: 500; }
.btn.warn { background: var(--surface); color: var(--warn); border-color: var(--border); }
.btn:disabled { opacity: 0.45; cursor: default; }
.btn:focus-visible, a:focus-visible, input:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* --- Section heading --- */
.section-title {
  font-family: 'IBM Plex Mono', monospace;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  font-size: 11px;
  color: var(--ink-faint);
  margin: 26px 0 10px;
}
.section-title:first-child { margin-top: 0; }

.box-name {
  font-size: 15.5px;
  font-weight: 600;
  color: var(--ink);
  margin: 0 0 3px;
}

/* --- Empty state --- */
.empty {
  text-align: center;
  padding: 44px 16px;
  color: var(--ink-soft);
}
.empty .empty-title {
  font-weight: 700;
  font-size: 15px;
  color: var(--ink);
  margin-bottom: 6px;
}
.empty .btn { margin-top: 14px; }

/* --- Item list: one row per item, photo on the left --- */
.photo-grid {
  display: flex;
  flex-direction: column;
  margin-top: 10px;
}
.photo-tile {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 9px 2px;
  border-bottom: 1px solid var(--border);
}
.photo-tile:first-child { border-top: 1px solid var(--border); }
/* Author display rules always beat the UA's [hidden] { display: none },
   regardless of selector order — without this, setting .hidden = true on a
   tile (e.g. to hide a non-matching item while search-filtering) has no
   visible effect. */
.photo-tile[hidden] { display: none; }
.item-list {
  flex: 1;
  min-width: 0;
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: 4px;
}
.item-row {
  display: flex;
  align-items: center;
  gap: 4px;
}
.item-label {
  min-width: 0;
  font-size: 14px;
  color: var(--ink);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.item-wrapper { padding: 3px 5px; margin: -3px -5px; border-radius: var(--radius); }
.item-wrapper.borrowed { background: var(--border); }
.item-row-spacer { flex: 1 1 auto; }
.item-actions { display: flex; align-items: center; flex: 0 0 auto; gap: 1px; }
.item-toggle.is-borrowed { color: var(--accent); }
/* Always visible under the name when the item has aliases — aliases are
   stored only for search, so this is the one place to see the current
   list, touch devices included (there's no hover there). Same treatment
   as a location's summary line: small, --ink-soft, truncated until
   tapped to expand. */
.item-aliases-line {
  font-size: 12px;
  color: var(--ink-soft);
  padding: 0 0 4px;
  cursor: pointer;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.item-aliases-line.expanded { white-space: normal; word-break: break-word; }

.item-edit-panel {
  padding: 6px 0;
  border-top: 1px dashed var(--border);
  margin-top: 2px;
}
.item-edit-panel .field-inline {
  display: flex;
  gap: 6px;
  margin-bottom: 6px;
}
.item-edit-panel input {
  flex: 1;
  min-width: 0;
  border: 1px solid var(--border);
  border-radius: 4px;
  padding: 5px 7px;
  font-size: 11.5px;
  background: var(--surface);
  color: var(--ink);
}
.item-edit-panel input:focus { outline: none; border-color: var(--ink); }
.item-aliases-current {
  font-size: 10.5px;
  color: var(--ink-faint);
  margin-bottom: 6px;
  line-height: 1.4;
}
.btn-mini {
  border: 1px solid var(--border);
  background: var(--surface);
  color: var(--ink);
  border-radius: 4px;
  padding: 5px 9px;
  font-size: 10.5px;
  font-weight: 600;
  cursor: pointer;
  flex: 0 0 auto;
}
.btn-mini.done { background: var(--ink); color: var(--surface); border-color: var(--ink); }
.btn-mini:disabled { opacity: 0.5; cursor: default; }

.box-suggestion {
  font-size: 10.5px;
  color: var(--ink-soft);
  padding: 0 0 6px;
  line-height: 1.4;
}
.box-suggestion a { color: var(--accent); font-weight: 600; }

/* --- Upload --- */
.upload-row {
  display: flex;
  gap: 8px;
  margin-top: 16px;
  flex-wrap: wrap;
}
.upload-row input[type="file"] { display: none; }
.upload-status {
  font-family: 'IBM Plex Mono', monospace;
  font-size: 12px;
  color: var(--accent);
  margin-top: 10px;
}

/* --- Form --- */
.field { margin-bottom: 14px; }
.field label {
  display: block;
  font-family: 'IBM Plex Mono', monospace;
  font-size: 11px;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--ink-faint);
  margin-bottom: 6px;
}
.field input {
  width: 100%;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 11px 12px;
  font-size: 16px;
  background: var(--surface);
  color: var(--ink);
}
.field input:focus { outline: none; border-color: var(--ink); }
.form-actions { display: flex; gap: 8px; }

/* --- Search results --- */
.result-group { margin-bottom: 20px; }
.result-group-head {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  margin-bottom: 8px;
}
.result-group-head .box-name { font-size: 14.5px; }
.match-thumbs {
  display: flex;
  gap: 8px;
  overflow-x: auto;
  padding-bottom: 2px;
}
.match-thumb { flex: 0 0 auto; width: 92px; }
.match-thumb img {
  width: 92px;
  height: 92px;
  object-fit: contain;
  background: var(--border);
  border-radius: var(--radius);
  border: 1px solid var(--border);
  display: block;
}
.match-thumb .label {
  font-size: 10.5px;
  color: var(--ink-soft);
  margin-top: 4px;
  line-height: 1.3;
}
.match-thumb .label.borrowed { color: var(--accent); }

/* --- Borrowed list --- */
.borrowed-row {
  display: flex;
  gap: 12px;
  padding: 12px 2px;
  border-bottom: 1px solid var(--border);
  align-items: center;
}
.borrowed-row:first-child { border-top: 1px solid var(--border); }
.borrowed-thumb {
  width: 52px;
  height: 52px;
  border-radius: var(--radius);
  object-fit: contain;
  background: var(--border);
  border: 1px solid var(--border);
  flex: 0 0 auto;
}
.borrowed-info { flex: 1; min-width: 0; }
.borrowed-label { font-weight: 600; font-size: 14px; }
.borrowed-meta { font-size: 12px; color: var(--ink-soft); margin-top: 2px; }
.borrowed-meta a { color: var(--accent); }

.loading { color: var(--ink-soft); font-family: 'IBM Plex Mono', monospace; font-size: 13px; padding: 20px 0; }
.error-msg { color: var(--warn); font-family: 'IBM Plex Mono', monospace; font-size: 13px; margin-top: 10px; }
.hint { color: var(--ink-faint); font-size: 12px; margin: 4px 0; }

.item-move-panel {
  border: 1px dashed var(--border);
  border-radius: var(--radius);
  padding: 10px;
  margin-bottom: 12px;
}
.item-move-panel input {
  width: 100%;
  border: 1px solid var(--border);
  border-radius: 4px;
  padding: 8px 10px;
  font-size: 14px;
  background: var(--surface);
  color: var(--ink);
  margin-bottom: 8px;
}
.item-move-panel input:focus { outline: none; border-color: var(--ink); }
.box-filter-row {
  text-align: left;
  border: 1px solid var(--border);
  background: var(--surface);
  border-radius: 4px;
  padding: 8px 10px;
  font-size: 13.5px;
  cursor: pointer;
}
.box-filter-row:hover { border-color: var(--ink); }
.box-filter-row-location { color: var(--ink-faint); font-size: 11.5px; }

/* --- Browse (tree) --- */
/* Each location is its own rounded card, nested inside its parent's —
   the card boundary itself shows nesting now, in place of the old
   left-line indent (see .browse-children below). Every level gets the
   same treatment, however deep; box-shadow gives each one a visible
   edge against its parent even though both share the same background. */
.browse-group {
  background: var(--surface);
  border: 1px solid rgba(0, 0, 0, 0.14);
  border-radius: 12px;
  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.10), 0 1px 2px rgba(0, 0, 0, 0.06);
}
/* A nested card sits on top of its parent's own white background — a
   faint off-white fill (on top of the border+shadow above) keeps a
   nested card visible as its own surface, not just an outline. */
.browse-children .browse-group { background: var(--bg); }
.browse-group-row {
  display: flex;
  align-items: center;
  padding: 0 6px;
}
/* flex: 0 1 auto (not flex: 1) — this element sizes to its own content
   (chevron + photo + title) and only shrinks under pressure, so the
   header doesn't stretch out past the count/actions at the row's end.
   .browse-group-spacer below is what actually absorbs the row's leftover
   space. It's a div, not a button — a real <button> can't legally contain
   the rename/regenerate buttons nested inside .browse-group-title below,
   so role="button" + tabindex + the keydown handler in browse.js keep it
   keyboard-operable instead. */
.browse-group-head {
  flex: 0 1 auto;
  min-width: 0;
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 10px 4px;
  background: none;
  border: none;
  border-radius: 5px;
  cursor: pointer;
  text-align: left;
}
/* No hover/press background — it's still clickable (cursor: pointer,
   above), but this area shouldn't visually change state on its own; the
   row's actual expand/collapse state is what the chevron shows. */
/* The chevron is rotation-only — collapsed points right, expanded points
   down — driven off aria-expanded so the icon and the actual state can
   never drift apart. */
.browse-toggle {
  flex: 0 0 auto;
  width: 18px;
  height: 18px;
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--ink-faint);
  transition: transform 0.15s ease;
}
.browse-toggle svg { width: 11px; height: 11px; display: block; }
.browse-group-head[aria-expanded="true"] .browse-toggle { transform: rotate(90deg); color: var(--ink-soft); }
/* Matches item-photo size (.browse-group-body .match-thumb img below) —
   the two now read as the same kind of thing at a glance. */
.browse-loc-photo {
  flex: 0 0 auto;
  width: 52px;
  height: 52px;
  border-radius: var(--radius);
  object-fit: contain;
  background: var(--border);
  border: 1px solid var(--border);
}
.browse-loc-photo-wrap { position: relative; flex: 0 0 auto; }
/* Shown instead of a photo when a location has none yet — nothing to
   peek, so this opens the file picker directly (data-action="add-photo",
   same as the wrap's own .photo-replace-btn below). */
.browse-loc-photo-placeholder {
  flex: 0 0 auto;
  width: 52px;
  height: 52px;
  display: flex;
  align-items: center;
  justify-content: center;
  border: 1px dashed var(--border);
  border-radius: var(--radius);
  background: none;
  color: var(--ink-faint);
  cursor: pointer;
}
.browse-loc-photo-placeholder:hover { color: var(--ink); border-color: var(--ink-faint); }
.browse-loc-photo-placeholder svg { width: 18px; height: 18px; display: block; }
/* Only shown once its photo is peeked (.img-peek) — replaces the old
   always-visible row-level "change photo" buttons for both items and
   locations. z-index above .img-peek's own (5) so it stays on top of the
   enlarged photo. */
.photo-replace-btn {
  display: none;
  position: absolute;
  bottom: 2px;
  right: 2px;
  width: 22px;
  height: 22px;
  align-items: center;
  justify-content: center;
  border: none;
  border-radius: 50%;
  background: var(--ink);
  color: var(--surface);
  cursor: pointer;
  z-index: 6;
}
.photo-replace-btn svg { width: 12px; height: 12px; display: block; }
.browse-loc-photo-wrap:has(.img-peek) .photo-replace-btn,
.match-thumb:has(.img-peek) .photo-replace-btn {
  display: flex;
}
/* The name/summary stack beside the chevron and photo, like an avatar next
   to a two-line title. min-width: 0 lets it shrink and ellipsis under
   pressure instead of blowing out the row width. */
.browse-group-title {
  flex: 1 1 auto;
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: 2px;
}
.browse-group-name-line {
  display: flex;
  align-items: center;
  gap: 6px;
}
.browse-group-name {
  flex: 0 1 auto;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-size: 15px;
  font-weight: 600;
  color: var(--ink);
}
.browse-group-spacer { flex: 1 1 auto; }
.browse-group-count {
  font-family: 'IBM Plex Mono', monospace;
  font-weight: 400;
  font-size: 11px;
  color: var(--ink-faint);
  text-align: right;
  white-space: nowrap;
  flex: 0 0 auto;
}
/* --- Browse: per-location action icons, to the right of the name --- */
.browse-group-actions {
  display: flex;
  align-items: center;
  flex: 0 0 auto;
  gap: 1px;
  padding-right: 2px;
}
.row-icon-btn {
  flex: 0 0 auto;
  width: 26px;
  height: 26px;
  display: flex;
  align-items: center;
  justify-content: center;
  border: none;
  background: none;
  border-radius: 5px;
  color: var(--ink-faint);
  cursor: pointer;
}
.row-icon-btn svg { width: 14px; height: 14px; display: block; }
.row-icon-btn:hover { background: var(--surface); color: var(--ink); }
.row-icon-btn:active { background: var(--border); }
.row-icon-danger:hover { color: var(--warn); }

/* overflow-anchor: none — a location's body goes from empty/tiny to its
   full height (or back) the first time it's opened or closed, and the
   browser's scroll anchoring can overcorrect for that the first time it
   sees this region resize, causing a brief upward jump. Opting the region
   out of anchor selection avoids that without touching scroll behavior
   anywhere else on the page. */
/* display: flex column + gap spaces out whichever of the add-buttons
   row, photo-grid, and browse-children happen to be present, uniformly
   — no per-child margin bookkeeping needed. */
.browse-group-body {
  display: flex;
  flex-direction: column;
  gap: 8px;
  padding: 8px 10px 10px;
  overflow-anchor: none;
}
/* Author display rules always beat the UA's [hidden] { display: none },
   regardless of selector order — same reasoning as .photo-tile[hidden]
   below. Without this, closing a location (bodyEl.hidden = true) had no
   visible effect once this element got its own display: flex. */
.browse-group-body[hidden] { display: none; }
.browse-group-body > .photo-grid { margin: 0; }
/* Nested location cards, one per child — the card boundary itself now
   shows nesting (see .browse-group above), so this is just a spacing
   wrapper, not a rail. */
.browse-children {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

/* --- Browse: inline rename / add-sub-location forms (replace the row's
   own content in place — no navigating to a separate page for either) --- */
.inline-rename-form,
.inline-sublocation-form {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 6px;
  padding: 8px 4px;
}
.inline-rename-input,
.inline-sublocation-input {
  flex: 1;
  min-width: 0;
  border: 1px solid var(--border);
  border-radius: 4px;
  padding: 7px 9px;
  font-size: 13.5px;
  background: var(--surface);
  color: var(--ink);
}
.inline-rename-input:focus,
.inline-sublocation-input:focus { outline: none; border-color: var(--ink); }
.inline-rename-form .error-msg,
.inline-sublocation-form .error-msg { flex-basis: 100%; margin: 0; }

/* --- Browse: drag a whole item or location row onto another row to
   reassign it (interact.js). Scoped to tree thumbnails only (not search
   results, which have no drop targets to drag onto) via the
   .browse-group-body ancestor. --- */
.browse-group-body .match-thumb {
  flex: 0 0 auto;
  /* Explicit size (not width: auto) — a cropped photo's <img> is
     positioned absolutely (see applyCrop in browse.js), taking it out of
     normal flow, so the wrapper needs its own size to collapse into
     rather than sizing to the now-absent in-flow content. */
  width: 52px;
  height: 52px;
  position: relative;
  overflow: hidden;
  /* A non-square crop is letterboxed (see applyCrop), not cropped
     further to fill the tile — this shows through the letterbox bars. */
  background: var(--border);
}
.browse-group-body .match-thumb img {
  width: 52px;
  height: 52px;
  flex: 0 0 auto;
  border-radius: var(--radius);
  object-fit: contain;
  background: var(--border);
  display: block;
}
/* The drag source is the whole row now (see browse.js's
   interact('.photo-tile') / interact('.browse-group-row') calls), armed
   only after a hold — so touch-action: pan-y (not none) leaves ordinary
   vertical scrolling free right up until a hold actually arms a drag,
   instead of capturing every touch on a row. */
.photo-tile,
.browse-group-row {
  -webkit-touch-callout: none;
  -webkit-user-select: none;
  user-select: none;
  touch-action: pan-y;
}
.photo-tile.drag-pending { opacity: 0.5; pointer-events: none; }
/* interact.js moves the dragged element itself (via a CSS transform, reset
   on drop) rather than a separate cloned ghost node. box-shadow, not
   filter: drop-shadow — Safari fails to invalidate the previous frame for
   an element that has both an active filter and a transform rewritten on
   every pointermove, leaving visible ghost trails behind as it moves;
   box-shadow doesn't go through that filter/compositing path.
   will-change promotes the element to its own layer up front, which
   Safari in particular needs to track a fast-changing transform cleanly. */
.being-dragged {
  position: relative;
  z-index: 1000;
  opacity: 0.92;
  cursor: grabbing;
  box-shadow: 0 8px 16px rgba(0, 0, 0, 0.28);
  will-change: transform;
}
/* The whole row highlights, not just the title area — this is also the
   whole hit-region interact.js's dropzone accepts a drop on (see
   browse.js's interact('.browse-group-row, .browse-root-drop') call), so
   what lights up always matches what you can actually release on. A drop
   back onto an item's current location, or a location's current parent,
   still counts as a legal (if no-op) target — see invalidDropTargetIds
   in browse.js — so there's only ever this one highlight, no separate
   "invalid" color. */
.browse-group-row.drop-target,
.browse-root-drop.drop-target {
  background: rgba(47, 93, 80, 0.12);
  outline: 2px solid var(--accent);
  outline-offset: -2px;
}
body.drag-active { touch-action: none; overscroll-behavior: none; }
.drag-error { margin-bottom: 10px; }

/* --- Browse: root pseudo-node (items/locations with no parent) --- */
.browse-root { margin-bottom: 4px; }
/* Present but visually near-nothing until a drag is underway, so it can
   still catch drops without cluttering the page the rest of the time. */
.browse-root-drop {
  height: 0;
  border-radius: var(--radius);
  overflow: hidden;
  transition: height 0.15s ease;
}
body.drag-active .browse-root-drop {
  height: 40px;
  margin-bottom: 6px;
  border: 1px dashed var(--border);
  display: flex;
  align-items: center;
  justify-content: center;
  font-family: 'IBM Plex Mono', monospace;
  font-size: 10.5px;
  color: var(--ink-faint);
}
body.drag-active .browse-root-drop::after { content: 'Drop here for the top level'; }
/* Root has no header row of its own (see .browse-group above, which
   wraps a row + body together) — so its card border/shadow lives here
   instead, directly on its body. */
.browse-root-body {
  background: var(--surface);
  border: 1px solid rgba(0, 0, 0, 0.14);
  border-radius: 12px;
  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.10), 0 1px 2px rgba(0, 0, 0, 0.06);
}

/* --- Browse: "+ Add item" / "+ Add sub-location" (and, at the root,
   "Regenerate summaries") — always the first thing in a body now, rather
   than icons in the header. --- */
.browse-add-buttons {
  display: flex;
  gap: 8px;
  flex-wrap: wrap;
}

/* --- Browse: a location's subtree content summary, on its own line below
   the name. Its Regenerate button (present only while a regenerate would
   actually change something) sits right after the text, or alone when
   content_summary is still null. --- */
.browse-group-summary-line {
  display: flex;
  align-items: center;
  gap: 4px;
  min-height: 22px;
}
.browse-group-summary {
  flex: 1 1 auto;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  color: var(--ink-soft);
  font-size: 12px;
  font-weight: 400;
}
.browse-group-summary-line .row-icon-btn {
  width: 22px;
  height: 22px;
}
.browse-group-summary-line .row-icon-btn svg { width: 12px; height: 12px; }
.row-icon-btn.spinning svg { animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }

/* --- Suggest location: ephemeral chip row under an item row --- */
.suggest-panel {
  padding: 6px 0 4px;
  border-top: 1px dashed var(--border);
  margin-top: 2px;
}
.suggest-chip-row { display: flex; flex-wrap: wrap; gap: 6px; }
.suggest-chip {
  border: 1px solid var(--border);
  background: var(--surface);
  color: var(--ink);
  border-radius: 999px;
  padding: 5px 10px;
  font-size: 10.5px;
  cursor: pointer;
}
.suggest-chip:hover { border-color: var(--accent); color: var(--accent); }
.suggest-chip:disabled { opacity: 0.5; cursor: default; }

/* --- Photo zoom: clicking or tapping a photo scales it up in place
   (".img-peek", toggled by dom.js's delegated click listener) — the same
   effect and the same trigger on desktop and touch. --- */
/* -webkit-user-drag: none stops the browser's own native image-drag
   ghost from starting alongside interact.js's pointer-based row drag in
   browse.js — without it, dragging a photo-tile row showed a translucent
   drag-image trail on top of the tile interact.js was already moving. */
.zoomable-img { transition: transform 0.1s ease; cursor: zoom-in; -webkit-user-drag: none; user-drag: none; }
.zoomable-img.img-peek { position: relative; z-index: 5; transform: scale(5); }
/* A photo's own peek-zoom is a second transform animating on top of
   whatever .being-dragged is doing to its parent row — visually noisy on
   its own, and one less moving transform for Safari to track correctly
   during a drag. */
body.drag-active .zoomable-img.img-peek {
  transform: none;
}

/* --- Photo-stack modal: every photo an item has, opened by clicking its
   primary photo in the tree (browse.js's openPhotoStackModal) — the
   first modal/overlay this app has, so nothing here is shared with
   other components. --- */
.photo-stack-backdrop {
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.5);
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 20px;
  z-index: 2000;
}
.photo-stack-panel {
  background: var(--surface);
  border-radius: 12px;
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
  padding: 16px;
  width: 100%;
  max-width: 460px;
  max-height: 80vh;
  overflow-y: auto;
}
.photo-stack-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  margin-bottom: 12px;
}
.photo-stack-title {
  font-size: 15px;
  font-weight: 600;
  color: var(--ink);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.photo-stack-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 8px;
}
/* touch-action: none — interact.js's own drag needs to own touch
   gestures on these tiles instead of the browser treating a drag start
   as a scroll attempt. */
.photo-stack-tile {
  position: relative;
  aspect-ratio: 1;
  border-radius: var(--radius);
  overflow: hidden;
  background: var(--border);
  cursor: grab;
  touch-action: none;
  -webkit-user-select: none;
  user-select: none;
}
.photo-stack-tile img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  -webkit-user-drag: none;
  user-drag: none;
}
.photo-stack-tile.drop-target {
  outline: 2px solid var(--accent);
  outline-offset: -2px;
}
.photo-stack-tile.being-dragged {
  opacity: 0.75;
  z-index: 10;
  cursor: grabbing;
}
.photo-stack-delete-btn {
  position: absolute;
  bottom: 4px;
  right: 4px;
  width: 22px;
  height: 22px;
  display: flex;
  align-items: center;
  justify-content: center;
  border: none;
  border-radius: 50%;
  background: rgba(0, 0, 0, 0.6);
  color: #fff;
  cursor: pointer;
}
.photo-stack-delete-btn svg { width: 12px; height: 12px; display: block; }
.photo-stack-add-tile {
  aspect-ratio: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  border: 1px dashed var(--border);
  border-radius: var(--radius);
  background: none;
  color: var(--ink-faint);
  cursor: pointer;
}
.photo-stack-add-tile:hover { color: var(--ink); border-color: var(--ink-faint); }
.photo-stack-add-tile svg { width: 20px; height: 20px; display: block; }
