/* ==========================================================================
   Reusable UI components
   ========================================================================== */

/* ---------- Buttons ---------- */
.btn[hidden] { display: none; }
.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-2);
  padding: var(--space-3) var(--space-5);
  min-height: 44px;
  border-radius: var(--radius-md);
  border: 1px solid transparent;
  background: var(--color-bg-subtle);
  color: var(--color-text);
  font-weight: var(--weight-medium);
  font-size: var(--text-sm);
  line-height: 1;
  cursor: pointer;
  position: relative;
  isolation: isolate;
  overflow: hidden;
  transition: background var(--duration-fast) var(--ease-standard),
              border-color var(--duration-fast) var(--ease-standard),
              box-shadow var(--duration-fast) var(--ease-standard),
              transform var(--duration-base) var(--ease-spring);
  text-decoration: none;
  white-space: nowrap;
}

.btn:hover { background: var(--color-bg-inset); transform: translateY(-1px); }
.btn:active { transform: scale(0.97) translateY(0); }
.btn:disabled,
.btn[aria-disabled="true"] {
  opacity: 0.5;
  cursor: not-allowed;
  transform: none;
}

/* A soft diagonal sheen, like light catching a glass surface — sits above
   the button's own background (isolate + z-index) but below its label. */
.btn-primary,
.btn-danger {
  position: relative;
}
.btn-primary::before,
.btn-danger::before {
  content: "";
  position: absolute;
  inset: 0;
  background: linear-gradient(135deg, rgba(255, 255, 255, 0.28), transparent 55%);
  pointer-events: none;
  z-index: -1;
}

/* A bar of light that sweeps across on hover, like a reflection catching
   the glass — sits in the same isolated layer as the static sheen above. */
.btn-primary::after,
.btn-danger::after {
  content: "";
  position: absolute;
  inset: 0;
  left: -60%;
  width: 40%;
  background: linear-gradient(115deg, transparent, rgba(255, 255, 255, 0.45), transparent);
  transform: skewX(-18deg);
  transition: left var(--duration-slow) var(--ease-standard);
  pointer-events: none;
  z-index: -1;
}
.btn-primary:hover::after,
.btn-danger:hover::after { left: 130%; }

.btn-primary {
  background: var(--color-accent);
  color: var(--color-text-on-accent);
  box-shadow: 0 6px 16px color-mix(in srgb, var(--color-accent) 35%, transparent);
}
.btn-primary:hover {
  background: var(--color-accent-hover);
  box-shadow: 0 8px 22px color-mix(in srgb, var(--color-accent) 45%, transparent), var(--glow-accent);
}
.btn-primary:active { background: var(--color-accent-active); }
.btn-primary:focus-visible { box-shadow: var(--shadow-focus), var(--glow-accent-sm); }

.btn-secondary {
  background: var(--color-bg-elevated);
  border-color: var(--color-border-strong);
}

.btn-danger {
  background: var(--color-danger);
  color: #fff;
}
.btn-danger:hover { background: var(--color-danger-hover); }

.btn-ghost {
  background: transparent;
}
.btn-ghost:hover { background: var(--color-bg-subtle); }

/* The tier below ghost: an action that sits *inside* other content — beside
   a section heading, at the end of a sentence in an alert, next to a
   checkbox — where a real button's weight would compete with the thing it
   belongs to. Reads as a link, behaves as a button: proper hit target,
   focus ring, room for an icon.
   No hover lift, unlike every other .btn. A heading row that bobs when the
   pointer crosses it is distracting in a way an isolated button is not. */
.btn-quiet {
  background: transparent;
  border-color: transparent;
  color: var(--color-accent-text);
  font-size: var(--text-xs);
  font-weight: var(--weight-semibold);
  padding: var(--space-1) var(--space-3);
  /* Matches .btn-sm rather than shrinking further. The look wants to be
     light, but this is still a tap target on a phone, and 36px is the floor
     the rest of the app's small controls hold to. */
  min-height: 36px;
  border-radius: var(--radius-pill);
}
.btn-quiet:hover {
  background: var(--color-accent-subtle);
  transform: none;
}
.btn-quiet:active { transform: scale(0.96); }
/* .btn.btn-quiet, not .btn-quiet: `.btn .icon` further down this file has the
   same specificity and would win on source order, leaving these at the
   full 18px and this rule doing nothing. */
.btn.btn-quiet .icon { width: 15px; height: 15px; }

.btn-sm {
  padding: var(--space-2) var(--space-3);
  min-height: 36px;
  font-size: var(--text-xs);
}

.btn-lg {
  padding: var(--space-4) var(--space-6);
  min-height: 52px;
  font-size: var(--text-base);
}

.btn-icon {
  padding: var(--space-2);
  min-width: 44px;
  min-height: 44px;
}

.btn-block { width: 100%; }

.btn .icon { width: 18px; height: 18px; flex-shrink: 0; }

/* Fab-style quick create button */
.btn-fab {
  position: fixed;
  right: var(--space-5);
  bottom: var(--bottom-nav-clearance);
  z-index: var(--z-bottom-nav);
  border-radius: var(--radius-pill);
  box-shadow: var(--glass-shadow-lg);
  padding: var(--space-4) var(--space-6);
  animation: fab-breathe 3.6s ease-in-out infinite;
}

@keyframes fab-breathe {
  0%, 100% { box-shadow: var(--glass-shadow-lg), 0 6px 16px color-mix(in srgb, var(--color-accent) 35%, transparent); }
  50% { box-shadow: var(--glass-shadow-lg), 0 8px 22px color-mix(in srgb, var(--color-accent) 45%, transparent), var(--glow-accent); }
}

@media (prefers-reduced-motion: reduce) {
  .btn-fab { animation: none; }
}

@media (min-width: 1024px) {
  .btn-fab { bottom: var(--space-6); }
}

/* ---------- Forms ---------- */
.field {
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
  margin-bottom: var(--space-5);
}

.field-row {
  display: flex;
  gap: var(--space-4);
  flex-wrap: wrap;
}
.field-row > .field { flex: 1 1 200px; }

.field-label {
  font-weight: var(--weight-medium);
  font-size: var(--text-sm);
  color: var(--color-text);
}

.field-label .optional {
  font-weight: var(--weight-regular);
  color: var(--color-text-faint);
  margin-left: var(--space-1);
}

.field-hint {
  font-size: var(--text-xs);
  color: var(--color-text-muted);
}

.field-error {
  font-size: var(--text-xs);
  color: var(--color-danger);
  display: none;
  align-items: center;
  gap: var(--space-1);
}

.field.has-error .field-error { display: flex; }
.field.has-error .input,
.field.has-error textarea,
.field.has-error select {
  border-color: var(--color-danger);
}

.input,
textarea,
select {
  width: 100%;
  padding: var(--space-3) var(--space-4);
  min-height: 44px;
  border: 1px solid var(--color-border-strong);
  border-radius: var(--radius-md);
  background: var(--color-bg-elevated);
  color: var(--color-text);
  box-shadow: inset 0 1px 2px rgba(21, 24, 43, 0.04);
  transition: border-color var(--duration-fast) var(--ease-standard), box-shadow var(--duration-fast) var(--ease-standard);
}

textarea {
  min-height: 96px;
  resize: vertical;
}

.input:focus,
textarea:focus,
select:focus {
  border-color: var(--color-accent);
  box-shadow: var(--shadow-focus), var(--glow-accent-sm);
}

.input::placeholder,
textarea::placeholder {
  color: var(--color-text-faint);
}

select {
  appearance: none;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none' stroke='%235b6178' stroke-width='2'%3E%3Cpath d='M5 8l5 5 5-5'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: right var(--space-3) center;
  background-size: 18px;
  padding-right: var(--space-8);
}

.checkbox-row,
.radio-row {
  display: flex;
  align-items: flex-start;
  gap: var(--space-3);
}

.checkbox-row input,
.radio-row input {
  /* Nudged down so the box lines up with the first line of a label that
     wraps to two — hence align-items: flex-start on the row above. */
  margin-top: 2px;
}

.form-actions {
  display: flex;
  gap: var(--space-3);
  justify-content: flex-end;
  flex-wrap: wrap;
  margin-top: var(--space-6);
}

