:root {
	--bg: #faf8f6;
	--surface: #ffffff;
	--surface-2: #f1ede8;
	--border: #e8e2db;
	--text: #2a2622;
	--text-muted: #766f66;
	--accent: #c1543a;
	--accent-hover: #a8432e;
	--error: #b00020;
	--radius: 10px;
	/* Pastel green "this is a group" signal (lightbox tag chips/suggestions,
	   Type column badge) — darker than the pre-existing #1e5e2e used by
	   .import-summary/the "New" import tag, since at chip/badge sizes that
	   shade read as a bit low-contrast against #e3f4e6. */
	--group-bg: #e3f4e6;
	--group-text: #14532d;
	/* Map view photo placemarks (mapview.js). Blue, not --accent's warm
	   red-orange — that's a poor match against the warm/reddish palettes
	   common to geological map sheets specifically (the actual case that
	   prompted this), and blue is already this app's own "selected/active
	   row" hue elsewhere (see .lightbox-contact-row.selected's
	   rgba(120,170,255,...) tint) — reusing that family keeps one
	   consistent "selected" language across the app rather than
	   introducing a second, unrelated one just for the map. Amber for the
	   highlighted state specifically because it needs to read clearly
	   against BOTH the blue default marker and whatever's underneath on
	   the map — a color close to either would defeat the point. */
	--marker: #2f6fda;
	--marker-selected: #f2a900;

	/* --- Z-index registry ---------------------------------------------
	   Every z-index in this file that's a real, page-wide stacking
	   decision — something that can end up layered against an unrelated
	   component, not just its own fixed neighbor — is named here, in
	   ascending order, and referenced everywhere it applies. Never
	   hardcode a new number for one of these; add a tier here instead.

	   This is what a bare "z-index: 2100" silently drifting out of date
	   cost twice already: the lightbox's own "?" tooltip, then the map
	   view's, both stopped clearing a newer, higher overlay added after
	   the number was chosen (see .hover-tooltip's own git history/
	   comments). A named, centrally-visible scale turns "does this need
	   to be above X" into a question answered at the point a new layer
	   is added, instead of a number picked in isolation that quietly
	   stops being true the next time something higher gets added
	   elsewhere.

	   Deliberately NOT used for the many plain `z-index: 1`/`2`/`5`
	   declarations scattered through component-local rules (e.g. the
	   lightbox toolbar sitting above its own photo, a sticky table
	   header within one scroll region). Those only ever compare against
	   a fixed, always-present sibling inside the same small component —
	   there's no "a new global layer might outrank this" risk for them
	   to guard against, and forcing them through named variables would
	   just be indirection with nothing to show for it. This registry is
	   only for things that can end up stacked against a component they
	   have no direct relationship to. */
	--z-dropdown: 10;          /* .dropdown-menu — small contextual popups (Tools/Download menus); above ordinary page content, but any true overlay below should still cover it */
	--z-download-tray: 1000;   /* .download-tray — NOTE: currently below --z-lightbox/--z-mapview, i.e. hidden while either is open. Untested whether that's actually the wanted behavior for a download still running in the background; revisit if it turns out to matter. */
	--z-lightbox: 2000;        /* .lightbox-overlay */
	--z-lightbox-popup: 2001;  /* .lightbox-menu — its blank-space/contacts-table context menu, floats above the lightbox that opened it */
	--z-modal: 2100;           /* .sort-preview-overlay — general-purpose blocking dialog, currently just the sort-by-filename preview; above --z-lightbox in case a future caller ever opens one from inside it, below --z-mapview */
	--z-mapview: 2200;         /* .mapview-overlay — can itself be opened from inside an already-open lightbox, so sits above --z-lightbox */
	--z-notification: 2250;    /* .toast — confirmed needed above both overlays: lightbox.js's own delete-failure toast fires while the lightbox is open, and would otherwise render invisibly behind it */
	--z-tooltip: 2300;         /* .hover-tooltip — one shared element reused everywhere data-tooltip appears (lightbox, map view, everywhere else), so this must always stay the highest VISUALLY-STACKED tier of all */
	--z-caret-probe: 2400;     /* lightbox.js's captionCaretMirrorEl — invisible (opacity:0), so unlike every other entry here it never actually competes for the user's attention; still needs to be the literal highest z-index regardless, since caretPositionFromPoint/caretRangeFromPoint hit-test by real stacking order, not visibility. Without this the genuinely-on-top, genuinely-visible caption textarea it's probing (elevated by --z-lightbox) always won the hit-test instead of the invisible probe sitting right underneath it — confirmed live, this is what silently broke Ctrl+Up/Down line movement in the first place. */
}

* {
	box-sizing: border-box;
}

body {
	margin: 0;
	background: var(--bg);
	color: var(--text);
	font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
	line-height: 1.5;
}

.container {
	margin: 4rem auto;
	padding: 0 1.5rem;
}

.container-sm { max-width: 420px; }
.container-md { max-width: 640px; }
.container-lg { max-width: 900px; }
/* Import page: filter panel + album tree side by side want more room. */
.container-xl { max-width: 1200px; }

h1 {
	font-size: 1.4rem;
	margin: 0 0 .25rem;
}

h2 {
	font-size: 1.1rem;
	color: var(--text-muted);
	font-weight: 600;
	margin: 0 0 1.5rem;
}

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

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

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

.card {
	background: var(--surface);
	border: 1px solid var(--border);
	border-radius: var(--radius);
	padding: 1.5rem;
}

.error {
	color: var(--error);
	background: #fbe9e9;
	border: 1px solid #f2c6c6;
	border-radius: var(--radius);
	padding: .6rem .9rem;
	margin-bottom: 1rem;
	font-size: .9rem;
}

.upload-status-info {
	color: var(--accent-hover);
	background: var(--surface-2);
	border: 1px solid var(--border);
	border-radius: var(--radius);
	padding: .6rem .9rem;
	margin-bottom: 1rem;
	font-size: .9rem;
}

label {
	display: block;
	font-size: .9rem;
	color: var(--text-muted);
	margin-bottom: 1rem;
}

input[type="text"],
input[type="email"],
input[type="password"],
input[type="number"],
input[type="date"],
input[type="time"],
textarea {
	display: block;
	width: 100%;
	margin-top: .35rem;
	padding: .6rem .7rem;
	font-size: 1rem;
	font-family: inherit;
	color: var(--text);
	background: var(--surface);
	border: 1px solid var(--border);
	border-radius: var(--radius);
}

/* Date + optional-time pair (album manual date-range editing) — date gets
   the bulk of the width, time is inherently shorter content. app.js wraps
   every date/time input in a .date-picker-wrap (to fit its canonical-
   format readout below it without breaking this flex row — the wrapper,
   not the bare input, is the actual flex item once that JS runs). */
.date-time-row {
	display: flex;
	gap: .5rem;
}

.date-time-row input[type="date"],
.date-time-row input[type="time"] {
	margin-top: 0;
}

.date-time-row .date-picker-wrap-date {
	flex: 2;
}

.date-time-row .date-picker-wrap-time {
	flex: 1;
}

.date-picker-wrap {
	display: flex;
	flex-direction: column;
}

/* Always ISO (yyyy-mm-dd / 24h HH:MM) — that's the whole point, so it
   reads as data, not prose. Reserves a line even when empty so the layout
   doesn't jump the moment a value is entered. */
.date-readout {
	display: block;
	min-height: 1em;
	margin-top: .2rem;
	font-family: ui-monospace, "SF Mono", Consolas, monospace;
	font-size: .75rem;
	color: var(--text-muted);
}

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

/* Label-beside-control layout, scoped to opt-in rows (the contact
   add/edit form) rather than changing the global stacked `label` rule
   every other form on the site still relies on. */
.form-row {
	display: flex;
	align-items: center;
	gap: .75rem;
	margin-bottom: .9rem;
}

.form-row > label {
	flex: 0 0 70px;
	margin-bottom: 0;
}

.form-row > input,
.form-row > .group-picker {
	flex: 1;
	min-width: 0;
	margin-top: 0;
}

button,
.btn {
	display: inline-block;
	font-size: 1rem;
	font-family: inherit;
	font-weight: 600;
	padding: .65rem 1.3rem;
	border-radius: var(--radius);
	border: none;
	background: var(--accent);
	color: #fff;
	text-decoration: none;
	cursor: pointer;
}

button:hover,
.btn:hover {
	background: var(--accent-hover);
}

/* .btn is applied to <a> as well as <button> — the global `a:hover` rule
   (element + pseudo-class) outranks the plain-class `.btn` color rule
   above, so without this, hovering an <a class="btn"> turned its text the
   same --accent-hover as the background it's now sitting on, making it
   unreadable. .btn:hover (class + pseudo-class) has enough specificity to
   win back over `a:hover`. */
.btn:hover {
	color: #fff;
}

.btn-link {
	background: none;
	border: none;
	color: var(--accent);
	text-decoration: underline;
	cursor: pointer;
	padding: 0;
	font-weight: normal;
	font-size: .9rem;
}

.btn-link:hover {
	background: none;
	color: var(--accent-hover);
}

nav.top {
	display: flex;
	gap: 1rem;
	align-items: center;
	margin-bottom: 1.5rem;
	font-size: .9rem;
}

/* Deliberately *not* inside nav.top — that's exactly the spot every other
   page's "back" link sits, and sign-out is a different, one-way kind of
   action that shouldn't share a click target with muscle-memory "go
   back" clicks. Fixed to the corner so it stays out of that flow (and
   stays reachable) regardless of scroll position. */
.sign-out-link {
	position: fixed;
	top: 1rem;
	right: 1.5rem;
	padding: .4rem .8rem;
	border-radius: 999px;
	background: var(--surface);
	border: 1px solid var(--border);
	color: var(--text-muted);
	text-decoration: none;
	font-size: .85rem;
	box-shadow: 0 2px 8px rgba(0, 0, 0, .08);
	z-index: 10;
}

.sign-out-link:hover {
	color: var(--accent-hover);
	border-color: var(--accent-hover);
}

/* --- Admin nav bar (admin_base.html) ------------------------------------ */
/* Deliberately distinct from .sign-out-link above, not reused — an admin
   session and a member session are different kinds of thing to be signed
   into, and this should never read as "the same control" as the member
   one, in either position or appearance. Sits inline in the nav row itself
   (not fixed-corner) and is explicitly labeled "Admin sign-out", not just
   "Sign out". */
.admin-nav {
	flex-wrap: wrap;
}

.admin-nav-link.admin-nav-active {
	font-weight: 600;
	color: var(--text);
	text-decoration: none;
	cursor: default;
}

.admin-signout-btn {
	margin-left: auto;
	padding: .3rem .8rem;
	border-radius: 999px;
	background: var(--surface);
	border: 1px solid var(--border);
	color: var(--text-muted);
	text-decoration: none;
	font-size: .8rem;
}

.admin-signout-btn:hover {
	color: var(--error);
	border-color: var(--error);
}

/* Pill / segmented control — real radios for accessibility, visually
   hidden, with the sibling <span> styled based on :checked state. */
.pill-group {
	display: inline-flex;
	background: var(--surface-2);
	border-radius: 999px;
	padding: 4px;
	gap: 4px;
	margin-bottom: 1.25rem;
}

.pill {
	position: relative;
	margin: 0;
}

.pill input {
	position: absolute;
	opacity: 0;
	width: 100%;
	height: 100%;
	margin: 0;
	cursor: pointer;
}

.pill span {
	display: block;
	padding: .5rem 1.1rem;
	border-radius: 999px;
	font-size: .9rem;
	color: var(--text-muted);
	transition: background .15s, color .15s;
	white-space: nowrap;
}

.pill input:checked + span {
	background: var(--accent);
	color: #fff;
}

.pill input:focus-visible + span {
	outline: 2px solid var(--accent);
	outline-offset: 2px;
}

.album-list {
	list-style: none;
	padding: 0;
}

.album-list li {
	padding: .6rem 0;
	border-bottom: 1px solid var(--border);
}

.album-grid {
	display: grid;
	grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
	gap: 1rem;
	margin: 1rem 0 2rem;
}

.album-card {
	display: block;
	background: var(--surface);
	border: 1px solid var(--border);
	border-radius: var(--radius);
	overflow: hidden;
	color: inherit;
	text-decoration: none;
}

.album-card:hover {
	border-color: var(--accent);
}

/* The gallery's card is a <div> holding two sibling links (the whole-card
   link and the Edit button) — a link can't nest inside another link. */
.album-card {
	position: relative;
}

.album-card-link {
	display: block;
	color: inherit;
	text-decoration: none;
}

.album-card-edit {
	position: absolute;
	top: .4rem;
	right: .4rem;
	padding: .2rem .6rem;
	border-radius: 999px;
	background: var(--surface);
	border: 1px solid var(--border);
	color: var(--text-muted);
	font-size: .8rem;
	text-decoration: none;
	box-shadow: 0 1px 4px rgba(0, 0, 0, .15);
}

.album-card-edit:hover {
	color: var(--accent-hover);
	border-color: var(--accent-hover);
}

.album-card-thumbs {
	display: flex;
	height: 100px;
	background: var(--surface-2);
}

.album-card-thumbs img {
	flex: 1;
	height: 100%;
	object-fit: cover;
	min-width: 0;
}

/* A video thumbnail here is wrapped in .video-thumb-wrap (see below,
   for the play-icon overlay), so it's that wrapper — not the <video>
   itself — that needs to be the flex item; the video then just fills
   100% of whatever the wrapper ends up sized to. */
.album-card-thumbs .video-thumb-wrap {
	flex: 1;
	height: 100%;
	min-width: 0;
}

.album-card-thumbs .video-thumb-wrap .video-thumb {
	width: 100%;
	height: 100%;
	object-fit: cover;
}

.album-card-thumbs .thumb-fallback {
	flex: 1;
	display: flex;
	align-items: center;
	justify-content: center;
	color: var(--text-muted);
	font-size: .7rem;
	text-align: center;
	padding: .25rem;
}

.album-card-body {
	padding: .75rem .9rem;
}

.album-card-title {
	font-weight: 600;
	margin: 0 0 .25rem;
}

.album-card-desc {
	font-size: .85rem;
	color: var(--text-muted);
	margin: 0 0 .4rem;
	overflow: hidden;
	text-overflow: ellipsis;
	white-space: nowrap;
}

.album-card-meta {
	font-size: .75rem;
	color: var(--text-muted);
	margin: 0;
}

/* Deliberately distinct from .album-card-meta's "updated" timestamp above
   it — this is when the photos were actually taken, not when the album
   record itself last changed. */
.album-card-range {
	font-size: .75rem;
	color: var(--text-muted);
	margin: .2rem 0 0;
}

.photo-grid {
	display: flex;
	flex-wrap: wrap;
	gap: .75rem;
}

.table-scroll {
	max-height: 320px;
	overflow-y: auto;
	border: 1px solid var(--border);
	border-radius: var(--radius);
}

table.contacts {
	width: 100%;
	border-collapse: collapse;
	font-size: .9rem;
}

table.contacts th {
	position: sticky;
	top: 0;
	background: var(--surface-2);
	text-align: left;
	padding: .6rem .75rem;
	font-weight: 600;
	color: var(--text-muted);
	border-bottom: 1px solid var(--border);
}

table.contacts th:first-child,
table.contacts td:first-child {
	width: 2.5rem;
	text-align: right;
	color: var(--text-muted);
}

table.contacts td {
	padding: .6rem .75rem;
	border-bottom: 1px solid var(--border);
	vertical-align: top;
}

.col-sort {
	background: none;
	border: none;
	padding: 0;
	margin: 0;
	font: inherit;
	font-weight: 600;
	color: inherit;
	cursor: pointer;
	white-space: nowrap;
}

.col-sort:hover,
.col-sort.active {
	color: var(--text);
}

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

table.contacts tr.editing-row td {
	background: #fbf0ec;
}

/* Shared by the zip-cache and performance-metrics admin pages — same
   plain data-table look, kept under one selector list rather than
   duplicated per page. */
table.zip-cache,
table.admin-table {
	width: 100%;
	border-collapse: collapse;
	font-size: .9rem;
	margin-bottom: 1.5rem;
}

table.zip-cache th,
table.admin-table th {
	text-align: left;
	padding: .6rem .75rem;
	font-weight: 600;
	color: var(--text-muted);
	border-bottom: 1px solid var(--border);
}

table.zip-cache td,
table.admin-table td {
	padding: .6rem .75rem;
	border-bottom: 1px solid var(--border);
	vertical-align: top;
}

table.zip-cache tr:last-child td,
table.admin-table tr:last-child td {
	border-bottom: none;
}

/* --- Admin performance page ------------------------------------------------ */

.metrics-stat-row {
	display: flex;
	flex-wrap: wrap;
	gap: .75rem;
	margin-bottom: 1.5rem;
}

.metrics-stat {
	background: var(--surface);
	border: 1px solid var(--border);
	border-radius: var(--radius);
	padding: .6rem 1rem;
	min-width: 90px;
}

.metrics-stat span {
	display: block;
	font-size: .75rem;
	color: var(--text-muted);
}

.metrics-stat strong {
	display: block;
	font-size: 1.15rem;
	margin-top: .1rem;
}

.tag {
	display: inline-block;
	background: var(--surface-2);
	color: var(--text-muted);
	border-radius: 999px;
	padding: .1rem .6rem;
	font-size: .75rem;
	margin: 0 .25rem .25rem 0;
}

/* A "?" badge that carries its full explanation in a custom tooltip
   (data-tooltip, wired up in app.js) instead of inline muted text —
   keeps a label from wrapping onto several lines just to fit an aside. */
.info-hint {
	display: inline-flex;
	align-items: center;
	justify-content: center;
	width: 15px;
	height: 15px;
	border-radius: 50%;
	border: 1px solid var(--text-muted);
	color: var(--text-muted);
	font-size: .65rem;
	font-weight: 600;
	cursor: help;
	user-select: none;
	vertical-align: middle;
}

/* The bubble app.js shows/positions on hover or focus of any element
   carrying a data-tooltip attribute (the "?" info badges, thumbnail
   position hints, ...). Starts hidden and off-screen-irrelevant
   (display:none) rather than at 0×0 in the corner, so nothing flashes at
   (0,0) before the first real position is computed. */
.hover-tooltip {
	display: none;
	position: absolute;
	max-width: 260px;
	background: var(--text);
	color: var(--surface);
	font-size: .75rem;
	line-height: 1.4;
	padding: .5rem .65rem;
	border-radius: 6px;
	box-shadow: 0 4px 12px rgba(0, 0, 0, .18);
	/* This is a single, page-body-level element reused everywhere
	   data-tooltip is used — the lightbox's "?" info badge, the map
	   view's own "?" help button, and anywhere else this pattern gets
	   used later — so it must always be --z-tooltip, the top of the
	   registry in :root. Twice now a hardcoded number here quietly
	   stopped being "the highest" once a new overlay was added elsewhere
	   with a bigger one (.lightbox-overlay, then .mapview-overlay) — see
	   the registry's own comment for why every cross-cutting z-index in
	   this file goes through a named tier now instead of a bare number. */
	z-index: var(--z-tooltip);
	pointer-events: none;
}