@media (max-width: 640px) {
  .form-actions { flex-direction: column-reverse; }
  .form-actions .btn { width: 100%; }
}

/* Character-based toggle switch */
.switch {
  position: relative;
  display: inline-flex;
  align-items: center;
  gap: var(--space-3);
  cursor: pointer;
}

.switch input {
  position: absolute;
  opacity: 0;
  width: 44px;
  height: 24px;
  margin: 0;
  cursor: pointer;
}

.switch-track {
  width: 44px;
  height: 24px;
  border-radius: var(--radius-pill);
  background: var(--color-bg-inset);
  border: 1px solid var(--color-border-strong);
  position: relative;
  transition: background var(--duration-fast) var(--ease-standard);
  flex-shrink: 0;
}

.switch-track::after {
  content: "";
  position: absolute;
  top: 2px;
  left: 2px;
  width: 18px;
  height: 18px;
  border-radius: 50%;
  background: var(--color-bg-elevated);
  box-shadow: var(--shadow-sm);
  transition: transform var(--duration-fast) var(--ease-standard);
}

.switch input:checked + .switch-track {
  background: var(--color-accent);
  border-color: var(--color-accent);
  box-shadow: var(--glow-accent-sm);
}
.switch input:checked + .switch-track::after {
  transform: translateX(20px);
  background: #fff;
}
.switch input:focus-visible + .switch-track {
  box-shadow: var(--shadow-focus);
}

/* ---------- Cards ----------
   "Glass-lite": translucent and edge-lit like the rest of the glass system,
   but no backdrop-filter — these repeat by the dozen in card-lists, and
   blurring every one of them would cost real paint time for a difference
   nobody would notice next to a plain elevated surface. */
.card {
  background: var(--glass-bg-lite);
  border: 1px solid var(--glass-border);
  border-radius: var(--radius-lg);
  padding: var(--space-4);
  box-shadow: var(--glass-shadow);
  position: relative;
}

@media (min-width: 640px) {
  .card { padding: var(--space-5); }
}

.card-interactive {
  display: block;
  text-decoration: none;
  color: inherit;
  transition: box-shadow var(--duration-base) var(--ease-standard),
              border-color var(--duration-fast) var(--ease-standard),
              transform var(--duration-base) var(--ease-spring);
}
.card-interactive:hover {
  box-shadow: var(--glass-shadow-lg), var(--glow-accent-sm);
  border-color: color-mix(in srgb, var(--color-accent) 45%, var(--color-border-strong));
  transform: translateY(-2px);
}

/* Cursor-tracked spotlight — a soft light following the pointer under the
   glass, set by initSpotlight() in ui.js via --mx/--my. Own border-radius
   means it self-clips without needing overflow:hidden on the card (which
   would otherwise cut off anything the card positions outside its box). */
.card-interactive::after,
.stat-tile::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  background: radial-gradient(var(--spotlight-size) circle at var(--mx, 50%) var(--my, 50%),
              color-mix(in srgb, var(--color-accent) 16%, transparent), transparent 70%);
  opacity: 0;
  transition: opacity var(--duration-base) var(--ease-standard);
  pointer-events: none;
}

@media (hover: hover) and (pointer: fine) {
  .card-interactive:hover::after,
  .stat-tile:hover::after { opacity: 1; }
}

.card-header {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: var(--space-3);
  margin-bottom: var(--space-3);
}

/* ---------- Badges & status pills ---------- */
.badge {
  display: inline-flex;
  align-items: center;
  gap: var(--space-1);
  padding: var(--space-1) var(--space-3);
  border-radius: var(--radius-pill);
  font-size: var(--text-xs);
  font-weight: var(--weight-semibold);
  border: 1px solid transparent;
  line-height: 1.6;
  white-space: nowrap;
}

.badge-neutral { background: var(--color-bg-subtle); color: var(--color-text-muted); }
.badge-accent { background: var(--color-accent-subtle); color: var(--color-accent-text); }
.badge-info { background: var(--color-info-subtle); color: var(--color-info); }
.badge-warning { background: var(--color-warning-subtle); color: var(--color-warning); }
.badge-danger { background: var(--color-danger-subtle); color: var(--color-danger); }

.badge-available { background: var(--status-available-bg); color: var(--status-available); border-color: var(--status-available-border); }
.badge-maybe { background: var(--status-maybe-bg); color: var(--status-maybe); border-color: var(--status-maybe-border); }
.badge-unavailable { background: var(--status-unavailable-bg); color: var(--status-unavailable); border-color: var(--status-unavailable-border); }
.badge-unanswered { background: var(--status-unanswered-bg); color: var(--status-unanswered); border-color: var(--status-unanswered-border); }

/* Only the two badges that mark a *settled* outcome get light — glowing
   every badge would flatten the distinction they exist to draw. */
.badge-available { box-shadow: 0 0 12px color-mix(in srgb, var(--status-available) 28%, transparent); }
.badge-accent { box-shadow: 0 0 12px color-mix(in srgb, var(--color-accent) 25%, transparent); }

.badge-dot::before {
  content: "";
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: currentColor;
  box-shadow: 0 0 6px currentColor;
}

/* ---------- Avatars ---------- */
.avatar {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 40px;
  height: 40px;
  border-radius: 50%;
  background: var(--color-accent-subtle);
  color: var(--color-accent-text);
  font-weight: var(--weight-semibold);
  font-size: var(--text-sm);
  flex-shrink: 0;
  overflow: hidden;
  border: 2px solid var(--color-bg-elevated);
  box-shadow: 0 0 0 1px var(--color-border), 0 2px 6px rgba(21, 24, 43, 0.1);
}

.avatar img { width: 100%; height: 100%; object-fit: cover; border-radius: inherit; }

.avatar-sm { width: 28px; height: 28px; font-size: var(--text-xs); }

/* Large sizes are only ever used as an editable profile-photo preview
   (settings.php, next to the upload control) — a soft accent glow marks
   them out as "yours" rather than a plain member-list avatar. */
.avatar-lg, .avatar-xl {
  box-shadow: 0 0 0 1px var(--color-border), 0 2px 6px rgba(21, 24, 43, 0.1), var(--glow-accent-sm);
}
.avatar-lg { width: 64px; height: 64px; font-size: var(--text-lg); }
.avatar-xl { width: 96px; height: 96px; font-size: var(--text-2xl); }

.avatar-stack {
  display: flex;
}
.avatar-stack .avatar {
  margin-left: -10px;
  transition: transform var(--duration-base) var(--ease-spring),
              box-shadow var(--duration-base) var(--ease-standard),
              margin-left var(--duration-base) var(--ease-standard);
}
.avatar-stack .avatar:first-child { margin-left: 0; }

/* The overlapped stack spreads apart on hover so faces stop hiding behind
   each other — margin, not transform, so the row genuinely makes room
   instead of the avatars sliding over their neighbours. */
@media (hover: hover) and (pointer: fine) {
  .avatar-stack:hover .avatar { margin-left: -2px; }
  .avatar-stack:hover .avatar:first-child { margin-left: 0; }
  .avatar-stack .avatar:hover {
    transform: translateY(-3px) scale(1.08);
    box-shadow: 0 0 0 1px var(--color-border), var(--glow-accent-sm);
    z-index: 1;
  }
}

/* ---------- Group / plan icon tile ---------- */
.group-icon {
  width: 48px;
  height: 48px;
  border-radius: var(--radius-md);
  background: var(--color-accent-subtle);
  color: var(--color-accent-text);
  display: flex;
  align-items: center;
  justify-content: center;
  font-weight: var(--weight-bold);
  font-size: var(--text-lg);
  flex-shrink: 0;
  position: relative;
  overflow: hidden;
  box-shadow: inset 0 1px 0 var(--glass-highlight);
  transition: transform var(--duration-base) var(--ease-spring), box-shadow var(--duration-base) var(--ease-standard);
}

/* Diagonal sheen across the tile, matching the one on primary buttons —
   ties the letter-initial tiles into the same glass language. */
.group-icon::before {
  content: "";
  position: absolute;
  inset: 0;
  background: linear-gradient(135deg, rgba(255, 255, 255, 0.35), transparent 60%);
  pointer-events: none;
}
.group-icon-lg { width: 72px; height: 72px; font-size: var(--text-2xl); border-radius: var(--radius-lg); }

/* Above the sheen, not under it — the gloss is meant to give the plain
   letter-initial tiles some dimension, not to wash out a group's actual
   uploaded photo. */
.group-icon img { border-radius: inherit; position: relative; z-index: 1; }

/* ---------- Empty / loading / error states ---------- */
.state-block {
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
  gap: var(--space-3);
  padding: var(--space-9) var(--space-4);
  color: var(--color-text-muted);
}

.state-block .icon {
  width: 56px;
  height: 56px;
  color: var(--color-text-faint);
  animation: state-icon-breathe 4s ease-in-out infinite;
}

@keyframes state-icon-breathe {
  0%, 100% { opacity: 0.7; transform: scale(1); }
  50% { opacity: 1; transform: scale(1.05); }
}

@media (prefers-reduced-motion: reduce) {
  .state-block .icon { animation: none; }
}

.state-block h3 { color: var(--color-text); }

.skeleton {
  position: relative;
  overflow: hidden;
  background: var(--color-bg-inset);
  border-radius: var(--radius-md);
}

.skeleton::after {
  content: "";
  position: absolute;
  inset: 0;
  background: linear-gradient(90deg,
    transparent,
    color-mix(in srgb, var(--color-accent) 22%, rgba(255, 255, 255, 0.25)),
    transparent);
  animation: skeleton-sweep 1.4s infinite;
}

@media (prefers-reduced-motion: reduce) {
  .skeleton::after { animation: none; }
}

@keyframes skeleton-sweep {
  0% { transform: translateX(-100%); }
  100% { transform: translateX(100%); }
}

.skeleton-line { height: 14px; margin-bottom: var(--space-2); }
.skeleton-line:last-child { margin-bottom: 0; width: 70%; }

.spinner {
  width: 20px;
  height: 20px;
  border-radius: 50%;
  border: 2.5px solid var(--color-border);
  border-top-color: var(--color-accent);
  animation: spin 0.7s linear infinite;
  box-shadow: 0 0 12px color-mix(in srgb, var(--color-accent) 30%, transparent);
}

@media (prefers-reduced-motion: reduce) {
  .spinner { animation: spin 1.4s linear infinite; }
}

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

.spinner-lg { width: 32px; height: 32px; border-width: 3px; }

/* ---------- Toasts ----------
   Fixed to the viewport (not the page — see z-toast, the highest layer in
   the whole app) and anchored under the sticky header so it never sits
   behind it. Stacks newest-on-top; each toast owns its own auto-dismiss
   timer (see notify() in notifications.js), this file only draws the shape. */
.toast-region {
  position: fixed;
  z-index: var(--z-toast);
  top: calc(var(--header-height) + var(--space-3));
  right: var(--space-3);
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
  width: min(380px, calc(100vw - var(--space-6)));
  align-items: stretch;
  pointer-events: none;
}

.toast-region .toast { pointer-events: auto; }

@media (max-width: 640px) {
  .toast-region {
    left: var(--space-3);
    right: var(--space-3);
    width: auto;
  }
}

.toast {
  display: flex;
  align-items: flex-start;
  gap: var(--space-3);
  background: var(--color-text);
  color: var(--color-bg);
  padding: var(--space-4);
  border-radius: var(--radius-md);
  box-shadow: var(--shadow-lg);
  animation: toast-in var(--duration-base) var(--ease-spring);
}

.toast.is-leaving {
  animation: toast-out var(--duration-fast) var(--ease-standard) forwards;
}

@media (prefers-reduced-motion: reduce) {
  .toast { animation: none; }
  .toast.is-leaving { animation: none; opacity: 0; }
}