/* Follow-cursor tooltips (the thumbnail "X of Y" readout) — the app's own
   accent color instead of the "?" badge's neutral dark grey, so this
   reads as its own distinct kind of hint at a glance, plus a snugger
   padding to suit its short, numeric content ("3 of 24") rather than the
   longer prose the badge tooltip usually carries. */
.hover-tooltip-cursor {
	background: var(--accent);
	color: var(--surface);
	padding: .25rem .4rem;
}

/* Status, not membership — outlined rather than filled so it doesn't
   read as just another group tag. */
.tag-archived {
	background: transparent;
	border: 1px solid var(--text-muted);
	text-transform: uppercase;
	letter-spacing: .02em;
	font-weight: 600;
	font-size: .65rem;
}

/* --- Groups management (contacts page) ------------------------------- */

/* Collapsed by default (see contacts.html) so it doesn't sit between the
   contacts table and the add/edit form on every visit — group management
   is occasional, not the primary task on this page. Styled as its own
   card (not just a bare <details>) so it reads as a distinct block with
   its own margin, rather than a heading that happens to be collapsible. */
.groups-section {
	margin-bottom: 1.5rem;
}

.groups-section summary {
	display: flex;
	align-items: baseline;
	gap: .6rem;
	cursor: pointer;
	list-style: none;
}

.groups-section[open] summary {
	margin-bottom: .75rem;
}

.groups-section summary::-webkit-details-marker {
	display: none;
}

.groups-section summary h2 {
	display: inline-block;
	margin: 0;
}

.groups-section summary h2::before {
	content: "▶";
	display: inline-block;
	font-size: .65em;
	margin-right: .5rem;
	color: var(--text-muted);
	transition: transform .15s;
}

.groups-section[open] summary h2::before {
	transform: rotate(90deg);
}

.group-create {
	display: flex;
	gap: .5rem;
	margin-top: 1rem;
	max-width: 400px;
}

.group-create input {
	flex: 1;
	margin-top: 0;
}

.group-create-error {
	max-width: 400px;
}

.group-list-empty-note {
	margin: 0 0 .5rem;
}

.groups-list {
	display: flex;
	flex-direction: column;
	gap: .5rem;
}

.group-item {
	background: var(--surface);
	border: 1px solid var(--border);
	border-radius: var(--radius);
	padding: .3rem .9rem;
}

/* Otherwise every card's border is the same pale grey and an expanded
   group visually runs into its neighbors — this gives the open one a
   clearly distinct edge, same accent color already used for focus rings. */
.group-item[open] {
	border-color: var(--accent);
}

.group-item summary {
	cursor: pointer;
	padding: .5rem 0;
	list-style: none;
	display: flex;
	align-items: baseline;
	gap: .6rem;
}

.group-item summary::-webkit-details-marker {
	display: none;
}

.group-item-name {
	font-weight: 600;
}

.group-item-body {
	padding: 0 0 .9rem;
	border-top: 1px solid var(--border);
	margin-top: -.1rem;
	padding-top: .75rem;
}

.group-rename-form {
	display: flex;
	gap: .5rem;
	margin: .75rem 0 .5rem;
}

.group-rename-form input {
	flex: 1;
}

.group-member-chips {
	display: flex;
	flex-wrap: wrap;
	gap: .35rem;
	margin-bottom: .5rem;
}

.group-empty-note {
	margin-bottom: .5rem;
}

.group-add-member {
	margin: .75rem 0;
	max-width: 260px;
}

/* --- Group picker (contact add/edit form) ----------------------------- */

.group-picker {
	margin-bottom: 1rem;
}

.group-picker-chips {
	display: flex;
	flex-wrap: wrap;
	gap: .35rem;
	margin-bottom: .4rem;
}

.tag-chip {
	display: inline-flex;
	align-items: center;
	gap: .3rem;
	background: var(--surface-2);
	color: var(--text);
	border-radius: 999px;
	padding: .15rem .5rem .15rem .7rem;
	font-size: .8rem;
}

.tag-chip button {
	background: none;
	border: none;
	color: var(--text-muted);
	opacity: .8;
	cursor: pointer;
	font-size: .9rem;
	line-height: 1;
	padding: 0;
}

.tag-chip button:hover {
	opacity: 1;
}

.group-picker-input-wrap {
	position: relative;
}

.group-picker-suggestions {
	position: absolute;
	top: 100%;
	left: 0;
	right: 0;
	margin-top: .25rem;
	background: var(--surface);
	border: 1px solid var(--border);
	border-radius: 8px;
	box-shadow: 0 8px 24px rgba(0, 0, 0, .15);
	max-height: 180px;
	overflow-y: auto;
	z-index: 1;
}

.group-picker-suggestion {
	padding: .45rem .7rem;
	font-size: .85rem;
	color: var(--text);
	cursor: pointer;
}

.group-picker-suggestion:hover,
.group-picker-suggestion.highlighted {
	background: var(--surface-2);
}

.group-picker-create {
	color: var(--accent);
	font-style: italic;
}

.toolbar {
	display: flex;
	gap: .75rem;
	align-items: flex-start;
	margin: 1rem 0 1.5rem;
	flex-wrap: wrap;
}

/* Native <details>/<summary> dropdown — no JS required. */
.dropdown {
	position: relative;
	display: inline-block;
}

.dropdown summary {
	display: inline-block;
	list-style: none;
}

.dropdown summary::-webkit-details-marker {
	display: none;
}

.dropdown-menu {
	position: absolute;
	top: calc(100% + 4px);
	left: 0;
	background: var(--surface);
	border: 1px solid var(--border);
	border-radius: var(--radius);
	box-shadow: 0 4px 12px rgba(0, 0, 0, .08);
	min-width: 200px;
	z-index: var(--z-dropdown);
	overflow: hidden;
}

/* The download menu's item text (see .dropdown-item below) is now
   nowrap, so this needs room to actually grow to fit it — its generated
   labels ("Part 2 of 3 — ~340 MB — click to prepare") run well past the
   200px default. Capped rather than left unbounded so one long album
   title in the "Manifest (...)" entry can't push it past the viewport on
   a narrow screen. */
.dropdown-menu[data-download-status-url] {
	max-width: min(92vw, 440px);
}

.dropdown-menu a {
	display: block;
	padding: .6rem .9rem;
	color: var(--text);
	text-decoration: none;
	font-size: .9rem;
}

.dropdown-menu a:hover {
	background: var(--surface-2);
}

/* One row of a download dropdown's part/manifest listing (see
   download.js) — `.dropdown-item` covers both a real link (a part that's
   already ready) and a `<button>` (not ready yet — clicking it starts
   tracking that one part in the download tray below), since only the
   former also picks up `.dropdown-menu a`'s hover/link styling above. The
   button reset (border/background/font/alignment) is needed because
   `<button>` doesn't inherit any of that the way `<a>` does. */
.dropdown-item {
	display: block;
	width: 100%;
	padding: .6rem .9rem;
	font-size: .9rem;
	font-family: inherit;
	color: var(--text);
	text-decoration: none;
	white-space: nowrap;
	text-align: left;
	background: none;
	border: none;
}

button.dropdown-item {
	cursor: pointer;
}

button.dropdown-item:hover {
	background: var(--surface-2);
}

.dropdown-item-pending {
	color: var(--text-muted);
}

/* Persistent bottom-right tray tracking whatever parts the user has
   actually clicked to prepare — deliberately not a modal: any number can
   be tracked at once, independently, and the rest of the page stays fully
   usable while they're in flight. Only exists in the DOM (well, only
   visible — see ensureTray's own comment on why `display` is toggled via
   inline style rather than the `hidden` attribute) once there's at least
   one row. */
.download-tray {
	position: fixed;
	right: 1.25rem;
	bottom: 1.25rem;
	width: 300px;
	max-width: calc(100vw - 2.5rem);
	background: var(--surface);
	border: 1px solid var(--border);
	border-radius: var(--radius);
	box-shadow: 0 8px 28px rgba(0, 0, 0, .18);
	z-index: var(--z-download-tray);
	overflow: hidden;
}

.download-tray-header {
	padding: .6rem .9rem;
	font-size: .85rem;
	font-weight: 600;
	background: var(--surface-2);
	border-bottom: 1px solid var(--border);
}

.download-tray-items {
	max-height: 260px;
	overflow-y: auto;
}

.download-tray-item {
	display: flex;
	align-items: center;
	justify-content: space-between;
	gap: .75rem;
	padding: .6rem .9rem;
	border-bottom: 1px solid var(--border);
}

.download-tray-item:last-child {
	border-bottom: none;
}

.download-tray-item-text {
	min-width: 0;
}

.download-tray-item-label {
	font-size: .85rem;
	white-space: nowrap;
	overflow: hidden;
	text-overflow: ellipsis;
}

.download-tray-item-status {
	font-size: .8rem;
	color: var(--text-muted);
}

.download-tray-item-action {
	flex-shrink: 0;
}

.import-summary {
	background: #eaf5ec;
	border: 1px solid #cfe8d4;
	border-radius: var(--radius);
	padding: .6rem .9rem;
	margin-bottom: 1rem;
	font-size: .9rem;
	color: #1e5e2e;
}

.photo-thumb {
	width: 160px;
	height: 160px;
	border-radius: var(--radius);
	object-fit: cover;
	display: block;
	background: var(--surface-2);
}

/* Combined with .photo-thumb/.reorder-thumb (sizing/object-fit/background
   all already apply the same to a <video> as to an <img>) — this only
   adds what's specific to it being a <video>. The actual frame only ever
   shows up because app.js seeks a moment into the video once its metadata
   loads — `preload="metadata"` alone doesn't reliably decode/paint one on
   its own, in Chrome or anywhere else. The `poster` attribute (an inline
   SVG data URI, no extra asset/request) is what shows until that seek
   completes, or permanently in a browser where it doesn't work.
   `pointer-events: none` keeps a click landing on the wrapping trigger/
   label exactly like it would for a plain <img> — a bare <video> has no
   controls here, but this removes any doubt across browsers. */
.video-thumb {
	pointer-events: none;
}

/* Wraps every .video-thumb (see the templates) purely so the play-icon
   badge below has somewhere to sit — <video>, like <img>, is a replaced
   element, and ::before/::after generated content doesn't apply to those
   in any browser, so the badge can't just be a pseudo-element on the
   video itself. inline-block + line-height:0 makes this wrapper shrink to
   exactly the video's own footprint (whatever .photo-thumb/.reorder-thumb
   size that ends up being) with no extra gap underneath — the usual
   inline-replaced-element quirk line-height:0 is the standard fix for. */
.video-thumb-wrap {
	position: relative;
	display: inline-block;
	line-height: 0;
}

/* A dark wash across the *whole* thumbnail (not just a badge behind the
   icon) + a centered pure-CSS triangle (the classic border trick — no
   image/SVG needed) — shown at all times over the frame once it's
   decoded, not just as a loading placeholder, so a video thumbnail reads
   as "video" regardless of whether the frame render worked out in a
   given browser. `inset:0` fills whatever size .video-thumb-wrap ends up
   being (160px grid thumb, 48px reorder-row thumb, ...) without needing
   its own fixed dimensions. */
.video-thumb-play {
	position: absolute;
	inset: 0;
	background: rgba(0, 0, 0, .35);
	display: flex;
	align-items: center;
	justify-content: center;
	pointer-events: none;
}

/* Matches whichever thumbnail shape this particular video sits in —
   .photo-thumb/.reorder-thumb's own border-radius values, since this is
   a separate absolutely-positioned sibling rather than a child the video
   itself could clip. Left un-rounded (the default) for the gallery
   album-card row (a bare .video-thumb, no .photo-thumb/.reorder-thumb) —
   there, the *card's* own overflow:hidden + border-radius is what rounds
   the row's outer corners; individual thumbnails in that flex row have
   square edges against each other already, so rounding this one would
   look wrong rather than matching anything. */
.video-thumb-wrap:has(.photo-thumb) .video-thumb-play {
	border-radius: var(--radius);
}

.video-thumb-wrap:has(.reorder-thumb) .video-thumb-play {
	border-radius: 6px;
}

.video-thumb-play::after {
	content: "";
	width: 0;
	height: 0;
	border-style: solid;
	border-width: 7px 0 7px 12px;
	border-color: transparent transparent transparent #fff;
	margin-left: 3px; /* optical centering — a triangle's own bounding box reads off-center otherwise */
}

/* 2x the base size specifically in the main album/share photo-grid (the
   160px .photo-thumb context) — left at the default size in the album
   edit page's reorder rows (.reorder-thumb, 48px), where the default
   already reads fine and this much bigger would be cramped. */
.video-thumb-wrap:has(.photo-thumb) .video-thumb-play::after {
	border-width: 14px 0 14px 24px;
	margin-left: 6px;
}

/* Owner-only "this is hidden from shares" badge — same dark-wash-across-
   the-whole-thumbnail treatment as .video-thumb-play, just with the
   lightbox private-toggle button's own crossed-eye icon (same reddish
   --error color, same slash direction) instead of a play triangle.
   .thumb-private only gets position:relative (everything else here
   already has no layout of its own to preserve) so a non-private
   thumbnail's markup/CSS is completely untouched. */
.thumb-trigger.thumb-private {
	position: relative;
	display: inline-block;
	line-height: 0;
}

.private-thumb-overlay {
	position: absolute;
	inset: 0;
	background: rgba(0, 0, 0, .67);
	border-radius: var(--radius);
	color: var(--error);
	display: flex;
	align-items: center;
	justify-content: center;
	pointer-events: none;
}

.private-thumb-overlay svg {
	width: 56px;
	height: 56px;
	flex-shrink: 0;
}

/* Same 160px-grid-vs-48px-reorder-row split as the video play icon
   above — smaller icon, and the reorder row's own corner radius, in that
   context. */
.thumb-trigger.thumb-private:has(.reorder-thumb) .private-thumb-overlay {
	border-radius: 6px;
}

.thumb-trigger.thumb-private:has(.reorder-thumb) .private-thumb-overlay svg {
	width: 32px;
	height: 32px;
}

.photo-thumb-fallback {
	width: 160px;
	height: 160px;
	border-radius: var(--radius);
	background: var(--surface-2);
	color: var(--text-muted);
	display: flex;
	align-items: center;
	justify-content: center;
	text-align: center;
	padding: .5rem;
	font-size: .8rem;
	word-break: break-word;
}

/* Toast — driven by a `?toast=`/`?toast_kind=` query param pair on
   redirect (see app.js), or called directly for a client-side-only event
   with no navigation involved. Confirms an action actually happened for
   plain server-rendered forms that would otherwise redirect with no
   visible feedback. Full-width rather than a small floating pill, and
   anchored to the top rather than the bottom, specifically because it
   always appears right after a full page navigation — the one moment a
   small corner chip is easiest to miss, since the viewer's attention is
   already reorienting to a freshly loaded page rather than parked on one
   spot waiting for a reaction.

   Three dispositions, each a diagonal-striped bar with the message in its
   own solid pill on top (so the text stays legible regardless of where it
   lands on the stripes): success (moss green) for a completed upload,
   warning (amber/rust) for a cancelled one, error (terracotta) for a
   failure. `.toast-message` carries the pill; `.toast` itself is just the
   positioning/animation shell plus whichever stripe background the kind
   class sets. */
.toast {
	position: fixed;
	top: 0;
	left: 0;
	right: 0;
	display: flex;
	justify-content: center;
	padding: .85rem 1rem;
	transform: translateY(-100%);
	opacity: 0;
	transition: opacity .25s ease, transform .25s ease;
	/* Was 1000 — invisible behind .lightbox-overlay/.mapview-overlay
	   (2000/2200) despite lightbox.js's own delete-failure toast firing
	   while the lightbox can be open. Found while building the z-index
	   registry above; see --z-notification's own comment there. */
	z-index: var(--z-notification);
	pointer-events: none;
}

.toast-visible {
	opacity: 1;
	transform: translateY(0);
}

.toast-message {
	max-width: 90%;
	padding: .55rem 1.3rem;
	border-radius: 999px;
	font-size: .95rem;
	font-weight: 500;
	box-shadow: 0 4px 14px rgba(0, 0, 0, .22);
}

.toast-error {
	background: repeating-linear-gradient(135deg,
		var(--accent) 0px, var(--accent) 16px,
		var(--accent-hover) 16px, var(--accent-hover) 32px);
}

.toast-error .toast-message {
	background: var(--text);
	color: var(--bg);
}

.toast-warning {
	background: repeating-linear-gradient(135deg,
		#e0983f 0px, #e0983f 16px,
		var(--accent-hover) 16px, var(--accent-hover) 32px);
}

.toast-warning .toast-message {
	background: var(--bg);
	color: #5a2e14;
}