.toast[data-variant="success"] { background: var(--color-success); color: #fff; box-shadow: var(--shadow-lg), 0 0 28px color-mix(in srgb, var(--color-success) 45%, transparent); }
.toast[data-variant="error"] { background: var(--color-danger); color: #fff; box-shadow: var(--shadow-lg), 0 0 28px color-mix(in srgb, var(--color-danger) 45%, transparent); }
.toast[data-variant="warning"] { background: var(--color-warning); color: #fff; box-shadow: var(--shadow-lg), 0 0 28px color-mix(in srgb, var(--color-warning) 45%, transparent); }
.toast[data-variant="info"] { background: var(--color-info); color: #fff; box-shadow: var(--shadow-lg), 0 0 28px color-mix(in srgb, var(--color-info) 45%, transparent); }

.toast-icon { width: 20px; height: 20px; flex-shrink: 0; margin-top: 1px; }

.toast-content { flex: 1; min-width: 0; }

.toast-title {
  font-size: var(--text-sm);
  font-weight: var(--weight-semibold);
  margin: 0;
}

.toast-body {
  font-size: var(--text-xs);
  margin: var(--space-1) 0 0;
  opacity: 0.9;
}

.toast-action {
  margin-top: var(--space-2);
  background: rgba(255, 255, 255, 0.16);
  border: 1px solid rgba(255, 255, 255, 0.3);
  color: inherit;
  min-height: 32px;
  padding: var(--space-1) var(--space-3);
  border-radius: var(--radius-sm);
  font-size: var(--text-xs);
  font-weight: var(--weight-semibold);
  cursor: pointer;
}
.toast-action:hover { background: rgba(255, 255, 255, 0.26); }

.toast-close {
  margin-left: auto;
  background: none;
  border: none;
  color: inherit;
  opacity: 0.8;
  cursor: pointer;
  padding: var(--space-1);
  flex-shrink: 0;
  border-radius: var(--radius-sm);
}
.toast-close:hover { opacity: 1; }
.toast-close:focus-visible { opacity: 1; outline: 2px solid currentColor; outline-offset: 2px; }
.toast-close .icon { width: 16px; height: 16px; display: block; }

@keyframes toast-in {
  from { opacity: 0; transform: translateY(-8px) scale(0.98); }
  to { opacity: 1; transform: translateY(0) scale(1); }
}

@keyframes toast-out {
  from { opacity: 1; transform: translateY(0) scale(1); }
  to { opacity: 0; transform: translateY(-8px) scale(0.98); }
}

/* ---------- Modal dialogs ---------- */
.modal-backdrop {
  position: fixed;
  inset: 0;
  background: rgba(10, 11, 20, 0.42);
  backdrop-filter: blur(8px) saturate(120%);
  -webkit-backdrop-filter: blur(8px) saturate(120%);
  animation: backdrop-in var(--duration-base) var(--ease-standard);
  z-index: var(--z-modal-backdrop);
  display: flex;
  align-items: flex-end;
  justify-content: center;
  padding: 0;
  /* Belt-and-suspenders alongside the JS body-scroll lock (see
     lockBodyScroll() in ui.js): stops any residual scroll gesture on the
     backdrop from chaining into the page behind it. Not touch-action:none —
     .modal itself still needs normal touch scrolling for tall content. */
  overscroll-behavior: contain;
}

@media (min-width: 640px) {
  .modal-backdrop { align-items: center; padding: var(--space-4); }
}

.modal {
  background: var(--glass-bg-strong);
  backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
  -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
  border: 1px solid var(--glass-border);
  z-index: var(--z-modal);
  width: 100%;
  max-width: 560px;
  max-height: 92vh;
  overflow-y: auto;
  overscroll-behavior: contain;
  border-radius: var(--radius-xl) var(--radius-xl) 0 0;
  box-shadow: var(--glass-shadow-lg), 0 0 0 1px color-mix(in srgb, var(--color-accent) 12%, transparent);
  animation: modal-in var(--duration-base) var(--ease-spring);
}

@media (min-width: 640px) {
  .modal { border-radius: var(--radius-xl); }
}

@keyframes backdrop-in {
  from { opacity: 0; }
  to { opacity: 1; }
}

/* Rises and settles into place with a touch of scale — paired with the
   backdrop fade above so the dialog and the dimming arrive together. */
@keyframes modal-in {
  from { opacity: 0; transform: translateY(24px) scale(0.97); }
  to { opacity: 1; transform: translateY(0) scale(1); }
}

@media (prefers-reduced-motion: reduce) {
  .modal { animation: none; }
  .modal-backdrop { animation: none; }
}

.modal-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-3);
  padding: var(--space-5);
  border-bottom: 1px solid var(--glass-border);
  position: sticky;
  top: 0;
  background: var(--glass-bg-strong);
}

.modal-body { padding: var(--space-5); }

.modal-footer {
  display: flex;
  justify-content: flex-end;
  gap: var(--space-3);
  padding: var(--space-5);
  border-top: 1px solid var(--glass-border);
  position: sticky;
  bottom: 0;
  background: var(--glass-bg-strong);
  flex-wrap: wrap;
}

@media (max-width: 640px) {
  .modal-footer .btn { flex: 1; }
}

.install-steps-img {
  display: block;
  width: 100%;
  height: auto;
  border-radius: var(--radius-md);
  box-shadow: var(--shadow-sm);
  margin-bottom: var(--space-4);
}

.modal-close {
  background: none;
  border: none;
  cursor: pointer;
  padding: var(--space-2);
  border-radius: var(--radius-md);
  color: var(--color-text-muted);
  width: 40px;
  height: 40px;
  flex-shrink: 0;
  transition: background var(--duration-fast) var(--ease-standard), transform var(--duration-fast) var(--ease-standard);
}
.modal-close:hover { background: var(--color-bg-subtle); }
.modal-close:active { transform: scale(0.92); }

/* ---------- Image cropper (avatar / group-icon upload) ----------
   The edit surface fills the dialog edge-to-edge instead of floating as a
   small padded widget inside it — the crop frame nearly touches the canvas
   bounds (see INSET in ui.js), so the frame reads as "this whole area is
   the photo", not a smaller shape dimmed inside extra chrome. */
.crop-modal-body {
  padding: 0;
  background: #0b0c12;
  line-height: 0;
}

.crop-canvas {
  display: block;
  width: 100%;
  aspect-ratio: 1 / 1;
  height: auto;
  touch-action: none;
  cursor: grab;
}
.crop-canvas:active { cursor: grabbing; }

.crop-controls {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  padding: var(--space-4) var(--space-5) 0;
}

.crop-zoom-icon {
  width: 15px;
  height: 15px;
  color: var(--color-text-faint);
  flex-shrink: 0;
}
.crop-zoom-icon.is-lg { width: 21px; height: 21px; color: var(--color-text-muted); }

.crop-zoom-slider {
  flex: 1;
  -webkit-appearance: none;
  appearance: none;
  height: 4px;
  min-height: 0;
  border-radius: var(--radius-pill);
  background: var(--color-bg-inset);
  outline: none;
  padding: 0;
  border: none;
}
.crop-zoom-slider::-webkit-slider-thumb {
  -webkit-appearance: none;
  width: 18px;
  height: 18px;
  border-radius: 50%;
  background: var(--color-accent);
  box-shadow: 0 2px 6px rgba(21, 24, 43, 0.3), 0 0 0 3px var(--color-bg-elevated);
  cursor: pointer;
  transition: transform var(--duration-fast) var(--ease-spring);
}
.crop-zoom-slider::-webkit-slider-thumb:active { transform: scale(1.15); }
.crop-zoom-slider::-moz-range-thumb {
  width: 18px;
  height: 18px;
  border-radius: 50%;
  border: none;
  background: var(--color-accent);
  box-shadow: 0 2px 6px rgba(21, 24, 43, 0.3), 0 0 0 3px var(--color-bg-elevated);
  cursor: pointer;
  transition: transform var(--duration-fast) var(--ease-spring);
}
.crop-zoom-slider::-moz-range-thumb:active { transform: scale(1.15); }
.crop-zoom-slider::-moz-range-track {
  height: 4px;
  border-radius: var(--radius-pill);
  background: var(--color-bg-inset);
}

.crop-hint {
  padding: var(--space-3) var(--space-5) var(--space-5);
  font-size: var(--text-xs);
}

/* ---------- Dropdown menus ---------- */
.dropdown {
  position: relative;
  display: inline-block;
}

.dropdown-menu {
  position: absolute;
  top: calc(100% + var(--space-2));
  right: 0;
  min-width: 220px;
  max-height: min(70vh, 420px);
  overflow-y: auto;
  background: var(--glass-bg-strong);
  backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
  -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
  border: 1px solid var(--glass-border);
  border-radius: var(--radius-lg);
  box-shadow: var(--glass-shadow-lg);
  padding: var(--space-2);
  z-index: var(--z-dropdown);
  display: none;
  transform-origin: top right;
}

.dropdown-menu.is-open { display: block; animation: dropdown-in var(--duration-fast) var(--ease-spring); }
.dropdown-menu[data-align="left"] { right: auto; left: 0; transform-origin: top left; }

@keyframes dropdown-in {
  from { opacity: 0; transform: scale(0.94) translateY(-4px); }
  to { opacity: 1; transform: scale(1) translateY(0); }
}

.dropdown-item {
  position: relative;
  display: flex;
  align-items: center;
  gap: var(--space-3);
  width: 100%;
  padding: var(--space-3);
  border: none;
  background: none;
  text-align: left;
  border-radius: var(--radius-sm);
  cursor: pointer;
  color: var(--color-text);
  text-decoration: none;
  font-size: var(--text-sm);
  transition: background var(--duration-fast) var(--ease-standard);
}
.dropdown-item:hover { background: var(--color-bg-subtle); }

/* A short glowing accent bar grows in from the left on hover/focus — a
   small "which row" indicator to match the rest of the glow language. */
.dropdown-item::before {
  content: "";
  position: absolute;
  left: 0;
  top: 22%;
  bottom: 22%;
  width: 3px;
  border-radius: var(--radius-pill);
  background: var(--color-accent);
  box-shadow: var(--glow-accent-sm);
  opacity: 0;
  transform: scaleY(0.4);
  transition: opacity var(--duration-fast) var(--ease-standard), transform var(--duration-fast) var(--ease-spring);
}
.dropdown-item:hover::before,
.dropdown-item:focus-visible::before { opacity: 1; transform: scaleY(1); }
.dropdown-item.is-danger::before { background: var(--color-danger); box-shadow: 0 0 12px color-mix(in srgb, var(--color-danger) 45%, transparent); }
.dropdown-item.is-danger { color: var(--color-danger); }
.dropdown-item .icon { width: 18px; height: 18px; color: var(--color-text-muted); flex-shrink: 0; }
.dropdown-item.is-danger .icon { color: var(--color-danger); }

.dropdown-divider {
  height: 1px;
  background: var(--color-border);
  margin: var(--space-2);
}

/* Rotates a trailing chevron (e.g. plan.php's "Manage plan" trigger) to
   show open/closed state. Harmless no-op on triggers whose last .icon isn't
   a chevron (e.g. a symmetric "more" dots icon looks identical rotated). */
[data-dropdown-trigger] .icon:last-child {
  transition: transform var(--duration-fast) var(--ease-standard);
}
[data-dropdown-trigger][aria-expanded="true"] .icon:last-child {
  transform: rotate(180deg);
}

.dropdown-account {
  padding: var(--space-3);
  border-bottom: 1px solid var(--color-border);
  margin-bottom: var(--space-1);
}
.dropdown-account-email { font-size: var(--text-xs); }

/* ---------- Tabs ---------- */
.tablist {
  display: flex;
  gap: var(--space-1);
  border-bottom: 1px solid var(--color-border);
  overflow-x: auto;
  -webkit-overflow-scrolling: touch;
}

.tab {
  padding: var(--space-3) var(--space-4);
  border: none;
  background: none;
  border-bottom: 2px solid transparent;
  color: var(--color-text-muted);
  font-weight: var(--weight-medium);
  font-size: var(--text-sm);
  cursor: pointer;
  white-space: nowrap;
  transition: color var(--duration-fast) var(--ease-standard), border-color var(--duration-fast) var(--ease-standard);
}

.tab { position: relative; }
.tab:hover { color: var(--color-text); }
.tab[aria-selected="true"] {
  color: var(--color-accent-text);
  border-bottom-color: var(--color-accent);
}

/* The 2px border above carries the indicator; this adds the light around
   it. Separate element rather than a box-shadow on .tab itself so the glow
   hugs the underline instead of outlining the whole tab rectangle. */
.tab[aria-selected="true"]::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  bottom: -2px;
  height: 2px;
  border-radius: var(--radius-pill);
  background: var(--color-accent);
  box-shadow: 0 0 10px color-mix(in srgb, var(--color-accent) 75%, transparent);
  animation: tab-indicator-in var(--duration-base) var(--ease-spring) both;
}

@keyframes tab-indicator-in {
  from { transform: scaleX(0.4); opacity: 0; }
  to { transform: scaleX(1); opacity: 1; }
}

.tabpanel { padding-top: var(--space-5); }
.tabpanel[hidden] { display: none; }

/* ---------- Chips (member / subgroup selection) ---------- */
.chip {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  padding: var(--space-2) var(--space-3);
  border-radius: var(--radius-pill);
  border: 1px solid var(--color-border-strong);
  background: var(--color-bg-elevated);
  font-size: var(--text-sm);
  cursor: pointer;
  user-select: none;
  transition: background var(--duration-fast) var(--ease-standard),
              border-color var(--duration-fast) var(--ease-standard),
              box-shadow var(--duration-fast) var(--ease-standard),
              transform var(--duration-fast) var(--ease-spring),
              color var(--duration-fast) var(--ease-standard);
}

.chip:hover { border-color: var(--color-accent); box-shadow: var(--glow-accent-sm); }
.chip:active { transform: scale(0.96); }

.chip.is-selected {
  background: var(--color-accent-subtle);
  border-color: var(--color-accent);
  color: var(--color-accent-text);
  font-weight: var(--weight-medium);
  box-shadow: var(--glow-accent-sm);
  animation: chip-pop var(--duration-base) var(--ease-spring);
}

@keyframes chip-pop {
  0% { transform: scale(0.92); }
  60% { transform: scale(1.04); }
  100% { transform: scale(1); }
}

@media (prefers-reduced-motion: reduce) {
  .chip.is-selected { animation: none; }
}

.chip-remove {
  background: none;
  border: none;
  cursor: pointer;
  color: inherit;
  display: flex;
  padding: 0;
  margin-left: var(--space-1);
}

/* ---------- Selectable list rows (member picker) ---------- */
.select-row {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  padding: var(--space-3);
  border-radius: var(--radius-md);
  border: 1px solid transparent;
  cursor: pointer;
  transition: background var(--duration-fast) var(--ease-standard), border-color var(--duration-fast) var(--ease-standard);
}

.select-row { position: relative; }
.select-row:hover { background: var(--color-bg-subtle); }

.select-row.is-selected {
  background: var(--color-accent-subtle);
  border-color: var(--color-accent);
  box-shadow: var(--glow-accent-sm);
}

.select-row.is-excluded {
  opacity: 0.5;
}

.select-row-main { flex: 1; min-width: 0; }
.select-row-name { font-weight: var(--weight-medium); }
.select-row-meta { font-size: var(--text-xs); color: var(--color-text-muted); }



/* ---------- Progress bar (attendance results) ---------- */
.meter {
  height: 10px;
  border-radius: var(--radius-pill);
  background: var(--color-bg-inset);
  overflow: hidden;
  display: flex;
  box-shadow: inset 0 1px 2px rgba(21, 24, 43, 0.12);
}

/* Segments grow out from the left on first paint. Animating the transform
   rather than width means no layout work per frame, and the inline width
   the server rendered stays the source of truth the whole time. */
.meter-segment {
  height: 100%;
  transform-origin: 0 50%;
  animation: meter-grow var(--duration-slow) var(--ease-standard) both;
}
.meter-segment.available { background: var(--status-available); box-shadow: 0 0 10px color-mix(in srgb, var(--status-available) 55%, transparent); }
.meter-segment.maybe { background: var(--status-maybe); box-shadow: 0 0 10px color-mix(in srgb, var(--status-maybe) 55%, transparent); }
.meter-segment.unavailable { background: var(--status-unavailable); box-shadow: 0 0 10px color-mix(in srgb, var(--status-unavailable) 55%, transparent); }
.meter-segment.unanswered { background: var(--status-unanswered); }

@keyframes meter-grow {
  from { transform: scaleX(0); }
  to { transform: scaleX(1); }
}

@media (prefers-reduced-motion: reduce) {
  .meter-segment { animation: none; }
}

/* ---------- Alert / banner ---------- */
.alert {
  position: relative;
  display: flex;
  gap: var(--space-3);
  padding: var(--space-4);
  padding-left: calc(var(--space-4) + 3px);
  border-radius: var(--radius-md);
  border: 1px solid var(--color-border);
  background: var(--color-bg-subtle);
  align-items: flex-start;
  overflow: hidden;
}

/* A glowing rail down the left edge, tinted by the variant's own colour so
   the alert reads at a glance from the side even before the icon
   registers. --alert-accent (not currentColor) because the unvariant
   .alert inherits body text colour, which would paint a dark bar and a
   dark "glow" that just looks like a rendering artefact. */
.alert {
  --alert-accent: var(--color-border-strong);
}
.alert::before {
  content: "";
  position: absolute;
  left: 0;
  top: 0;
  bottom: 0;
  width: 3px;
  background: var(--alert-accent);
  box-shadow: 0 0 12px var(--alert-accent);
  opacity: 0.9;
}

.alert .icon { width: 20px; height: 20px; flex-shrink: 0; margin-top: 1px; }

/* An alert that ends in something to do. Two templates built this shape with
   an inline style attribute, which the CSP drops — so the button sat under
   the text at full width instead of beside it. As a class it survives, and
   the action stays vertically centred against however many lines of text
   precede it, dropping below them only when the row genuinely runs out of
   room. */
.alert-with-action {
  align-items: center;
  flex-wrap: wrap;
  margin-bottom: var(--space-6);
}
.alert-with-action .alert-body { flex: 1 1 260px; }
.alert-with-action .btn { flex-shrink: 0; }
.alert-with-action .btn .icon { width: 16px; height: 16px; margin-top: 0; }
.alert-body > span { display: block; font-size: var(--text-sm); opacity: 0.85; }
.alert-info { background: var(--color-info-subtle); border-color: transparent; color: var(--color-info); --alert-accent: var(--color-info); }
.alert-warning { background: var(--color-warning-subtle); border-color: transparent; color: var(--color-warning); --alert-accent: var(--color-warning); }
.alert-danger { background: var(--color-danger-subtle); border-color: transparent; color: var(--color-danger); --alert-accent: var(--color-danger); }
.alert-success { background: var(--color-success-subtle); border-color: transparent; color: var(--color-success); --alert-accent: var(--color-success); }

/* Only the coloured variants get a glowing icon — on the neutral base
   alert the icon inherits dark body text, and a drop-shadow of that reads
   as a blur, not a glow. */
.alert-info .icon,
.alert-warning .icon,
.alert-danger .icon,
.alert-success .icon { filter: drop-shadow(0 0 5px currentColor); }
.alert p { color: inherit; margin: 0; }
.alert-body { color: var(--color-text); }
.alert-body strong { color: inherit; }

/* ---------- Tooltip (title-based, CSS only) ---------- */
[data-tooltip] { position: relative; }

/* ---------- Calendar / date grid ---------- */
.calendar {
  display: grid;
  /* minmax(0, 1fr), not bare 1fr: a plain 1fr track has an implicit auto
     (content-based) minimum, so on a phone rendering the day-number text
     even slightly wider than a desktop simulator did, the grid refuses to
     shrink columns and overflows the screen instead. minmax(0, 1fr) forces
     every column to fit the available width no matter what. */
  grid-template-columns: repeat(7, minmax(0, 1fr));
  width: 100%;
  row-gap: var(--space-1);
  column-gap: var(--space-4);
}

.calendar-weekday {
  text-align: center;
  font-size: var(--text-xs);
  color: var(--color-text-faint);
  padding-bottom: var(--space-2);
  font-weight: var(--weight-medium);
}

.calendar-cell {
  aspect-ratio: 1;
  border-radius: var(--radius-md);
  border: none;
  background: var(--color-bg-subtle);
  color: var(--color-text-muted);
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: 44px;
  position: relative;
  text-decoration: none;
  font-weight: var(--weight-semibold);
  font-size: var(--text-sm);
}

/* .calendar-cell is sometimes a <button> (the paintable availability
   calendar) instead of a <div>/<a> — strip default button chrome so it's
   pixel-identical to the non-interactive cells. */
button.calendar-cell { padding: 0; margin: 0; appearance: none; -webkit-appearance: none; }

.calendar-cell.is-outside { visibility: hidden; }

.calendar-cell.is-today { box-shadow: inset 0 0 0 2px var(--color-accent-text), var(--glow-accent-sm); }

.calendar-cell.is-clickable { cursor: pointer; transition: transform 0.12s ease, box-shadow 0.12s ease; }
.calendar-cell.is-clickable:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }

/* Hover lift only for devices that have real hover — on touch, a :hover
   style applied on tap can get "stuck" until the user taps elsewhere,
   leaving the tapped day looking permanently offset/lifted. Touch instead
   gets instant :active press feedback with no lingering state. */
@media (hover: hover) and (pointer: fine) {
  .calendar-cell.is-clickable:hover { transform: translateY(-2px) scale(1.04); box-shadow: var(--shadow-md); z-index: 1; }
}
.calendar-cell.is-clickable:active { transform: scale(0.96); }

/* Painted days get a soft halo in their own status colour — the calendar
   is the densest surface in the app, and the light is what separates a
   painted day from an empty one at a glance. */
.calendar-cell[data-status="available"] { background: var(--status-available); color: #fff; box-shadow: 0 0 12px color-mix(in srgb, var(--status-available) 45%, transparent); }
.calendar-cell[data-status="maybe"] { background: var(--status-maybe); color: #1a1a1a; box-shadow: 0 0 12px color-mix(in srgb, var(--status-maybe) 45%, transparent); }
.calendar-cell[data-status="unavailable"] { background: var(--status-unavailable); color: #fff; box-shadow: 0 0 12px color-mix(in srgb, var(--status-unavailable) 45%, transparent); }
.calendar-cell[data-status="unanswered"] { background: var(--status-unanswered); color: #fff; }

.calendar-cell.is-today[data-status] { box-shadow: inset 0 0 0 2px var(--color-bg-elevated); }

.calendar-cell.is-perfect {
  box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.7), 0 0 16px color-mix(in srgb, var(--status-available) 65%, transparent);
}
.calendar-cell.is-perfect::after {
  content: "★";
  position: absolute;
  top: 2px;
  right: 4px;
  font-size: 11px;
  line-height: 1;
  color: #fff;
  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
  animation: star-twinkle 3s ease-in-out infinite;
}

/* The unanimous-availability day is the single best outcome the whole
   heatmap exists to surface — worth one small piece of delight. */
@keyframes star-twinkle {
  0%, 100% { opacity: 0.85; transform: scale(1) rotate(0deg); }
  50% { opacity: 1; transform: scale(1.25) rotate(12deg); }
}

@media (prefers-reduced-motion: reduce) {
  .calendar-cell.is-perfect::after { animation: none; }
}

.calendar-month-label {
  margin: var(--space-5) 0 var(--space-2);
  color: var(--color-text-muted);
  font-size: var(--text-sm);
  text-transform: uppercase;
  letter-spacing: 0.02em;
}
.calendar-month-label:first-child { margin-top: 0; }

/* ---------- Paint-to-save prompt (paintable availability calendar) ---------- */
.btn.is-unsaved {
  background: var(--color-accent);
  color: #fff;
  border-color: var(--color-accent);
  animation: unsaved-pulse 1.6s ease-in-out infinite;
}

@keyframes unsaved-pulse {
  0%, 100% { box-shadow: 0 0 0 0 rgba(91, 95, 239, 0.5); }
  50% { box-shadow: 0 0 0 8px rgba(91, 95, 239, 0); }
}

@media (prefers-reduced-motion: reduce) {
  .btn.is-unsaved { animation: none; }
}

/* ---------- Day detail popup (modal, CSS-:target driven — works with JS disabled) ---------- */
.modal-backdrop[id] {
  display: none;
}
.modal-backdrop[id]:target {
  display: flex;
}
/* JS-driven visibility (see initModals() in ui.js) — takes over from :target
   once JS is running, so opening/closing doesn't depend on :target
   re-matching after a history.pushState() hash change. */
.modal-backdrop[id].is-open {
  display: flex;
}

.day-detail-list {
  max-height: 280px;
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
}

/* Simple candidate date list rows (used alongside/instead of the grid) */
.date-list { display: flex; flex-direction: column; gap: var(--space-2); }

.date-row {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  padding: var(--space-3) var(--space-4);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-md);
  background: var(--color-bg-elevated);
  flex-wrap: wrap;
}

.date-row-label { flex: 1; min-width: 140px; }
.date-row-label strong { display: block; }

.status-toggle-group {
  display: inline-flex;
  border: 1px solid var(--color-border-strong);
  border-radius: var(--radius-pill);
  overflow: hidden;
}

.status-toggle {
  border: none;
  background: var(--color-bg-elevated);
  padding: var(--space-2) var(--space-3);
  font-size: var(--text-xs);
  font-weight: var(--weight-semibold);
  cursor: pointer;
  color: var(--color-text-muted);
  border-right: 1px solid var(--color-border-strong);
}
.status-toggle:last-child { border-right: none; }

.status-toggle { transition: background var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard); }
.status-toggle[data-status="available"][aria-pressed="true"] { background: var(--status-available); color: #fff; box-shadow: inset 0 0 14px color-mix(in srgb, #fff 22%, transparent), var(--glow-success); }
.status-toggle[data-status="maybe"][aria-pressed="true"] { background: var(--status-maybe); color: #fff; box-shadow: inset 0 0 14px color-mix(in srgb, #fff 22%, transparent), var(--glow-warning); }
.status-toggle[data-status="unavailable"][aria-pressed="true"] { background: var(--status-unavailable); color: #fff; box-shadow: inset 0 0 14px color-mix(in srgb, #fff 22%, transparent), var(--glow-danger); }

/* ---------- Comment thread ---------- */
.comment {
  display: flex;
  gap: var(--space-3);
}

.comment-body {
  flex: 1;
  background: var(--color-bg-subtle);
  border-radius: var(--radius-md);
  padding: var(--space-3) var(--space-4);
}

.comment-meta {
  display: flex;
  gap: var(--space-2);
  align-items: baseline;
  margin-bottom: var(--space-1);
}

.comment-author { font-weight: var(--weight-semibold); font-size: var(--text-sm); }
.comment-time { font-size: var(--text-xs); color: var(--color-text-faint); }

/* ---------- Timeline / history ---------- */
.timeline {
  display: flex;
  flex-direction: column;
}

.timeline-item {
  display: flex;
  gap: var(--space-3);
  padding-bottom: var(--space-5);
  position: relative;
}

/* The connecting line fades out toward the bottom rather than stopping at
   a hard edge — reads as a thread of light running through the entries. */
.timeline-item::before {
  content: "";
  position: absolute;
  left: 5px;
  top: 20px;
  bottom: 0;
  width: 1px;
  background: linear-gradient(to bottom,
    color-mix(in srgb, var(--color-accent) 45%, transparent),
    var(--color-border));
}

.timeline-item:last-child::before { display: none; }

.timeline-dot {
  width: 11px;
  height: 11px;
  border-radius: 50%;
  background: var(--color-accent);
  margin-top: 5px;
  flex-shrink: 0;
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent) 18%, transparent),
              0 0 12px color-mix(in srgb, var(--color-accent) 60%, transparent);
}

/* The newest entry (rendered first) pulses gently — the one thing on a
   history list worth drawing the eye to. */
.timeline-item:first-child .timeline-dot {
  animation: timeline-dot-pulse 2.8s ease-in-out infinite;
}

@keyframes timeline-dot-pulse {
  0%, 100% { box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent) 18%, transparent), 0 0 12px color-mix(in srgb, var(--color-accent) 60%, transparent); }
  50% { box-shadow: 0 0 0 5px color-mix(in srgb, var(--color-accent) 10%, transparent), 0 0 18px color-mix(in srgb, var(--color-accent) 75%, transparent); }
}