.toast-success {
	/* Swapped the dark-green stripe for option B's own pastel orange
	   (#e0983f) — keeping the pastel green (#6b8f5e) — so success shares a
	   warmer note with warning instead of reading as a colder, more
	   isolated green against the rest of the site. */
	background: repeating-linear-gradient(135deg,
		#e0983f 0px, #e0983f 16px,
		#6b8f5e 16px, #6b8f5e 32px);
}

.toast-success .toast-message {
	background: var(--bg);
	color: #26401f;
}

table.reorder-table {
	width: 100%;
	border-collapse: collapse;
	font-size: .9rem;
	margin-bottom: .25rem;
}

table.reorder-table th {
	text-align: left;
	padding: .5rem .6rem;
	font-weight: 600;
	color: var(--text-muted);
	border-bottom: 1px solid var(--border);
}

table.reorder-table td {
	padding: .4rem .6rem;
	border-bottom: 1px solid var(--border);
	vertical-align: middle;
}

table.reorder-table tr[draggable="true"] {
	cursor: grab;
}

table.reorder-table tr.dragging {
	opacity: .4;
	background: var(--surface-2);
}

.rename-input {
	width: 160px;
	margin-right: .4rem;
	padding: .2rem .4rem;
	font-size: .9rem;
}

.star-col {
	width: 1.5rem;
	text-align: center;
}

.star-toggle {
	background: none;
	border: none;
	cursor: pointer;
	font-size: 1.2rem;
	line-height: 1;
	color: var(--text-muted);
	padding: 0;
}

.star-toggle.starred {
	color: #d4a017;
}

.star-toggle:hover {
	color: #d4a017;
}

.drag-handle {
	color: var(--text-muted);
	width: 1.5rem;
	text-align: center;
	user-select: none;
}

.reorder-thumb {
	width: 48px;
	height: 48px;
	border-radius: 6px;
	object-fit: cover;
	display: block;
	background: var(--surface-2);
}

.reorder-thumb-fallback {
	display: flex;
	align-items: center;
	justify-content: center;
	color: var(--text-muted);
	font-size: .65rem;
	text-align: center;
}

/* Also a lightbox trigger (see album_edit.html) — a <span>, unlike an
   <a>, doesn't get a pointer cursor for free. */
.filename-display {
	cursor: pointer;
}

/* --- Drag-reorder chrome (Manage Albums list, photo-order table) ------ */
/* Shared by any drag-reorder list on the site — the border/highlight/
   save-row mechanics are identical regardless of what's being reordered;
   only the row content itself (.album-manage-row vs table.reorder-table's
   own rows) is feature-specific. See STYLE_GUIDE.md's "Drag-and-drop
   reordering" section. */

.reorder-list {
	display: flex;
	flex-direction: column;
	border: 2px solid var(--border);
	border-radius: var(--radius);
	overflow: hidden;
	transition: border-color .15s ease;
}

/* A list wrapping a <table> (rather than row divs directly) wants a little
   breathing room between the border and the table's own edge-to-edge
   cells/header. Also caps its own height and scrolls internally — a large
   album's photo list would otherwise make the whole page very tall — while
   .reorder-list's own overflow:hidden (for the border-radius corners)
   stays untouched on the x-axis; only overflow-y is overridden here. */
.reorder-list-table {
	padding: .4rem;
	max-height: 80vh;
	overflow-y: auto;
}

/* Keeps the column labels in view while the row list above scrolls under
   them. Needs its own opaque background (matching the page background the
   table otherwise sits on with no background of its own) so scrolled-past
   rows don't show through underneath it. */
table.reorder-table thead th {
	position: sticky;
	top: 0;
	background: var(--bg);
	z-index: 1;
}

/* Unsaved drag-reorder — set/cleared by comparing the live row order
   against the order the page loaded with, not just "has a drag ever
   happened". The two .reorder-save-row elements (top and bottom) get this
   same class toggled on them directly by that same check, so both stay in
   sync without either depending on DOM position relative to this element. */
.reorder-list.dirty {
	border-color: var(--accent);
}

.album-manage-row {
	display: flex;
	align-items: center;
	gap: .75rem;
	padding: .5rem .75rem;
	background: var(--surface);
	border-bottom: 1px solid var(--border);
	cursor: grab;
}

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

.album-manage-row.dragging {
	opacity: .4;
	background: var(--surface-2);
}

.album-manage-thumb-link {
	flex-shrink: 0;
	line-height: 0;
}

.album-manage-thumb {
	width: 56px;
	height: 56px;
	border-radius: 6px;
	object-fit: cover;
	display: block;
	background: var(--surface-2);
}

.album-manage-thumb-fallback {
	/* Same box, just no image — an empty or video/other-only album. */
}

.album-manage-info {
	flex: 1;
	min-width: 0;
}

.album-manage-title {
	display: block;
	font-weight: 600;
	color: var(--text);
	text-decoration: none;
	overflow: hidden;
	text-overflow: ellipsis;
	white-space: nowrap;
}

.album-manage-title:hover {
	color: var(--accent);
}

.album-manage-meta {
	margin: .15rem 0 0;
	font-size: .8rem;
	color: var(--text-muted);
	overflow: hidden;
	text-overflow: ellipsis;
	white-space: nowrap;
}

/* Always laid out, hidden by visibility only until there's an unsaved
   reorder — see .dirty below. Its space is reserved up front on purpose:
   toggling display instead made the table (and everything under it) jump
   down/up by the rows' height every time the staged state flipped.
   visibility: hidden also makes the buttons unclickable and unfocusable
   while hidden, so there's nothing else to gate them on. Still the
   exclusive owner of "is this showing" (STYLE_GUIDE.md's "Structural
   show/hide vs. layout mode") — nothing else should touch visibility. */
.reorder-save-row {
	display: flex;
	visibility: hidden;
	align-items: center;
	justify-content: center;
	gap: .6rem;
	/* Straddles the list's border on its own side (top row overlaps the
	   top edge, bottom row the bottom edge) rather than floating near it —
	   relative + z-index so each row's (opaque) buttons reliably paint
	   over that border segment regardless of which one is earlier in DOM
	   order (only true by default for the bottom row otherwise). */
	position: relative;
	z-index: 1;
}

.reorder-save-row.dirty {
	visibility: visible;
}

/* Anchors the album-edit view-sort banner (below), which overlays the top
   save row's reserved slot instead of taking space of its own. */
.reorder-wrap {
	position: relative;
}

.reorder-save-row-top {
	margin-bottom: -1.05rem;
}

.reorder-save-row-bottom {
	margin-top: -1.05rem;
}

.reorder-dirty-note {
	font-size: .8rem;
	font-weight: 600;
	color: var(--accent);
}

.reorder-save-btn {
	background: var(--accent);
	border: 2px solid var(--accent);
	color: #fff;
	cursor: pointer;
	transition: background .15s ease, border-color .15s ease;
}

.reorder-save-btn:hover {
	background: var(--accent-hover);
	border-color: var(--accent-hover);
}

/* Opens the revert-confirm popup (see reorderlist.js) — outlined rather
   than filled so "Save order" still reads as the primary action of the
   pair. */
.reorder-revert-btn {
	background: var(--surface);
	border: 2px solid var(--border);
	color: var(--text-muted);
	cursor: pointer;
	transition: border-color .15s ease, color .15s ease;
}

.reorder-revert-btn:hover {
	border-color: var(--text-muted);
	color: var(--text);
}

/* --- Sort-by-filename preview modal (album_edit.html) ------------------ */
/* Built and torn down entirely in JS (created on open, removed on close/
   Cancel/Apply) rather than a class-toggled always-in-DOM element — the
   DOM-removal approach STYLE_GUIDE.md's "Structural show/hide" section
   prefers, and it sidesteps the display/visibility pairing that a
   class-toggled overlay would otherwise need, since there's simply nothing
   in the DOM to accidentally show. */

/* The "matching filenames" prompt (see uploadconflicts.js): same recipe as the
   sort-preview dialog just below — a fixed backdrop and a centered card. */
.name-conflict-overlay {
	position: fixed;
	inset: 0;
	background: rgba(0, 0, 0, .45);
	z-index: var(--z-modal);
	display: flex;
	align-items: center;
	justify-content: center;
	padding: 1.5rem;
}

.name-conflict-card {
	background: var(--surface);
	border-radius: var(--radius);
	box-shadow: 0 8px 24px rgba(0, 0, 0, .25);
	padding: 1.25rem 1.5rem;
	width: 100%;
	max-width: 760px;
	max-height: 88vh;
	overflow-y: auto;
}

.name-conflict-card h3 {
	margin: 0 0 .5rem;
}

.name-conflict-intro {
	margin: 0 0 .75rem;
}

.name-conflict-all {
	display: flex;
	align-items: center;
	gap: .5rem;
	font-size: .9rem;
	margin-bottom: .75rem;
}

.name-conflict-all select {
	width: auto;
	margin: 0;
	padding: .25rem .4rem;
}

.name-conflict-list {
	border: 1px solid var(--border);
	border-radius: var(--radius);
	max-height: 42vh;
	overflow-y: auto;
	margin-bottom: 1rem;
}

.name-conflict-row {
	display: grid;
	grid-template-columns: 56px minmax(0, 1fr) auto;
	gap: .75rem;
	align-items: start;
	padding: .6rem .75rem;
	border-bottom: 1px solid var(--border);
}

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

.name-conflict-thumb {
	width: 56px;
	height: 56px;
	border-radius: 6px;
	background: rgba(127, 127, 127, .15);
	overflow: hidden;
	display: flex;
	align-items: center;
	justify-content: center;
}

.name-conflict-thumb img {
	width: 100%;
	height: 100%;
	object-fit: cover;
}

.name-conflict-thumb-none {
	font-size: .7rem;
	opacity: .7;
}

.name-conflict-name {
	font-weight: 600;
	overflow-wrap: anywhere;
}

.name-conflict-detail {
	font-size: .8rem;
	margin-top: .15rem;
}

.name-conflict-picker {
	margin: .35rem 0 0;
	padding: .2rem .35rem;
	font-size: .8rem;
}

.name-conflict-notes {
	margin-top: .3rem;
	display: flex;
	flex-wrap: wrap;
	gap: .35rem;
}

.name-conflict-chip {
	font-size: .7rem;
	padding: .05rem .45rem;
	border: 1px solid var(--border);
	border-radius: 999px;
}

.name-conflict-clear {
	margin-bottom: 1rem;
}

.name-conflict-clear-title {
	font-size: .85rem;
	font-weight: 600;
	margin-bottom: .3rem;
}

.name-conflict-clear-list {
	border: 1px solid var(--border);
	border-radius: var(--radius);
	max-height: 18vh;
	overflow-y: auto;
	padding: .3rem .6rem;
}

.name-conflict-clear-row {
	display: flex;
	gap: .6rem;
	align-items: baseline;
	font-size: .8rem;
	padding: .1rem 0;
}

.name-conflict-clear-name {
	overflow-wrap: anywhere;
}

.name-conflict-clear-meta {
	white-space: nowrap;
	font-size: .75rem;
}

.name-conflict-actions {
	display: flex;
	flex-direction: column;
	gap: .2rem;
	font-size: .85rem;
}

.name-conflict-radio,
.name-conflict-option {
	display: flex;
	align-items: baseline;
	gap: .35rem;
	margin: 0;
	font-size: .85rem;
	color: var(--text);
	cursor: pointer;
}

.name-conflict-radio input,
.name-conflict-option input {
	width: auto;
	margin: 0;
}

.name-conflict-options {
	display: flex;
	flex-direction: column;
	gap: .3rem;
	margin-bottom: .75rem;
}

.name-conflict-hint {
	font-size: .8rem;
}

.name-conflict-warning {
	font-size: .85rem;
	margin: 0 0 1rem;
	color: var(--error);
}

.name-conflict-buttons {
	display: flex;
	justify-content: flex-end;
	gap: .5rem;
}

.sort-preview-overlay {
	position: fixed;
	inset: 0;
	background: rgba(0, 0, 0, .4);
	z-index: var(--z-modal);
	display: flex;
	align-items: center;
	justify-content: center;
	padding: 1.5rem;
}

.sort-preview-card {
	background: var(--surface);
	border-radius: var(--radius);
	box-shadow: 0 8px 24px rgba(0, 0, 0, .2);
	padding: 1.5rem;
	width: 100%;
	max-width: 560px;
	max-height: 85vh;
	display: flex;
	flex-direction: column;
}

.sort-preview-card h3 {
	margin: 0 0 1rem;
}

.sort-preview-options {
	display: flex;
	flex-wrap: wrap;
	align-items: center;
	gap: 1rem;
	margin-bottom: 1rem;
	font-size: .9rem;
}

.sort-preview-option {
	display: inline-flex;
	align-items: center;
	gap: .4rem;
}

.sort-preview-option select {
	width: auto;
	margin: 0;
	padding: .25rem .4rem;
}

.sort-preview-table-wrap {
	border: 1px solid var(--border);
	border-radius: var(--radius);
	overflow-y: auto;
	max-height: 50vh;
	margin-bottom: 1.25rem;
}

table.sort-preview-table {
	width: 100%;
	border-collapse: collapse;
	font-size: .9rem;
}

table.sort-preview-table th {
	position: sticky;
	top: 0;
	background: var(--surface);
	text-align: left;
	padding: .5rem .6rem;
	font-weight: 600;
	color: var(--text-muted);
	border-bottom: 1px solid var(--border);
	z-index: 1;
}

table.sort-preview-table td {
	padding: .35rem .6rem;
	border-bottom: 1px solid var(--border);
}

.sort-preview-delta {
	color: var(--text-muted);
	font-variant-numeric: tabular-nums;
	font-weight: 700;
}

/* Sign-based, not direction-based — there's no inherent "good" direction
   for a reorder, but a quick red/green read of the sign itself (same as a
   spreadsheet's negative-number formatting) is still useful at a glance. */
.sort-preview-delta-negative {
	color: var(--error);
}

.sort-preview-delta-positive {
	color: #26401f; /* same green as .toast-success's text — see there */
}

.sort-preview-footer {
	display: flex;
	gap: 1rem;
	justify-content: flex-end;
}

.sort-preview-btn-group {
	display: flex;
	flex-direction: column;
	align-items: center;
	gap: .35rem;
	max-width: 160px;
}

.sort-preview-btn-caption {
	font-size: .7rem;
	color: var(--text-muted);
	text-align: center;
}

/* Cancel — outlined rather than filled, same "secondary action" visual
   convention as .reorder-revert-btn next to .reorder-save-btn. */
.sort-preview-cancel-btn {
	background: var(--surface);
	border: 2px solid var(--border);
	color: var(--text-muted);
	cursor: pointer;
	transition: border-color .15s ease, color .15s ease;
}

.sort-preview-cancel-btn:hover {
	border-color: var(--text-muted);
	color: var(--text);
}

.lightbox-overlay {
	display: none;
	/* Deliberately redundant with display: none above, not decorative —
	   display and visibility cascade as fully independent properties, so a
	   later rule that overrides display alone (a layout-mode variant like
	   .lightbox-nav-below, say) can't also silently make this visible
	   unless it independently touches visibility too, which no legitimate
	   layout rule has any reason to do. This is the actual fix for a real
	   bug: .lightbox-overlay.lightbox-nav-below used to set `display: grid`
	   without requiring .lightbox-open, so it kept the whole overlay
	   visible and fully interactive after an otherwise-correct close.
	   visibility stays the exclusive property of this open/closed gate —
	   no other rule on this element should ever touch it. */
	visibility: hidden;
	position: fixed;
	inset: 0;
	background: rgba(20, 16, 12, .92);
	z-index: var(--z-lightbox);
	align-items: center;
	justify-content: center;
	/* Nothing in here is meant to be text-selected — most relevantly, this
	   keeps a double-click on blank space from highlighting the nearest
	   text/image instead of just toggling the popup menu. */
	-webkit-user-select: none;
	-moz-user-select: none;
	user-select: none;
}

.lightbox-tag-input,
.lightbox-caption-input,
.lightbox-caption,
.lightbox-counter-coords-wrap,
.lightbox-counter-index,
.lightbox-counter-filename {
	/* The two actual text inputs are a deliberate exception to the
	   overlay-wide no-select rule above — and so is every other read-only
	   text readout that has no click handling of its own (caption, "N of
	   total," filename, coordinates): none of them has any reason to
	   resist a drag-select just because the blank backdrop around them
	   does. Deliberately NOT extended to readouts that ARE click targets
	   (.lightbox-counter-date-interactive, the star/private toggles, …) —
	   selectable text there would just reintroduce the same click-vs-drag
	   ambiguity coordsLinkEl's own click listener (lightbox.js) and the
	   blank-space menu's `wasDrag` check exist to avoid, for a case that
	   doesn't actually need to be copy-pasted the way coordinates or a
	   caption might. draggable="false" on the coords link itself, set
	   where it's built in lightbox.js, only stops the *link* from being
	   dragged as a URL — this is what actually lets its text be
	   selected/copied at all.
	   -coords-wrap (not -coords, the link, directly) is what actually
	   holds the padding &nbsp;s alongside the link — see lightbox.js's own
	   doc comment for why they're grouped into one flex item together —
	   so this is the one that needs the exemption; the link inside it
	   inherits from here rather than needing its own copy. */
	-webkit-user-select: text;
	-moz-user-select: text;
	user-select: text;
}

.lightbox-overlay.lightbox-open {
	display: flex;
	visibility: visible;
}

.lightbox-media {
	display: flex;
	align-items: center;
	justify-content: center;
	/* The image itself keeps whatever zoom/pan transform
	   applyZoomTransform already gives it (see lightbox.js) — the
	   display-edit preview (rotate/flip) is applied one level up, here on
	   the container, specifically so it never has to know or care about
	   that. No CSS transition on transform: that's a live, instant
	   preview tracking every button click in the edit panel, not
	   something that should visibly animate into place — opacity (below)
	   is a separate, deliberately-transitioned property, unaffected by
	   that. */
	transform-origin: center center;
	transition: opacity .25s ease;
}

/* Fades whatever's still on screen while a slow load drags on — same
   debounce as .lightbox-loading (setFullResLoading, ~1.5s), so a normal
   load where the thumbnail already exists never triggers this at all;
   only shows up once a load is genuinely taking long enough that leaving
   the old content at full opacity risks reading as "this already is the
   new photo" rather than "still catching up" — confirmed live as a real
   point of confusion after editing a photo, flipping away, then flipping
   straight back to it before its full-res bake had finished. */
.lightbox-media.lightbox-media-stale {
	opacity: .35;
}

.lightbox-media img,
.lightbox-media video {
	max-width: 92vw;
	/* min() with a fixed-rem floor, not just 88vh alone — a pure vh cap
	   left only 6vh (12vh split top+bottom) for the bottom-center caption
	   bar on a tall/portrait photo, which is what overlapped it. The
	   calc() term guarantees at least ~3.5rem of clearance above/below
	   regardless of viewport height, while 88vh still wins (and this has
	   no effect) on any photo that isn't already hugging the height cap. */
	max-height: min(88vh, calc(100vh - 7rem));
	border-radius: var(--radius);
}

/* Alternate layout for a photo that ends up hugging the full allowed
   width (see updateNavLayout in lightbox.js) — the default flex-row
   layout below (prev/media/next side by side) leaves no room for the nav
   buttons beside a photo like that, so this moves them below it instead.
   Grid, not flex — lets the two buttons share one row of their own,
   centered as a pair, without a wrapping element around just those two:
   media spans both columns in row 1, each button takes one column in
   row 2. Absolutely-positioned siblings (.lightbox-toolbar, .lightbox-
   counter, ...) are unaffected either way, same as the default flex
   layout — switching the container's own display mode doesn't change
   how they resolve their position.
   .lightbox-open is required here (not just .lightbox-nav-below alone)
   even though it doesn't change the grid layout itself — without it this
   selector ties in specificity with .lightbox-overlay.lightbox-open's own
   `display: flex` above and wins on source order, which meant closing the
   lightbox on a wide/landscape photo (lightbox-nav-below toggled on by
   updateNavLayout, and never cleared by closeLightboxDom — there's no
   reason to, it's harmless while actually closed) left this rule alone
   still forcing `display: grid`, keeping the whole overlay visible and
   fully interactive with lightbox-open already removed. Confirmed live as
   the actual cause of the "close does nothing" bug — the close mechanism
   itself (JS/history) was never actually broken. */
.lightbox-overlay.lightbox-open.lightbox-nav-below {
	display: grid;
	grid-template-columns: auto auto;
	column-gap: 1.5rem;
	row-gap: .5rem;
	justify-content: center;
	align-content: center;
}

.lightbox-overlay.lightbox-nav-below .lightbox-media {
	grid-column: 1 / -1;
	grid-row: 1;
}

.lightbox-overlay.lightbox-nav-below .lightbox-prev {
	grid-column: 1;
	grid-row: 2;
	justify-self: end;
}

.lightbox-overlay.lightbox-nav-below .lightbox-next {
	grid-column: 2;
	grid-row: 2;
	justify-self: start;
}

/* Extra vertical room reserved for that button row (~4rem more than the
   default budget) — mirrored exactly by fitConstraints' navBelow branch
   in lightbox.js, so the thumbnail placeholder box never renders a
   different size than the full-res image that goes on to replace it. */
.lightbox-overlay.lightbox-nav-below .lightbox-media img,
.lightbox-overlay.lightbox-nav-below .lightbox-media video {
	max-height: min(88vh, calc(100vh - 11rem));
}

.lightbox-media img {
	transform-origin: center center;
	/* No CSS transition on transform: wheel/drag-driven zoom and pan need
	   to track the input immediately, not ease toward it. */
	/* Matters specifically while the low-res thumbnail is standing in for
	   the full-res image (see loadPhotoProgressively/computeFitSize in
	   lightbox.js) — that JS sets an explicit width/height on the box
	   itself (max-width/max-height alone can only ever shrink an
	   oversized image, never grow an undersized one), and object-fit:
	   contain is what actually stretches the thumbnail's much-smaller
	   bitmap to fill that box rather than leaving it pinned to its own
	   native (small) pixel size. A no-op once the full-res image has
	   loaded, since the explicit width/height gets cleared then and the
	   image's own intrinsic size already matches the box exactly. */
	object-fit: contain;
}

.lightbox-toolbar {
	position: absolute;
	top: 1rem;
	right: 1.25rem;
	display: flex;
	align-items: center;
	gap: .25rem;
	/* Same reasoning as .lightbox-nav's z-index: a zoomed photo's own
	   `transform` gives it a stacking context too (transform != none does
	   that automatically), putting it on equal footing with this
	   absolutely-positioned toolbar — and since the image comes later in
	   the markup, DOM-order tie-breaking would let it paint (and swallow
	   clicks) over the toolbar once panned underneath it. */
	z-index: 1;
}

/* Right edge, 25% down from the top rather than dead-center — vertically
   centered used to land right on top of .lightbox-next (confirmed live:
   that button isn't pinned to a fixed screen position the way this panel
   is, it's flex-centered alongside the image itself, so its own vertical
   center tracks the image's and can end up anywhere near mid-screen).
   25% down clears that in practice without needing to actually measure
   .lightbox-next's position, which would mean this panel caring about
   another element's layout the way this project has already been burned
   by once (see updateCaptionMaxWidth's own history). Same right-margin
   convention (1.25rem) the top/bottom-right toolbars already use. Same
   zoomed-image z-index reasoning as .lightbox-toolbar above — z-index 3,
   not 1, deliberately one tier above .lightbox-crop-overlay's own z-index
   2 (see its own comment): this panel is meant to always win any overlap
   with the crop box, the one thing here allowed a higher tier than the
   rest of this file's plain z-index 1. */
.lightbox-edit-panel {
	position: absolute;
	top: 25%;
	right: 1.25rem;
	transform: translateY(-50%);
	z-index: 3;
	display: flex;
	flex-direction: column;
	align-items: center;
	gap: .4rem;
	background: rgba(20, 16, 12, .82);
	border: 1px solid rgba(255, 255, 255, .18);
	border-radius: var(--radius);
	padding: .4rem .5rem;
	color: #fff;
}

/* This element is only ever focused programmatically (tabindex="-1" — see
   its own markup comment in lightbox.js), never reached by Tab, so the
   default focus ring a browser would otherwise draw around the whole
   panel has nothing useful to communicate to a keyboard user here — the
   panel's real interactive children (buttons) keep their own normal focus
   rings when actually tabbed to. */
.lightbox-edit-panel:focus {
	outline: none;
}

/* A backdrop, not just the card alone — dims everything else in the
   lightbox (including the edit panel itself) while this is open, and
   doubles as the "click outside to dismiss" target (see its own click
   listener in lightbox.js, same pattern .lightbox-overlay's own
   blank-space click already uses). z-index 4, plain and local like the
   crop overlay's own 2 and the edit panel's own 3 (see their comments) —
   one tier above even the edit panel, since this is meant to read as a
   modal sitting on top of the whole editing UI, not a peer floating
   alongside it. */
.lightbox-edit-help-overlay {
	position: absolute;
	inset: 0;
	z-index: 4;
	display: flex;
	align-items: center;
	justify-content: center;
	background: rgba(0, 0, 0, .55);
}

/* Overrides .lightbox-overlay's own blanket user-select: none (see its
   own comment) — unlike that ancestor rule's actual targets (drag
   gestures, click-vs-drag-sensitive readouts — see the coords-wrap
   exemption's own comment for that distinction), nothing in this panel is
   a click target of its own beyond the close button and the backdrop
   dismiss, so there's no drag-vs-click ambiguity a selectable read-only
   manual could reintroduce — no reason to leave it unselectable. */
.lightbox-edit-help-panel {
	max-width: min(420px, 88vw);
	max-height: 80vh;
	overflow-y: auto;
	background: rgba(20, 16, 12, .96);
	border: 1px solid rgba(255, 255, 255, .18);
	border-radius: var(--radius);
	padding: 1rem 1.2rem;
	color: #fff;
	-webkit-user-select: text;
	-moz-user-select: text;
	user-select: text;
}

/* The "unsaved adjustments" prompt (see promptUnsavedEdit in lightbox.js):
   same recipe as .lightbox-edit-help-overlay — an absolute backdrop over
   the whole lightbox with a centered card — but z-index 5, one above the
   help overlay's 4, since it must block everything underneath. */
.lightbox-unsaved-overlay {
	position: absolute;
	inset: 0;
	z-index: 5;
	display: flex;
	align-items: center;
	justify-content: center;
	background: rgba(0, 0, 0, .6);
}

.lightbox-unsaved-card {
	max-width: min(420px, 88vw);
	background: rgba(20, 16, 12, .97);
	border: 1px solid rgba(255, 255, 255, .18);
	border-radius: var(--radius);
	padding: 1rem 1.2rem;
	color: #fff;
}

.lightbox-unsaved-title {
	font-size: 1rem;
	font-weight: 600;
	margin-bottom: .5rem;
}

.lightbox-unsaved-body {
	margin: 0 0 1rem;
	font-size: .9rem;
	opacity: .85;
}

.lightbox-unsaved-actions {
	display: flex;
	flex-wrap: wrap;
	justify-content: flex-end;
	gap: .5rem;
}

.lightbox-unsaved-actions button {
	font-size: .85rem;
	padding: .4rem .8rem;
	border-radius: 6px;
	border: 1px solid rgba(255, 255, 255, .25);
	background: rgba(255, 255, 255, .08);
	color: #fff;
	cursor: pointer;
}

.lightbox-unsaved-actions button:hover:not(:disabled),
.lightbox-unsaved-actions button:focus-visible {
	background: rgba(255, 255, 255, .18);
	outline: none;
}

.lightbox-unsaved-actions button:disabled {
	opacity: .5;
	cursor: default;
}

/* Save is the default action (initial focus), so it carries the accent
   like the edit panel's own Save. */
.lightbox-unsaved-actions .lightbox-unsaved-save {
	border-color: var(--accent);
	background: var(--accent);
}

.lightbox-edit-help-header {
	display: flex;
	align-items: center;
	justify-content: space-between;
	gap: .75rem;
	font-size: 1rem;
	font-weight: 600;
	margin-bottom: .75rem;
}

/* Same recipe as .lightbox-edit-close (see its own comment) — a plain "×"
   in the corner, no border, so it doesn't visually compete with anything
   else in this header. */
.lightbox-edit-help-close {
	flex: 0 0 auto;
	display: flex;
	align-items: center;
	justify-content: center;
	width: 1.3rem;
	height: 1.3rem;
	padding: 0;
	border: none;
	border-radius: 50%;
	background: none;
	color: #fff;
	font-size: 1.1rem;
	line-height: 1;
	cursor: pointer;
	opacity: .8;
}

.lightbox-edit-help-close:hover {
	opacity: 1;
	background: rgba(255, 255, 255, .12);
}

.lightbox-edit-help-body {
	margin: 0;
	font-size: .85rem;
	line-height: 1.5;
}

.lightbox-edit-help-body dt {
	font-weight: 600;
	color: var(--accent);
	margin-top: .85rem;
}

.lightbox-edit-help-body dt:first-child {
	margin-top: 0;
}

.lightbox-edit-help-body dd {
	margin: .2rem 0 0;
	opacity: .9;
}

.lightbox-edit-panel-header {
	display: flex;
	align-items: center;
	gap: .4rem;
	font-size: .85rem;
	opacity: .85;
}

/* flex: 1 1 auto so this grows to fill whatever room the row's other
   (fixed-size) buttons don't need — pushes them flush to the row's right
   edge (no explicit justify-content needed for that) and, as a side
   effect, gives the drag handle (see onPanelTitleMouseDown in lightbox.js)
   a much bigger grab target than the bare text alone would be. */
.lightbox-edit-panel-title {
	flex: 1 1 auto;
	cursor: move;
}

/* Same small round button as .lightbox-edit-info (see its own comment),
   just an "×" instead of "?" and — unlike every other button in this
   header — no border, so it doesn't visually compete with the actually-
   informational "?" button right next to it for which one looks more
   like "the important circle here." */
.lightbox-edit-close {
	flex: 0 0 auto;
	display: flex;
	align-items: center;
	justify-content: center;
	width: 1.1rem;
	height: 1.1rem;
	padding: 0;
	border: none;
	border-radius: 50%;
	background: none;
	color: #fff;
	font-size: .9rem;
	line-height: 1;
	cursor: pointer;
	opacity: .8;
}

.lightbox-edit-close:hover {
	opacity: 1;
	background: rgba(255, 255, 255, .12);
}

/* Same small round "?" as .lightbox-location-info, deliberately — same
   role (explain a display-only setting isn't touching the real file),
   same treatment, including the flex-centering and zeroed padding that
   actually make the "?" land centered in a true circle rather than a
   button-default-padding-skewed oval. */
.lightbox-edit-info {
	flex: 0 0 auto;
	display: flex;
	align-items: center;
	justify-content: center;
	width: 1.1rem;
	height: 1.1rem;
	padding: 0;
	border-radius: 50%;
	border: 1px solid rgba(255, 255, 255, .5);
	background: none;
	color: #fff;
	font-size: .7rem;
	line-height: 1;
	font-family: inherit;
	cursor: pointer;
	opacity: .8;
}

.lightbox-edit-info:hover {
	opacity: 1;
}

/* Same recipe as .lightbox-edit-info, just a touch bigger to fit an SVG
   glyph legibly instead of a single character. */
.lightbox-edit-copy,
.lightbox-edit-paste {
	flex: 0 0 auto;
	display: flex;
	align-items: center;
	justify-content: center;
	width: 1.3rem;
	height: 1.3rem;
	padding: 0;
	border-radius: 50%;
	border: 1px solid rgba(255, 255, 255, .5);
	background: none;
	color: #fff;
	cursor: pointer;
	opacity: .8;
}

.lightbox-edit-copy:hover,
.lightbox-edit-paste:hover {
	opacity: 1;
}

/* Same "an inline <svg>'s width/height attributes collapse inside a flex
   container" issue as the .lightbox-tags-toggle/.lightbox-edit-crop group
   above — this button is a flex item too (.lightbox-edit-panel-header is
   display:flex), so its icon needs the same explicit re-sizing. */
.lightbox-edit-copy svg,
.lightbox-edit-paste svg {
	width: 13px;
	height: 13px;
	flex-shrink: 0;
}

/* The two "how is this photo currently framed" glyphs — see their own
   markup comments (buildOverlay, lightbox.js) for what each shows. */
.lightbox-edit-status-icons {
	display: flex;
	align-items: center;
	gap: .6rem;
	opacity: .85;
}

.lightbox-edit-orientation-icon,
.lightbox-edit-crop-icon {
	width: 20px;
	height: 20px;
	flex-shrink: 0;
}

/* scale()/rotate() is applied to both via JS (updateEditStatusIcons —
   the crop icon gets the identical transform the orientation icon does,
   so the crop region rotates/flips along with the frame around it) —
   same center-pivot convention as .lightbox-media/.lightbox-media img, so
   each glyph rotates in place rather than around its own top-left corner. */
.lightbox-edit-orientation-icon,
.lightbox-edit-crop-icon {
	transform-origin: center center;
}

.lightbox-edit-controls {
	display: flex;
	gap: .25rem;
}

.lightbox-edit-controls button {
	display: flex;
	align-items: center;
	justify-content: center;
	width: 2.25rem;
	height: 2.25rem;
	background: none;
	border: none;
	border-radius: 8px;
	color: #fff;
	font-size: 1.3rem;
	line-height: 1;
	cursor: pointer;
	opacity: .85;
}

/* :not(.active) here, deliberately: without it, this rule's higher
   specificity (an extra :not()) than the plain .active rule below would
   win while hovering an active button, silently replacing its accent
   fill with this neutral white tint and reintroducing the exact
   active-vs-hover ambiguity that rule exists to avoid — excluding .active
   here instead of fighting it on specificity keeps the two states from
   ever competing for the same `background` property at all. */
.lightbox-edit-controls button:hover:not(:disabled):not(.active) {
	opacity: 1;
	background: rgba(255, 255, 255, .12);
}

/* No control in this row ships disabled today, but this is written
   generically for whenever one does need to state "not currently
   actionable" without requiring a click to discover it. */
.lightbox-edit-controls button:disabled {
	opacity: .35;
	cursor: default;
}

/* Flip, crop, and expand-canvas buttons are stateful (on/off) — flip
   reflects whether it's currently applied, crop reflects whether its box
   is currently open (see toggleCropBox), expand-canvas reflects whether
   the full uncropped source is currently on screen (see
   toggleExpandToCanvas) — unlike the two rotate ones, each a
   fire-and-forget quarter-turn with nothing to reflect afterward.
   A solid --accent fill (same "this is the important/active one" language
   as .lightbox-edit-save's own background) rather than a lighter white
   tint — confirmed live as a real problem with the previous, lighter
   treatment: it used the same rgba-white-over-dark-background *language*
   as :hover (just a different opacity), so an active-but-not-hovered
   button and an inactive-but-hovered one could look the same at a glance,
   with no way to tell "is this ON" from "is my mouse just over this."
   A distinctly *colored* fill has no such ambiguity with a hover state
   that stays monochrome. */
.lightbox-edit-flip-h.active,
.lightbox-edit-flip-v.active,
.lightbox-edit-crop.active,
.lightbox-edit-expand-canvas.active {
	background: var(--accent);
	opacity: 1;
}

.lightbox-edit-flip-h.active:hover,
.lightbox-edit-flip-v.active:hover,
.lightbox-edit-crop.active:hover,
.lightbox-edit-expand-canvas.active:hover {
	filter: brightness(1.15);
}

.lightbox-edit-actions {
	display: flex;
	gap: .5rem;
}

.lightbox-edit-actions button {
	font-size: .8rem;
	padding: .3rem .7rem;
	border-radius: 6px;
	border: 1px solid rgba(255, 255, 255, .25);
	background: rgba(255, 255, 255, .08);
	color: #fff;
	cursor: pointer;
}

.lightbox-edit-actions button:hover:not(:disabled) {
	background: rgba(255, 255, 255, .16);
}

.lightbox-edit-actions button:disabled {
	opacity: .4;
	cursor: default;
}

.lightbox-edit-save {
	border-color: var(--accent) !important;
	background: var(--accent) !important;
}

/* Compact, deliberately — same convenience-navigation role as the main
   .lightbox-prev/.lightbox-next, just small enough to sit in this row
   alongside Reset/Save without widening the panel. Narrow horizontal
   padding rather than a fixed width: the glyph itself is the only
   content, so there's nothing to keep centered/aligned that a fixed
   width would help with. */
.lightbox-edit-prev,
.lightbox-edit-next {
	font-size: 1rem;
	padding: .3rem .45rem;
}

/* Same "explicit and unconditional, not :focus-visible" reasoning as
   .lightbox-nav:focus (see its own comment) — a click OR the edit panel's
   own arrow-key handling (onKeydown's ArrowLeft/ArrowRight) both leave
   this focused with Enter/Space then repeating that direction natively
   (a real <button>), so the highlight should show immediately either way,
   not only once the browser's own focus-visible heuristic decides a
   keyboard interaction happened. More opaque than this row's own plain
   :hover (.16 — see .lightbox-edit-actions button:hover above) so the two
   stay visually distinct from each other. */
.lightbox-edit-prev:focus,
.lightbox-edit-next:focus {
	outline: none;
	background: rgba(255, 255, 255, .28);
	border-color: rgba(255, 255, 255, .5);
}

.lightbox-edit-status {
	font-size: .75rem;
	opacity: .7;
	min-height: 1em;
}

/* Sized/positioned entirely via JS (lightbox.js's positionCropOverlay) to
   match the currently displayed <img>'s own on-screen rect — this box's
   left/top/width/height are a snapshot of that measurement, not something
   CSS derives on its own. Deliberately NOT a descendant of .lightbox-media
   (which carries the live rotate/flip preview transform — see
   .lightbox-media's own comment): staying outside that transform is what
   lets this remain a plain, unrotated rectangle in screen space instead of
   needing its own rotation-matrix correction the way panning does (see
   rotatePanDeltaForPreview in lightbox.js). pointer-events: none here,
   reopened lower down only for the box/handles that actually need to
   receive drags — everywhere else in this rect should still let clicks
   through untouched.
   z-index 2, one tier above the plain z-index 1 most of this overlay's
   other floating chrome uses (toolbar, counter, bottom toolbar/coords
   row, nav arrows, ...) — confirmed live as a real bug at the shared
   z-index 1 this used to have: the crop box's own drag handles could sit
   underneath the coordinates row or the map-view button, wherever the
   two visually overlapped, making a handle right there undraggable. Only
   .lightbox-edit-panel is allowed to still win over this (see its own
   z-index 3 and comment) — every other z-index 1 element in this overlay
   is deliberately meant to lose to an open crop box now. */
.lightbox-crop-overlay {
	position: absolute;
	z-index: 2;
	pointer-events: none;
}

/* The four rectangles that dim everything outside the crop box — top/
   bottom span the full width, left/right fill only the vertical band
   between them (see renderCropGeometry in lightbox.js), the standard
   four-rectangle "mask everything but this box" construction, no actual
   CSS masking/clip-path needed. */
.lightbox-crop-mask {
	position: absolute;
	background: rgba(0, 0, 0, .55);
}

.lightbox-crop-mask-top {
	top: 0;
	left: 0;
	right: 0;
}

.lightbox-crop-mask-bottom {
	bottom: 0;
	left: 0;
	right: 0;
}

.lightbox-crop-mask-left {
	left: 0;
}

.lightbox-crop-mask-right {
	right: 0;
}

.lightbox-crop-box {
	position: absolute;
	border: 1px solid #fff;
	/* A drag started anywhere in the box's own interior (not just its
	   border) moves it — see onCropBoxMouseDown's e.target check, which is
	   what actually restricts this to the box itself vs. its handle
	   children despite the shared "move" cursor family here. */
	cursor: move;
	pointer-events: auto;
}

/* Faint rule-of-thirds guide inside the box — a fixed 3x3 grid via
   background gradients rather than extra DOM elements, purely decorative
   (no drag behavior of its own), so it's safe to leave `pointer-events`
   alone here and let mousedowns fall through to .lightbox-crop-box above. */
.lightbox-crop-box::before {
	content: "";
	position: absolute;
	inset: 0;
	background-image: linear-gradient(rgba(255, 255, 255, .5) 1px, transparent 1px), linear-gradient(90deg, rgba(255, 255, 255, .5) 1px, transparent 1px);
	background-size: 33.333% 33.333%;
	pointer-events: none;
}

.lightbox-crop-handle {
	position: absolute;
	width: 14px;
	height: 14px;
	background: #fff;
	border: 1px solid rgba(0, 0, 0, .4);
	border-radius: 50%;
	pointer-events: auto;
}

.lightbox-crop-handle-nw {
	left: -7px;
	top: -7px;
	cursor: nwse-resize;
}

.lightbox-crop-handle-n {
	left: 50%;
	top: -7px;
	margin-left: -7px;
	cursor: ns-resize;
}

.lightbox-crop-handle-ne {
	right: -7px;
	top: -7px;
	cursor: nesw-resize;
}

.lightbox-crop-handle-e {
	right: -7px;
	top: 50%;
	margin-top: -7px;
	cursor: ew-resize;
}

.lightbox-crop-handle-se {
	right: -7px;
	bottom: -7px;
	cursor: nwse-resize;
}

.lightbox-crop-handle-s {
	left: 50%;
	bottom: -7px;
	margin-left: -7px;
	cursor: ns-resize;
}

.lightbox-crop-handle-sw {
	left: -7px;
	bottom: -7px;
	cursor: nesw-resize;
}

.lightbox-crop-handle-w {
	left: -7px;
	top: 50%;
	margin-top: -7px;
	cursor: ew-resize;
}

/* The caption-editing toggle's own home — bottom-right, near where the
   caption itself is actually edited (.lightbox-info, bottom-center),
   rather than the top toolbar it used to share with buttons that aren't
   related to the caption at all. Same zoomed-image z-index reasoning as
   .lightbox-toolbar above.

   A column, not a row: the coordinates readout (.lightbox-counter-coords-row,
   when present) sits above .lightbox-bottom-toolbar-buttons rather than
   inline with it, so that — anchored by `bottom` here, same as
   .lightbox-counter on the opposite corner — the buttons' own position
   never shifts depending on whether the current photo happens to have
   one. max-width mirrors .lightbox-counter's own cap on the other
   corner, so a long coordinate string wraps/ellipsizes instead of
   running off the edge of the screen. */
.lightbox-bottom-toolbar {
	position: absolute;
	bottom: .75rem;
	right: 1.25rem;
	z-index: 1;
	display: flex;
	flex-direction: column;
	align-items: flex-end;
	gap: .35rem;
	max-width: 55vw;
	color: #fff;
}

.lightbox-bottom-toolbar-buttons {
	display: flex;
	align-items: center;
	gap: .25rem;
}

.lightbox-close,
.lightbox-help-toggle,
.lightbox-download,
.lightbox-star,
.lightbox-private-toggle,
.lightbox-caption-mode,
.lightbox-edit-mode,
.lightbox-tags-toggle,
.lightbox-delete-photo {
	display: flex;
	align-items: center;
	justify-content: center;
	width: 2.25rem;
	height: 2.25rem;
	background: none;
	border: none;
	border-radius: 8px;
	color: #fff;
	line-height: 1;
	cursor: pointer;
	opacity: .85;
}

.lightbox-close {
	font-size: 1.8rem;
}

.lightbox-help-toggle {
	font-size: 1.15rem;
	font-weight: 600;
}

/* The lightbox-wide help reuses the edit panel's help card; a touch wider
   since it has more to say. */
.lightbox-main-help-overlay .lightbox-edit-help-panel {
	max-width: min(520px, 90vw);
}

.lightbox-download,
.lightbox-star,
.lightbox-caption-mode,
.lightbox-tags-toggle {
	font-size: 1.3rem;
}

/* An inline <svg>'s own width/height HTML attributes aren't reliable
   inside a flex container — as a flex item its layout box can collapse to
   zero width (Chrome/Firefox both do this) unless CSS explicitly sizes it;
   the attribute is only an intrinsic-size hint, not something flex sizing
   actually respects. */
.lightbox-tags-toggle svg,
.lightbox-delete-photo svg,
.lightbox-private-toggle svg,
.lightbox-edit-mode svg {
	width: 18px;
	height: 18px;
	flex-shrink: 0;
}

.lightbox-edit-crop svg,
.lightbox-edit-expand-canvas svg {
	width: 16px;
	height: 16px;
	flex-shrink: 0;
}

.lightbox-star.starred {
	color: #f4c542;
	opacity: 1;
}

/* Same prohibition-sign language as .lightbox-counter-date.date-hidden —
   a reddish diagonal slash across the icon, here instead of an oval
   border since the icon itself (not a variable-width text pill) is what
   needs circumscribing. */
.lightbox-private-toggle {
	position: relative;
}

.lightbox-private-toggle.is-private {
	color: var(--error);
	opacity: 1;
}

.lightbox-private-toggle.is-private::after {
	content: "";
	position: absolute;
	left: 20%;
	right: 20%;
	top: 50%;
	border-top: 1.5px solid var(--error);
	transform: translateY(-50%) rotate(-45deg);
}

.lightbox-caption-mode.active,
.lightbox-edit-mode.active,
.lightbox-tags-toggle.active {
	background: rgba(255, 255, 255, .18);
	opacity: 1;
}

.lightbox-close:hover,
.lightbox-help-toggle:hover,
.lightbox-download:hover,
.lightbox-star:hover,
.lightbox-private-toggle:hover,
.lightbox-caption-mode:hover,
.lightbox-edit-mode:hover,
.lightbox-tags-toggle:hover {
	opacity: 1;
	background: rgba(255, 255, 255, .12);
}

/* Its own (red-tinted) hover rather than joining the group above — the
   one destructive action in this toolbar, worth visually saying so. */
.lightbox-delete-photo:hover {
	opacity: 1;
	background: rgba(176, 0, 32, .35);
	color: #ff8a80;
}

/* Bottom-left — pairs with the top-right toolbar and the bottom-right
   caption-mode button so every corner carries one piece of chrome,
   rather than stacking two of them (this and the tags-toggle tab) into
   the same top-left corner. */
.lightbox-counter {
	position: absolute;
	bottom: .75rem;
	left: 1.25rem;
	color: #fff;
	max-width: 55vw;
	/* Same zoomed-image-stacking-context reasoning as .lightbox-toolbar. */
	z-index: 1;
}

.lightbox-counter-index {
	display: block;
	font-size: .85rem;
	opacity: .8;
}

.lightbox-counter-filename-row {
	display: flex;
	align-items: baseline;
	gap: .5rem;
	margin-top: .15rem;
	max-width: 100%;
}

.lightbox-counter-filename {
	flex: 1 1 auto;
	font-size: .75rem;
	opacity: .6;
	white-space: nowrap;
	overflow: hidden;
	text-overflow: ellipsis;
	/* Lets the filename (not the date, which should always stay fully
	   readable — see .lightbox-counter-date's flex: none below) be the
	   one that shrinks/ellipsizes when the two together outgrow the
	   available width — a flex child's default min-width is auto (its
	   own content size), which would otherwise stop it from shrinking
	   below that at all. */
	min-width: 0;
}

.lightbox-counter-date {
	flex: 0 0 auto;
	font: inherit;
	font-size: .75rem;
	opacity: .6;
	padding: 0;
	background: none;
	border: none;
	color: inherit;
	cursor: default;
	white-space: nowrap;
}

.lightbox-counter-date-interactive {
	cursor: pointer;
}

.lightbox-counter-date-interactive:hover {
	opacity: .9;
}

/* The "hidden from share links" state — a reddish oval circumscribing
   the date with a diagonal slash through it, same prohibition-sign
   language as a "no" icon, applied directly to the text itself rather
   than a separate icon glyph. */
.lightbox-counter-date.date-hidden {
	position: relative;
	opacity: 1;
	padding: .1rem .55rem;
	border: 1.5px solid var(--error);
	border-radius: 999px;
	color: var(--error);
}

.lightbox-counter-date.date-hidden::after {
	content: "";
	position: absolute;
	left: -6%;
	right: -6%;
	top: 50%;
	border-top: 1.5px solid var(--error);
	transform: translateY(-50%) rotate(-25deg);
}

.lightbox-counter-coords-row {
	display: flex;
	align-items: baseline;
	gap: .4rem;
	margin-top: .15rem;
	max-width: 100%;
}

/* The flex item of .lightbox-counter-coords-row — not the link itself,
   see the doc comment on this in lightbox.js. Owns the truncation/growth
   properties that used to live on .lightbox-counter-coords alone, since
   it's this whole padded unit that should grow/shrink and ellipsize
   together, not the link on its own leaving the padding stranded outside
   that behavior. */
.lightbox-counter-coords-wrap {
	flex: 1 1 auto;
	min-width: 0;
	overflow: hidden;
	white-space: nowrap;
	text-overflow: ellipsis;
}

.lightbox-counter-coords {
	font-size: .75rem;
	opacity: .6;
	color: inherit;
	text-decoration: none;
}

.lightbox-counter-coords:hover {
	opacity: .9;
	text-decoration: underline;
}

.lightbox-location-toggle,
.lightbox-location-info {
	flex: 0 0 auto;
	position: relative;
	display: flex;
	align-items: center;
	justify-content: center;
	width: 1.1rem;
	height: 1.1rem;
	padding: 0;
	background: none;
	border: none;
	color: inherit;
	opacity: .6;
	cursor: pointer;
}

.lightbox-location-toggle:hover,
.lightbox-location-info:hover {
	opacity: 1;
}

.lightbox-location-toggle svg {
	width: 14px;
	height: 14px;
	flex-shrink: 0;
}

/* Same icon-diagonal-slash language as .lightbox-private-toggle.is-private
   (a square icon, so the same 45deg corner-to-corner angle applies —
   .lightbox-counter-date's shallower -25deg is specific to circumscribing
   a wide oval pill instead of a square icon). */
.lightbox-location-toggle.is-hidden {
	color: var(--error);
	opacity: 1;
}

.lightbox-location-toggle.is-hidden::after {
	content: "";
	position: absolute;
	left: 18%;
	right: 18%;
	top: 50%;
	border-top: 1.5px solid var(--error);
	transform: translateY(-50%) rotate(-45deg);
}

.lightbox-location-info {
	border-radius: 50%;
	border: 1px solid currentColor;
	font-size: .65rem;
	line-height: 1;
	font-family: inherit;
}

/* Top-center — clear of the top-right toolbar and the tags-panel tab
   poking out at top-left. Shown while the thumbnail (or the previous
   photo, if there's no thumbnail) is standing in for a full-res image
   still loading — see loadPhotoProgressively's setFullResLoading calls.
   Same zoomed-image-stacking-context reasoning as .lightbox-toolbar/
   .lightbox-counter for the z-index. Pill + CSS-only spinner, cross-
   checked against the CPG199 portal's own lightbox, which uses the same
   treatment for the same purpose. */
.lightbox-loading {
	display: none;
	position: absolute;
	top: 1rem;
	left: 50%;
	transform: translateX(-50%);
	z-index: 1;
	align-items: center;
	gap: .45rem;
	background: rgba(0, 0, 0, .55);
	color: #fff;
	font-size: .8rem;
	padding: .35rem .9rem;
	border-radius: 999px;
	pointer-events: none;
}

.lightbox-loading::before {
	content: "";
	width: 11px;
	height: 11px;
	border-radius: 50%;
	border: 2px solid rgba(255, 255, 255, .35);
	border-top-color: #fff;
	animation: lightbox-loading-spin .7s linear infinite;
}

@keyframes lightbox-loading-spin {
	to {
		transform: rotate(360deg);
	}
}

/* Same top-center pill treatment as .lightbox-loading, and safe to share
   the exact position — the two are mutually exclusive in practice (zoom
   always resets on every photo navigation, which is exactly when
   .lightbox-loading might be showing instead). Shows the current zoom as
   a percentage of the photo's own native resolution — see
   updateZoomReadout in lightbox.js — for as long as a photo (not a
   video) is on screen. A real <button> (the zoom-to-100%/back-to-fit
   toggle — see toggleZoomTo100Percent), so this resets the usual button
   chrome rather than the pointer-events:none a passive pill like
   .lightbox-loading gets away with. */
.lightbox-zoom-readout {
	display: none;
	position: absolute;
	top: 1rem;
	left: 50%;
	transform: translateX(-50%);
	z-index: 1;
	align-items: center;
	background: rgba(0, 0, 0, .55);
	color: #fff;
	font-size: .8rem;
	font-family: inherit;
	padding: .35rem .9rem;
	border-radius: 999px;
	border: none;
	cursor: pointer;
}

.lightbox-zoom-readout:hover,
.lightbox-zoom-readout:focus {
	background: rgba(0, 0, 0, .72);
	outline: none;
}

/* Subtly de-emphasized relative to the percentage it sits beside — an
   aside, not the headline figure. */
.lightbox-zoom-readout-fit-tag {
	opacity: .65;
	margin-left: .35rem;
	font-size: .72rem;
}

.lightbox-menu {
	position: fixed;
	display: flex;
	flex-direction: column;
	background: var(--surface);
	border: 1px solid var(--border);
	border-radius: var(--radius);
	box-shadow: 0 8px 24px rgba(0, 0, 0, .35);
	overflow: hidden;
	z-index: var(--z-lightbox-popup);
	min-width: 170px;
}

.lightbox-menu button {
	background: none;
	border: none;
	text-align: left;
	padding: .6rem .9rem;
	font-size: .9rem;
	color: var(--text);
	cursor: pointer;
	/* Harmless for a plain leaf item's single text node (space-between
	   with one child just left-aligns it, same as before) — only a
	   submenu trigger's two spans (label, .lightbox-menu-item-caret)
	   actually spread apart. */
	display: flex;
	align-items: center;
	justify-content: space-between;
	gap: .75rem;
}

/* Bold and a size up from the label text, not just a trailing character
   at the label's own weight — meant to actually read as "this opens
   something," not blend into the label. */
.lightbox-menu-item-caret {
	font-weight: 700;
	font-size: 1.15rem;
	line-height: 1;
	flex-shrink: 0;
}

.lightbox-menu button:hover,
.lightbox-menu button:focus,
.lightbox-menu button.lightbox-menu-item-open {
	background: var(--surface-2);
	outline: none;
}

/* An inert, informational item — see popupmenu.js's own top-of-file
   comment on {label, disabled: true}. Deliberately no hover/focus
   background of its own (unlike the rule above): nothing happens when
   this is interacted with, so it shouldn't look interactive either. */
.lightbox-menu button:disabled {
	color: var(--text-muted);
	cursor: default;
}

.lightbox-menu button + button {
	border-top: 1px solid var(--border);
}

/* Back to normal flex flow beside .lightbox-media (hugging the image's
   own edges) rather than pinned to fixed screen positions — that made
   the reach between them too far and put the left one over the tags
   panel. The center-collapse-while-loading problem this was chasing is
   now solved at the source instead: show()/loadPhotoProgressively keep
   the previous photo's element in place (and thus this row's width
   stable) until the new one is actually ready to swap in, rather than
   clearing mediaContainer up front and leaving it briefly empty. */
.lightbox-nav {
	position: relative;
	/* A zoomed-and-panned image can visually bleed well past its own box;
	   without this, that overflow (unpositioned, so painted in plain DOM
	   order) would paint over these buttons and swallow their clicks. */
	z-index: 1;
	background: none;
	border: none;
	color: #fff;
	font-size: 2.5rem;
	line-height: 1;
	cursor: pointer;
	padding: 0 1rem;
	opacity: .8;
}

.lightbox-nav:hover {
	opacity: 1;
}

/* Explicit and unconditional (not :focus-visible) — a click leaves the
   button focused, and Enter/Space then repeats that direction natively,
   so the highlight should show immediately on click, not only once the
   browser's focus-visible heuristic decides a keyboard interaction
   happened. */
.lightbox-nav:focus {
	opacity: 1;
	outline: none;
	background: rgba(255, 255, 255, .18);
	border-radius: 8px;
}

/* Bottom-center, between the two bottom-corner buttons. Centered via
   left:50%/translateX(-50%), which centers on this box's *own* width —
   the status readout used to be a flex sibling counted in that width,
   which pulled the whole bar's centerpoint away from the caption box's
   own center (true centering needs the caption/input to be the only
   thing this centering math accounts for; see .lightbox-caption-status
   below for where the status readout actually lives now). */
.lightbox-info {
	position: absolute;
	bottom: .75rem;
	left: 50%;
	transform: translateX(-50%);
	/* Wide enough to not clip the caption input below, which is allowed
	   to get about as wide as the photo itself. */
	max-width: 92vw;
	color: #fff;
	z-index: 1;
}

/* Its own backdrop (unlike the plain white text this used to be) —
   legible over whatever the photo underneath happens to look like,
   which matters now that a tall photo's bottom edge can end up close by
   (see the max-height calc() on .lightbox-media img/video). Hidden
   entirely when there's no caption (existing display:none toggle in
   renderCaption), so there's never an empty pill floating here. */
.lightbox-caption {
	font-size: .95rem;
	background: rgba(20, 16, 12, .75);
	padding: .225rem .9rem;
	border-radius: var(--radius);
}

/* Wider ("a longer, single line" — rows="1" in the markup) and no
   longer manually resizable, since a single line is the whole point;
   already had its own background, so no legibility change needed here
   the way the caption view text above did. Capped at 960px (the 92vw
   term only matters below ~1040px viewport width, as a narrow-window
   fallback) — updateCaptionMaxWidth in lightbox.js may additionally set
   an inline max-width narrower than this at high browser zoom, to stay
   clear of .lightbox-bottom-toolbar; see that function's own doc comment.
   Centered text (not left-aligned) so the cursor starts in the middle of
   the box rather than pinned to its far left edge, which is where the
   width increase would otherwise leave it stranded.

   min-height gives a caption up to ~3 lines of room before the box grows
   further — updateCaptionVerticalCentering in lightbox.js vertically
   centers shorter content within it (redistributing the padding below,
   not by anything in this rule) rather than leaving it flush against the
   top. */
.lightbox-info textarea.lightbox-caption-input {
	width: min(92vw, 960px);
	text-align: center;
	background: rgba(0, 0, 0, .35);
	border: 1px solid rgba(255, 255, 255, .25);
	border-radius: 8px;
	color: #fff;
	font-family: inherit;
	font-size: .9rem;
	padding: .25rem .6rem;
	resize: none;
	min-height: 3em;
}

.lightbox-info textarea.lightbox-caption-input::placeholder {
	color: rgba(255, 255, 255, .5);
}

/* Floats just past the caption box's right edge — taken out of normal
   flow entirely (rather than a flex sibling counted in .lightbox-info's
   own width) so its changing content ("...", "Saved", the longer error
   text, or nothing at all) can never shift the caption box's own
   position or true-centering, only its own. .lightbox-info establishes
   the containing block this positions against (it's already
   position:absolute, which does that regardless of display type). */
.lightbox-caption-status {
	position: absolute;
	left: 100%;
	top: 50%;
	transform: translateY(-50%);
	margin-left: .6rem;
	font-size: .75rem;
	opacity: .65;
	white-space: nowrap;
}

/* Stands in for the browser's own native <video controls> only when a
   display rotation/flip is active — see updateVideoControls in
   lightbox.js and videoHasDisplayEdit's own doc comment for why native
   controls can't be used in that case at all (a CSS transform on
   mediaContainer would rotate them right along with the picture, since
   they aren't a separate element). Deliberately NOT nested inside
   mediaContainer — sitting outside it, as its own sibling positioned
   against .lightbox-overlay, is what keeps this bar itself unrotated
   regardless of whatever transform the video next to it is carrying.
   Positioned above .lightbox-info (bottom: .75rem) rather than sharing
   its row, so a caption doesn't crowd it. Shown/hidden purely via inline
   style from JS (setElementVisible's display+visibility pairing) — no
   class-based toggle, same policy as everywhere else in this file (see
   STYLE_GUIDE.md's "Structural show/hide vs. layout mode"). */
.lightbox-video-controls {
	position: absolute;
	bottom: 3.25rem;
	left: 50%;
	transform: translateX(-50%);
	max-width: 92vw;
	display: flex;
	align-items: center;
	gap: .6rem;
	background: rgba(20, 16, 12, .82);
	padding: .4rem .75rem;
	border-radius: var(--radius);
	color: #fff;
	z-index: 1;
}

.lightbox-video-play,
.lightbox-video-mute,
.lightbox-video-fullscreen {
	background: none;
	border: none;
	color: inherit;
	cursor: pointer;
	font-size: 1rem;
	line-height: 1;
	padding: .2rem;
	display: flex;
	align-items: center;
	justify-content: center;
}

.lightbox-video-seek {
	width: min(50vw, 320px);
	accent-color: var(--accent);
	cursor: pointer;
}

.lightbox-video-time {
	font-size: .75rem;
	opacity: .8;
	white-space: nowrap;
	font-variant-numeric: tabular-nums;
}

/* Deliberately narrower than the seek bar — this is a secondary control
   next to the mute button, not something that needs to command as much
   width/attention. */
.lightbox-video-volume {
	width: 70px;
	accent-color: var(--accent);
	cursor: pointer;
}

.lightbox-tags-panel {
	position: fixed;
	top: 0;
	left: 0;
	height: 100%;
	width: 280px;
	max-width: 80vw;
	background: rgba(20, 16, 12, .96);
	border-right: 1px solid rgba(255, 255, 255, .1);
	padding-top: 4.5rem;
	box-sizing: border-box;
	transform: translateX(-100%);
	transition: transform .2s ease;
	z-index: 1;
	display: flex;
	flex-direction: column;
}

.lightbox-tags-panel.open {
	transform: translateX(0);
}

/* The toggle button lives inside the panel and rides along with its
   slide transform instead of sitting in the top toolbar — it's
   positioned just past the panel's own right edge, which — because a
   translateX(-100%) shift moves everything inside the panel (including
   this child) by exactly the panel's own width — lands it right back at
   the screen's top-left corner once closed. No separate open/closed
   position logic needed: the same translateX toggle that already opens
   and closes the panel carries the button along with it. */
.lightbox-tags-panel > .lightbox-tags-toggle {
	position: absolute;
	top: 1rem;
	right: -3.5rem;
}

.lightbox-tags-panel-top {
	flex: 0 0 auto;
	padding: 0 1.1rem 1rem;
}

.lightbox-tags-panel-header {
	color: #fff;
	font-size: .85rem;
	font-weight: 600;
	opacity: .8;
	margin-bottom: .75rem;
}

.lightbox-tags-missing-note {
	color: #ff9b9b;
	font-size: .78rem;
	margin: -.4rem 0 .6rem;
}

/* The deliberate-browse counterpart to the type-ahead combobox above it —
   every contact, checkbox-toggleable, in a fixed alphabetical order that
   never reshuffles as you check things off (a row jumping position the
   instant you click it would defeat the point of a scan-through list). Its
   own scroll region, separate from the panel as a whole, so the combobox
   stays reachable without scrolling back up through a long roster. */
.lightbox-contacts-table {
	flex: 1 1 auto;
	overflow-y: auto;
	padding: .5rem 1.1rem 1.1rem;
	border-top: 1px solid rgba(255, 255, 255, .1);
}

/* Sticky within .lightbox-contacts-table's own scroll region, so the
   column labels (and the ability to re-sort) stay reachable while
   scrolling through a long roster. */
.lightbox-contacts-headerrow {
	position: sticky;
	top: 0;
	margin: -.5rem -1.1rem .4rem;
	padding: .5rem 1.1rem .4rem;
	display: flex;
	gap: .55rem;
	background: rgba(20, 16, 12, .96);
	border-bottom: 1px solid rgba(255, 255, 255, .15);
	z-index: 1;
}

.lightbox-contacts-sort {
	background: none;
	border: none;
	color: #fff;
	opacity: .65;
	font-size: .75rem;
	font-weight: 600;
	text-transform: uppercase;
	letter-spacing: .02em;
	cursor: pointer;
	padding: 0;
	text-align: left;
	white-space: nowrap;
}

.lightbox-contacts-sort:hover,
.lightbox-contacts-sort.active {
	opacity: 1;
}

.lightbox-contacts-sort-tagged {
	/* Not narrowed to the checkbox column's width — "Tagged" (plus its sort
	   arrow once active) doesn't fit in that little space, and a fixed
	   width just clips/overlaps the text instead of wrapping it. */
	flex-shrink: 0;
}

.lightbox-contacts-sort-name {
	flex: 1;
}

.lightbox-contacts-sort-type {
	flex-shrink: 0;
}

/* Focusable (tabindex="0", see lightbox.js) for keyboard nav, but without
   the browser's own default focus ring — same reasoning, and same rule,
   as mapview.js's own photo list (.mapview-photo-rows:focus): the row
   selection highlight itself is already the visible "you're navigating
   here" signal. */
.lightbox-contacts-rows:focus {
	outline: none;
}

.lightbox-contact-row {
	display: flex;
	align-items: center;
	gap: .55rem;
	padding: .35rem 0;
	border-bottom: 1px solid rgba(255, 255, 255, .06);
	/* Row selection (independent of the checkbox) reads as text selection
	   to a browser by default — same reasoning as the overlay-wide rule,
	   just called out again here since drag-to-select is exactly the
	   gesture that would otherwise paint a native selection highlight. */
	user-select: none;
}

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

.lightbox-contact-row.selected {
	background: rgba(120, 170, 255, .18);
}

/* Deliberately only the checkbox itself is the click target — not wrapped
   in a <label> spanning the row — so scrolling past or reading a name
   can't register as an accidental toggle. */
.lightbox-contact-row input[type="checkbox"] {
	width: auto;
	margin: 0;
	flex-shrink: 0;
	cursor: pointer;
}

.lightbox-contact-name {
	flex: 1;
	min-width: 0;
	color: #fff;
	font-size: .85rem;
	overflow: hidden;
	text-overflow: ellipsis;
	white-space: nowrap;
}

.lightbox-contact-type {
	flex-shrink: 0;
	font-size: .75rem;
	color: rgba(255, 255, 255, .5);
}

/* Same pastel green family already used for "positive"/"new" signals
   elsewhere (.import-summary, the "New" tag on the import-confirm page) —
   reused here (via --group-bg/--group-text, a slightly darker text shade
   for contrast at this size) rather than introducing a second green, per
   the badge/pill approach (not a full-row tint) agreed for distinguishing
   groups. */
.lightbox-contact-type-group {
	background: var(--group-bg);
	color: var(--group-text);
	border-radius: 999px;
	padding: .1rem .55rem;
	font-weight: 600;
}

/* Fixed height, reserved up front, rather than sized to content — a
   checkbox click in the table below shouldn't shift the table (and the row
   you just clicked) by however tall the newly-added chip happens to be.
   align-content packs wrapped chip rows toward the bottom, so with few
   chips the reserved space sits empty at the *top* instead of pushing the
   combobox down. The `safe` keyword matters: plain `flex-end` is "unsafe"
   alignment per the CSS box-alignment spec — once chips overflow the fixed
   height, the overflow happens off the *top* (opposite the packing
   direction), which most browsers then render as unreachable via scroll
   even with overflow-y: auto set (no scrollbar, content just clipped).
   `safe flex-end` keeps the bottom-packed look while everything fits, and
   automatically falls back to top-packed (properly scrollable) the moment
   it wouldn't. */
.lightbox-tag-chips {
	display: flex;
	flex-wrap: wrap;
	align-content: safe flex-end;
	gap: .35rem;
	height: 20vh;
	overflow-y: auto;
	margin-bottom: .75rem;
}

.lightbox-tag-chip {
	display: inline-flex;
	align-items: center;
	gap: .3rem;
	background: rgba(255, 255, 255, .15);
	color: #fff;
	border-radius: 999px;
	padding: .2rem .5rem .2rem .7rem;
	font-size: .8rem;
}

.lightbox-tag-chip button {
	background: none;
	border: none;
	color: #fff;
	opacity: .75;
	cursor: pointer;
	font-size: .9rem;
	line-height: 1;
	padding: 0;
}

.lightbox-tag-chip button:hover {
	opacity: 1;
}

.lightbox-tag-chip-group {
	background: var(--group-bg);
	color: var(--group-text);
	font-weight: 600;
}

.lightbox-tag-chip-group button {
	color: var(--group-text);
}

.lightbox-tag-picker {
	position: relative;
	margin-bottom: .6rem;
}

.lightbox-tag-input {
	width: 100%;
}

.lightbox-tag-suggestions {
	position: absolute;
	top: 100%;
	left: 0;
	right: 0;
	margin-top: .25rem;
	background: var(--surface);
	border: 1px solid var(--border);
	border-radius: 8px;
	box-shadow: 0 8px 24px rgba(0, 0, 0, .35);
	max-height: 160px;
	overflow-y: auto;
	/* Higher than .lightbox-contacts-headerrow's z-index: 1 — with equal
	   z-index, the sticky table header wins on DOM order alone (it comes
	   later in the markup than the combobox), so it was painting over the
	   dropdown wherever the two visually overlapped. */
	z-index: 2;
}

.lightbox-tag-suggestion {
	padding: .45rem .7rem;
	font-size: .85rem;
	color: var(--text);
	cursor: pointer;
}

.lightbox-tag-suggestion:hover,
.lightbox-tag-suggestion.highlighted {
	background: var(--surface-2);
}

.lightbox-tag-suggestion-group {
	background: var(--group-bg);
	color: var(--group-text);
	font-weight: 600;
}

.lightbox-tag-suggestion-group:hover,
.lightbox-tag-suggestion-group.highlighted {
	background: #cdeacf;
}

.upload-progress {
	height: 8px;
	background: var(--surface-2);
	border-radius: 999px;
	overflow: hidden;
	margin: .75rem 0;
}

.upload-cancel {
	display: block;
	margin: -.5rem 0 .75rem;
}

.upload-progress-bar {
	height: 100%;
	width: 0%;
	background: var(--accent);
	transition: width .15s ease;
}

/* Distinguishes the "server processing" phase (thumbnail generation, after
   the upload itself has finished sending) from the upload-sending phase. */
.upload-progress-bar.processing {
	background: var(--accent-hover);
}

.quota-label {
	font-size: .85rem;
	color: var(--text-muted);
	margin-bottom: .5rem;
}

.upload-progress-bar.quota-bar-warning {
	background: var(--error);
}

/* --- Import-from-another-album page ----------------------------------- */

.import-action-bar {
	display: flex;
	align-items: center;
	flex-wrap: wrap;
	gap: 1rem;
	margin-bottom: 1rem;
}

/* Inline beside the submit button, unlike the global block-with-space label. */
.import-order-label {
	display: flex;
	align-items: center;
	gap: .4rem;
	margin-bottom: 0;
}

.import-order-label select {
	font-family: inherit;
	font-size: .9rem;
	padding: .35rem .45rem;
	border: 1px solid var(--border);
	border-radius: 6px;
	background: var(--surface);
	color: var(--text);
}

.import-clear {
	background: none;
	border: none;
	color: var(--accent);
	cursor: pointer;
	font-size: .85rem;
	font-weight: normal;
	padding: 0;
	text-decoration: underline;
}

.import-clear:hover {
	background: none;
	color: var(--accent-hover);
}

.import-hidden-note {
	color: var(--text-muted);
	font-size: .85rem;
}

.import-layout {
	display: flex;
	align-items: flex-start;
	gap: 1.25rem;
}

/* Sticky, bounded to the viewport, and a flex column: the top section
   (chips + combobox + mode toggle) stays put while the tag table below
   gets the remaining height as its own scroll region — mirroring the
   lightbox's tags panel, where the combobox and in-play chips stay visible
   no matter how far down the table you've scrolled. The tree on the right
   scrolls independently of all of it. */
.import-filter-panel {
	flex: 0 0 320px;
	position: sticky;
	top: 1rem;
	max-height: calc(100vh - 2rem);
	display: flex;
	flex-direction: column;
	background: var(--surface);
	border: 1px solid var(--border);
	border-radius: var(--radius);
}

/* Positioned + raised so everything in the top section that floats
   (the combobox's suggestions dropdown) stacks above the table section
   below it — the sticky header row down there carries z-index: 1, and
   both being at 1 would let the later-in-DOM header paint over the
   dropdown. */
.import-filter-top {
	position: relative;
	z-index: 2;
	padding: 1rem 1rem .25rem;
}

.import-filter-panel h3 {
	margin: 0 0 .4rem;
}

.import-filter-hint {
	font-size: .8rem;
	margin: 0 0 .6rem;
}

/* Fixed height, reserved up front, same reasoning as .lightbox-tag-chips:
   a checkbox click in the table below shouldn't shift the table (and the
   row you just clicked) by however tall a newly-added chip happens to be.
   Bottom-packed with `safe` alignment so overflowing chips stay reachable
   by scroll — see the .lightbox-tag-chips comment for the full story. */
.import-filter-chips {
	display: flex;
	flex-wrap: wrap;
	align-content: safe flex-end;
	gap: .35rem;
	height: 11vh;
	overflow-y: auto;
	margin-bottom: .5rem;
}

/* Same pastel "this is a group" signal as the lightbox's chips, on the
   light-theme .tag-chip base the contacts page already uses. */
.tag-chip-group {
	background: var(--group-bg);
	color: var(--group-text);
	font-weight: 600;
}

.tag-chip-group button {
	color: var(--group-text);
}

.group-picker-suggestion.import-tag-suggestion-group {
	color: var(--group-text);
	font-weight: 600;
}

.import-filter-mode {
	font-size: .85rem;
	color: var(--text-muted);
	margin: .6rem 0;
}

.import-filter-mode label {
	display: flex;
	align-items: center;
	gap: .35rem;
	margin: .2rem 0 0;
	font-size: .85rem;
	color: var(--text);
}

/* The table is its own scroll region (the flex remainder of the panel),
   with the column headers sticky inside it — same structure as
   .lightbox-contacts-table, in this page's light theme. */
.import-tag-table {
	flex: 1 1 auto;
	min-height: 0;
	max-height: 400px;
	overflow-y: auto;
	padding: .5rem 1rem 1rem;
	border-top: 1px solid var(--border);
}

/* Sticky within .import-tag-table's own scroll region, so the column
   labels (and the ability to re-sort) stay reachable while scrolling
   through a long roster. */
.import-tag-headerrow {
	position: sticky;
	top: 0;
	margin: -.5rem -1rem .4rem;
	padding: .5rem 1rem .4rem;
	display: flex;
	gap: .55rem;
	background: var(--surface);
	border-bottom: 1px solid var(--border);
	z-index: 1;
}

.import-tag-sort {
	background: none;
	border: none;
	color: var(--text-muted);
	font-size: .75rem;
	font-weight: 600;
	text-transform: uppercase;
	letter-spacing: .02em;
	cursor: pointer;
	padding: 0;
	text-align: left;
	white-space: nowrap;
}

.import-tag-sort:hover,
.import-tag-sort.active {
	background: none;
	color: var(--text);
}

.import-tag-sort-filter {
	flex-shrink: 0;
}

.import-tag-sort-name {
	flex: 1;
}

.import-tag-sort-type {
	flex-shrink: 0;
}

.import-tag-row {
	display: flex;
	align-items: center;
	gap: .55rem;
	padding: .35rem 0;
	border-bottom: 1px solid var(--border);
	font-size: .85rem;
	/* Drag-to-select is exactly the gesture that would otherwise paint a
	   native text-selection highlight. */
	user-select: none;
}

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

.import-tag-row.selected {
	background: rgba(120, 170, 255, .22);
}

/* Only the checkbox itself is the filter toggle target — the row body is
   the selection surface (same split as the lightbox table). */
.import-tag-row input[type="checkbox"] {
	width: auto;
	margin: 0;
	flex-shrink: 0;
	cursor: pointer;
}

.import-tag-name {
	flex: 1;
	min-width: 0;
	overflow: hidden;
	text-overflow: ellipsis;
	white-space: nowrap;
}

.import-tag-type {
	font-size: .7rem;
	color: var(--text-muted);
}

.import-tag-type-group {
	background: var(--group-bg);
	color: var(--group-text);
	border-radius: 999px;
	padding: .05rem .45rem;
}

.import-tree {
	flex: 1;
	min-width: 0;
}

/* No overflow: hidden here (despite the rounded corners) — the per-photo
   tags popup positions itself outside its row and must not get clipped at
   the card's edge. Nothing inside paints its own background over the
   corners anyway. */
.import-album {
	background: var(--surface);
	border: 1px solid var(--border);
	border-radius: var(--radius);
	margin-bottom: .6rem;
}

.import-album-row {
	display: flex;
	align-items: center;
	gap: .6rem;
	padding: .55rem .75rem;
}

.import-album-expander {
	background: none;
	border: none;
	color: var(--text-muted);
	cursor: pointer;
	font-size: 1.2rem;
	line-height: 1;
	padding: 0 .2rem;
	transition: transform .15s;
}

.import-album-expander:hover {
	background: none;
	color: var(--text);
}

.import-album.expanded .import-album-expander {
	transform: rotate(90deg);
}

.import-album-title {
	font-weight: 600;
	cursor: pointer;
}

.import-album-count {
	font-size: .85rem;
	margin-left: auto;
}

.import-photo-list {
	border-top: 1px solid var(--border);
	padding: .35rem .75rem .5rem 2.4rem;
}

/* A label (click anywhere on the row toggles its checkbox), but laid out
   as a row — undo the global block-label spacing/coloring. */
.import-photo-row {
	display: flex;
	align-items: center;
	gap: .6rem;
	margin: 0;
	padding: .25rem 0;
	font-size: .9rem;
	color: var(--text);
	cursor: pointer;
}

.import-photo-name {
	min-width: 0;
	overflow-wrap: anywhere;
}

.import-photo-note {
	font-size: .8rem;
}

/* Right-aligned cluster at the end of a photo row: tags readout,
   "already in this album" note, and the lightbox preview link — one
   wrapper carries the margin-left: auto so the cluster stays right-aligned
   whichever of its members happen to be present. */
.import-photo-row-right {
	display: flex;
	align-items: center;
	gap: .6rem;
	margin-left: auto;
	flex-shrink: 0;
}

/* Per-photo tags readout: a one-line summary that ellipses when long;
   hovering it reveals the full set as chips in a floating panel.
   Position: relative on the outer span (not overflow: hidden — the popup
   has to escape it), ellipsis on the inner summary. */
.import-photo-tags {
	position: relative;
}

.import-photo-tags-summary {
	display: block;
	max-width: 220px;
	overflow: hidden;
	text-overflow: ellipsis;
	white-space: nowrap;
	font-size: .8rem;
}

.import-photo-tags-full {
	display: none;
	position: absolute;
	right: 0;
	top: calc(100% + .25rem);
	z-index: 5;
	width: max-content;
	max-width: 300px;
	flex-wrap: wrap;
	gap: .3rem;
	background: var(--surface);
	border: 1px solid var(--border);
	border-radius: var(--radius);
	box-shadow: 0 8px 24px rgba(0, 0, 0, .15);
	padding: .5rem .6rem;
}

.import-photo-tags:hover .import-photo-tags-full {
	display: flex;
}

.import-open-link {
	font-size: .85rem;
	white-space: nowrap;
}

.import-lightbox-link {
	font-size: .8rem;
	white-space: nowrap;
}

.import-photo-in-target {
	opacity: .55;
	cursor: default;
}

/* --- Sharing center ----------------------------------------------------- */

.sharing-recipient-card,
.sharing-isolated-card {
	margin-bottom: 1rem;
}

.sharing-recipient-heading {
	display: flex;
	align-items: center;
	gap: .5rem;
	margin: 0 0 .75rem;
}

/* Same pastel "this is a group" signal used everywhere else (lightbox tag
   chips, import filter table) — plain surface-2 pill for a contact. */
.sharing-kind-badge {
	font-size: .7rem;
	font-weight: 600;
	text-transform: uppercase;
	letter-spacing: .02em;
	color: var(--text-muted);
	background: var(--surface-2);
	border-radius: 999px;
	padding: .1rem .55rem;
}

.sharing-kind-badge-group {
	color: var(--group-text);
	background: var(--group-bg);
}

/* Admin dashboard's environment reference — neutral for the real
   production instance, the same warning orange as .toast-warning
   otherwise, so a test/dev instance's admin page can't be mistaken for
   the live one at a glance. */
.env-badge {
	font-size: .7rem;
	font-weight: 600;
	text-transform: uppercase;
	letter-spacing: .02em;
	color: var(--text-muted);
	background: var(--surface-2);
	border-radius: 999px;
	padding: .1rem .55rem;
}

.env-badge-nonlive {
	color: #5a2e14;
	background: #e0983f;
}

.sharing-item-list {
	display: flex;
	flex-direction: column;
	gap: .5rem;
	margin-bottom: .75rem;
}

.sharing-summary-thumbs {
	display: flex;
	flex-wrap: wrap;
	gap: .4rem;
	margin-top: .6rem;
}

.sharing-summary-thumb {
	width: 44px;
	height: 44px;
	font-size: .75rem;
	font-weight: 600;
}

.sharing-item-row {
	display: flex;
	align-items: center;
	gap: .6rem;
}

.sharing-item-title {
	flex: 1;
	min-width: 0;
	overflow-wrap: anywhere;
}

.sharing-links-label {
	margin: .75rem 0 .4rem;
	border-top: 1px solid var(--border);
	padding-top: .75rem;
}

.sharing-link-list {
	display: flex;
	flex-direction: column;
	gap: .5rem;
}

.sharing-link-row {
	display: flex;
	align-items: center;
	gap: .6rem;
	flex-wrap: wrap;
}

.sharing-link-description {
	font-style: italic;
	font-size: .85rem;
	margin: .35rem 0 .75rem;
}

.sharing-link-input {
	flex: 1 1 260px;
	min-width: 0;
	margin: 0;
	font-size: .8rem;
	font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
	padding: .35rem .5rem;
	color: var(--text-muted);
	background: var(--surface-2);
}

/* A <form> is block-level by default, which would drop the revoke button
   onto its own line inside an otherwise-inline row. */
.sharing-revoke-form {
	display: inline-block;
	margin: 0;
}

/* --- Sharing: content-selection tree (extends the import-page classes) - */

/* An album whose true, total membership ended up fully selected (see
   compute_share_items on the server) — reuses the import-album-count slot
   this text already lives in, just recolored to call out the state
   change from a plain item count. */
.import-album-count.sharing-select-full {
	color: var(--accent);
	font-weight: 600;
}

.sharing-options-card {
	margin-top: 1.5rem;
	max-width: 480px;
}

.sharing-expiry-label {
	margin: 1rem 0;
}

/* --- Sharing: recipient link results (extends sharing-center classes) -- */

.sharing-email-known {
	color: var(--accent);
	font-size: .8rem;
	font-weight: 600;
}

.sharing-email-unknown {
	font-size: .8rem;
}

.sharing-result-expander {
	display: block;
	margin-top: .5rem;
}

.sharing-member-table {
	margin-top: .5rem;
	border-top: 1px solid var(--border);
	padding-top: .5rem;
}

.sharing-member-row {
	display: flex;
	justify-content: space-between;
	gap: 1rem;
	padding: .25rem 0;
	font-size: .85rem;
}

/* --- Sharing: results-page content summary ------------------------------ */

.sharing-content-summary {
	max-height: 260px;
	overflow-y: auto;
	display: flex;
	flex-direction: column;
	gap: .5rem;
}

/* --- Public share-viewing page ------------------------------------------ */

.share-collapsible {
	background: var(--surface);
	border: 1px solid var(--border);
	border-radius: var(--radius);
	margin: .75rem 0;
	overflow: hidden;
}

.share-collapsible-header {
	display: flex;
	align-items: center;
	gap: .6rem;
	width: 100%;
	background: none;
	border: none;
	color: var(--text);
	font-family: inherit;
	font-weight: 600;
	font-size: 1rem;
	text-align: left;
	padding: .75rem 1rem;
	cursor: pointer;
}

.share-collapsible-header:hover {
	background: var(--surface-2);
}

.share-collapsible-chevron {
	color: var(--text-muted);
	transition: transform .15s;
}

.share-collapsible.expanded .share-collapsible-chevron {
	transform: rotate(90deg);
}

.share-collapsible-title {
	flex: 1;
	min-width: 0;
	overflow-wrap: anywhere;
}

/* Fixed (not flex: 1) with a max-width cap — without the cap, flexbox
   would rather shrink this (its natural, unconstrained size) than the
   title (flex: 1, min-width: 0, so it has no size floor to protect it),
   letting a long hint squeeze the title down instead of wrapping itself.
   The cap forces this to wrap once it outgrows it, keeping the title at
   its natural width. font-weight: normal against the header's own bold,
   so the hint doesn't visually compete with the title. */
.share-collapsible-hint {
	flex: 0 0 auto;
	max-width: 45%;
	text-align: right;
	font-weight: normal;
	font-size: .85rem;
	line-height: 1.3;
}

.share-collapsible-body {
	padding: 0 1rem 1rem;
}

/* Right-aligned so the Download button sits under the "X items" readout
   at the far end of the header row above it, rather than under the
   title at the left. .dropdown-menu's own default (left:0, growing
   rightward) would run the menu off the edge of the page from way out
   here on the right, so it's flipped to grow leftward from the button's
   right edge instead — scoped to just this row rather than changing the
   shared .dropdown-menu default every other dropdown in the app relies on. */
.share-download-row {
	text-align: right;
	margin: 0 0 .75rem;
}

.share-download-row .dropdown-menu {
	left: auto;
	right: 0;
}

/* --- Admin members page -------------------------------------------------- */

.admin-quota-form {
	display: flex;
	align-items: center;
	gap: .4rem;
}

.admin-quota-form input {
	width: 70px;
}

/* --- Sortable admin tables (members, ...) — see sortable-table.js -------- */

table.admin-table th.sortable-column {
	cursor: pointer;
	user-select: none;
}

table.admin-table th.sortable-column:hover {
	color: var(--text);
}

table.admin-table th.sorted-asc::after {
	content: " \25B2";
}

table.admin-table th.sorted-desc::after {
	content: " \25BC";
}

/* --- Map view overlay — see mapview.js ------------------------------------
   Layout: .mapview-body is a flex row filling the overlay — a fixed-width,
   collapsible .mapview-sidebar (layers + photos + thumbnail, see below) on
   the left, .mapview-map taking whatever's left. .mapview-close/
   -return-to-lightbox/-sidebar-toggle all float above that via their own
   z-index, independent of which side of the split they visually sit near. */

.mapview-overlay {
	position: fixed;
	inset: 0;
	background: var(--bg);
	/* Above --z-lightbox — this can be opened from inside an already-open
	   lightbox (see mapview.js's own doc comment) and needs to sit fully
	   on top of it. See the z-index registry in :root for the full
	   ordering this and every other cross-cutting z-index here follows. */
	z-index: var(--z-mapview);
}

.mapview-body {
	position: absolute;
	inset: 0;
	display: flex;
}

.mapview-sidebar {
	flex-shrink: 0;
	width: 320px;
	overflow-x: hidden;
	/* No overflow-y here anymore — each section below scrolls on its own
	   (see .mapview-section-scroll) instead of the sidebar as a whole
	   sharing one scroll region. */
	display: flex;
	flex-direction: column;
	background: var(--surface);
	border-right: 1px solid var(--border);
	/* Collapsing animates width to 0 rather than toggling display — see
	   .sidebar-collapsed below — so the map's own resize (map.invalidateSize())
	   has something to run after, matching the transition's own duration. */
	transition: width .2s ease;
}

.mapview-body.sidebar-collapsed .mapview-sidebar {
	width: 0;
	border-right: none;
}

.mapview-sidebar-section {
	display: flex;
	flex-direction: column;
	padding: 1rem;
	/* Clips content to whatever height this section actually has (its own
	   content up to the default cap, or a dragged explicit height) —
	   without this, a section with more content than fits would just grow
	   the box instead of leaving that to .mapview-section-scroll's own
	   overflow-y. */
	overflow: hidden;
}

/* Base layers and Thumbnail: size to their own content by default (never
   force the full 200px if there's less to show — see the design
   discussion this came out of), capped there and scrolling internally
   past it. A drag (setupSplitter in mapview.js) clears max-height and
   sets an explicit height instead, turning "fits content, capped" into
   "a fixed size the user chose," persisted across reopening the map view
   (see persistedSectionHeights). Never grows/shrinks on its own
   (flex: 0 0 ...) — Photos is the only section that absorbs leftover
   space automatically. */
.mapview-sidebar-section-layers,
.mapview-sidebar-section-preview {
	flex: 0 0 auto;
	max-height: 200px;
}

/* The only section that's flex-grow — everything neither of the other
   two is currently using. min-height is a floor so an aggressive drag on
   either splitter can't squeeze this away to nothing. */
.mapview-sidebar-section-photos {
	flex: 1 1 0;
	min-height: 60px;
}

/* Fills whatever height its own .mapview-sidebar-section ends up with
   (content-sized-and-capped, or a dragged explicit size) and scrolls
   internally past that — min-height: 0 is what actually lets a flex
   child shrink below its content's natural size at all, without which
   overflow-y here would never kick in. */
.mapview-section-scroll {
	flex: 1 1 auto;
	min-height: 0;
	overflow-y: auto;
}

/* Draggable boundary between two sections — see setupSplitter() in
   mapview.js. A thin hit target with a visible grip mark, not a hard
   border; the grip's own opacity is what signals "this is draggable"
   rather than looking like a fixed divider. */
.mapview-sidebar-splitter {
	flex: 0 0 auto;
	position: relative;
	height: 9px;
	cursor: row-resize;
	border-top: 1px solid var(--border);
}

.mapview-sidebar-splitter::after {
	content: "";
	position: absolute;
	left: 50%;
	top: 50%;
	transform: translate(-50%, -50%);
	width: 28px;
	height: 3px;
	border-radius: 2px;
	background: var(--text-muted);
	opacity: .35;
}

.mapview-sidebar-splitter:hover::after,
.mapview-sidebar-splitter.dragging::after {
	opacity: .8;
}

.mapview-sidebar-splitter.dragging {
	background: var(--surface-2);
}

.mapview-sidebar-heading {
	flex-shrink: 0;
	margin: 0 0 .6rem;
	font-size: .8rem;
	font-weight: 600;
	text-transform: uppercase;
	letter-spacing: .03em;
	color: var(--text-muted);
}

/* Only the Base layers heading uses this — see buildOverlay's own note on
   why the map view's one help button lives here rather than floating
   independently. The row itself owns the bottom margin the heading
   otherwise carries on its own (see .mapview-sidebar-heading above), so
   it isn't doubled. */
.mapview-sidebar-heading-row {
	flex-shrink: 0;
	display: flex;
	align-items: center;
	justify-content: space-between;
	gap: .5rem;
	margin-bottom: .6rem;
}

.mapview-sidebar-heading-row .mapview-sidebar-heading {
	margin-bottom: 0;
}

.mapview-help-btn {
	flex: 0 0 auto;
	display: flex;
	align-items: center;
	justify-content: center;
	width: 1.1rem;
	height: 1.1rem;
	padding: 0;
	border-radius: 50%;
	border: 1px solid var(--text-muted);
	background: none;
	color: var(--text-muted);
	font-size: .65rem;
	line-height: 1;
	font-family: inherit;
	cursor: pointer;
}

.mapview-help-btn:hover {
	color: var(--text);
	border-color: var(--text);
}

/* A small tab at the sidebar's current right edge, independent of the
   sidebar's own overflow:hidden-during-collapse — a child of it would get
   clipped mid-animation, so this is its own element instead, animating in
   lockstep via the same left offset + transition timing. */
.mapview-sidebar-toggle {
	position: absolute;
	top: 50%;
	left: 320px;
	transform: translate(-50%, -50%);
	z-index: 2;
	width: 24px;
	height: 48px;
	border-radius: 0 6px 6px 0;
	border: 1px solid var(--border);
	border-left: none;
	background: var(--surface);
	color: var(--text-muted);
	cursor: pointer;
	transition: left .2s ease;
}

.mapview-body.sidebar-collapsed .mapview-sidebar-toggle {
	left: 0;
}

.mapview-sidebar-toggle:hover {
	color: var(--text);
}

.mapview-map {
	flex: 1 1 auto;
	position: relative;
	/* Explicit z-index (any value) is what actually matters here, not
	   the number itself: combined with `position`, it makes this
	   element establish its own stacking context. Without it, Leaflet's
	   internal z-index values (.leaflet-top/.leaflet-bottom's 1000 for
	   the zoom control, its tile pane's 200, etc. — see leaflet.css)
	   have nothing to contain them and are compared directly against
	   sibling overlay UI in the *parent* stacking context instead of
	   staying scoped to this one — which is how the zoom control and an
	   enabled OSM layer previously ended up rendering over the layer
	   panel despite its own z-index. Same underlying category of bug as
	   .hover-tooltip's fix elsewhere in this file. */
	z-index: 0;
}

.mapview-close {
	position: absolute;
	z-index: 3;
	display: flex;
	align-items: center;
	justify-content: center;
	border: none;
	background: var(--surface);
	color: var(--text);
	cursor: pointer;
	box-shadow: 0 1px 4px rgba(0, 0, 0, .25);
	top: .75rem;
	right: .75rem;
	width: 40px;
	height: 40px;
	border-radius: 50%;
	font-size: 1.5rem;
	/* Centering comes from the flex rule above, not line-height — a
	   single glyph like &times; isn't reliably centered by font
	   metrics/line-height alone (see the off-center screenshot this
	   fixed), same reasoning as the lightbox's own .lightbox-close. */
}

.mapview-close:hover {
	background: var(--surface-2);
}

/* Wraps "Back to photo" (came from the lightbox) and "View [name]" (map
   view's own current selection, when it's drifted from that) — a flex
   row rather than two independently-positioned buttons, so the
   filename-driven width of the second one doesn't need a guessed pixel
   offset to sit next to the fixed-width first one. */
.mapview-return-buttons {
	position: absolute;
	z-index: 3;
	bottom: .75rem;
	right: .75rem;
	display: flex;
	gap: .5rem;
	max-width: calc(100% - 1.5rem);
}

.mapview-return-to-lightbox,
.mapview-view-last-touched {
	display: flex;
	align-items: center;
	justify-content: center;
	border: none;
	background: var(--surface);
	color: var(--text);
	cursor: pointer;
	box-shadow: 0 1px 4px rgba(0, 0, 0, .25);
	padding: .55rem 1rem;
	border-radius: 999px;
	font-size: .85rem;
	font-weight: 600;
	overflow: hidden;
	text-overflow: ellipsis;
	white-space: nowrap;
}

/* Fixed, short text — stands its ground; its filename-bearing sibling is
   the one that shrinks/ellipsizes first when space is tight. */
.mapview-return-to-lightbox {
	flex-shrink: 0;
}

.mapview-view-last-touched {
	flex: 1 1 auto;
	min-width: 0;
}

.mapview-return-to-lightbox:hover,
.mapview-view-last-touched:hover {
	background: var(--surface-2);
}

.mapview-empty {
	position: absolute;
	inset: 0;
	display: flex;
	align-items: center;
	justify-content: center;
	text-align: center;
	max-width: 360px;
	margin: 0 auto;
	color: var(--text-muted);
	padding: 0 1.5rem;
}

/* --- Sidebar: base layers list (reorderable, session-only) --------------- */

.mapview-layer-rows {
	display: flex;
	flex-direction: column;
	user-select: none;
}

.mapview-layer-row {
	display: flex;
	align-items: center;
	gap: .6rem;
	padding: .35rem .3rem;
}

.mapview-layer-thumb {
	flex-shrink: 0;
	width: 32px;
	height: 32px;
	border-radius: 6px;
	object-fit: cover;
	background: var(--surface-2);
}

/* Stand-in for OSM's row, which has no preview image of its own. */
.mapview-layer-thumb-osm {
	display: flex;
	align-items: center;
	justify-content: center;
	color: var(--text-muted);
	font-size: .65rem;
	font-weight: 600;
	text-transform: uppercase;
}

.mapview-layer-title {
	flex: 1;
	min-width: 0;
	overflow: hidden;
	text-overflow: ellipsis;
	white-space: nowrap;
	font-size: .85rem;
}

.mapview-layer-row input[type="checkbox"] {
	width: auto;
	margin: 0;
	flex-shrink: 0;
	cursor: pointer;
}

/* --- Sidebar: photo list (multi-select + right-click bulk toggle) -------- */

.mapview-photo-rows {
	display: flex;
	flex-direction: column;
	user-select: none;
}

/* Focusable (tabindex="0", see mapview.js) for keyboard nav, but without
   the browser's own default focus ring — the row selection highlight
   itself is already the visible "you're navigating here" signal. */
.mapview-photo-rows:focus {
	outline: none;
}

.mapview-photo-row {
	display: flex;
	align-items: center;
	gap: .55rem;
	padding: .3rem .3rem;
	border-radius: 4px;
	font-size: .85rem;
	cursor: pointer;
}

.mapview-photo-row.selected {
	/* Same tint used everywhere else in this app for "this row is part
	   of the current selection" — see .lightbox-contact-row.selected. */
	background: rgba(120, 170, 255, .28);
}

.mapview-photo-row input[type="checkbox"] {
	width: auto;
	margin: 0;
	flex-shrink: 0;
	cursor: pointer;
}

.mapview-photo-name {
	flex: 1;
	min-width: 0;
	overflow: hidden;
	text-overflow: ellipsis;
	white-space: nowrap;
}

/* No GPS on this photo (or its governing album has coordinates turned
   off) — listed for context rather than omitted. Still selectable (for
   the thumbnail preview and "View in lightbox") and still keyboard-
   navigable, just visually out of the way and with nothing to toggle:
   see buildPhotoTable's own comment for why the checkbox and the
   Show/Hide context-menu items are the only things actually gated on
   having a location. */
.mapview-photo-row-unlocatable {
	opacity: .55;
}

.mapview-photo-row-unlocatable .mapview-photo-name {
	font-style: italic;
}

/* The two "click on nothing to clear the selection" targets — see
   clearSelection() in mapview.js. */
.mapview-photo-clear-heading {
	cursor: pointer;
}

.mapview-photo-clear-heading:hover {
	color: var(--text);
}

.mapview-photo-clear-zone {
	height: 1.1rem;
	cursor: pointer;
}

/* .mapview-photo-preview itself carries no rules of its own — it's the
   sole content of its own section now (not stacked directly below the
   photo rows within a shared one, which is what used to need a top
   separator here); the splitter above it and the section's own padding
   already provide that spacing. */
.mapview-photo-preview-img,
.mapview-photo-preview-video {
	display: block;
	max-width: 100%;
	max-height: 160px;
	border-radius: 6px;
	margin: 0 auto .4rem;
	background: var(--surface-2);
}

.mapview-photo-preview-name {
	margin: 0;
	font-size: .8rem;
	color: var(--text-muted);
	text-align: center;
	overflow: hidden;
	text-overflow: ellipsis;
	white-space: nowrap;
}

/* Clickable affordance for opening the previewed photo in the lightbox —
   only the image and filename, not the video (native <video controls>
   already claims clicks on its own area for play/pause, so stacking a
   second click behavior on top of it would fight the player) — same
   opacity-on-hover convention as the lightbox's own clickable readouts
   (.lightbox-counter-coords, .lightbox-counter-date-interactive). */
.mapview-photo-preview-img,
.mapview-photo-preview-name {
	cursor: pointer;
}

.mapview-photo-preview-img:hover,
.mapview-photo-preview-name:hover {
	opacity: .9;
}

/* Below the filename, only when this photo has known coordinates —
   plain link styling (this app's default var(--accent), not the muted
   --text-muted the filename above uses) since unlike the name this is
   actually clickable, opening Google Maps in a new tab. */
.mapview-photo-preview-coords {
	display: block;
	margin: .15rem 0 0;
	font-size: .75rem;
	text-align: center;
	overflow: hidden;
	text-overflow: ellipsis;
	white-space: nowrap;
}

.mapview-photo-no-location {
	flex-shrink: 0;
	width: 1em;
	text-align: center;
	color: var(--text-muted);
}

/* Right-drag rubber-band select on the map — see setupMapRubberBandSelect()
   in mapview.js. A plain div, not a Leaflet layer — .mapview-map's own
   z-index:0 stacking context (see above) is all it needs to render above
   the map's own tiles/overlays/markers without competing with anything
   outside that context. */
.mapview-select-rect {
	position: absolute;
	z-index: 1000;
	pointer-events: none;
	background: rgba(47, 111, 218, .15);
	border: 1px dashed var(--marker);
}

/* Toggled on .mapview-map while a right-drag select is active — a marker
   tooltip popping up mid-drag (from the cursor merely passing over one)
   is just visual noise for a gesture that isn't about any one marker.
   CSS-only (not touching Leaflet's own tooltip open/close machinery):
   .leaflet-tooltip doesn't otherwise set `display`, so this wins outright
   without a specificity fight. */
.mapview-map-suppress-tooltips .leaflet-tooltip {
	display: none;
}

/* --- Photo placemarks ----------------------------------------------------- */

/* `color` drives the SVG's own `fill="currentColor"` — see buildMarkerIcon()
   in mapview.js. Selection just toggles the modifier class on the marker's
   already-rendered element (Leaflet keeps a real DOM node per marker), no
   icon rebuild needed. */
.mapview-marker {
	color: var(--marker);
}

.mapview-marker.mapview-marker-selected {
	color: var(--marker-selected);
}

.mapview-marker svg {
	filter: drop-shadow(0 1px 2px rgba(0, 0, 0, .45));
}

/* Leaflet tooltip content — see buildMarkerTooltip() in mapview.js. Plain
   selectors (not scoped under a wrapper class) since Leaflet renders
   tooltip content into its own .leaflet-tooltip container that this app
   doesn't otherwise style. */
.mapview-tooltip-thumb {
	display: block;
	width: 140px;
	max-width: 100%;
	height: auto;
	border-radius: 4px;
	margin-bottom: .3rem;
}

.mapview-tooltip-name {
	font-weight: 600;
}

.mapview-tooltip-date {
	display: block;
	font-size: .8em;
	color: var(--text-muted);
}

/* --- Member nav bar (member_base.html) ---------------------------------- */

.member-nav-wrap {
	background: var(--surface);
	border-bottom: 1px solid var(--border);
}

.member-nav {
	display: flex;
	align-items: center;
	flex-wrap: wrap;
	gap: 1.25rem;
	max-width: 1100px;
	margin: 0 auto;
	padding: .6rem 1.5rem;
	font-size: .95rem;
}

.member-nav-brand {
	font-weight: 700;
	color: var(--text);
	text-decoration: none;
	margin-right: .5rem;
}

.member-nav-link {
	color: var(--text-muted);
	text-decoration: none;
	padding: .2rem 0;
	border-bottom: 2px solid transparent;
}

.member-nav-link:hover {
	color: var(--accent-hover);
}

.member-nav-link.member-nav-active {
	color: var(--text);
	font-weight: 600;
	border-bottom-color: var(--accent);
}

.member-nav-spacer {
	flex: 1;
}

.member-nav-user {
	color: var(--text-muted);
	font-size: .85rem;
}

.member-nav-signout {
	color: var(--text-muted);
	text-decoration: none;
	font-size: .85rem;
	padding: .25rem .7rem;
	border: 1px solid var(--border);
	border-radius: 999px;
}

.member-nav-signout:hover {
	color: var(--accent-hover);
	border-color: var(--accent-hover);
}

/* The nav bar replaces the old fixed top-of-page sign-out pill and the
   4rem top margin that made room for it. */
.member-nav-wrap + .container {
	margin-top: 1.5rem;
}

/* Quota chip — label + slim bar in the nav; hover/focus opens the detail
   popup (pure CSS, so keyboard focus works the same as hover). */
.quota-chip {
	position: relative;
	display: flex;
	align-items: center;
	gap: .5rem;
	font-size: .8rem;
	color: var(--text-muted);
	cursor: default;
	padding: .2rem .5rem;
	border: 1px solid var(--border);
	border-radius: 999px;
}

.quota-chip[hidden] {
	display: none;
}

.quota-chip-bar {
	display: inline-block;
	width: 48px;
	height: 6px;
	border-radius: 3px;
	background: var(--surface-2);
	overflow: hidden;
}

.quota-chip-bar-fill {
	display: block;
	height: 100%;
	background: var(--accent);
}

.quota-chip-bar-fill.quota-bar-warning {
	background: #e0983f;
}

.quota-chip-popup {
	display: none;
	position: absolute;
	top: calc(100% + .4rem);
	right: 0;
	z-index: var(--z-dropdown);
	min-width: 230px;
	padding: .8rem 1rem;
	background: var(--surface);
	border: 1px solid var(--border);
	border-radius: var(--radius);
	box-shadow: 0 8px 24px rgba(0, 0, 0, .15);
	color: var(--text);
	font-size: .85rem;
}

.quota-chip:hover .quota-chip-popup,
.quota-chip:focus .quota-chip-popup,
.quota-chip:focus-within .quota-chip-popup {
	display: block;
}

.quota-chip-popup p {
	margin: .35rem 0;
}

.quota-chip-popup-title {
	font-weight: 600;
	margin-top: 0 !important;
}

/* --- Albums page (albums.html) ------------------------------------------ */

.albums-toolbar {
	display: flex;
	align-items: center;
	justify-content: space-between;
	flex-wrap: wrap;
	gap: 1rem;
	margin-bottom: 1.25rem;
}

/* The Tools menu sits at the right edge of the Albums toolbar: open it
   leftwards (the shared dropdown default grows rightwards from the button). */
.albums-toolbar .albums-tools .dropdown-menu {
	left: auto;
	right: 0;
}

.albums-toolbar-actions {
	display: flex;
	align-items: center;
	gap: .75rem;
}

.view-toggle {
	display: inline-flex;
	border: 1px solid var(--border);
	border-radius: var(--radius);
	overflow: hidden;
}

.view-toggle-btn {
	background: var(--surface);
	color: var(--text-muted);
	border: none;
	border-radius: 0;
	padding: .5rem 1rem;
	font-size: .9rem;
	font-weight: 500;
	cursor: pointer;
}

.view-toggle-btn:hover {
	background: var(--surface-2);
	color: var(--text);
}

.view-toggle-btn[aria-pressed="true"] {
	background: var(--accent);
	color: #fff;
}

.activity-card {
	padding: .5rem 1rem;
}

.activity-row {
	display: flex;
	flex-wrap: wrap;
	align-items: baseline;
	gap: .2rem .75rem;
	padding: .45rem 0;
	border-bottom: 1px solid var(--border);
	font-size: .9rem;
}

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

.activity-when {
	min-width: 9.5rem;
	font-size: .8rem;
}

.activity-label {
	font-weight: 600;
}

.activity-detail {
	font-size: .8rem;
}

.breadcrumb {
	color: var(--text-muted);
	/* Its own size, not the .9rem it would inherit from nav.top (which the
	   buttons beside it still use). */
	font-size: 1.1rem;
}

.breadcrumb a {
	color: var(--text-muted);
}

.breadcrumb a:hover {
	color: var(--accent-hover);
}

/* --- Overview page (overview.html) -------------------------------------- */

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

.overview-section-header {
	display: flex;
	align-items: baseline;
	justify-content: space-between;
	gap: 1rem;
	margin-bottom: .6rem;
}

.overview-section-header h3 {
	margin: 0;
}

.overview-section-header a {
	font-size: .9rem;
}

.overview-count {
	font-size: .85rem;
	font-weight: 400;
}

.overview-note {
	margin: 0 0 .6rem;
	font-size: .85rem;
}

.overview-columns {
	display: grid;
	grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
	gap: 1.5rem;
}

.overview-list {
	padding: .4rem 1rem;
}

.overview-row {
	display: flex;
	align-items: center;
	gap: .6rem;
	padding: .45rem 0;
	border-bottom: 1px solid var(--border);
	font-size: .9rem;
}

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

.overview-row-title {
	font-weight: 600;
	flex: 1;
	min-width: 0;
	overflow: hidden;
	text-overflow: ellipsis;
	white-space: nowrap;
}

.overview-row-thumb {
	width: 32px;
	height: 32px;
	border-radius: 4px;
	object-fit: cover;
	background: var(--surface-2);
}

/* --- Sharing center tabs + collapsible rows (sharing_center.html) ------- */

.sharing-tabs {
	display: flex;
	gap: .25rem;
	border-bottom: 1px solid var(--border);
	margin-bottom: 1rem;
}

.sharing-tab {
	background: none;
	border: none;
	border-bottom: 2px solid transparent;
	border-radius: 0;
	color: var(--text-muted);
	font-size: .95rem;
	font-weight: 500;
	padding: .6rem 1rem;
	margin-bottom: -1px;
	cursor: pointer;
}

.sharing-tab:hover {
	background: none;
	color: var(--accent-hover);
}

.sharing-tab[aria-selected="true"] {
	color: var(--text);
	font-weight: 600;
	border-bottom-color: var(--accent);
}

.sharing-tab-count {
	font-size: .75rem;
	font-weight: 600;
	color: var(--text-muted);
	background: var(--surface-2);
	border-radius: 999px;
	padding: .05rem .5rem;
	margin-left: .25rem;
}

.sharing-list-toolbar {
	display: flex;
	align-items: center;
	gap: 1rem;
	margin-bottom: .5rem;
}

.sharing-list-toolbar-spacer {
	flex: 1;
}

.sharing-filter {
	width: 220px;
	margin: 0;
	padding: .35rem .6rem;
	font-size: .9rem;
}

.sharing-expiry-badge {
	font-size: .7rem;
	font-weight: 600;
	color: #5a2e14;
	background: #f4dcae;
	border-radius: 999px;
	padding: .1rem .55rem;
	margin-left: .4rem;
}

.sharing-expiry-expired {
	color: var(--error);
	background: #fbe9e9;
}

/* Anonymous-link rows: Copy/Revoke pinned over the header's right end so
   they're usable while collapsed; the header reserves room for them, and
   its hint sits left of that rather than right-aligned against them. */
.share-collapsible.sharing-has-actions {
	position: relative;
}

.sharing-has-actions .share-collapsible-header {
	padding-right: 11rem;
}

.sharing-collapsed-actions {
	position: absolute;
	top: 0;
	right: 1rem;
	height: 2.9rem;
	display: flex;
	align-items: center;
	gap: .9rem;
}

.sharing-sort-label {
	display: inline-flex;
	align-items: center;
	gap: .4rem;
	margin: 0;
	font-size: .9rem;
	font-weight: 500;
}

.sharing-sort-select {
	width: auto;
	margin: 0;
	padding: .3rem .5rem;
	font-size: .9rem;
}

/* --- Album-edit photo table: view-only column sort ---------------------- */

th.sortable-th {
	cursor: pointer;
	user-select: none;
	white-space: nowrap;
}

th.sortable-th:hover {
	color: var(--text);
}

.sort-arrow {
	font-size: .7em;
	color: var(--accent);
}

.view-sort-banner {
	display: flex;
	flex-wrap: wrap;
	align-items: center;
	justify-content: space-between;
	gap: .5rem 1rem;
	/* Overlays the top save row's reserved slot (see .reorder-wrap) so its
	   appearing doesn't push the table down. Covering that row's buttons
	   while a view sort is on is intentional: saving is blocked then anyway
	   (preparePhotoOrder). */
	position: absolute;
	top: 0;
	left: 0;
	right: 0;
	z-index: 3;
	margin: 0;
	padding: .5rem .8rem;
	border: 1px solid var(--accent);
	border-radius: var(--radius);
	background: var(--surface);
	font-size: .9rem;
}

.view-sort-banner-actions {
	display: inline-flex;
	gap: 1rem;
}

/* Drag makes no sense inside a view-only sort — see album_edit.html. */
tbody.view-sorted tr {
	cursor: default;
}

tbody.view-sorted .drag-handle {
	opacity: .3;
}

/* Row-number column in the album-edit photo table. */
.num-col {
	width: 2rem;
	text-align: right;
	white-space: nowrap;
	font-variant-numeric: tabular-nums;
}

/* A staged (unsaved) position change, shown as "old -> new". */
.row-num.row-moved {
	color: var(--accent);
	font-weight: 600;
}

/* A thumbnail whose item was deleted from the lightbox this page view —
   the page doesn't re-render, so it stays but reads as gone and is inert
   (see reindexThumbnailsAfterRemoval in lightbox.js). */
.thumb-removed {
	opacity: .35;
	filter: grayscale(1);
	cursor: not-allowed;
}

/* Album edit: collapsible details card with a condensed read-only summary */
.album-details-summary { cursor: pointer; list-style: none; display: block; }
.album-details-summary::-webkit-details-marker { display: none; }
.album-details-head { display: block; font-weight: 600; }
.album-details-head::before { content: "\25B8"; display: inline-block; width: 1em; }
.album-details-card[open] .album-details-head::before { content: "\25BE"; }
.album-details-toggle { font-weight: 400; }
.album-details-card:not([open]) .album-details-when-open { display: none; }
.album-details-card[open] .album-details-when-closed { display: none; }
.album-details-card[open] .album-details-condensed { display: none; }
.album-details-condensed { display: block; margin-top: .6rem; font-size: .9rem; }
.album-details-row { display: flex; gap: .75rem; margin-bottom: .25rem; }
.album-details-row > .muted { flex: 0 0 6.5rem; }
.album-details-val { flex: 1; min-width: 0; overflow-wrap: anywhere; }
.album-details-clamp { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
/* Text fields stretch with the full-width card; date/time rows keep their compact width */
.album-details-card .date-time-row { max-width: 480px; }