@media (prefers-reduced-motion: reduce) {
  .timeline-item:first-child .timeline-dot { animation: none; }
}

/* ---------- Countdown ---------- */
.countdown {
  display: flex;
  gap: var(--space-3);
  flex-wrap: wrap;
}

.countdown-unit {
  background: var(--glass-bg-lite);
  border: 1px solid var(--glass-border);
  border-radius: var(--radius-md);
  padding: var(--space-3) var(--space-4);
  text-align: center;
  min-width: 72px;
  box-shadow: var(--glass-shadow);
  transition: box-shadow var(--duration-base) var(--ease-standard), transform var(--duration-base) var(--ease-spring);
}
.countdown-unit:hover {
  box-shadow: var(--glass-shadow), var(--glow-accent-sm);
  transform: translateY(-1px);
}

.countdown-unit .num {
  font-size: var(--text-xl);
  font-weight: var(--weight-bold);
  display: block;
  font-variant-numeric: tabular-nums;
}

/* See the note on .stat-tile .num — gradient only where the text clip
   actually works, so the fallback is a solid numeral, never an invisible
   one. Doubly important here: this is a live countdown. */
@supports ((background-clip: text) or (-webkit-background-clip: text)) {
  .countdown-unit .num {
    background: var(--gradient-accent);
    -webkit-background-clip: text;
    background-clip: text;
    color: transparent;
  }
}

.countdown-unit .label {
  font-size: var(--text-xs);
  color: var(--color-text-muted);
}

/* ---------- Table (admin) ---------- */
.table-wrap {
  overflow-x: auto;
  border: 1px solid var(--color-border);
  border-radius: var(--radius-lg);
}

.data-table {
  width: 100%;
  min-width: 640px;
}

.data-table th,
.data-table td {
  text-align: left;
  padding: var(--space-3) var(--space-4);
  border-bottom: 1px solid var(--color-border);
  font-size: var(--text-sm);
}

.data-table th {
  color: var(--color-text-muted);
  font-weight: var(--weight-medium);
  background: var(--glass-bg-strong);
  backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
  -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
  position: sticky;
  top: 0;
  z-index: 1;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  font-size: var(--text-xs);
}

.data-table tbody tr { transition: background-color var(--duration-fast) var(--ease-standard), box-shadow var(--duration-fast) var(--ease-standard); }
.data-table tbody tr:hover {
  background: var(--color-bg-subtle);
  box-shadow: inset 3px 0 0 var(--color-accent), inset 0 0 24px color-mix(in srgb, var(--color-accent) 6%, transparent);
}
.data-table tbody tr:last-child td { border-bottom: none; }

/* ---------- Progress steps (create-plan wizard) ---------- */
.steps {
  display: flex;
  gap: var(--space-2);
  overflow-x: auto;
  padding-bottom: var(--space-2);
}

.step {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  flex-shrink: 0;
  color: var(--color-text-faint);
  font-size: var(--text-sm);
  font-weight: var(--weight-medium);
}

.step-num {
  width: 26px;
  height: 26px;
  border-radius: 50%;
  border: 1.5px solid var(--color-border-strong);
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: var(--text-xs);
  flex-shrink: 0;
}

.step.is-active { color: var(--color-text); }
.step.is-active .step-num {
  border-color: var(--color-accent);
  color: var(--color-accent-text);
  background: var(--color-accent-subtle);
  box-shadow: var(--glow-accent-sm);
  animation: step-active-pulse 2.4s ease-in-out infinite;
}
.step.is-complete .step-num { border-color: var(--color-success); background: var(--color-success); color: #fff; box-shadow: var(--glow-success); }
.step-connector { width: 20px; height: 1px; background: var(--color-border-strong); flex-shrink: 0; }

/* A connector trailing a finished step is itself "done" — filling it in
   turns the row into a progress track rather than disconnected dots. */
.step.is-complete + .step-connector {
  background: linear-gradient(to right, var(--color-success), color-mix(in srgb, var(--color-success) 40%, var(--color-border-strong)));
  box-shadow: 0 0 8px color-mix(in srgb, var(--color-success) 45%, transparent);
}

@keyframes step-active-pulse {
  0%, 100% { box-shadow: var(--glow-accent-sm); }
  50% { box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-accent) 55%, transparent), 0 0 20px color-mix(in srgb, var(--color-accent) 55%, transparent); }
}

@media (prefers-reduced-motion: reduce) {
  .step.is-active .step-num { animation: none; }
}

/* An <svg class="icon"> has no intrinsic size, and until now every one of
   them got its dimensions from a context rule (`.btn .icon`, `.alert .icon`,
   `.side-nav .nav-link .icon`, …). Drop one somewhere without such a rule —
   which `icon()` now makes a one-liner — and it renders at the full width of
   its container, which is a startling way to find out. This is the floor.
   Every context rule is more specific, so nothing that already had a size
   changes; only the previously-unstyled case is caught. */
.icon { width: 20px; height: 20px; flex-shrink: 0; }

/* ---------- Icon + label pairing ----------
   `.btn` and `.dropdown-item` were already flex rows with a gap, so a glyph
   dropped beside their label needed nothing. The controls below were not —
   each one is a plain padded box that would have stacked an inline <svg>
   against its text baseline. They get the same treatment in one place
   rather than a nudge per component.

   Sizes step down with the control: 18px on a button, 15–16px on a tab or a
   chip, 14px on the smallest inline affordances. An icon that matches its
   label's cap-height reads as part of the word; one that matches the line
   box reads as a separate object shouting for attention. */
.tab,
.status-toggle,
.theme-option-label,
.file-link,
.ai-msg-copy-btn {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
}

.tab .icon { width: 16px; height: 16px; flex-shrink: 0; }
.status-toggle .icon { width: 14px; height: 14px; flex-shrink: 0; }
.theme-option-label .icon { width: 16px; height: 16px; flex-shrink: 0; }
.file-link .icon { width: 16px; height: 16px; flex-shrink: 0; color: var(--color-text-muted); }
.ai-msg-copy-btn .icon { width: 13px; height: 13px; flex-shrink: 0; }

/* The tab row scrolls sideways on a phone; with a glyph now sharing the
   line, its own padding is what stops two tabs merging into one word. */
.tab { gap: var(--space-2); }

/* Unselected tabs and toggles carry a quieter glyph than their label, so
   the row reads as text first and pictures second. Selected ones let the
   icon come up to full strength along with everything else. */
.tab .icon { opacity: 0.75; }
.tab[aria-selected="true"] .icon { opacity: 1; }
.status-toggle .icon { opacity: 0.8; }
.status-toggle[aria-pressed="true"] .icon { opacity: 1; }

.theme-option-label { justify-content: center; width: 100%; }

/* An attachment row is a filename and nothing else; the paper glyph is what
   makes it scan as a file rather than as a link to another page. */
.file-link { text-decoration: none; }
.file-link:hover { text-decoration: underline; }

/* Copy-to-clipboard confirms in place: the label swaps to "Copied!" and the
   whole control goes green for a moment. Colour alone would be a weak
   signal on a control this small, which is why the text changes too. */
.ai-msg-copy-btn.is-copied { color: var(--color-success); }

/* ---------- Checkboxes & radios ----------
   These were the last native controls left in the app. `accent-color` tints
   a native box but leaves its shape, size and shadow to the platform, so the
   same form rendered as a flat grey square on Windows, a soft rounded one on
   macOS and something else again on Android — beside buttons and inputs that
   are identical everywhere. `appearance: none` hands the whole control over.

   The two shapes are deliberately far apart: a rounded *square* for a
   checkbox, a *circle* for a radio, because their meanings differ (many vs
   exactly one) and the shape is the only thing carrying that. 6px of radius
   rather than --radius-sm's 8px — on a 20px box, 8px reads as almost round
   and blurs the distinction the shape exists to make.

   Checked state borrows the accent fill and glow the rest of the app uses
   for "this one", and the tick is the same path as icon('check') in
   includes/icons.php, so the mark inside a checkbox is literally the mark on
   a confirm button. */
input[type="checkbox"],
input[type="radio"] {
  appearance: none;
  -webkit-appearance: none;
  flex-shrink: 0;
  width: 20px;
  height: 20px;
  min-height: 0;
  margin: 0;
  padding: 0;
  border: 1.5px solid var(--color-border-strong);
  border-radius: 6px;
  background-color: var(--color-bg-elevated);
  background-position: center;
  background-repeat: no-repeat;
  /* Held at zero so the glyph can spring open on check — see below. */
  background-size: 0;
  box-shadow: inset 0 1px 2px rgba(21, 24, 43, 0.04);
  cursor: pointer;
  transition: background-color var(--duration-fast) var(--ease-standard),
              border-color var(--duration-fast) var(--ease-standard),
              box-shadow var(--duration-fast) var(--ease-standard),
              background-size var(--duration-base) var(--ease-spring),
              transform var(--duration-fast) var(--ease-spring);
}

input[type="radio"] { border-radius: 50%; }

/* The glyphs are declared on the unchecked control, not added on check: a
   background-image swap would pop in at full size, whereas a size that is
   already zero can be transitioned open. Encoded exactly like the select
   chevron above — the CSP allows data: for images. */
input[type="checkbox"] {
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M5 12l5 5L19 7'/%3E%3C/svg%3E");
}

input[type="radio"] {
  background-image: radial-gradient(circle, #fff 0 46%, transparent 48%);
}

input[type="checkbox"]:checked,
input[type="radio"]:checked {
  background-color: var(--color-accent);
  border-color: var(--color-accent);
  box-shadow: var(--glow-accent-sm);
  animation: control-pop var(--duration-base) var(--ease-spring);
}

input[type="checkbox"]:checked { background-size: 68%; }
input[type="radio"]:checked { background-size: 10px 10px; }

/* Hover states are split: an empty box invites a click by warming its
   border, a filled one only needs to acknowledge the pointer, because
   brightening an already-accent fill is what "about to be unchecked" looks
   like and that would be a lie. */
input[type="checkbox"]:hover:not(:checked):not(:disabled),
input[type="radio"]:hover:not(:checked):not(:disabled) {
  border-color: color-mix(in srgb, var(--color-accent) 55%, var(--color-border-strong));
  box-shadow: inset 0 1px 2px rgba(21, 24, 43, 0.04), var(--glow-accent-sm);
}

input[type="checkbox"]:hover:checked:not(:disabled),
input[type="radio"]:hover:checked:not(:disabled) {
  background-color: var(--color-accent-hover);
  border-color: var(--color-accent-hover);
}

input[type="checkbox"]:focus-visible,
input[type="radio"]:focus-visible {
  outline: none;
  box-shadow: var(--shadow-focus), var(--glow-accent-sm);
}

/* Same press as .btn:active, scaled for how much smaller the target is. */
input[type="checkbox"]:active:not(:disabled),
input[type="radio"]:active:not(:disabled) { transform: scale(0.9); }

input[type="checkbox"]:disabled,
input[type="radio"]:disabled {
  opacity: 0.5;
  cursor: not-allowed;
  transform: none;
}

@keyframes control-pop {
  0% { transform: scale(0.8); }
  55% { transform: scale(1.12); }
  100% { transform: scale(1); }
}

@media (prefers-reduced-motion: reduce) {
  input[type="checkbox"],
  input[type="radio"] {
    transition: background-color var(--duration-fast) linear,
                border-color var(--duration-fast) linear;
  }
  input[type="checkbox"]:checked,
  input[type="radio"]:checked { animation: none; }
  input[type="checkbox"]:active:not(:disabled),
  input[type="radio"]:active:not(:disabled) { transform: none; }
}

/* Windows High Contrast (and any forced-colours mode) is the one place
   `appearance: none` can genuinely lose information: the OS replaces our
   colours with its own palette AND declines to paint background images, so
   the tick and the dot both disappear and every box looks empty. Mapping the
   states onto system colours keeps "checked" as a filled Highlight box
   against an empty Canvas one — no image required, and the user's chosen
   palette is still respected, which forced-color-adjust: none would not be. */
@media (forced-colors: active) {
  input[type="checkbox"],
  input[type="radio"] {
    border-color: CanvasText;
    background-color: Canvas;
  }
  input[type="checkbox"]:checked,
  input[type="radio"]:checked {
    background-color: Highlight;
    border-color: Highlight;
  }
  input[type="checkbox"]:disabled,
  input[type="radio"]:disabled {
    border-color: GrayText;
    opacity: 1; /* GrayText already carries "disabled"; dimming it twice hurts */
  }
  input[type="checkbox"]:focus-visible,
  input[type="radio"]:focus-visible {
    outline: 2px solid CanvasText;
    outline-offset: 2px;
  }
}

/* Inside a pill, 20px is most of the pill. */
.chip input[type="checkbox"],
.chip input[type="radio"] {
  width: 16px;
  height: 16px;
  border-radius: 5px;
}
.chip input[type="radio"] { border-radius: 50%; }
.chip input[type="checkbox"]:checked { background-size: 72%; }
.chip input[type="radio"]:checked { background-size: 8px 8px; }

/* Only the date-mode chips had their .is-selected kept in sync by ui.js; the
   subgroup chips on the create-plan and plan-settings screens got theirs
   server-rendered and then never updated, so ticking one lit up the box but
   left the pill around it looking untouched. This mirrors .is-selected off
   the checked state itself, the same way .lang-option already does, and
   costs no JavaScript. */
.chip:has(input:checked) {
  background: var(--color-accent-subtle);
  border-color: var(--color-accent);
  color: var(--color-accent-text);
  font-weight: var(--weight-medium);
  box-shadow: var(--glow-accent-sm);
}

/* Same story one component over: .select-row.is-selected was already defined
   but nothing ever set the class on the invitee pickers, so a row full of
   ticked people looked identical to an empty one. Reuse that exact
   treatment rather than inventing a second "selected" look. */
.select-row:has(input:checked) {
  background: var(--color-accent-subtle);
  border-color: var(--color-accent);
  box-shadow: var(--glow-accent-sm);
}

/* "Exclude specific people" reuses .select-row, so without this a ticked
   exclusion would glow the same accent as a ticked invitee — the app saying
   "added" about the one control that means "removed". Same row, opposite
   colour, and the box inside goes red with it. */
.select-row-exclude:has(input:checked) {
  background: var(--color-danger-subtle);
  border-color: var(--color-danger);
  box-shadow: none;
}
.select-row-exclude input[type="checkbox"]:checked {
  background-color: var(--color-danger);
  border-color: var(--color-danger);
  box-shadow: none;
}
.select-row-exclude input[type="checkbox"]:hover:checked:not(:disabled) {
  background-color: var(--color-danger-hover);
  border-color: var(--color-danger-hover);
}
.select-row-exclude input[type="checkbox"]:hover:not(:checked):not(:disabled) {
  border-color: color-mix(in srgb, var(--color-danger) 55%, var(--color-border-strong));
  box-shadow: inset 0 1px 2px rgba(21, 24, 43, 0.04);
}

/* ---------- Authorship line ----------
   Two shapes of the same credit (see includes/made-by.php): a quiet line
   under the signed-out cards, and a small block in Settings → About.
   Deliberately understated in both — a signature belongs in the corner of
   the canvas, not across the middle of it. */
.made-by {
  margin: 0;
  text-align: center;
  font-size: var(--text-xs);
  color: var(--color-text-faint);
  letter-spacing: 0.02em;
}

/* On the auth screens the line sits outside the glass card, over the ambient
   background, so it gets a touch more contrast than --text-faint would give
   it there and a max-width that keeps it on one or two tidy lines. */
.auth-shell > .made-by {
  max-width: 440px;
  color: var(--color-text-muted);
  opacity: 0.85;
}

.made-by-card {
  display: flex;
  align-items: center;
  gap: var(--space-4);
  text-align: left;
  padding: var(--space-4);
  border-radius: var(--radius-md);
  background: var(--color-bg-subtle);
  border: 1px solid var(--color-border);
}
.made-by-card strong {
  display: block;
  font-size: var(--text-base);
  color: var(--color-text);
  letter-spacing: 0;
}
.made-by-card p {
  margin: 2px 0 0;
  font-size: var(--text-sm);
  color: var(--color-text-muted);
}

.made-by-mark {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 44px;
  height: 44px;
  flex-shrink: 0;
  border-radius: var(--radius-md);
  background: var(--color-bg-elevated);
  border: 1px solid var(--color-border);
  box-shadow: var(--glow-accent-sm);
}
.made-by-mark img { width: 28px; height: 28px; border-radius: var(--radius-sm); }
