/*
 * Copyright © 2026 Mel Lent. All rights reserved.
 * Licensed under the Party Pal Personal-Use-Only License — see LICENSE.
 */

/* ── Breakpoint convention ──────────────────────────────────────────────────
   Two distinct mobile-side breakpoints:
     • "mobile" — `@media (max-width: 640px)` — Tailwind's general small
       breakpoint. Covers phones AND small tablets in portrait. Use this
       for layout shifts that should kick in at any "small screen".
     • "phone" / "xs" — `@media (max-width: 480px)` — true phones in
       portrait only (iPhone Pro Max is 430px; 480px gives buffer).
       Use this for tighter overrides that ONLY make sense on a held-in-
       one-hand phone, not a small tablet.
   When a phone-specific rule is missing, the mobile rule applies via the
   normal cascade — phone is a STRICT SUBSET of mobile.
*/
/* ── Base ── */
* { box-sizing: border-box; margin: 0; padding: 0; }

:root {
  /* Spacing — 8px grid; 4px only for icon↔text, 12px only for CTA padding */
  --s-1: 8px;
  --s-2: 16px;
  --s-3: 24px;
  --s-4: 32px;
  --s-5: 40px;
  --s-icon: 4px;     /* exception: icon ↔ text */
  --s-cta: 12px;     /* exception: CTA top/bottom padding */

  --max-content: 1024px;
  --label-w: 80px;
  --pad-x: 40px;
  --pad-x-mobile: 24px;
  /* Phone (xs, ≤480px) tightens horizontal padding from 24px → 16px so
     cards + header/footer content sit closer to the screen edges where
     thumbs naturally reach. Overridden inside @media (max-width:480px)
     below — every rule that already uses var(--pad-x-mobile) picks the
     new value up automatically via cascade. */

  /* Light theme tokens. */
  --color-bg: #f9fafb;
  --color-card: #fff;
  --color-border: #e5e7eb;
  --color-border-soft: #f3f4f6;
  --color-text: #111827;
  --color-text-muted: #6b7280;       /* 5.45:1 on white — AA */
  --color-text-soft: #767e8b;        /* 4.6:1 on white — AA */
  /* Party Pal Blue — accent used for the All chip when active, today
     indicators (calendar + agenda date headers), focus rings, modal CTAs,
     and other blue-accent hovers. Dark mode swaps these to a lighter blue
     for general accents and a warm yellow for the All chip + today (set
     via --color-accent below). */
  --color-blue: #2563eb;
  --color-blue-dark: #1d4ed8;
  --color-amber-bg: #fef3c7;
  --color-amber-fg: #92400e;
  --color-input-bg: #f9fafb;
  --color-cta-bg: #2563eb;
  --color-cta-fg: #ffffff;
  --color-cta-bg-hover: #1d4ed8;
  /* Accent — same as Party Pal Blue in light, dropdown-yellow in dark. */
  --color-accent-bg: #2563eb;
  --color-accent-fg: #ffffff;
  --color-accent-text: #2563eb;        /* for date-separator today coloring */
  /* "Selected" surface — Party Pal blue with white text/icon. Applies to
     the sort dropdown trigger, source toggle (Tech Week / Partiful), and
     range toggle (Agenda / Day / 2-day / 3-day / Week) when active.
     Outline matches bg so the active control reads as one solid blue mass.
     Non-active siblings keep the neutral gray outline. */
  --color-active-bg: #2563eb;
  --color-active-fg: #ffffff;
  --color-active-border: #2563eb;
  --color-active-bg-hover: #1d4ed8;
  /* Top nav (header) + bottom nav (footer) — Party Pal blue in light mode,
     white text + logo on top. Dark mode keeps the slate card surface. */
  --color-nav-bg: #2563eb;
  --color-nav-fg: #ffffff;
  /* Brand-fg = nav-fg (logo + Party Pal wordmark sit on the blue nav). */
  --color-brand-fg: #ffffff;
}

/* Dark theme — applied by adding [data-theme="dark"] to <html>.
   Muted/soft text colors lifted toward white to keep AA contrast (≥ 4.5:1)
   against the dark canvas. CTA bg stays a deep blue so white text on it
   still passes AA — independent from the lighter accent --color-blue used
   for in-text accents/links. */
html[data-theme="dark"] {
  --color-bg: #0b1020;
  --color-card: #131a2e;
  --color-border: #2a3552;
  --color-border-soft: #1e2745;
  --color-text: #f3f4f6;
  --color-text-muted: #c7cdd9;       /* 9.6:1 on bg — AAA */
  --color-text-soft: #9aa3b3;        /* 5.5:1 on bg — AA */
  --color-blue: #93c5fd;             /* accent: 9.4:1 on bg — AAA, used for in-text accents */
  --color-blue-dark: #60a5fa;
  --color-amber-bg: #422a08;
  --color-amber-fg: #fcd34d;         /* 8.4:1 on amber-bg — AAA */
  --color-input-bg: #1a223e;
  /* CTA: dark blue surface keeps white text at AA contrast in dark mode.
     We don't lighten the CTA bg the way we lifted --color-blue, because
     a CTA is a button surface (contrast against its own fg matters), not
     an accent (contrast against the page bg). */
  --color-cta-bg: #2563eb;            /* white on this = 4.65:1 — AA */
  --color-cta-fg: #ffffff;
  --color-cta-bg-hover: #3b82f6;
  /* Dark-mode "selected" surface — deep amber. Border matches the bold
     amber text so the outline carries the same confident weight as in
     light mode. AA: amber text (#fcd34d) on #3a2a08 = 9.4:1 (AAA) */
  /* Subtle slate active — the original neutral dark-mode active surface
     before the yellow experiment. Outline matches bg so it reads as one
     soft slate mass. */
  --color-active-bg: #2c3656;
  --color-active-fg: #f3f4f6;
  --color-active-border: #2c3656;
  --color-active-bg-hover: #364263;
  /* Dark-mode nav: matches the calendar SECTION background (the deeper navy
     of the page below the toolbar — where the agenda/cal-grid sits), not
     the card-slate the toolbar uses. So header reads as the page-canvas
     color, with the toolbar/cards floating above it. CTAs in the right
     cluster keep the search-input outline color so they read as a set. */
  --color-nav-bg: var(--color-bg);
  --color-nav-fg: var(--color-text);
  --color-brand-fg: var(--color-text);
  /* Dark-mode accent — yellow instead of blue. Used by the All chip when
     active, today highlights in calendar + agenda date headers. */
  --color-accent-bg: #fcd34d;
  --color-accent-fg: #111827;
  --color-accent-text: #fcd34d;
}
/* Dark mode tweaks: softer contrast on toggles, dropdowns, chips. Dark-tinted
   "active" chip backgrounds + accessible foreground text. */
html[data-theme="dark"] .chip {
  background: var(--color-card);
  color: var(--color-text);
  border-color: var(--color-border);
}
html[data-theme="dark"] .chip:hover { color: var(--color-blue); border-color: var(--color-blue); }
html[data-theme="dark"] .chip.active {
  background: #1f2a48;
  border-color: #3b4870;
  color: #f3f4f6;
}

/* RSVP-specific active chip tints — dimmed for dark mode (still readable, AA contrast) */
/* Dark-mode chips: same hues as light mode but inverted — deep tinted bg
   (Tailwind 950) with bright tinted text (Tailwind 300). All AAA. */
html[data-theme="dark"] .chip[data-rsvp="hosting"].active    { background: #4a044e; border-color: #86198f; color: #f0abfc; }
html[data-theme="dark"] .chip[data-rsvp="onlist"].active     { background: #422006; border-color: #854d0e; color: #fde047; }
html[data-theme="dark"] .chip[data-rsvp="going"].active      { background: #1a2e05; border-color: #3f6212; color: #bef264; }
html[data-theme="dark"] .chip[data-rsvp="interested"].active { background: #083344; border-color: #155e75; color: #67e8f9; }
html[data-theme="dark"] .chip[data-rsvp="invited"].active    { background: #500724; border-color: #9d174d; color: #f9a8d4; }
html[data-theme="dark"] .chip[data-rsvp="waitlist"].active   { background: #1e1b4b; border-color: #3730a3; color: #a5b4fc; }
html[data-theme="dark"] .chip[data-rsvp="maybe"].active      { background: #2e1065; border-color: #5b21b6; color: #c4b5fd; }
html[data-theme="dark"] .chip[data-rsvp="pending"].active    { background: #431407; border-color: #9a3412; color: #fdba74; }
html[data-theme="dark"] .chip[data-rsvp="declined"].active   { background: #450a0a; border-color: #991b1b; color: #fca5a5; }
html[data-theme="dark"] .chip[data-rsvp="inviteonly"].active { background: #2c2440; border-color: #5b4a8c; color: #cbb8e8; }
html[data-theme="dark"] .chip[data-rsvp="cancelled"].active  { background: #450a0a; border-color: #991b1b; color: #fca5a5; }
html[data-theme="dark"] .chip[data-rsvp="none"].active       { background: #1e2438; border-color: #404a6c; color: #d1d5db; }

/* Unified dark-mode outline for all control buttons (toggles, Today, arrows,
   dropdown trigger, search, theme button). Single color across the surface. */
html[data-theme="dark"] {
  --ctrl-outline: #3b4870;
}
html[data-theme="dark"] .seg-toggle,
html[data-theme="dark"] .view-toggle,
html[data-theme="dark"] .cal-today-btn,
html[data-theme="dark"] .cal-nav-arrows button,
html[data-theme="dark"] .theme-toggle,
html[data-theme="dark"] .search-wrap input {
  border-color: var(--ctrl-outline) !important;
}
/* .cs-trigger is intentionally excluded above — it's an "active" element
   (always shows the current selection) and must use the active-state amber
   border to match the rest of the selected-state styling in both themes. */
/* Note: previous explicit `html[data-theme="dark"] .seg-btn { color/bg: ... }`
   overrides were removed. The base rules already use tokens (var(--color-card),
   var(--color-text)) which swap correctly between themes — duplicating them
   under [data-theme="dark"] only raised specificity and stomped the
   .seg-btn.active / .cs-option.selected rules that wanted to override
   color + background. Same applies to the .cs-option / .cs-option:hover
   rules (also removed). */
html[data-theme="dark"] .seg-btn + .seg-btn { border-left-color: var(--ctrl-outline); }
html[data-theme="dark"] .cs-menu { background: var(--color-card); border-color: var(--color-border); }

/* Status badges in dark mode — same as chips */
html[data-theme="dark"] .badge-hosting    { background: #4a044e; border-color: #86198f; color: #f0abfc; }
html[data-theme="dark"] .badge-onlist     { background: #422006; border-color: #854d0e; color: #fde047; }
html[data-theme="dark"] .badge-going      { background: #1a2e05; border-color: #3f6212; color: #bef264; }
html[data-theme="dark"] .badge-interested { background: #083344; border-color: #155e75; color: #67e8f9; }
html[data-theme="dark"] .badge-invited    { background: #500724; border-color: #9d174d; color: #f9a8d4; }
html[data-theme="dark"] .badge-waitlist   { background: #1e1b4b; border-color: #3730a3; color: #a5b4fc; }
html[data-theme="dark"] .badge-maybe      { background: #2e1065; border-color: #5b21b6; color: #c4b5fd; }
html[data-theme="dark"] .badge-pending    { background: #431407; border-color: #9a3412; color: #fdba74; }
html[data-theme="dark"] .badge-declined   { background: #450a0a; border-color: #991b1b; color: #fca5a5; }
html[data-theme="dark"] .badge-inviteonly { background: #2c2440; border-color: #5b4a8c; color: #cbb8e8; }
html[data-theme="dark"] .badge-cancelled  { background: #450a0a; border-color: #991b1b; color: #fca5a5; }
html[data-theme="dark"] .badge-none       { background: #1e2438; border-color: #404a6c; color: #d1d5db; }

/* Calendar today highlight — much subtler in dark mode (was too bright before) */
html[data-theme="dark"] .cal-day.today .cal-day-header { background: #15244a; }

/* Search placeholder + value text — readable contrast */
html[data-theme="dark"] .search-wrap input {
  background: var(--color-card);
  color: var(--color-text);
  border-color: var(--color-border);
}
html[data-theme="dark"] .search-wrap input::placeholder { color: var(--color-text-muted); }
html[data-theme="light"] .search-wrap input::placeholder { color: var(--color-text-muted); }

/* Use dynamic viewport units so the body min-height tracks the actual
   visible viewport as the mobile URL bar shows/hides. With plain `100vh`
   the body is locked to the URL-bar-collapsed height even when the bar
   is expanded, which forces a phantom scroll that drags fixed elements
   during the URL-bar transition. `100dvh` updates live, eliminating the
   phantom. Falls back to `100vh` on older browsers that don't support
   dvh. */
html, body { min-height: 100vh; min-height: 100dvh; }

/* ── First-paint cascade ────────────────────────────────────────
   On initial load, the above-the-fold sections rise into view from below
   one at a time, each as a complete unit (text + shape together).

   Two things make this feel solid instead of skeleton-like:
   1. Until fonts are loaded, the whole page is visually held back (body
      lacks the .ready class — see fonts-loaded gating below). Once fonts
      load, body.ready triggers the cascade with text already in its final
      form, so nothing "swaps in" mid-animation.
   2. Each section animates as one parent — text inside rides along with
      the box, never separately. */

/* Pre-ready: hide each whole section as a single unit. Container shapes,
   backgrounds, padding — everything goes invisible together so nothing
   appears ahead of the cascade. Cards/calendar content also stays hidden
   until the cascade for the chrome is finished (gated via .cascade-done). */
body:not(.ready) .header,
body:not(.ready) .toolbar,
body:not(.ready) #calendar-view .cal-toolbar,
body:not(.ready) .footer,
body:not(.cascade-done) #calendar-view > :not(.cal-toolbar) {
  opacity: 0;
}

/* Page background also "floats in" with the sections instead of arriving
   ahead of them. While the page is in pre-ready state, the body matches
   the card surface (white) so nothing is visually distinguishable. When
   `body.ready` flips, the body smoothly transitions to its actual page bg
   in time with the cascade. */
body { background: var(--color-card); transition: background-color 0.6s 0.05s ease-out; }
body.ready { background: var(--color-bg); }

@keyframes shellSlideUp {
  from { opacity: 0; transform: translateY(20px); }
  to   { opacity: 1; transform: translateY(0); }
}
/* Animate at section level — each tier travels as ONE unit (container
   shape + all its contents together), top to bottom:
   1. Header (logo, title, theme toggle, sync bar)
   2. Toolbar (search field, RSVP chips, sort/source row — all together)
   3. Calendar toolbar (range toggle, Today, nav arrows — all together)
   Cards below the fold are handled by the IO-driven scroll-reveal. */
body.ready .header                       { animation: shellSlideUp 0.5s 0.00s cubic-bezier(0.22, 0.61, 0.36, 1) both; }
body.ready .toolbar                      { animation: shellSlideUp 0.5s 0.10s cubic-bezier(0.22, 0.61, 0.36, 1) both; }
body.ready #calendar-view .cal-toolbar   { animation: shellSlideUp 0.5s 0.20s cubic-bezier(0.22, 0.61, 0.36, 1) both; }
/* Date content tier — held invisible until cascade-done flips, then fades
   in as a backdrop for the IO-driven per-card scroll-reveal that handles
   individual cards. */
body.cascade-done #calendar-view > :not(.cal-toolbar)   { animation: shellSlideUp 0.45s 0.00s cubic-bezier(0.22, 0.61, 0.36, 1) both; }
body.cascade-done .footer                               { animation: shellSlideUp 0.45s 0.10s cubic-bezier(0.22, 0.61, 0.36, 1) both; }

/* "Party Pal" wordmark — letters tumble in one at a time after the header
   slides up. Each letter slightly overshoots and tilts for a playful feel,
   then settles. The CSS variable --i (set per-letter inline) drives the
   stagger. Hidden until body.ready so they don't flash before the cascade. */
.title-letter { display: inline-block; opacity: 0; will-change: transform, opacity; }
body.ready .title-letter {
  animation: titleLetterIn 0.5s calc(0.55s + var(--i, 0) * 55ms) cubic-bezier(0.34, 1.56, 0.64, 1) both;
}
@keyframes titleLetterIn {
  0%   { opacity: 0; transform: translateY(-10px) rotate(-6deg); }
  60%  { opacity: 1; transform: translateY(2px)  rotate(2deg); }
  100% { opacity: 1; transform: translateY(0)    rotate(0); }
}
@media (prefers-reduced-motion: reduce) {
  .title-letter { opacity: 1 !important; }
  body.ready .title-letter { animation: none !important; }
}

/* Logo bolt: tiny "spark" on initial load — one shot, doesn't loop. */
@keyframes logoSpark {
  0%   { transform: rotate(0deg) scale(1); filter: drop-shadow(0 0 0 rgba(37,99,235,0)); }
  40%  { transform: rotate(-8deg) scale(1.12); filter: drop-shadow(0 0 6px rgba(37,99,235,0.5)); }
  70%  { transform: rotate(4deg) scale(0.96); }
  100% { transform: rotate(0deg) scale(1); filter: drop-shadow(0 0 0 rgba(37,99,235,0)); }
}
.header-left .logo { animation: logoSpark 0.7s 0.15s ease-out both; }

/* Theme crossfade — tokens change instantly when [data-theme] swaps, but
   transitioning the *properties* that consume them gives a smooth visual blend.
   Scoped to elements likely to actually change color so we don't paint-thrash. */
body, .header, .toolbar, .footer, .modal-card, .modal-backdrop,
.event-card, .cal-block, .cal-pill, .cal-day-grid, .chip, .seg-btn,
.search-wrap input, .field-value, .badge {
  transition: background-color 0.22s ease, color 0.22s ease, border-color 0.22s ease, box-shadow 0.22s ease;
}

/* Calendar event-block hover lift — small upward drift + stronger shadow
   to communicate "this is clickable and distinct from background lines." */
.cal-block, .cal-pill {
  transition: transform 0.16s ease, box-shadow 0.16s ease, background-color 0.22s ease, color 0.22s ease;
}
.cal-block:hover {
  transform: translateY(-1px);
  box-shadow: 0 4px 12px rgba(0,0,0,0.12);
  z-index: 3;
}
.cal-pill:hover {
  transform: translateY(-1px);
  box-shadow: 0 2px 8px rgba(0,0,0,0.12);
}
html[data-theme="dark"] .cal-block:hover { box-shadow: 0 4px 12px rgba(0,0,0,0.45); }
html[data-theme="dark"] .cal-pill:hover  { box-shadow: 0 2px 8px rgba(0,0,0,0.45); }

/* "Today" date number — pulse on first paint when the page loads on the actual
   day. .is-today is set by the renderer when a date matches today. */
@keyframes todayPulse {
  0%   { transform: scale(1);    color: var(--color-accent-text); }
  35%  { transform: scale(1.12); color: var(--color-accent-text); }
  100% { transform: scale(1);    color: var(--color-accent-text); }
}
.event-date.is-today .day,
.cal-day.today .cal-day-num {
  animation: todayPulse 1.2s 0.4s ease-out both;
}

/* RSVP status badge: brief bounce when the status itself changes. .badge-pulse
   is added by JS in syncRsvpChipState() / renderActive() when a status
   changes between renders. */
@keyframes badgePulse {
  0%   { transform: scale(1); }
  35%  { transform: scale(1.18); }
  100% { transform: scale(1); }
}
.badge.badge-pulse { animation: badgePulse 0.35s ease-out; transform-origin: center; display: inline-block; }

/* Sync banner / fetch bar — slide in from the top when first appearing,
   slide back up when removed. Applies to the dashboard's own status bar and
   to anything else with the .slide-down-in class. */
@keyframes slideDownIn {
  from { opacity: 0; transform: translateY(-100%); }
  to   { opacity: 1; transform: translateY(0); }
}
.fetch-bar:not(.hidden), .slide-down-in {
  animation: slideDownIn 0.32s cubic-bezier(0.22, 0.61, 0.36, 1) both;
}

/* Sticky date separators — when the header pins to the top of the viewport,
   add a subtle shadow to lift it off the content below. JS adds .is-stuck
   to whichever separator is currently pinned. */
.date-separator {
  transition: box-shadow 0.18s ease;
}
.date-separator.is-stuck {
  box-shadow: 0 4px 16px rgba(0,0,0,0.06);
  /* When pinned, drop the extra top padding (the asymmetric +16 that
     anchored the headline to the next group) but keep equal-to-bottom
     base padding (8px). Without this, the headline sat flush against
     the page header above and read as cramped. The transition (on the
     base rule below) animates the snap smoothly. */
  padding-top: var(--s-1);
}
html[data-theme="dark"] .date-separator.is-stuck {
  box-shadow: 0 4px 16px rgba(0,0,0,0.4);
}
.date-separator { transition: box-shadow 0.18s ease, padding-top 0.18s ease-out; }

/* Empty state — soft fade + downward drift each time it appears,
   acknowledging the filter change rather than just popping. */
@keyframes emptyAppear {
  from { opacity: 0; transform: translateY(-6px); }
  to   { opacity: 1; transform: translateY(0); }
}
.empty-msg {
  animation: emptyAppear 0.36s ease-out both;
}

/* Zoom interpolation — when the day grid's px-per-hour changes via the
   +/− buttons or reset, smoothly interpolate the absolute positions.
   Suppressed during continuous wheel/pinch (body gets .cal-zooming-live)
   so dragging stays snappy. */
.cal-block, .cal-time-label, .cal-day-divider {
  transition: top 0.22s cubic-bezier(0.22, 0.61, 0.36, 1),
              height 0.22s cubic-bezier(0.22, 0.61, 0.36, 1);
}
body.cal-zooming-live .cal-block,
body.cal-zooming-live .cal-time-label,
body.cal-zooming-live .cal-day-divider {
  transition: none !important;
}

@media (prefers-reduced-motion: reduce) {
  .header, .toolbar, .toolbar > *, .header-left .logo,
  .cal-block, .cal-pill, .cal-block:hover, .cal-pill:hover,
  .event-date.is-today .day, .cal-day.today .cal-day-num,
  .badge.badge-pulse,
  .fetch-bar:not(.hidden), .slide-down-in,
  .empty-msg,
  .cal-block, .cal-time-label, .cal-day-divider {
    animation: none !important;
    transition: none !important;
    transform: none;
  }
}

/* Modal-open lock: prevents the page from scrolling behind the modal. We
   lock BOTH html and body — depending on browser/viewport, vertical scroll
   may live on either, and locking only body lets html slip through. The
   modal's own internal scroll container is untouched. */
body.modal-open,
html:has(body.modal-open) { overflow: hidden; }

/* ── Accessibility: keyboard focus ─────────────────────────────
   Every interactive element gets a visible focus ring when navigated via
   keyboard (Tab). Mouse clicks don't trigger this thanks to :focus-visible. */
:where(button, a, input, select, textarea, [role="link"], [role="button"], [tabindex]):focus-visible {
  outline: 2px solid var(--color-blue);
  outline-offset: 2px;
  border-radius: var(--s-icon);
}
/* Slightly different ring inside dark blue surfaces so the outline still
   contrasts. */
.modal-cta:focus-visible,
.cs-trigger:focus-visible,
.seg-btn.active:focus-visible {
  outline-color: #fff;
  outline-offset: 2px;
}
html {
  /* Defensive: prevent any descendant with too-wide intrinsic content
     (long unbreakable strings, runaway flex items, etc.) from pushing the
     page wider than the viewport and triggering horizontal scroll. Applied
     to <html> rather than <body> so body's positioning context (used by
     the sticky header) stays clean. */
  overflow-x: hidden;
}
body {
  /* Body / UI text — minimum 14px everywhere */
  font-family: "Switzer", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
  font-size: 14px;
  color: var(--color-text);
  background: var(--color-bg);
}
/* The whole dashboard sits inside <div id="app"> — that's the actual flex
   container, not <body>. Min-height fills the viewport (minus the fixed
   footer's reserved 56px below body), and the active view uses flex:1 to
   absorb whatever real leftover space remains after header + toolbar.
   That gives the bottom-cta-row's `margin: auto 0` something concrete to
   center within. */
#app {
  /* Dynamic viewport units (dvh) track the live visible area as the
     mobile URL bar shows/hides — preventing a phantom scroll that would
     otherwise drag fixed-positioned header/footer during the transition. */
  min-height: calc(100vh - 56px);
  min-height: calc(100dvh - 56px);
  display: flex;
  flex-direction: column;
}
#app > .header,
#app > .toolbar { flex: 0 0 auto; }
#app > #calendar-view,
#app > .notifications-view { flex: 1 1 auto; }
@media (max-width: 640px) {
  /* Page header shrinks to 48px on mobile, footer stays 48px. */
  #app {
    min-height: calc(100vh - 48px);
    min-height: calc(100dvh - 48px);
  }
}

button { font: inherit; }

/* Headings: Unbounded — display family for h1–h4, big numerals, status badges,
   and filter chips. Filter labels and the week label stay in body font. */
h1, h2, h3, h4,
.header-title,
.modal-name,
.event-name,
.modal-source h3,
.date-separator,
.agenda-date,
.event-date .day-num,
.event-date .day-name,
.event-date .month,
.cal-day-num,
.badge,
.chip {
  font-family: "Unbounded", "Switzer", -apple-system, BlinkMacSystemFont, sans-serif;
  letter-spacing: -0.01em;
}

/* Chips: heading font for the status label, body font for the count in parens.
   The count uses a slightly muted weight rather than reduced opacity so it
   stays AA-compliant in both themes. */
.chip-count {
  font-family: "Switzer", -apple-system, BlinkMacSystemFont, sans-serif;
  font-weight: 500;
  margin-left: var(--s-icon);
}


/* ── Header ── */
.header {
  position: sticky;
  top: 0;
  /* Above the filter drawer (z-index 300) so the drawer can never paint
     over the page's primary chrome. Tooltips (z-index 2147483647) and
     modals stay above this; everything else stays below. */
  z-index: 400;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--s-2);
  padding: 0 var(--pad-x);
  height: 56px;
  background: var(--color-nav-bg);
  color: var(--color-nav-fg);
  border-bottom: 1px solid var(--color-border);
}
/* In light mode the nav is solid blue — drop the divider line so it doesn't
   show as a thin gray pixel against the saturated bg. Dark mode keeps it. */
html:not([data-theme="dark"]) .header { border-bottom-color: transparent; }

.header-left { display: flex; align-items: center; gap: var(--s-1); }
/* Header-left is now an <a> linking to home — strip default link styling so
   it inherits the same nav-fg and underline-free look as before. */
a.header-left {
  text-decoration: none;
  color: var(--color-nav-fg);
  cursor: pointer;
  padding: var(--s-icon) var(--s-1);   /* 4px 8px — gives the hover tint somewhere to land */
  margin-left: calc(-1 * var(--s-1));  /* offset the padding so layout doesn't shift */
  border-radius: var(--s-1);
  transition: background-color 0.15s ease, color 0.15s ease;
}
a.header-left:hover { text-decoration: none; }
/* Hover treatment matches the bell + theme-toggle: subtle white tint
   in light mode, blue-accent (logo + wordmark shift to blue) in dark
   mode. Gated on `(hover: hover)` so the entire hover state — bg,
   color, beta blue — doesn't stick after taps on touch devices. */
@media (hover: hover) {
  a.header-left:hover { background: rgba(255, 255, 255, 0.12); }
  html[data-theme="dark"] a.header-left:hover {
    background: var(--color-bg);
    color: var(--color-blue);
  }
}
/* Hover tint for the wordmark + BETA + glyph in dark mode. Gated on
   `(hover: hover)` so touch devices don't get a sticky-hover effect that
   leaves the badge blue after a tap. Without this, tapping the home link
   on mobile dark mode leaves BETA stuck at the hover-blue color until the
   next tap elsewhere — which read as "BETA is always blue". */
@media (hover: hover) {
  html[data-theme="dark"] a.header-left:hover .logo,
  html[data-theme="dark"] a.header-left:hover .header-title,
  html[data-theme="dark"] a.header-left:hover .beta-badge { color: var(--color-blue); }
}
.logo {
  font-size: 18px;
  display: inline-flex;
  align-items: center;
  /* Brand glyph color — black in light mode, dropdown-yellow in dark. */
  color: var(--color-brand-fg);
}
.logo svg { display: block; height: 18px; width: auto; }
.header-title { font-weight: 700; font-size: 24px; color: var(--color-brand-fg); }
/* "BETA" label sits next to the wordmark — body-text size, white in both
   themes (the header strip is the same blue in light + dark, so white reads
   on both). Uppercase + small letter-spacing reads as a label, not a word.
   align-items:center on .header-left vertically centers it with the title.
   Margin lives in the parent's `gap`, so it's exactly 8px from "Pal".
   Slides in from above ~1s after page load — just after the title letters
   finish their cascade — so it feels like a follow-through, not a co-arrival. */
.beta-badge {
  font-size: 12px;
  font-weight: 400;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: #ffffff;
  line-height: 1;
  opacity: 0;
  will-change: transform, opacity;
  margin-left: 4px;
}
body.ready .beta-badge {
  animation: betaSlideIn 0.5s 1.05s cubic-bezier(0.34, 1.56, 0.64, 1) both;
}
@keyframes betaSlideIn {
  from { opacity: 0; transform: translateY(-8px); }
  to   { opacity: 1; transform: translateY(0);    }
}
@media (prefers-reduced-motion: reduce) {
  .beta-badge { opacity: 1 !important; }
  body.ready .beta-badge { animation: none !important; }
}

.header-right { display: flex; align-items: center; gap: var(--s-2); min-width: 0; }
/* Sync progress indicator hidden for now — kept in the DOM and JS so it can
   be brought back by removing this rule (or removing display:none here). */
.sync-label, .fetch-bar { display: none !important; }

/* Theme toggle in the header — sun OR moon, depending on theme. Visual
   language matches the dashboard's other outline CTAs (Today, nav arrows):
   solid card background, neutral border, blue border + icon on hover. */
.theme-toggle {
  background: var(--color-card);
  border: 1px solid #d1d5db;
  border-radius: var(--s-1);
  width: var(--ctrl-h);
  height: var(--ctrl-h);
  padding: 0;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  /* Light mode: transparent fill (matches blue header), white border, white
     sun icon — reads as a clean outlined ghost button on the blue nav. */
  background: transparent;
  border-color: #ffffff;
  color: #ffffff;
  cursor: pointer;
  flex-shrink: 0;
  transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease;
}
html[data-theme="dark"] .theme-toggle {
  /* Dark mode: card-fill button with the same outline color the search
     input uses, so all the right-side CTAs read as one consistent set. */
  background: var(--color-card);
  border-color: var(--ctrl-outline);
  color: var(--color-text);
}
/* Hover treatment matches the bell's exactly so the two header controls
   read as a single visual set: subtle white-tint background in light
   mode, blue-accent (bg-fade + blue border + blue icon) in dark mode. */
.theme-toggle:hover { background: rgba(255, 255, 255, 0.12); }
html[data-theme="dark"] .theme-toggle:hover {
  background: var(--color-bg);
  border-color: var(--color-blue);
  color: var(--color-blue);
}
.theme-toggle:active { background: rgba(255, 255, 255, 0.20); }
html[data-theme="dark"] .theme-toggle:active { background: var(--color-bg); }
.theme-toggle .theme-moon { display: none; }
html[data-theme="dark"] .theme-toggle .theme-sun { display: none; }
html[data-theme="dark"] .theme-toggle .theme-moon { display: inline-flex; }

/* Notification bell — mirrors the theme toggle exactly. Light mode: outline
   ghost on blue nav (transparent fill, white border + icon). Dark mode:
   slate card to match the dark theme toggle, with a FILLED bell so the two
   modes read as their own visual treatment. */
.notif-toggle {
  position: relative;
  background: transparent;
  border: 1px solid #ffffff;
  border-radius: var(--s-1);
  width: var(--ctrl-h);
  height: var(--ctrl-h);
  padding: 0;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  color: #ffffff;
  cursor: pointer;
  flex-shrink: 0;
  transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease;
}
html[data-theme="dark"] .notif-toggle {
  background: var(--color-card);
  border-color: var(--ctrl-outline);
  color: var(--color-text);
}
.notif-toggle:hover { background: rgba(255, 255, 255, 0.12); }
html[data-theme="dark"] .notif-toggle:hover { background: var(--color-bg); border-color: var(--color-blue); color: var(--color-blue); }
.notif-toggle:active { background: rgba(255, 255, 255, 0.20); }
html[data-theme="dark"] .notif-toggle:active { background: var(--color-bg); }

/* Outline bell in light mode, filled bell in dark mode. */
.notif-toggle .bell-filled  { display: none; }
html[data-theme="dark"] .notif-toggle .bell-outline { display: none; }
html[data-theme="dark"] .notif-toggle .bell-filled  { display: inline-flex; }

/* Notification dot — top-right of the bell when unread items exist. Color
   pulled from the experience's accent palette: amber in light mode (echoes
   today / hosting accents), warm yellow in dark mode (echoes the dropdown-
   yellow accent + dark-mode hosting/today). */
.notif-dot {
  position: absolute;
  /* Overlap the bell on the upper-right — most of the dot sits outside the
     button border so it reads as a sticker on top of the icon. -4px is the
     icon-adjacent (4px) exception, off-grid only by direction. */
  top: -4px;
  right: -4px;
  width: 10px;
  height: 10px;
  border-radius: 50%;
  background: #fde68a;                                  /* amber-200 — pops on the Party Pal blue nav with AA non-text contrast */
  box-shadow: 0 0 0 2px var(--color-nav-bg);            /* halo so it pops on the nav */
  pointer-events: none;
}
html[data-theme="dark"] .notif-dot {
  background: #fcd34d;                                  /* amber-300 — dark mode (slate nav) */
}

/* ── First-run welcome modal ───────────────────────────────────────────
   Onboarding card shown once per browser. Single primary CTA (no cancel)
   since dismissal isn't optional — the user must acknowledge before using
   the app. Same .modal-card chrome and tokens as the confirm modal. */
.welcome-card {
  width: min(520px, 100%);
  padding: var(--s-3);
  display: flex;
  flex-direction: column;
  gap: var(--s-2);
}
.welcome-title {
  font-family: "Unbounded", "Switzer", -apple-system, BlinkMacSystemFont, sans-serif;
  font-size: 22px;
  font-weight: 600;
  letter-spacing: -0.01em;
  color: var(--color-text);
  margin: 0;
  line-height: 1.25;
}
.welcome-subtitle {
  font-family: "Switzer", -apple-system, BlinkMacSystemFont, sans-serif;
  font-size: 14px;
  color: var(--color-text-muted);
  margin: 0;
  line-height: 1.5;
}
.welcome-points {
  list-style: none;
  margin: var(--s-1) 0 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: var(--s-2);
}
.welcome-points li {
  display: grid;
  grid-template-columns: 24px 1fr;
  gap: var(--s-1);
  align-items: start;
  font-family: "Switzer", -apple-system, BlinkMacSystemFont, sans-serif;
  font-size: 14px;
  color: var(--color-text-muted);
  line-height: 1.5;
}
.welcome-points strong { color: var(--color-text); font-weight: 600; }
/* Icon column for each welcome bullet — 20×20 SVG line icons matching the
   project's standard (viewBox 20×20, strokeWidth 1.5, currentColor stroke).
   Aligned to the cap height of the first text line. */
.welcome-icon {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 20px;
  height: 20px;
  color: var(--color-text);
  /* No optical-center nudge — keeps top-of-icon aligned with top-of-text
     via the parent grid's `align-items: start`. Strictly 8-grid compliant. */
}
.welcome-icon svg { width: 20px; height: 20px; flex-shrink: 0; }
.welcome-link {
  color: var(--color-blue);
  text-decoration: underline;
  text-decoration-style: dotted;
  text-underline-offset: 3px;
}
.welcome-link:hover { text-decoration-style: solid; }
.welcome-actions {
  display: flex;
  margin-top: var(--s-2);
}
/* Full-width CTA — single primary action, takes the whole row so it reads
   as the obvious "do this next" step. */
.welcome-actions .modal-cta {
  width: 100% !important;
  padding: var(--s-cta) var(--s-3) !important;
}

/* ── Confirm modal ─────────────────────────────────────────────────────
   Generic confirmation dialog (replaces window.confirm). Uses every
   established design token: Unbounded heading font for the title (matches
   .modal-name), Switzer body font for the body copy, .modal-cta (primary
   blue, Unbounded, --s-cta y-padding) for OK, .modal-cta-outline for
   Cancel. Card surface is --color-card so it adapts to both modes via the
   existing modal styling. */
.confirm-card {
  width: min(440px, 100%);
  max-height: none;
  padding: var(--s-3);
  display: flex;
  flex-direction: column;
  gap: var(--s-2);
}
.confirm-title {
  display: flex;
  align-items: center;
  gap: var(--s-1);                    /* 8px between icon and label */
  font-family: "Unbounded", "Switzer", -apple-system, BlinkMacSystemFont, sans-serif;
  font-size: 18px;
  font-weight: 600;
  letter-spacing: -0.01em;
  color: var(--color-text);
  margin: 0;
  line-height: 1.3;
}
.confirm-icon {
  width: 20px;
  height: 20px;
  flex-shrink: 0;
  /* Match the title text color in both modes — quieter than the amber, lets
     the red CTA do the work of signalling "destructive action". */
  color: var(--color-text);
}
.confirm-title-text { display: inline-block; }
.confirm-body {
  font-family: "Switzer", -apple-system, BlinkMacSystemFont, sans-serif;
  font-size: 14px;
  color: var(--color-text-muted);
  margin: 0;
  line-height: 1.5;
}
.confirm-actions {
  display: flex;
  justify-content: flex-end;
  gap: var(--s-2);                     /* 16px — standard spacing for two CTAs side-by-side */
  margin-top: var(--s-1);
  flex-wrap: wrap;
}
.confirm-actions .modal-cta {
  width: auto !important;
  padding: var(--s-cta) var(--s-3) !important;   /* 12px y, 24px x — matches notification page CTAs */
}
/* Danger variant — red fill for destructive primary actions like
   "Clear all notifications". Same Unbounded font + padding as primary CTA;
   only the bg/hover swap. */
/* Danger CTA — orange-700, in the same warm family as the "🚩 Cancelled"
   filter chip and pending-palette badges. Lighter than red but still reads
   as an "are-you-sure" action. White text → 5.17:1 contrast (AA). Hover
   deepens to orange-800 #9a3412 (matching the chip's exact text hue). */
.confirm-danger { background: #c2410c !important; color: #ffffff !important; }
.confirm-danger:hover { background: #9a3412 !important; }
html[data-theme="dark"] .confirm-danger { background: #c2410c !important; }
html[data-theme="dark"] .confirm-danger:hover { background: #9a3412 !important; }
@media (max-width: 480px) {
  .confirm-actions { flex-direction: column-reverse; align-items: stretch; }
  .confirm-actions .modal-cta { width: 100% !important; }
}

/* ── Generic empty-state block + bottom CTA row ────────────────────────
   Reuses the notification empty state's visual language so all empty
   states across the dashboard look like one design system. */
.empty-state-block {
  display: flex;
  align-items: center;
  justify-content: center;
  padding: var(--s-6) var(--s-3);
  /* Same pattern as .bottom-cta-row: when the parent flex column has leftover
     vertical room (page taller than the cluster needs), the auto margins split
     that leftover top/bottom so the cluster reads as vertically centered in
     the available area. When content overflows the viewport, the leftover
     collapses to 0 → auto margins do nothing → the padding above remains as
     the breathing room around the cluster. */
  margin: auto 0;
}
.empty-state-stack {
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
  gap: var(--s-3);
  max-width: 480px;
  margin: 0;
}
.empty-state-icon {
  width: 80px;
  height: 80px;
  border-radius: 50%;
  background: #eff6ff;
  color: var(--color-blue);
  display: flex;
  align-items: center;
  justify-content: center;
}
html[data-theme="dark"] .empty-state-icon { background: var(--color-border); color: var(--color-text-soft); }
.empty-state-headline {
  font-family: "Unbounded", "Switzer", -apple-system, BlinkMacSystemFont, sans-serif;
  font-size: 22px;
  font-weight: 600;
  letter-spacing: -0.01em;
  color: var(--color-text);
  margin: 0;
  line-height: 1.25;
  text-wrap: balance;
}
.empty-state-hint {
  font-family: "Switzer", -apple-system, BlinkMacSystemFont, sans-serif;
  font-size: 15px;
  color: var(--color-text-soft);
  margin: 0;
  line-height: 1.5;
}
.empty-state-cta {
  width: auto !important;
  padding: var(--s-cta) var(--s-3) !important;
}

/* Bottom CTA row — sits at the end of a list view (agenda or notifications).
   Auto margins on top + bottom: when the parent flex column has leftover
   space (content is short, viewport is tall), the auto margins split that
   leftover equally → the CTA centers vertically in the empty area.
   When content fills the viewport, leftover collapses to 0 → the auto
   margins do nothing → only the padding-top remains as the gap to content
   above. That gap is `--s-5` (40px) to mirror the visual top gap of the
   sticky header (16px padding-bottom + 24px margin-bottom = 40px). */
.bottom-cta-row {
  display: flex;
  justify-content: center;
  /* Symmetric vertical padding so when `margin: auto 0` centers the row
     in leftover flex space, the button itself reads as visually centered
     (asymmetric padding would offset the button within the centered row).
     40px floor matches the sticky-header → first-card visual gap. */
  padding: var(--s-5) 0;
  margin: auto 0;
}
.bottom-cta-row .modal-cta {
  width: auto !important;
  padding: var(--s-cta) var(--s-3) !important;
}
/* Centered CTAs that live OUTSIDE modals (bottom-cta-row, empty states):
   default to fit-content width on tablet and up. Base .modal-cta has
   `width: 100%` for use inside modals (where filling makes sense given
   the smaller surface), but on a full-width agenda/notifications page
   a centered CTA reads better at its natural button width.
   Mobile override below brings them back to full-width for easy thumb
   targets in a narrow column. */
.empty-state-cta,
.notif-empty-cta { width: auto !important; }

@media (max-width: 640px) {
  .bottom-cta-row .modal-cta,
  .empty-state-cta,
  .notif-empty-cta { width: 100% !important; }
}

/* HTML `hidden` attribute should always win over our flex/grid display
   rules — without this, .modal-cta[hidden] still shows because .modal-cta
   sets display:flex. Global !important rule keeps `el.hidden = true` working
   reliably for every element on the page. */
[hidden] { display: none !important; }

/* When on #/notifications, hide every top-level dashboard container so only
   the notifications view shows. Uses body class instead of per-element JS so
   adding new containers doesn't break the routing. */
body.route-notifications .toolbar,
body.route-notifications #calendar-view { display: none !important; }

/* Notifications route uses the same page-canvas as the agenda — body inherits
   --color-bg, cards sit on it as elevated --color-card surfaces. Mirrors the
   agenda's exact figure/ground relationship. */

/* ── Notifications view ─────────────────────────────────────────────── */
.notifications-view {
  /* Match other pages: same horizontal --pad-x. Vertical bottom padding
     is 0 so the bottom-cta-row's 40px padding-bottom fully controls the
     gap before the footer (otherwise centering math comes out asymmetric).
     Height is driven by `flex: 1 1 auto` from body's flex column — fills
     whatever real leftover space exists after header + toolbar + footer. */
  padding: var(--s-3) var(--pad-x) 0;
  width: 100%;
  box-sizing: border-box;
  display: flex;
  flex-direction: column;
}
.notif-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  /* 8px row gap so when the CTAs wrap onto a row below "Last synced"
     (mobile/phone widths), the vertical gap between rows stays tight. */
  gap: var(--s-1);
  flex-wrap: wrap;
  /* Sticky so the actions stay accessible while scrolling through a long
     notification log. Sits just under the page header (56px). Negative side
     margins extend the bg to the viewport edges so the bar reads as full-
     width when stuck. No border — visual separation comes from the bg
     matching the page (the cards below are tiles on this same surface).
     Bottom margin is 0 on tablet/desktop so the day separator below reads
     as part of the same section; mobile keeps a small gap (the CTAs wrap
     into stacked rows there and need to stay distinct from the date). */
  position: sticky;
  top: 56px;
  z-index: 10;
  background: var(--color-bg);
  margin: 0 calc(-1 * var(--pad-x));
  /* Tablet/desktop: keep the original 16px top, but drop bottom to 0 so
     the date headline below reads as part of the same section, not as a
     far-away second row. Mobile keeps its own larger padding (next rule)
     since the action CTAs wrap into stacked rows there and need separation
     from the day label. */
  padding: var(--s-2) var(--pad-x) 0;
}
@media (max-width: 640px) {
  /* Page header shrinks to 48px on mobile — sticky bar follows so it
     remains flush. Tighten margin-bottom + padding so the buttons row
     directly below sits close to "Last synced", not a screen away. */
  .notif-header {
    top: 48px;
    margin: 0 calc(-1 * var(--pad-x-mobile)) 0;
    padding: var(--s-2) var(--pad-x-mobile) var(--s-1);
  }
}
/* "Last synced" text — sits on the left side of the notifications header,
   opposite the action CTAs. Body font, muted grey, left-aligned. Always
   formatted in the user's own local time zone (toLocaleString defaults to
   the browser's locale and tz). */
.notif-last-synced-wrap {
  display: inline-flex;
  /* Center-align the icon with the text so the 20×20 sync glyph sits
     vertically centered against the 14px label (baseline alignment
     stuck the icon's bottom edge on the text baseline, which read as
     the icon floating high). The icon button's vertical padding is
     zeroed below so the wrap's height still tracks the icon height
     and doesn't add phantom space between this row and the date
     headline beneath it. */
  align-items: center;
  gap: var(--s-icon);                        /* 4px — icon ↔ text exception */
  color: var(--color-text-muted);
}
.notif-last-synced {
  font-family: "Switzer", -apple-system, BlinkMacSystemFont, sans-serif;
  font-size: 14px;
  color: inherit;
  white-space: nowrap;
}
/* Sync-now icon button — same muted color as the "Last synced" text it
   sits next to. 20×20 (project icon standard). 32×32 hit-target via
   padding for easier clicking. Hover darkens to var(--color-text) which
   is the project's AA-passing high-contrast text color on every theme.
   Disabled state during in-flight sync drops opacity. */
.notif-resync {
  background: transparent;
  border: none;
  padding: 0 4px;                            /* horizontal hit area only — vertical padding would push wrap height past the 14px label and create a phantom gap below */
  margin: 0;
  cursor: pointer;
  color: inherit;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  transition: color 0.12s;
}
/* Hover changes the icon color only — no container fill. AA contrast on
   both themes (--color-text is the high-contrast text token). */
.notif-resync:hover { color: var(--color-text); }
.notif-resync:focus-visible {
  outline: 2px solid var(--color-accent-bg);
  outline-offset: 2px;
}
.notif-resync[disabled] { opacity: 0.6; cursor: progress; }

.notif-resync-icon { display: block; }
/* Default state: sync icon visible, check hidden. Spin while syncing.
   When the sync completes, JS swaps to the .is-done state which fades
   the sync icon out and the check in for ~1.5s. */
.notif-resync-icon-check { display: none; }
.notif-resync.is-syncing .notif-resync-icon-sync { animation: notifResyncSpin 1.05s linear infinite; }
.notif-resync.is-done    .notif-resync-icon-sync  { display: none; }
.notif-resync.is-done    .notif-resync-icon-check { display: block; }
@keyframes notifResyncSpin {
  from { transform: rotate(0deg); }
  to   { transform: rotate(360deg); }
}
/* Stalled state: red-ish ring around the icon to flag that the sync never
   reached Partiful (or got stuck before any progress landed). The subline
   carries the actionable message. */
/* Stalled state: match the surrounding text color so the icon stays
   high-contrast in both themes (white-ish in dark, dark in light)
   instead of the previous red which clashed and read as low-contrast
   on the page bg. The "Couldn't reach Partiful" message itself
   communicates the warning state — we don't need a red icon too. */
.notif-resync.is-stalled { color: var(--color-text); }
.notif-resync.is-stalled .notif-resync-icon-sync { animation: none; }
/* Stalled-sync error sits in the right zone of the .notif-header (next to
   Mark all / Clear all), on the same row as "Last synced" + sync button on
   the left. Warn-colored glyph + muted body text + underlined link to
   open Partiful directly. Same font size as the "Last synced" label. */
.notif-resync-error {
  display: inline-flex;
  align-items: center;
  gap: var(--s-icon);                                        /* 4px — icon ↔ text exception */
  color: var(--color-text-muted);
  font-size: 14px;
  font-family: "Switzer", -apple-system, BlinkMacSystemFont, sans-serif;
}
.notif-resync-error[hidden] { display: none; }
.notif-resync-error-icon {
  flex-shrink: 0;
  /* Match the text color so the icon stays high-contrast on both themes —
     red was failing the contrast bar against the page bg, and the text
     already conveys the error state. */
  color: var(--color-text);
}
.notif-resync-error-link {
  color: inherit;
  text-decoration: underline;
}
.notif-resync-error-link:hover { color: var(--color-text); }
/* Live progress sub-line shown during a resync. On tablet+ it sits on the
   same row as the "Last synced" stamp + sync icon, right-aligned in the
   leftover space (margin-left:auto pushes it past the wrap, .notif-header's
   space-between then pins .notif-header-actions to the far right). On
   mobile/phone (≤640px) it falls below as a stacked subline so the row
   doesn't get cramped. */
.notif-resync-subline {
  margin-left: auto;
  text-align: right;
  font-family: "Switzer", -apple-system, BlinkMacSystemFont, sans-serif;
  font-size: 13px;
  color: var(--color-text-muted);
  transition: opacity 200ms ease;
}
.notif-resync-subline[hidden] { display: none; }
@media (max-width: 640px) {
  .notif-resync-subline {
    margin-left: 0;
    width: 100%;
    text-align: left;
    margin-top: 4px;
  }
}
/* Last-synced label that sits centered under the Tech Week refresh CTA.
   Same typography + muted color as .notif-last-synced; the parent row
   becomes a column so the label stacks 16px below the CTA. */
.last-synced-label {
  font-family: "Switzer", -apple-system, BlinkMacSystemFont, sans-serif;
  font-size: 14px;
  color: var(--color-text-muted);
  text-align: center;
}
#techweek-refresh-cta {
  flex-direction: column;
  /* Without `align-items: center`, the default `stretch` would force every
     child to fill the cross-axis (horizontal here, since direction is
     column) — which overrides the `.bottom-cta-row .modal-cta { width:auto }`
     rule and made the "Open Tech Week" CTA full-width on tablet/desktop.
     Centering puts the children at their natural width. */
  align-items: center;
  gap: var(--s-2);    /* 16px between the CTA and the last-synced label */
}
.notif-header-actions {
  display: flex;
  align-items: center;
  gap: var(--s-2);
  flex-wrap: wrap;
}
@media (max-width: 480px) {
  .notif-header { flex-direction: column; align-items: flex-start; }
  /* Phone: header-actions row left-aligned. The Mark all / Clear all
     CTAs no longer live here (they're in each date-separator now), so
     the only thing this row holds is the inline stalled-sync error,
     which reads better aligned to the same left edge as "Last synced"
     above it than pushed to the right edge. */
  .notif-header-actions { width: 100%; justify-content: flex-start; }
}

.notif-mark-all,
.notif-clear {
  /* CTAs — match every other primary action via .modal-cta sizing. The
     width:100% default is overridden so they size to content in the inline
     header row. Padding bumped to --s-3 (24px) horizontal for a spacious
     header treatment. */
  width: auto !important;
  padding: var(--s-cta) var(--s-3) !important;
}

/* Outline CTA variant — same dimensions and font as the filled .modal-cta,
   transparent fill + outline border. Used for the "Clear all" secondary
   action next to the primary "Mark all as read". */
.modal-cta-outline {
  background: transparent !important;
  color: var(--color-text) !important;
  border: 1px solid var(--color-border) !important;
}
.modal-cta-outline:hover {
  background: var(--color-border-soft) !important;
  border-color: var(--color-text-muted) !important;
  color: var(--color-text) !important;
}
@media (max-width: 480px) {
  .notif-header { justify-content: stretch; gap: var(--s-1); }
  .notif-mark-all,
  .notif-clear { flex: 1; }
}
.notif-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--s-2); }
/* Per-day grouping headers inside the notification list. Reuse the agenda's
   .date-separator visual + sticky behavior, but offset further down so they
   stick BELOW the .notif-header bar (which is already pinned at top:56px).
   124px = 56 (page header) + ~68 (notif-header height). On mobile both
   shrink, so the offset shrinks with them.
   Transitions on padding-top so the animated tightening when the header
   reaches its pinned position reads as a smooth shift, not a jump. */
/* CTAs render INSIDE each date-separator. Vertical alignment with the
   headline is automatic via flex baseline. visibility: hidden hides
   non-stuck separators' CTAs so only the currently-pinned headline
   shows them. The only-ONE-is-stuck logic in JS (updateStickyHeaders)
   prevents the brief two-stuck flicker that was masking CTAs during
   sticky handoff. */
.notif-list .date-separator-actions {
  display: inline-flex;
  align-items: center;
  gap: var(--s-1);
  margin-left: auto;             /* push CTAs to the row's right edge */
  flex-shrink: 0;
  /* Hidden by default — only the stuck (or first when none stuck)
     separator's CTAs become visible. visibility (not display) reserves
     layout space on every row so the headline doesn't shift. */
  visibility: hidden;
  pointer-events: none;
}
.notif-list .date-separator.is-stuck .date-separator-actions {
  visibility: visible;
  pointer-events: auto;
}
.notif-list:not(:has(.date-separator.is-stuck))
  .date-separator:first-of-type
  .date-separator-actions {
  visibility: visible;
  pointer-events: auto;
}
.notif-list .date-separator-actions .modal-cta {
  width: auto !important;
  flex: 0 0 auto;
}
@media (max-width: 640px) {
  /* Mobile/phone: CTAs go on their own row ABOVE the headline so the
     date label gets the full row width and never wraps to two lines.
     column-reverse stacks DOM-order [label, actions] visually as
     [actions on top, label on bottom]. align-items:flex-start so
     both rows start at the left edge. */
  .notif-list .date-separator {
    flex-direction: column-reverse;
    align-items: flex-start;
    gap: var(--s-1);
  }
  .notif-list .date-separator-actions {
    margin-left: 0;
    width: 100%;
    justify-content: flex-start;
  }
  .notif-list .date-separator-label {
    width: 100%;
    /* Without the inline CTAs eating horizontal space, "Mon, May 4th"
       comfortably fits on one line at every phone width — keep it
       single-line so the row height stays predictable. */
    white-space: nowrap;
  }
  /* Critical: with column-reverse on mobile, the hidden CTA row still
     reserved its full height above each non-sticky separator (because
     `visibility: hidden` keeps layout in flex/grid). That painted as
     extra whitespace above non-pinned date headers. Switch to
     display-based hiding ON MOBILE ONLY so the row collapses. The
     stuck (or first-when-none-stuck) separator's CTAs are flipped
     back to display:flex below. Tablet+ still uses visibility so
     the inline-row layout stays stable across handoffs. */
  .notif-list .date-separator-actions { display: none; }
  .notif-list .date-separator.is-stuck .date-separator-actions,
  .notif-list:not(:has(.date-separator.is-stuck))
    .date-separator:first-of-type
    .date-separator-actions {
    display: flex;
  }
}
/* Standalone floating bar element — no longer used in this layout. */
.notif-actions-bar { display: none !important; }
.notif-list .date-separator {
  /* CTAs no longer live here (single floating bar above). Just the headline
     label, with normal flex baseline alignment from the base rule. */
  /* Sticky offset comes from a CSS variable set by JS based on the
     notif-header's actual measured bottom — accounts for the header
     wrapping to multiple rows on mobile. Falls back to 124px desktop
     / 112px mobile if JS hasn't run yet. */
  top: var(--notif-sticky-top, 124px);
  z-index: 11;                                /* one above .notif-header so it occludes the bar's bottom edge cleanly */
  background: var(--color-bg);                /* full opacity — overrides any inherited transparency */
  /* Negative side margins extend the bg to the viewport edges, matching
     the .notif-header bar above. Without this, content scrolling past on
     the left/right of the cards would peek through. */
  margin: 0 calc(-1 * var(--pad-x));
  /* Tighter top padding than the agenda's date-separator — the agenda has
     32px of toolbar margin above, which already gives breathing room, so
     its 24px padding-top reads as comfortable. The notif day separator
     sits directly under the "Last synced" bar with only 8px between them,
     so a 24px padding-top here would feel like a wide blank gap. Bottom
     stays at var(--s-2) so the CTAs have agenda-matched spacing below.
     When the row is sticky-pinned (.is-stuck below), padding-top animates
     from 8px → 0 so it snaps flush against the bar above. */
  /* 24px above / 8px below — exactly mirrors agenda's `.date-separator`.
     Both lists are flex columns with gap:16, both separators use the
     same padding, so the gap above any non-first headline should match
     visually. */
  padding: var(--s-3) var(--pad-x) var(--s-1);
  /* Flex row: headline left, CTAs right (CTAs inside this separator).
     align-items: center so headline text vertically centers with the
     taller button row — single source of alignment, no JS positioning
     math needed. */
  display: flex;
  align-items: center;
  gap: var(--s-1);
  transition: padding-top 0.18s ease-out, box-shadow 0.18s ease;
  /* Clip the upward-bleed of the .is-stuck box-shadow so it doesn't paint
     a faint line between this header and the .notif-header above it. The
     shadow continues to lift it off the cards below. */
  clip-path: inset(0 -32px -32px -32px);
}
.notif-list .date-separator.is-stuck {
  /* Pinned to the top → drop padding-top to 0 so the gap between
     "Last synced" (notif-header bottom) and the headline is the SAME
     whether the first or any subsequent separator is currently stuck.
     Without this, the first stuck would have a 0px gap (first-child
     override) while subsequent stuck would have 8px (this rule) —
     visible inconsistency on scroll. */
  padding-top: 0;
}
/* First headline flush against "Last synced" — 0px gap on top of the
   header's own 0px padding-bottom (tablet+), so the two read as a single
   banded section. */
.notif-list .date-separator:first-child {
  padding-top: 0;
}
@media (max-width: 640px) {
  .notif-list .date-separator:first-child {
    padding-top: 0;
  }
}
@media (max-width: 640px) {
  .notif-list .date-separator {
    margin: 0 calc(-1 * var(--pad-x-mobile));
    /* Mobile/phone: 24/16 (vs desktop 24/8) — the larger bottom padding
       compensates for the mobile notif-list's tighter list-gap (8 vs 16
       on desktop), so the total visual gap from headline-bottom to
       next-card-top stays a consistent 24px on every breakpoint. */
    padding: var(--s-3) var(--pad-x-mobile) var(--s-2);
  }
  /* First-child uses base 24px top — same as desktop, same as every other
     separator. Stable bar alignment requires uniform padding. */
}
@media (max-width: 640px) {
  .notif-list { gap: var(--s-1); }
}
/* IDENTICAL to agenda event-card reveal — same opacity:0 + translateY
   pending state, same 0.7s fade duration, same 70ms stagger. */
.notif-item.card-pending {
  opacity: 0;
  transform: translateY(28px);
}
.notif-item.card-revealed {
  animation: fadeInCard 0.7s cubic-bezier(0.22, 0.61, 0.36, 1) both;
  animation-delay: calc(var(--i, 0) * 70ms);
}
@media (prefers-reduced-motion: reduce) {
  .notif-item.card-pending,  .notif-item.card-revealed { opacity: 1; transform: none; animation: none; }
}
/* Each notification is a card laid out the same way as agenda event-cards:
   date column on the left (96px), event name + meta in the body, dismiss
   X aligned to the right edge. Reuses .event-date / .event-name styling
   so it looks identical to an agenda card. */
.notif-item {
  position: relative;
  display: block;
}
.notif-card {
  position: relative;
  background: var(--color-card);
  /* Same edge-to-edge treatment as the agenda event card: no real border;
     1px outline simulated via inset box-shadow drawn over the swatch tint. */
  border: none;
  box-shadow:
    inset 0 0 0 1px var(--color-border),
    0 1px 24px rgba(15, 23, 42, 0.05);
  border-radius: var(--s-1);
  /* Match the agenda event-card padding exactly: 16px top/bottom, 16px left,
     32px right. Agenda cards' rhythm carries straight through. */
  padding: var(--s-2) var(--s-4) var(--s-2) var(--s-2);
  display: grid;
  /* Three columns mirror the agenda card: 96px date, fluid body, auto-sized
     dismiss column on the right. */
  grid-template-columns: 96px 1fr auto;
  column-gap: var(--s-3);
  align-items: center;            /* vertically center every column */
  width: 100%;
  box-sizing: border-box;
  text-align: left;
  transition: box-shadow 0.12s, border-color 0.12s;
  text-decoration: none;
  color: inherit;
  overflow: hidden;               /* clip the right-side stippled backdrop */
}
.notif-card {
  /* Identical timing + easing as agenda card hover. */
  transition:
    transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
    box-shadow 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.notif-card-link:hover {
  /* Match agenda card hover — same 1.02 scale + same lift. */
  transform: translateY(-2px) scale(1.02);
  box-shadow:
    inset 0 0 0 1px var(--color-border),
    0 4px 12px rgba(15, 23, 42, 0.10);
  text-decoration: none;
}
/* Use the same event-name wrapping rules as agenda cards: 2-line clamp,
   .fits-balanced added by JS when the title fits in ≤2 lines so the wrap
   distributes evenly. No overrides needed — inherit from .event-name. */

/* ── Stippled right-edge tint ─────────────────────────────────────
   Mirrors the agenda card's airbrush pattern — multiple radial blobs in
   shades of the kind color, layered, blurred, then masked so the color
   only shows on the right half of the card. Fades the busy meta area into
   a colorful canvas without obstructing text on the left. */
.notif-card::after {
  content: "";
  position: absolute;
  inset: -15% 0 -15% 25%;
  pointer-events: none;
  z-index: 0;
  background:
    radial-gradient(circle 28px at 92% 18%, var(--nt-c1, transparent) 0%, transparent 80%),
    radial-gradient(circle 36px at 78% 65%, var(--nt-c2, transparent) 0%, transparent 80%),
    radial-gradient(circle 32px at 100% 45%, var(--nt-c3, transparent) 0%, transparent 80%),
    radial-gradient(circle 26px at 84% 88%, var(--nt-c4, transparent) 0%, transparent 80%),
    radial-gradient(ellipse 55% 70% at 100% 30%, var(--nt-c1, transparent) 0%, transparent 80%),
    radial-gradient(ellipse 50% 80% at 88% 80%, var(--nt-c3, transparent) 0%, transparent 80%);
  filter: blur(8px);
  mask: linear-gradient(to right, transparent 0%, transparent 30%, rgba(0,0,0,0.15) 55%, rgba(0,0,0,0.45) 80%, rgba(0,0,0,1) 100%);
  -webkit-mask: linear-gradient(to right, transparent 0%, transparent 30%, rgba(0,0,0,0.15) 55%, rgba(0,0,0,0.45) 80%, rgba(0,0,0,1) 100%);
  /* Same opacity as the agenda card swatch backdrop so notifications and
     event cards read at the same visual quietness, not heavier. */
  opacity: 0.32;
}
.notif-card > * { position: relative; z-index: 1; }

/* Four tint palettes drawn entirely from existing chip / badge tints —
   no new colors. Each palette has 4 shades from the same family so the
   stipple has natural variation like the agenda swatches. */
.notif-tint-changed     { --nt-c1: #fcd34d; --nt-c2: #fde68a; --nt-c3: #fef3c7; --nt-c4: #f59e0b; }   /* amber */
.notif-tint-rsvp        { --nt-c1: #bef264; --nt-c2: #d9f99d; --nt-c3: #ecfccb; --nt-c4: #84cc16; }   /* lime */
.notif-tint-cancelled   { --nt-c1: #fdba74; --nt-c2: #fed7aa; --nt-c3: #ffedd5; --nt-c4: #fb923c; }   /* orange */
.notif-tint-uncancelled { --nt-c1: #c4b5fd; --nt-c2: #ddd6fe; --nt-c3: #ede9fe; --nt-c4: #a78bfa; }   /* purple */
html[data-theme="dark"] .notif-card::after { opacity: 0.25; }

/* ── Kind chip — same shape language as the RSVP filter chips ─────── */
.notif-kind-chip {
  display: inline-flex;
  align-items: center;
  gap: var(--s-icon);
  padding: var(--s-icon) var(--s-cta);
  border-radius: 999px;
  /* 1px outline matching the agenda card badge convention — per-kind
     border-color set in .notif-kind-* rules below. */
  border: 1px solid transparent;
  font-family: "Unbounded", "Switzer", -apple-system, BlinkMacSystemFont, sans-serif;
  font-size: 14px;
  font-weight: 500;
  letter-spacing: -0.01em;
  line-height: 1.2;
  white-space: nowrap;
  align-self: flex-start;
  /* No margin-top — the parent .event-body's flex gap handles spacing
     uniformly between event name, detail, and chip (matching the agenda
     card's body rhythm exactly). */
}
.notif-kind-emoji { font-family: "Switzer", sans-serif; }

/* Group spacing inside the notification body — the change-description line
   and the kind chip read as a pair, while the event name floats above with
   extra breathing room. Body gap bumped from the agenda's 4px to 8px, then
   the first sibling after .event-name gets an additional 8px margin-top:
     name → detail/chip: 16px (8 gap + 8 margin)
     detail → chip:       8px (gap only)
   All values stay on the 8-grid. Scoped to .notif-card so the agenda
   event-card body keeps its tighter rhythm. */
.notif-card .event-body { gap: var(--s-1); }
.notif-card .event-body > .event-name + * { margin-top: var(--s-1); }
/* Each chip family is the existing badge palette of the same name —
   no new colors. (Hosting amber, going lime, pending orange, maybe purple.)
   Cancelled uses pending's orange because most people never see the
   hosting amber so a "warning"-feeling color from a chip everyone sees
   carries the meaning better. */
.notif-kind-chip.notif-kind-changed     { background: #fde68a; border-color: #fcd34d; color: #78350f; }   /* amber — used here only for "changed" notification entries (hosting is now fuchsia) */
.notif-kind-chip.notif-kind-rsvp        { background: #d9f99d; border-color: #a3e635; color: #365314; }   /* lime-400 border to give visual weight matching other chips (lime-300 looks too close to lime-200) */
.notif-kind-chip.notif-kind-cancelled   { background: #fed7aa; border-color: #fdba74; color: #9a3412; }   /* pending orange */
.notif-kind-chip.notif-kind-uncancelled { background: #ddd6fe; border-color: #c4b5fd; color: #5b21b6; }   /* maybe purple */
html[data-theme="dark"] .notif-kind-chip.notif-kind-changed     { background: #422a08; border-color: #735310; color: #fcd34d; }
html[data-theme="dark"] .notif-kind-chip.notif-kind-rsvp        { background: #1a2e05; border-color: #3f6212; color: #bef264; }
html[data-theme="dark"] .notif-kind-chip.notif-kind-cancelled   { background: #431407; border-color: #9a3412; color: #fdba74; }
html[data-theme="dark"] .notif-kind-chip.notif-kind-uncancelled { background: #2e1065; border-color: #5b21b6; color: #c4b5fd; }

/* The "old → new" change line — sits under the event name, above the chip.
   No margin-top: parent flex gap handles vertical spacing identical to the
   agenda card body. */
.notif-detail {
  font-family: "Switzer", -apple-system, BlinkMacSystemFont, sans-serif;
  font-size: 14px;
  color: var(--color-text-muted);
  word-break: break-word;
  line-height: 1.4;
}
.notif-detail strong { color: var(--color-text); font-weight: 600; }
.notif-sep { opacity: 0.45; margin: 0 4px; }
/* (Duplicate rule from earlier in the cascade — left empty so the
   primary .notif-card-link:hover above wins cleanly.) */
/* Meta line under the event name: "Date moved · May 7 → May 8 · 5m ago" */
.notif-meta {
  margin-top: var(--s-icon);
  font-family: "Switzer", -apple-system, BlinkMacSystemFont, sans-serif;
  font-size: 13px;
  color: var(--color-text-muted);
  word-break: break-word;
  line-height: 1.4;
}
.notif-meta strong { color: var(--color-text); font-weight: 600; }
.notif-sep { opacity: 0.45; margin: 0 4px; }

/* Unread per-card indicator — same amber as the nav bell's notification
   dot, so the two pieces of the unread visual language read as one set.
   Default: inline-block right after the title text; the dot follows the
   last visible character on whichever line the title ends. Short titles
   distribute naturally; mid titles wrap to 2 lines with the dot at the
   end of line 2.
   Fallback: when the title is so long that the dot wraps to a phantom
   line 3 (which -webkit-line-clamp would clip), JS adds .dot-overlaid
   to the .event-name and the dot is repositioned absolutely at the
   lower-right corner of the visible 2-line box, with 16px of right
   padding reserved so the line-clamp ellipsis doesn't crash into it.
   Light: amber-200; dark: amber-300. */
.notif-unread-dot {
  display: inline-block;
  /* 10px — matches the bell-icon dot exactly so the unread visual
     language reads as one consistent set across the page. */
  width: 10px;
  height: 10px;
  border-radius: 50%;
  background: #fde68a;
  margin-left: var(--s-1);
  /* `vertical-align: middle` aligns to baseline + x-height/2 — which sits
     a couple px below the optical center of capital letters. Nudging up
     ~2px lands the dot's center on the cap-height midline so it reads
     as truly aligned with the title text on its row, instead of slightly
     low. */
  vertical-align: middle;
  position: relative;
  top: -2px;
  flex-shrink: 0;
}
html[data-theme="dark"] .notif-unread-dot {
  background: #fcd34d;
}
/* When the dot sits on an agenda event card or a notification card (next to
   the event/notification name), use Party Pal blue in LIGHT mode instead of
   amber. Dark mode keeps the existing amber — it's tuned to pop on the dark
   card surface. The bell-icon dot (.notif-dot, on the page header) stays
   amber in both modes. */
html:not([data-theme="dark"]) .event-card .notif-unread-dot,
html:not([data-theme="dark"]) .event-card-wrap .notif-unread-dot,
html:not([data-theme="dark"]) .notif-item .notif-unread-dot,
html:not([data-theme="dark"]) .notif-card .notif-unread-dot {
  background: var(--color-blue);
}

/* Dismiss X — third grid column on the right of every card. 36px circular
   hit-target, 20px icon (matches the project icon standard), full text
   color for high contrast against the stippled tint behind. */
.notif-dismiss {
  z-index: 2;
  width: var(--ctrl-h);            /* 32px — matches every other control on the page */
  height: var(--ctrl-h);
  border: none;
  background: transparent;
  color: var(--color-text);
  cursor: pointer;
  border-radius: 50%;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  transition: background-color 0.15s ease, color 0.15s ease;
  align-self: center;
}
.notif-dismiss svg { width: 20px; height: 20px; }
.notif-dismiss:hover {
  background: var(--color-card);
  color: var(--color-text);
}
html[data-theme="dark"] .notif-dismiss:hover { background: var(--color-bg); }

/* Empty state fills the remaining height of the notifications view and
   vertically centers the icon + headline + body + CTA stack. All spacing
   is in 8-multiples. */
.notif-empty {
  flex: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: var(--s-3) 0;
}
.notif-empty-stack {
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
  gap: var(--s-3);                /* 24px between every element */
  max-width: 480px;
  margin: 0;
}
.notif-empty-icon {
  width: 80px;
  height: 80px;
  border-radius: 50%;
  /* Light: pale blue footer color + Party Pal blue bell. Echoes the today
     calendar highlight + footer treatment so it feels woven in. */
  background: #eff6ff;
  color: var(--color-blue);
  display: flex;
  align-items: center;
  justify-content: center;
  margin: 0;
}
/* Dark mode: page is now card-bg, so border-soft would blend. Lift the
   circle to --color-border (one step lighter) for visible contrast. */
html[data-theme="dark"] .notif-empty-icon { background: var(--color-border); color: var(--color-text-soft); }
.notif-empty-headline {
  /* Same heading font as the dashboard's section titles + modal headers. */
  font-family: "Unbounded", "Switzer", -apple-system, BlinkMacSystemFont, sans-serif;
  font-size: 22px;
  font-weight: 600;
  letter-spacing: -0.01em;
  color: var(--color-text);
  margin: 0;
  line-height: 1.25;
  /* Force a two-line break at roughly the midpoint of the headline ("You
     don't have any" / "notifications yet"). max-width tuned in ch so it
     scales with the font-size; balance distributes evenly across the two. */
  text-wrap: balance;
  max-width: 18ch;
}
.notif-empty-hint {
  font-family: "Switzer", -apple-system, BlinkMacSystemFont, sans-serif;
  font-size: 15px;
  color: var(--color-text-soft);
  margin: 0;
  line-height: 1.5;
}
.notif-empty-cta {
  /* Inherits .modal-cta sizing (--s-cta y-padding, 14px font, Unbounded
     family, --color-cta-bg). width:auto so it fits the label, not stretch. */
  width: auto !important;
  padding: var(--s-cta) var(--s-3) !important;   /* 12px y, 24px x for spaciousness */
}
/* Mobile — stay in the same 3-column row (date / body / dismiss) so the X
   keeps its center vertical alignment and the body block (name + chip +
   detail) reads as one centered group beside the date. Just shrink the
   date column. Padding matches the agenda card's mobile rule (--s-cta). */
@media (max-width: 640px) {
  /* Outside-card horizontal padding routes through --pad-x-mobile so the
     phone breakpoint (which redefines --pad-x-mobile to 16px) gets a
     tighter outer gutter than mobile/tablet. */
  .notifications-view { padding: var(--s-2) var(--pad-x-mobile) var(--s-3); }
  .notif-kind-chip { font-size: 13px; }
}
@media (max-width: 480px) {
  .event-date .day-num { font-size: 22px; }
}

.fetch-bar {
  width: 144px;
  height: 4px;
  background: var(--color-border);
  border-radius: 2px;
  overflow: hidden;
  flex-shrink: 0;
}
.fetch-fill {
  height: 100%;
  background: var(--color-blue);
  border-radius: 2px;
  width: 0%;
  transition: width 0.4s ease;
}

/* ── Toolbar ── */
.toolbar {
  background: var(--color-card);
  border-bottom: 1px solid var(--color-border);
  /* padding-y = section gap = 32px so search ↔ first row ↔ last row ↔ bottom edge are evenly spaced */
  padding: var(--s-4) var(--pad-x);
  display: flex;
  flex-direction: column;
  gap: var(--s-4);
  container-type: inline-size;
  container-name: toolbar;
}
/* Above mobile, the toolbar reserves the drawer's slot only when the
   drawer is open. Closed = toolbar fills full width and the filter
   button sits at the page's right edge; open = toolbar compresses by
   `--drawer-w`, the button shifts left along with the new edge, and
   the drawer slides into the freed space. */
@media (min-width: 641px) {
  body.drawer-open .toolbar {
    padding-right: calc(var(--pad-x) + var(--drawer-w));
    transition: padding-right 0.25s cubic-bezier(0.22, 0.61, 0.36, 1);
  }
}

/* Search + filter-toggle row. Search input grows to fill remaining width;
   filter button stays at a fixed dimension (matches header CTA height
   --ctrl-h = 32px) so it lines up with theme/notification buttons in the
   header. 16px gap between input and button per spec. */
.search-row {
  display: flex;
  align-items: center;
  gap: var(--s-2);                /* 16px between search and filter btn */
}
.search-wrap { position: relative; flex: 1 1 auto; min-width: 0; }
.search-wrap input {
  width: 100%;
  /* Match header CTA height (32px) so search aligns with the filter
     button on its right. Vertical padding shrunk to (32 - 14 line-height
     - 2 border) / 2 = 8px. */
  height: var(--ctrl-h);
  padding: 0 calc(var(--s-2) + 16px + var(--s-1)) 0 var(--s-2);
  border: 1px solid #d1d5db;
  border-radius: var(--s-1);
  font-size: 14px;
  font-family: inherit;
  outline: none;
  background: var(--color-bg);
}
.search-icon {
  position: absolute;
  right: var(--s-2);                  /* mirrors the input's left padding so the icon's gap from the input's right edge matches the placeholder's gap from the input's left edge */
  top: 50%;
  transform: translateY(-50%);
  /* Faint icon — same chrome color as the input border. The placeholder
     stays muted text color (separate role from icon). */
  color: #d1d5db;
  pointer-events: none;
  transition: color 0.15s ease;
}
html[data-theme="dark"] .search-icon { color: var(--ctrl-outline); }
/* On focus the input border turns blue — keep the icon in sync. */
.search-wrap input:focus + .search-icon { color: var(--color-blue); }
.search-wrap input:focus + .search-icon { color: var(--color-blue); }
.search-wrap input:focus {
  border-color: var(--color-blue);
  background: var(--color-card);
  box-shadow: 0 0 0 2px rgba(37,99,235,0.12);
}

/* Filter-drawer toggle button. Outline ghost on the page background,
   same 32×32 footprint as header CTAs (theme/notif). Lives in the
   toolbar's search row, but its visual treatment reads as a header
   control so the user sees one consistent set of right-side buttons.
   Active state (drawer open) inverts to a filled blue accent. */
.filter-toggle {
  width: var(--ctrl-h);
  height: var(--ctrl-h);
  padding: 0;
  background: var(--color-card);
  border: 1px solid var(--ctrl-outline, #d1d5db);
  border-radius: var(--s-1);
  /* Black/high-contrast in both themes — matches the active nav text
     ("Partiful" segmented label, selected filter chip label) so the
     filter button reads as a primary affordance, not a muted glyph
     like the search icon. */
  color: var(--color-text);
  display: inline-flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  flex-shrink: 0;
  transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease;
}
.filter-toggle:hover {
  border-color: var(--color-blue);
  color: var(--color-blue);
}
.filter-toggle[aria-pressed="true"] {
  background: var(--color-blue);
  border-color: var(--color-blue);
  color: #ffffff;
}
.filter-toggle:focus-visible {
  outline: 2px solid var(--color-blue);
  outline-offset: 2px;
}
/* Dark mode pressed (drawer open): accent yellow with a dark icon.
   Resting state stays the normal deselected outline so the button
   only "lights up" yellow once it's actively toggling something. */
html[data-theme="dark"] .filter-toggle[aria-pressed="true"] {
  background: var(--color-accent-bg);
  border-color: var(--color-accent-bg);
  color: var(--color-accent-fg);
}
/* Drawer hidden on mobile per spec — the toolbar filter button goes
   with it, to avoid a dead-end click that opens nothing. */
@media (max-width: 640px) {
  #filter-toggle { display: none; }
}
/* When the drawer is open, the toolbar's filter button hides and its
   sibling inside the drawer header takes over the toggle role. The
   search input below grows to fill the freed space (it's flex:1 1
   auto, so removing the button + 16px gap from the row gives that
   space straight back). */
body.drawer-open #filter-toggle { display: none; }
body:not(.drawer-open) #filter-toggle-inner { display: none; }
/* When 3-days is the rightmost visible button (Week hidden at narrow
   content widths), explicitly round its right corner. The seg-toggle
   wrapper already has overflow:hidden + border-radius, but rendering
   engines occasionally show a stray corner pixel on a hover/active
   fill that bleeds outside the wrapper's clip path. */
body.eff-mobile .cal-range-toggle [data-range="3"] {
  border-top-right-radius: var(--s-1);
  border-bottom-right-radius: var(--s-1);
}

/* ── Filter drawer ────────────────────────────────────────────────
   Fixed-positioned panel pinned to the right edge of the viewport,
   spanning from the bottom of the (sticky) header to the top of the
   (fixed) footer. Closed = translated off-screen to the right; open
   = slid into the toolbar's already-reserved right strip so the
   surrounding layout doesn't shift to make room.
   Hidden entirely below the mobile breakpoint per spec. */
.filter-drawer {
  position: fixed;
  top: 56px;                       /* header height */
  bottom: 56px;                    /* footer height */
  right: 0;
  width: var(--drawer-w);
  /* Above the toolbar even when the toolbar's z-index gets bumped to
     200 by an open custom-select dropdown — without this, the
     toolbar's right portion paints over the drawer's "Filters"
     header whenever the sort dropdown is open. */
  z-index: 300;
  background: var(--color-card);
  border-left: 1px solid var(--color-border);
  display: flex;
  flex-direction: column;
  transform: translateX(100%);
  transition: transform 0.25s cubic-bezier(0.22, 0.61, 0.36, 1);
  overflow: hidden;                /* body scrolls internally */
}
body.drawer-open .filter-drawer {
  transform: translateX(0);
}
@media (max-width: 640px) {
  .filter-drawer { display: none; }
}
/* Tablet + larger footer matches header at 56px; phone footer is
   shorter (48px) but the drawer is hidden there anyway. */

.filter-drawer-header {
  /* Padding-top matches the toolbar's padding-top (--s-4 = 32px) so
     the "Filters" heading + close button line up vertically with the
     search input across the divider. */
  padding: var(--s-4) var(--pad-x) var(--s-2);
  border-bottom: 1px solid var(--color-border);
  display: flex;
  align-items: center;
  justify-content: space-between;
  flex: 0 0 auto;
  gap: var(--s-2);
}
.filter-drawer-title {
  margin: 0;
  font-size: 20px;
  font-weight: 600;
  color: var(--color-text);
}
.filter-drawer-body {
  flex: 1 1 auto;
  overflow-y: auto;
  padding: var(--s-2) var(--pad-x) var(--s-4);
  display: flex;
  flex-direction: column;
  gap: var(--s-3);
}

/* ── Match-mode toggle (Any / All) ─────────────────────────────────
   Mirrors the existing Tech Week / Partiful seg-toggle styling
   exactly so the two segmented controls read as a set. */
.filter-match-row {
  display: flex;
  align-items: center;
  gap: var(--s-2);
  padding-top: var(--s-1);
}
/* Right-aligned outline "Clear" button. Same height/padding/radius
   as the sort-field trigger so the two read as a control set. Stays
   outline regardless of click — it's an action, not a toggle.
   Disabled when no chips are selected (still outlined, but muted
   text + not-allowed cursor). */
.filter-clear-btn {
  margin-left: auto;
  height: var(--ctrl-h);
  padding: 0 var(--ctrl-pad-x);
  border: 1px solid var(--ctrl-outline, #d1d5db);
  border-radius: var(--s-1);
  background: var(--color-card);
  color: var(--color-text);
  font: inherit;
  font-size: var(--ctrl-font);
  font-weight: 500;
  cursor: pointer;
  transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease;
}
/* Hover state mirrors the deselected seg-btn (Any/All when not the
   active one): pale-blue background + blue text in light mode, normal
   text in dark. Keeps the Clear button's hover affordance consistent
   with the rest of the segmented controls on the page. */
.filter-clear-btn:hover:not([disabled]) {
  background: #eff6ff;
  color: var(--color-blue);
  border-color: var(--color-blue);
}
html[data-theme="dark"] .filter-clear-btn:hover:not([disabled]) {
  background: var(--color-border-soft);
  color: var(--color-text);
  border-color: var(--color-blue);
}
.filter-clear-btn:focus-visible {
  outline: 2px solid var(--color-blue);
  outline-offset: 2px;
}
.filter-clear-btn[disabled] {
  /* Disabled keeps the same outline + black text so it reads like a
     deselected seg-btn (Partiful when Tech Week is active) instead
     of looking grayed-out. Cursor changes are the only "disabled"
     signal — the click handler itself bails out. */
  cursor: not-allowed;
}
.filter-match-label {
  font-family: "Unbounded", "Switzer", -apple-system, BlinkMacSystemFont, sans-serif;
  font-size: 16px;
  font-weight: 600;
  line-height: 1.2;
  letter-spacing: -0.01em;
  color: var(--color-text);
}

/* ── Filter sections ───────────────────────────────────────────────
   Each category (Topics, Types, Tracks, Sponsors, Start time,
   Neighborhoods) is a collapsible section. The header is a button
   that flips the body's visibility and rotates a caret. Sections
   render in a fixed order; user expand/collapse choices persist
   across drawer toggles. */
.filter-section {
  display: flex;
  flex-direction: column;
  border-top: 1px solid var(--color-border);
  padding-top: var(--s-3);
}
/* Full-bleed divider between the match-row and the first filter section.
   Same negative-margin trick as the Preferences divider below. */
.filter-match-divider {
  border-top: 1px solid var(--color-border);
  margin: 0 calc(-1 * var(--pad-x));
}
.filter-match-divider + .filter-section {
  border-top: none;
  padding-top: 0;
}

/* Full-bleed divider above the "Preferences" section. The Preferences
   chips (Has open spots / No sports) are hard-refinement filters
   that always apply regardless of Any/All match mode — visually
   separating them from the regular Any/All chip sections above
   signals that they're a different kind of filter. Same line weight
   as a normal section break, but the negative horizontal margin
   counters the drawer body's --pad-x so the rule runs edge-to-edge
   of the drawer column instead of being inset. */
.filter-preferences-divider {
  border-top: 1px solid var(--color-border);
  /* Negative horizontal margin counters the drawer body's --pad-x so
     the rule runs full-bleed across the drawer column. Vertical
     spacing comes from the drawer body's `gap: var(--s-3)`, which
     already gives equal padding above and below this flex child. */
  margin: 0 calc(-1 * var(--pad-x));
}
/* Drop the next section's own border-top + top padding so the divider
   line is the only rule and the gap above/below it stays symmetric. */
.filter-preferences-divider + .filter-section {
  border-top: none;
  padding-top: 0;
}
/* Preferences section title matches the drawer's "Filters" h2 header
   exactly. h2 inherits the project-wide heading font (Unbounded)
   from the headings rule near the top of this file — so the override
   below uses the same family, not Switzer. */
.filter-section[data-category="exclusions"] .filter-section-name {
  font-family: "Unbounded", "Switzer", -apple-system, BlinkMacSystemFont, sans-serif;
  font-size: 16px;
  font-weight: 600;
  line-height: 1.2;
  letter-spacing: -0.01em;
  color: var(--color-text);
}
/* Selected-count badge that follows the section title (e.g. "Topics (3)"). */
.filter-section-count {
  font-family: "Switzer", -apple-system, BlinkMacSystemFont, sans-serif;
  font-weight: 500;
  color: var(--color-blue);
  margin-left: 4px;
}
/* Filter-toggle button in the toolbar — show selected-chip count
   inside the button. When count > 0 the count span gets a margin so
   the icon and number sit at 4px apart. */
.filter-toggle-count {
  font-family: "Switzer", -apple-system, BlinkMacSystemFont, sans-serif;
  font-size: 13px;
  font-weight: 500;
  margin-left: 4px;
}
/* Filter button reads "active" any time filters are applied, with the
   same color in both states (open OR closed) so the user gets a
   consistent visual signal. Light mode = blue, dark mode = yellow.
   The aria-pressed=true open-drawer styling already paints those
   colors; this rule extends them to the collapsed-with-selections
   case so the button doesn't dim out as soon as the drawer closes. */
.filter-toggle[data-has-selections="true"] {
  background: var(--color-blue);
  border-color: var(--color-blue);
  color: #ffffff;
}
html[data-theme="dark"] .filter-toggle[data-has-selections="true"] {
  background: var(--color-accent-bg);
  border-color: var(--color-accent-bg);
  color: var(--color-accent-fg);
}
/* When count is present the button needs to grow horizontally to fit
   icon + count. Keep min-width = control height for square shape
   when empty. */
.filter-toggle:has(.filter-toggle-count) {
  width: auto;
  min-width: var(--ctrl-h);
  padding: 0 var(--s-cta);
}
.filter-section-toggle {
  background: transparent;
  border: none;
  padding: 0;
  display: flex;
  align-items: center;
  justify-content: space-between;
  cursor: pointer;
  color: var(--color-text);
  width: 100%;
  text-align: left;
  font-family: inherit;
  border-radius: var(--s-icon);
}
.filter-section-toggle:focus-visible {
  outline: 2px solid var(--color-blue);
  outline-offset: 4px;
}
.filter-section-name {
  /* Mirror the RSVP / Sort label styling on the toolbar: Switzer 500,
     14px, no letter-spacing, no transform, regular text color. */
  font-family: "Switzer", -apple-system, BlinkMacSystemFont, sans-serif;
  font-size: 14px;
  font-weight: 500;
  letter-spacing: 0;
  text-transform: none;
  color: var(--color-text);
}
.filter-section-caret {
  transition: transform 0.18s ease;
  color: var(--color-text-muted);
}
.filter-section-toggle.is-collapsed .filter-section-caret {
  transform: rotate(-90deg);
}
.filter-section-body {
  padding-top: var(--s-2);
  display: flex;
  flex-direction: column;
  gap: var(--s-2);
}
.filter-section-body[hidden] { display: none; }

/* ── Chips inside the drawer ──────────────────────────────────────
   Same dimensions + treatment as the existing RSVP chips so the two
   chip systems read as one. Multi-select: clicking toggles the
   .active class. The count pill mirrors .chip-count. */
.filter-chips {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s-1);
}
.filter-chip {
  font-family: "Unbounded", "Switzer", -apple-system, BlinkMacSystemFont, sans-serif;
  letter-spacing: -0.01em;
  padding: var(--s-icon) var(--s-cta);
  border-radius: 999px;
  border: 1px solid #d1d5db;
  background: var(--color-card);
  font-size: 14px;
  color: #374151;
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  gap: var(--s-icon);
  white-space: nowrap;
  transition: background 0.18s ease, border-color 0.12s ease, transform 0.08s ease;
}
/* Dark mode: deselected chip text was the hardcoded #374151 charcoal,
   too low-contrast against the dark card surface. Promote to the
   page's high-contrast text color so labels read clearly. */
html[data-theme="dark"] .filter-chip {
  color: var(--color-text);
  border-color: var(--color-border);
}
.filter-chip:hover { border-color: var(--color-blue); color: var(--color-blue); }
.filter-chip:active { transform: scale(0.94); }
.filter-chip:focus-visible {
  outline: 2px solid var(--color-blue);
  outline-offset: 2px;
}
.filter-chip.active {
  background: var(--color-accent-bg);
  border-color: var(--color-accent-bg);
  color: var(--color-accent-fg);
}
/* Dark-mode override: the page-wide rule
   `html[data-theme="dark"] .filter-chip { color: var(--color-text); }`
   has higher specificity than `.filter-chip.active`, so without this
   selected chips were rendering with the page's light text color
   over the yellow accent — bad contrast. Pin selected chips to the
   accent-fg token (dark gray) so the label stays readable. */
html[data-theme="dark"] .filter-chip.active {
  color: var(--color-accent-fg);
}
/* Count rendering mirrors the RSVP chips exactly: body font (Switzer
   500), inherits the chip's 14px size, sits inline with the label
   text wrapped in parens. Inherits color from the chip so active
   state stays AA-compliant on the lime background. */
.filter-chip-count {
  font-family: "Switzer", -apple-system, BlinkMacSystemFont, sans-serif;
  font-weight: 500;
}

/* ── Borough subgrouping inside Neighborhoods ────────────────────── */
.filter-borough { display: flex; flex-direction: column; gap: var(--s-1); }
.filter-borough + .filter-borough { margin-top: var(--s-2); }
.filter-borough-name {
  font-family: "Switzer", -apple-system, BlinkMacSystemFont, sans-serif;
  font-size: 12px;
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--color-text-muted);
}

/* Agenda content (the main calendar/list area) resizes when the
   drawer opens. Closed: extends to the page's normal right edge.
   Open: pulled in by `--drawer-w` so it ends at the drawer's
   leading edge. The container-type lets descendants opt into
   container queries that respond to the *content area* width
   instead of the viewport — so layouts demote to tablet/mobile
   sizings naturally as the content shrinks. */
#calendar-view {
  transition: padding-right 0.25s cubic-bezier(0.22, 0.61, 0.36, 1);
  container-type: inline-size;
  container-name: agenda;
}
@media (min-width: 641px) {
  body.drawer-open #calendar-view {
    /* Preserve the page's normal right gutter on top of the drawer
       reservation so cards retain their left/right breathing room
       (the original padding is `var(--pad-x)`; we add the drawer's
       full width on top of that). */
    padding-right: calc(var(--pad-x) + var(--drawer-w));
  }
}

/* Filter rows: label sits flush-left of the chips with just enough gap to
   read as separate. If the chips don't fit alongside the label, they wrap
   onto subsequent lines (the label stays at top-left). */
.filter-row {
  display: flex;
  align-items: flex-start;
  gap: var(--s-2);             /* 16px between label and chips/dropdown */
  /* No flex-wrap on the row itself: above 720px, label sits inline with
     content; .chips and .chips-row wrap their *own* internal items if
     needed. Below 720px, the container query collapses both rows to
     column simultaneously — so RSVP and Sort always stay in sync. */
}

.filter-label {
  flex: 0 0 auto;
  font-family: "Switzer", -apple-system, BlinkMacSystemFont, sans-serif;
  font-size: 14px;
  font-weight: 500;
  color: var(--color-text);
  letter-spacing: 0;
  text-transform: none;
  padding-top: 8px;
}

.chips { flex: 1 1 auto; min-width: 0; }
.chips-row { flex: 1 1 auto; min-width: 0; }

/* JS adds .toolbar-stacked to <body> when the RSVP chips no longer fit on
   a single line. When that happens BOTH filter rows collapse together —
   RSVP label above the chips, Sort label above the dropdown row — so the
   two rows always stay in sync regardless of exact toolbar width. */
body.toolbar-stacked .filter-row {
  flex-direction: column;
  align-items: stretch;
  gap: var(--s-1);
}
body.toolbar-stacked .filter-label { padding-top: 0; padding-bottom: 2px; }
body.toolbar-stacked .chips-row { flex-wrap: wrap; }

.chips {
  display: flex;
  gap: var(--s-1);
  flex-wrap: wrap;
  align-items: center;
}

.chip {
  padding: var(--s-icon) var(--s-cta);
  border-radius: 999px;
  border: 1px solid #d1d5db;
  background: var(--color-card);
  font-size: 14px;
  color: #374151;
  cursor: pointer;
  /* Two timings: fast on transform/border (snappy press feedback), slower on
     background/color (smoother becoming-active fill). */
  /* Color snaps instantly when .active flips so text stays readable through
     the bg color transition (otherwise mid-gray text + mid-blue bg dropped
     contrast badly mid-transition, reading as the label "disappearing"). */
  transition: background 0.18s ease, border-color 0.12s ease, transform 0.08s ease;
  white-space: nowrap;
}
.chip:hover { border-color: var(--color-blue); color: var(--color-blue); }
.chip:active { transform: scale(0.94); }     /* tactile press feedback */
.chip.chip-pulse { animation: chipPulse 0.32s ease-out; }
.chip.active { background: var(--color-accent-bg); border-color: var(--color-accent-bg); color: var(--color-accent-fg); }

@keyframes chipPulse {
  0%   { transform: scale(1); }
  35%  { transform: scale(1.06); }
  100% { transform: scale(1); }
}
@media (prefers-reduced-motion: reduce) {
  .chip:active, .chip.chip-pulse { transform: none; animation: none; }
}

/* When an RSVP chip is active, use its status color (matches badges + calendar) */
/* Cohesive rainbow palette — all chips at the same saturation/lightness tier
   (Tailwind ~200 bg, ~300 border, ~800-900 text), each a distinct hue, in
   rough warm→cool order: hosting→onlist→going→interested→invited→waitlist
   →maybe→pending→declined. Dark mode versions sit at the same hue but
   inverted to ~950 bg + ~300 text below. AA passing throughout. */
.chip[data-rsvp="hosting"].active    { background: #f5d0fe; border-color: #f0abfc; color: #86198f; } /* fuchsia — unique to hosting, distinct from invited pink */
.chip[data-rsvp="onlist"].active     { background: #fef08a; border-color: #fde047; color: #713f12; } /* yellow */
.chip[data-rsvp="going"].active      { background: #d9f99d; border-color: #a3e635; color: #365314; } /* lime — bumped to lime-400 border so the outline reads at parity with other chips */
.chip[data-rsvp="interested"].active { background: #a5f3fc; border-color: #67e8f9; color: #155e75; } /* cyan */
.chip[data-rsvp="invited"].active    { background: #fbcfe8; border-color: #f9a8d4; color: #9d174d; } /* pink */
.chip[data-rsvp="waitlist"].active   { background: #c7d2fe; border-color: #a5b4fc; color: #3730a3; } /* indigo */
.chip[data-rsvp="maybe"].active      { background: #ddd6fe; border-color: #c4b5fd; color: #5b21b6; } /* violet */
.chip[data-rsvp="pending"].active    { background: #fed7aa; border-color: #fdba74; color: #9a3412; } /* orange */
.chip[data-rsvp="declined"].active   { background: #fecaca; border-color: #fca5a5; color: #991b1b; } /* red */
.chip[data-rsvp="inviteonly"].active { background: #ede4f7; border-color: #cbb8e8; color: #6b21a8; }
.chip[data-rsvp="cancelled"].active  { background: #fee2e2; border-color: #fca5a5; color: #991b1b; }
.chip[data-rsvp="none"].active       { background: #e5e7eb; border-color: #d1d5db; color: #374151; }

/* Sort row: dropdown + count cluster on the left, source toggle on the right.
   Mirrors the RSVP .chips pattern — flex: 1 so it sits ON THE SAME LINE as
   the "Sort" label whenever the toolbar has the width to fit them. The
   container query collapses everything to column at narrow widths,
   matching how the RSVP row collapses simultaneously. */
.chips-row {
  display: flex;
  gap: var(--s-2);
  flex: 1 1 auto;
  min-width: 0;
  flex-wrap: nowrap;
  align-items: center;
  justify-content: space-between;
}
@container toolbar (max-width: 720px) {
  .chips-row { flex-wrap: wrap; }
}

/* Sort dropdown: fixed width sized for the longest option ("Date & time" /
   "RSVP status") so the trigger doesn't resize on selection change. Text
   left-aligned with 12px left padding (matching chip/toggle). Count sits at
   a consistent position next to the dropdown. */
.sort-group {
  display: flex;
  align-items: center;
  gap: var(--s-2);
}
.sort-group .cs-value {
  display: inline-block;
  min-width: 96px;
  text-align: left;
}

/* Custom dropdown — same height + outline as segmented toggles, but with the
   active visual (black bg, white text). Menu options use page design tokens. */
.custom-select {
  position: relative;
  display: inline-block;
  height: var(--ctrl-h);
}
/* When the menu is open, lift the wrapper's whole stacking context above
   sibling sections (.cal-toolbar etc.) so the dropdown can never go behind
   later page elements. The shellSlideUp animations create stacking contexts
   on `.toolbar` and `.cal-toolbar` that would otherwise trap the menu. */
.custom-select.open { z-index: 200; }
.toolbar { position: relative; z-index: 20; }
.toolbar:has(.custom-select.open) { z-index: 200; }
.cs-trigger {
  display: inline-flex;
  align-items: center;
  gap: var(--s-1);
  height: var(--ctrl-h);
  padding: 0 var(--ctrl-pad-x);
  border: 1px solid var(--color-active-border);
  border-radius: var(--s-1);
  background: var(--color-active-bg);
  color: var(--color-active-fg);
  font: inherit;
  font-size: var(--ctrl-font);
  font-weight: 700;
  line-height: var(--ctrl-line);
  cursor: pointer;
  white-space: nowrap;
}
.cs-caret { display: inline-flex; flex-shrink: 0; }
/* Hover affordance — slight darkening of the dark background, plus a faint
   blue ring on the border so it's clear the trigger is interactive. */
.cs-trigger { transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease; }
/* Hover stays in the active-state (yellow) palette so the trigger reads as
   consistent with the .range-btn.active and .seg-btn.active hover treatment.
   Blue would compete with the warm yellow surface. */
.cs-trigger:hover {
  background: var(--color-active-bg-hover);
  border-color: var(--color-active-border);
  color: var(--color-active-fg);
}
.cs-trigger:focus-visible { outline: 2px solid var(--color-blue); outline-offset: 2px; }
/* Light mode — the sort dropdown is treated like a "selected" pill: blue
   fill with a 4-sided blue outline that matches its fill color. */
html:not([data-theme="dark"]) .cs-trigger,
html:not([data-theme="dark"]) .cs-trigger:hover { border-color: var(--color-active-border); }
.cs-caret { font-size: 14px; opacity: 0.85; }
.cs-menu {
  position: absolute;
  top: calc(100% + 4px);
  left: 0;
  min-width: 100%;
  background: var(--color-card);
  border: 1px solid var(--color-border);
  border-radius: var(--s-1);
  box-shadow: 0 8px 24px rgba(0,0,0,0.12);
  list-style: none;
  margin: 0;
  padding: var(--s-icon);
  /* Above any agenda-side sticky element (date-separator z-index 10
     inside #calendar-view's container-type stacking context) so the
     dropdown overlay isn't clipped by sticky headers when it extends
     below the toolbar. */
  z-index: 1000;
  /* Animated open/close: kept in the DOM (display:block) but invisible +
     non-interactive when closed, so we can transition opacity + a small
     downward unfurl in/out of view. */
  display: block;
  opacity: 0;
  transform: translateY(-6px) scaleY(0.96);
  transform-origin: top center;
  pointer-events: none;
  visibility: hidden;
  transition: opacity 0.16s ease, transform 0.18s cubic-bezier(0.22, 0.61, 0.36, 1), visibility 0s linear 0.18s;
}
.custom-select.open .cs-menu {
  opacity: 1;
  transform: translateY(0) scaleY(1);
  pointer-events: auto;
  visibility: visible;
  transition: opacity 0.16s ease, transform 0.22s cubic-bezier(0.22, 0.61, 0.36, 1), visibility 0s linear 0s;
}
/* Subtle stagger so options appear to unfold one after the other when opening */
.custom-select.open .cs-option {
  animation: csOptionIn 0.22s ease-out both;
}
.custom-select.open .cs-option:nth-child(1) { animation-delay: 0.02s; }
.custom-select.open .cs-option:nth-child(2) { animation-delay: 0.05s; }
.custom-select.open .cs-option:nth-child(3) { animation-delay: 0.08s; }
.custom-select.open .cs-option:nth-child(4) { animation-delay: 0.11s; }
.custom-select.open .cs-option:nth-child(5) { animation-delay: 0.14s; }
.custom-select.open .cs-option:nth-child(n+6) { animation-delay: 0.17s; }
@keyframes csOptionIn {
  from { opacity: 0; transform: translateY(-4px); }
  to   { opacity: 1; transform: translateY(0); }
}
/* Caret rotates when open for clear "expanded" affordance */
.cs-caret { transition: transform 0.18s ease; }
.custom-select.open .cs-caret { transform: rotate(180deg); }

@media (prefers-reduced-motion: reduce) {
  .cs-menu, .custom-select.open .cs-menu, .custom-select.open .cs-option, .cs-caret {
    animation: none !important;
    transition: opacity 0.1s, visibility 0s !important;
    transform: none !important;
  }
}
.cs-option {
  padding: var(--s-1) var(--s-cta);
  border-radius: var(--s-icon);
  font-size: 14px;
  color: var(--color-text);
  cursor: pointer;
  white-space: nowrap;
}
.cs-option:hover { background: var(--color-bg); }
.cs-option.selected {
  background: var(--color-active-bg);
  color: var(--color-active-fg);
  font-weight: 700;
  box-shadow: inset 0 0 0 1px var(--color-active-border);
}
.cs-option.selected:hover {
  background: var(--color-active-bg-hover);
  color: var(--color-active-fg);
}

/* Shared "control button" sizing — identical height for toggle, sort dropdown,
   Today, and arrow buttons. Achieved via fixed line-height + matching padding. */
:root {
  --ctrl-pad-y: 6px;
  --ctrl-pad-x: 12px;
  --ctrl-font:  14px;       /* minimum text size — never go below 14px */
  --ctrl-line:  18px;
  --ctrl-h:     32px;       /* 6 + 18 + 6 + 2 (border) = 32px */
  /* Filter drawer width. Sized to fit the widest chip
     ("International / Expansion (58)" ≈ 280px) plus equal `--pad-x`
     gutters on both sides plus a scrollbar's worth of safety. The
     toolbar gets `padding-right: var(--drawer-w)` only when the
     drawer is OPEN, so the search-input + filter-toggle compress
     to fit. When closed, the toolbar fills the full toolbar width. */
  --drawer-w:   360px;
}

.seg-toggle, .view-toggle {
  display: inline-flex;
  height: var(--ctrl-h);
  border: 1px solid #d1d5db;
  border-radius: var(--s-1);
  overflow: hidden;
  flex-shrink: 0;
}
.seg-btn, .view-btn {
  padding: 0 var(--ctrl-pad-x);
  height: 100%;
  border: none;
  background: var(--color-card);
  font-size: var(--ctrl-font);
  line-height: var(--ctrl-line);
  /* Inactive text uses the page text color (readable on the white card bg in
     light mode, muted off-white in dark via the override below). The active
     button's white text comes from --color-active-fg via .seg-btn.active. */
  color: var(--color-text);
  cursor: pointer;
  white-space: nowrap;
  display: inline-flex;
  align-items: center;
}
/* Dark-mode inactive: muted off-white (overrides --color-text). Doesn't
   touch .active so the white active text still reads. */
html[data-theme="dark"] .seg-btn:not(.active),
html[data-theme="dark"] .view-btn:not(.active) { color: var(--color-text-muted); }
/* Color snaps instantly on .active; only bg transitions. Otherwise text
   passes through mid-gray on a darkening bg and becomes briefly unreadable. */
.seg-btn, .view-btn { transition: background-color 0.15s ease; }
/* Hover preview — uses mode-swapping tokens so the bg/text contrast is
   preserved in BOTH themes (light gray + black text in light, dark slate +
   white text in dark). High-specificity selector on the dark mode rule
   below beats the inactive-button color override. */
.seg-btn:hover, .view-btn:hover { background: var(--color-border-soft); color: var(--color-text); }
/* Light-mode pale-blue hover signals "this becomes blue when selected"
   without mimicking the saturated selected state. */
html:not([data-theme="dark"]) .seg-btn:not(.active):hover,
html:not([data-theme="dark"]) .view-btn:not(.active):hover { background: #eff6ff; color: var(--color-blue); }
html[data-theme="dark"] .seg-btn:not(.active):hover,
html[data-theme="dark"] .view-btn:not(.active):hover { color: var(--color-text); }
.seg-btn.active, .view-btn.active {
  background: var(--color-active-bg);
  color: var(--color-active-fg);
  font-weight: 700;
}
.seg-btn.active:hover, .view-btn.active:hover { background: var(--color-active-bg-hover); color: var(--color-active-fg); }
/* When a button inside a segmented group is active, color the dividers on
   either side of it with the active border tone so the outline reads as
   continuous around the active button + the surrounding container border. */
/* When any button in a segmented toggle is active, all interior dividers
   take the amber active-border color so the whole group reads as one
   cohesive yellow chunk in both light and dark mode. */
/* Light mode: non-selected segments keep their neutral dark outline so the
   group reads as black-outlined with one blue cell. Dark mode keeps the
   unified active-color outline for the cohesive slate look. */
html[data-theme="dark"] .seg-toggle:has(.seg-btn.active) .seg-btn + .seg-btn,
html[data-theme="dark"] .view-toggle:has(.view-btn.active) .view-btn + .view-btn {
  border-left-color: var(--color-active-border) !important;
}
html[data-theme="dark"] .seg-toggle:has(.seg-btn.active),
html[data-theme="dark"] .view-toggle:has(.view-btn.active) { border-color: var(--color-active-border) !important; }
.seg-btn + .seg-btn,
.view-btn + .view-btn { border-left: 1px solid #d1d5db; }
.seg-btn.active + .seg-btn,
.seg-btn + .seg-btn.active,
.view-btn.active + .view-btn,
.view-btn + .view-btn.active { border-left-color: var(--color-text); }
/* Light mode — drop the wrapper's frame and give every segment its own
   1px border. Non-active = gray, active = blue (matches fill, on all 4
   sides). Adjacent buttons overlap by 1px so the gray dividers read as
   one line; the active button's z-index lifts it so its blue border
   replaces the neighbor-side gray. Rounding stays only on the first and
   last segments so the overall pill shape is preserved. */
html:not([data-theme="dark"]) .seg-toggle,
html:not([data-theme="dark"]) .view-toggle { border: none; overflow: visible; }
html:not([data-theme="dark"]) .seg-btn,
html:not([data-theme="dark"]) .view-btn {
  border: 1px solid #d1d5db;
  position: relative;
}
html:not([data-theme="dark"]) .seg-btn:not(.active),
html:not([data-theme="dark"]) .view-btn:not(.active) { color: var(--color-text); }
html:not([data-theme="dark"]) .seg-btn + .seg-btn,
html:not([data-theme="dark"]) .view-btn + .view-btn { margin-left: -1px; }
html:not([data-theme="dark"]) .seg-btn:first-child,
html:not([data-theme="dark"]) .view-btn:first-child { border-top-left-radius: var(--s-1); border-bottom-left-radius: var(--s-1); }
html:not([data-theme="dark"]) .seg-btn:last-child,
html:not([data-theme="dark"]) .view-btn:last-child { border-top-right-radius: var(--s-1); border-bottom-right-radius: var(--s-1); }
html:not([data-theme="dark"]) .seg-btn.active,
html:not([data-theme="dark"]) .view-btn.active { border-color: var(--color-active-border); color: var(--color-active-fg); z-index: 1; }
/* Hover on the selected segment — darken bg + border to the deeper blue
   so it reads as an interactive surface, not just static. */
html:not([data-theme="dark"]) .seg-btn.active:hover,
html:not([data-theme="dark"]) .view-btn.active:hover { background: var(--color-active-bg-hover); border-color: var(--color-active-bg-hover); color: var(--color-active-fg); }

/* Row container that holds two segmented toggles side-by-side */
.segmented-row {
  display: inline-flex;
  gap: var(--s-3);          /* 24px between toggles on desktop */
  flex-wrap: wrap;
  align-items: center;
}
@media (max-width: 480px) {
  .segmented-row { gap: var(--s-1); }   /* 8px on mobile */
}

/* ── Views ── */
.view { display: none; }
.view.active { display: block; }

/* ── Count bar — sits inline next to the Sort dropdown ── */
.count-bar {
  font-size: 14px;
  font-weight: 400;
  color: var(--color-text-muted);
  white-space: nowrap;
  /* Tabular numerals + per-digit fixed width so the slot animation doesn't
     wobble the rest of the row when digits cycle through different glyphs. */
  font-variant-numeric: tabular-nums;
}
.count-digit {
  display: inline-block;
  min-width: 0.6em;
  text-align: center;
}

/* ── Event list ── */
#event-list {
  padding: var(--s-2) var(--pad-x);
  display: flex;
  flex-direction: column;
  gap: var(--s-1);
  width: 100%;
  box-sizing: border-box;
}

.date-separator {
  position: sticky;
  top: 56px;
  z-index: 10;
  background: var(--color-bg);
  font-size: 20px;
  font-weight: 700;
  letter-spacing: -0.01em;
  color: var(--color-text);
  /* 24px above / 8px below — strong asymmetry so the headline visually
     anchors to the next group of cards, not the previous one. Sticky-pinned
     state collapses padding-top to 0 (see .date-separator.is-stuck above). */
  padding: var(--s-3) var(--pad-x) var(--s-1);
  margin: 0 calc(-1 * var(--pad-x));
  /* Label left, "Back to top" link right — same baseline. */
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--s-2);
}
.date-separator-label { min-width: 0; }
/* Inline count next to the section label — "Fri May 1st, 2026 (12)".
   Switzer at 18px (one step up from default body) keeps it readable
   alongside the 20px Unbounded label without competing with it.
   Color: inherit from the parent .date-separator so each section's
   themed color (status hue for rsvp sort, accent for today's date)
   carries through. */
.date-separator-count {
  font-family: "Switzer", -apple-system, BlinkMacSystemFont, sans-serif;
  font-size: 18px;
  font-weight: 500;
  color: inherit;
  letter-spacing: 0;
  margin-left: var(--s-icon);
}
/* "Back to top" — uses the standard outline CTA treatment (.modal-cta +
   .modal-cta-outline) so it matches every other secondary action in the
   dashboard. Hidden by default; shown ONLY on the date-separator that's
   currently sticky-pinned at the top of the viewport. Without the
   .is-stuck gate, "Back to top" appeared simultaneously on the pinned
   separator AND any inline separator scrolled into view further down
   the page — visually duplicated. */
.date-separator-top {
  width: auto !important;
  padding: var(--s-cta) var(--s-3) !important;
  flex-shrink: 0;
  white-space: nowrap;
  display: none !important;
}
body.toolbar-out-of-view .date-separator.is-stuck .date-separator-top {
  display: inline-flex !important;
}
/* All sticky headers keep consistent padding so scrolling between days
   doesn't visually shift heights. */

/* Agenda — gap between event cards: 16px desktop, 8px mobile */
.cal-agenda {
  display: flex;
  flex-direction: column;
  gap: var(--s-2);
}
@media (max-width: 640px) {
  .cal-agenda { gap: var(--s-1); }
}
@media (max-width: 640px) {
  .date-separator { top: 48px; }
}
.date-separator.is-today { color: var(--color-accent-text); }

/* When grouped by RSVP status, the group header uses the status color.
   Light-mode values: dark text on light bg (4.5:1+ contrast). */
.date-separator.agenda-status-hosting    { color: #86198f; }
.date-separator.agenda-status-onlist     { color: #713f12; }
.date-separator.agenda-status-going      { color: #365314; }
.date-separator.agenda-status-interested { color: #155e75; }
.date-separator.agenda-status-invited    { color: #9d174d; }
.date-separator.agenda-status-waitlist   { color: #3730a3; }
.date-separator.agenda-status-maybe      { color: #5b21b6; }
.date-separator.agenda-status-pending    { color: #9a3412; }
.date-separator.agenda-status-declined   { color: #991b1b; }
.date-separator.agenda-status-inviteonly { color: #6b21a8; }
.date-separator.agenda-status-cancelled  { color: #991b1b; }
.date-separator.agenda-status-none       { color: #374151; }   /* matches the chip's active text color */

/* Dark-mode overrides — flip to the LIGHTER status colors (the same ones
   used on the badges in dark mode) so headers stay AA on the dark bg. */
html[data-theme="dark"] .date-separator.agenda-status-hosting    { color: #f0abfc; }
html[data-theme="dark"] .date-separator.agenda-status-onlist     { color: #fde047; }
html[data-theme="dark"] .date-separator.agenda-status-going      { color: #bef264; }
html[data-theme="dark"] .date-separator.agenda-status-interested { color: #67e8f9; }
html[data-theme="dark"] .date-separator.agenda-status-invited    { color: #f9a8d4; }
html[data-theme="dark"] .date-separator.agenda-status-waitlist   { color: #a5b4fc; }
html[data-theme="dark"] .date-separator.agenda-status-maybe      { color: #c4b5fd; }
html[data-theme="dark"] .date-separator.agenda-status-pending    { color: #fdba74; }
html[data-theme="dark"] .date-separator.agenda-status-declined   { color: #fca5a5; }
html[data-theme="dark"] .date-separator.agenda-status-inviteonly { color: #cbb8e8; }
html[data-theme="dark"] .date-separator.agenda-status-cancelled  { color: #fca5a5; }
html[data-theme="dark"] .date-separator.agenda-status-none       { color: #d1d5db; }

/* Letter group headers (when sorting by name) — neutral display */
.date-separator.agenda-letter { color: var(--color-text); }

button.event-card {
  all: unset;
  background: var(--color-card);
  /* No real `border` — that would push the swatch ::before inward via
     box-sizing, leaving a 1px card-color ring between swatch and edge.
     Instead simulate the outline with an inset box-shadow drawn ON TOP of
     the swatch, so the swatch fills edge-to-edge while the 1px ring still
     reads as a defined outline. Combined with the outer diffuse shadow. */
  border: none;
  box-shadow:
    inset 0 0 0 1px var(--color-border),
    0 1px 24px rgba(15, 23, 42, 0.05);
  border-radius: var(--s-1);
  /* Padding: 16px top/bottom, 16px left, 32px right.
     At maximum content width the visible left padding is ~24px (16px card
     padding + 8px centering slack). 32px right gives the badge breathing
     room so it's not flush against the edge — closer to the left feel. */
  padding: var(--s-2) var(--s-4) var(--s-2) var(--s-2);
  margin: 0;
  display: grid;
  grid-template-columns: 96px 1fr auto;
  column-gap: var(--s-3);
  align-items: center;
  cursor: pointer;
  width: 100%;
  max-width: 100%;
  box-sizing: border-box;
  text-align: left;
  transition: box-shadow 0.12s, border-color 0.12s;
}
button.event-card {
  /* Hover transitions: longer, gentler ease (Material's standard
     curve) so the scale feels fluid. 0.3s gives the eye time to read
     the motion as physical rather than mechanical. Matches the notif
     card timing exactly. */
  transition:
    transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
    box-shadow 0.3s cubic-bezier(0.4, 0, 0.2, 1),
    border-color 0.12s;
}
button.event-card:hover {
  /* Same hover treatment as notif cards: scale + lift + soft shadow. */
  transform: translateY(-2px) scale(1.02);
  box-shadow:
    inset 0 0 0 1px var(--color-border),
    0 4px 12px rgba(15, 23, 42, 0.10);
}

/* ── Card swatch backdrop ──────────────────────────────────────
   Layered stippled pattern — three repeating dot fields at different
   scales (fine, small, medium), plus two soft blobs to fill mid-tones.
   Looks like a ballpoint-pen stipple study with light spray on top.
   Right-anchored, fades to nothing well before the title text. Sits
   under the RSVP chip; the chip's own bg keeps it readable.

   Five colors come from --c1..--c5 set per-card in makeCard(); when
   an event has no real swatch yet, a hash-picked placebo palette is
   used so the visual test still shows variety. */
button.event-card {
  position: relative;
  overflow: hidden;
  /* Skip paint + layout work for cards that aren't on screen. Combined with
     contain-intrinsic-size to keep the scrollbar stable. Massive paint-cost
     win at 1200 cards: only the visible ~20 actually run the radial-gradient
     + blur work. */
  content-visibility: auto;
  contain-intrinsic-size: auto 96px;
}
button.event-card::before {
  content: "";
  position: absolute;
  inset: -15% 0 -15% 25%;
  z-index: 0;
  pointer-events: none;
  /* Organic airbrush, simplified for paint perf — 9 layers (was 18). Each
     color still appears 1-2x at varied sizes; combined with the 8px blur
     they bleed into each other so the loss of layers reads as "softer"
     rather than "fewer blobs". --ofx / --ofy nudge per-card so cards don't
     visually repeat. */
  background:
    /* Splashes — small focused pockets of color */
    radial-gradient(circle 40px at calc(90% + var(--ofx, 0px)) calc(20% + var(--ofy, 0px)), var(--c1, transparent) 0%, transparent 85%),
    radial-gradient(circle 36px at calc(78% - var(--ofx, 0px)) calc(45% + var(--ofy, 0px)), var(--c2, transparent) 0%, transparent 85%),
    radial-gradient(circle 34px at calc(95% - var(--ofy, 0px)) calc(70% + var(--ofx, 0px)), var(--c5, transparent) 0%, transparent 85%),
    /* Medium clouds — fill gaps between splashes */
    radial-gradient(ellipse 40% 50% at calc(85% + var(--ofx, 0px)) calc(30% + var(--ofy, 0px)), var(--c3, transparent) 0%, transparent 75%),
    radial-gradient(ellipse 42% 55% at calc(72% - var(--ofx, 0px)) calc(80% + var(--ofy, 0px)), var(--c4, transparent) 0%, transparent 75%),
    radial-gradient(ellipse 45% 55% at calc(95% - var(--ofy, 0px)) calc(15% - var(--ofx, 0px)), var(--c2, transparent) 0%, transparent 75%),
    radial-gradient(ellipse 38% 48% at calc(80% + var(--ofy, 0px)) calc(60% - var(--ofx, 0px)), var(--c1, transparent) 0%, transparent 75%),
    /* Atmospheric — wide background tone on the right side */
    radial-gradient(ellipse 75% 85% at calc(98% + var(--ofx, 0px)) calc(30% + var(--ofy, 0px)), var(--c5, transparent) 0%, transparent 85%),
    radial-gradient(ellipse 70% 80% at calc(82% - var(--ofx, 0px)) calc(80% - var(--ofy, 0px)), var(--c4, transparent) 0%, transparent 85%);
  filter: blur(8px);
  mask: linear-gradient(to right, transparent 0%, transparent 30%, rgba(0,0,0,0.15) 55%, rgba(0,0,0,0.45) 80%, rgba(0,0,0,1) 100%);
  -webkit-mask: linear-gradient(to right, transparent 0%, transparent 30%, rgba(0,0,0,0.15) 55%, rgba(0,0,0,0.45) 80%, rgba(0,0,0,1) 100%);
  opacity: var(--swatch-opacity, 0.32);
  transition: opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Swatch opacity stays put on hover — having a second animation run
   alongside the card's transform scale was the source of the "chunky"
   feel. The card now does exactly what notif cards do: scale + lift,
   nothing else competing. */
/* button.event-card:hover::before { opacity: var(--swatch-opacity-hover, 0.45); } */
button.event-card > * { position: relative; z-index: 1; }
/* Cancelled events read as struck-through and dimmed; a 🚩 prefix on the
   title in app.js completes the signal. */
button.event-card.is-cancelled .event-name-text { text-decoration: line-through; }
button.event-card.is-cancelled { opacity: 0.65; }
button.event-card.is-cancelled:hover { opacity: 0.85; }
html[data-theme="dark"] button.event-card {
  --swatch-opacity: 0.25;
  --swatch-opacity-hover: 0.40;
  /* Dark mode: visible 1px ring via inset shadow, plus a soft outer shadow
     for depth. Both modes carry the same outline language now. */
  box-shadow:
    inset 0 0 0 1px var(--color-border),
    0 1px 24px rgba(0, 0, 0, 0.35);
}
html[data-theme="dark"] button.event-card:hover {
  box-shadow:
    inset 0 0 0 1px var(--color-border),
    0 4px 12px rgba(0, 0, 0, 0.45);
}
html[data-theme="dark"] .notif-card {
  box-shadow:
    inset 0 0 0 1px var(--color-border),
    0 1px 24px rgba(0, 0, 0, 0.35);
}
html[data-theme="dark"] .notif-card-link:hover {
  box-shadow:
    inset 0 0 0 1px var(--color-border),
    0 4px 12px rgba(0, 0, 0, 0.45);
}
@media (prefers-reduced-motion: reduce) {
  button.event-card::before { transition: none; }
}

/* Wrapper around each agenda card — hosts the entry animation while the
   inner button hosts the hover. Using display:block so flex/grid parents
   still see one item per card. */
.event-card-wrap { display: block; }

/* Cards always end visible — the default rule wins if neither pending nor
   revealed is set, so a failed JS observer can never leave cards stuck blank. */
.event-card-wrap,
button.event-card,
.cal-pill,
.cal-block {
  opacity: 1;
}
/* Cards AND date headers start in the "pending" state (invisible, slightly
   down), then JS removes that class and adds .card-revealed when each item
   crosses the viewport — so headers and the cards under them reveal in DOM
   order (header → its cards → next header → its cards → …) instead of
   headers all appearing at once. */
/* Fade-in is applied to the wrapper (.event-card-wrap) — NOT the
   button — so the button's transform on hover has its own clean
   coordinate space, with no entry-animation residue interfering. This
   is exactly the same two-element split the notif card uses. */
.event-card-wrap.card-pending,
.date-separator.card-pending {
  opacity: 0;
  transform: translateY(28px);
}
.event-card-wrap.card-revealed,
.date-separator.card-revealed {
  animation: fadeInCard 0.7s cubic-bezier(0.22, 0.61, 0.36, 1) both;
  animation-delay: calc(var(--i, 0) * 70ms);
}
/* Re-render skip — applied when JS knows this is a quiet field refresh
   (background poll, sort, filter), not a fresh load. Cancels the entrance
   animation so the user doesn't see a 0.7s fade every 30s when the data
   hasn't structurally changed. Applies wherever cards use the
   .card-pending → .card-revealed entrance pattern. */
.event-card-wrap.card-instant,
.date-separator.card-instant,
.notif-item.card-instant {
  opacity: 1;
  transform: none;
  animation: none !important;
}
@keyframes fadeInCard {
  /* `from` matches the .card-pending state (opacity 0, 28px below);
     `to` only sets opacity, intentionally omitting `transform` so the
     ending state doesn't lock a transform onto the element — leaves
     hover-state transforms free to apply on the inner element. */
  from { opacity: 0; transform: translateY(28px); }
  to   { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
  .event-card-wrap.card-pending,  .date-separator.card-pending  { opacity: 1; transform: none; }
  .event-card-wrap.card-revealed, .date-separator.card-revealed { animation: none; }
}

/* Date column on the agenda card: items centered with each other (day name
   above number above time stack). The whole column hugs its widest item, so
   the column's left edge sits at the card's 16px padding. */
.event-date {
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
  gap: var(--s-3);
  align-self: stretch;
  justify-content: center;
}
.event-date .day-row {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--s-icon);
}
.event-date .month {
  font-size: 12px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--color-text);
}

/* Today's events: every text element in the date column turns blue to match
   the today highlight elsewhere on the page. */
.event-date.is-today .day-name,
.event-date.is-today .day-name,
.event-date.is-today .day-num,
.event-date.is-today .month,
.event-date.is-today .time { color: var(--color-accent-text); }
.event-date .day-name {
  font-size: 12px;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--color-text);
}
.event-date .day-num {
  font-size: 28px;
  font-weight: 700;
  color: var(--color-text);
  line-height: 1;
}
.event-date .time {
  font-size: 14px;
  color: var(--color-text-muted);
  white-space: nowrap;
  display: flex;
  flex-direction: column;
  gap: var(--s-icon);   /* 4px — tight text-to-text pair (start/end times stacked) */
  align-items: center;
  line-height: 1.3;
}
.event-date .no-date {
  font-size: 14px;
  color: var(--color-text-soft);
  font-style: italic;
}
/* Notification cards put a small bell glyph next to the time so it reads
   visually as "this is a notification timestamp." Same outline as the header
   bell, sized to text height (14px) with the icon-text exception 4px gap. */
.notif-card .event-date .time {
  flex-direction: row;
  align-items: center;
  gap: var(--s-icon);                 /* 4px — icon ↔ text exception */
}
.notif-card .event-date .time .notif-time-bell {
  flex-shrink: 0;
  color: currentColor;                /* picks up .time's muted color */
}

/* Event body — name (up to 2 lines) + location, centered as a pair vertically */
.event-body {
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: var(--s-icon);
  justify-content: center;
}
.event-name {
  font-size: 16px;
  font-weight: 600;
  color: var(--color-text);
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
  line-height: 1.3;
  /* Default: greedy wrap. Long titles fill line 1 fully, fill line 2 fully,
     truncate at end of line 2. JS adds .fits-balanced when the title fits in
     ≤2 lines and we can balance it for nicer distribution. */
  text-wrap: normal;
  word-break: break-word;
  overflow-wrap: anywhere;
}
.event-name.fits-balanced {
  text-wrap: balance;
}
.event-name-text { display: inline; }
.event-location {
  font-size: 14px;
  color: var(--color-text-muted);
  /* Allow up to 2 lines before truncating — full street addresses
     ("Pace University Seidenberg School Of Computer Science And
     Information Systems, 15 Beekman St") routinely overflow a single
     line. -webkit-line-clamp gives an ellipsis at the end of line 2. */
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
  word-break: break-word;
}
/* Address and neighborhood share the muted gray — neighborhood with a warning
   so the user knows it's incomplete and they should click in for the address */
.event-location.address      { color: var(--color-text-muted); }
.event-location.neighborhood { color: var(--color-text-muted); }
.event-location.unknown      { color: var(--color-text-soft); font-style: italic; }

.event-location .loc-warn {
  display: inline-flex;
  align-items: center;
  margin-right: var(--s-icon);   /* sits BEFORE the neighborhood text */
  color: inherit;                /* same gray as the location text */
  cursor: help;
  vertical-align: -2px;
}
.event-location .loc-warn svg { display: block; }

/* ── Custom tooltip ────────────────────────────────────────
   The tooltip is a single global element appended to <body> by JS. Using
   position: fixed guarantees it can never be clipped by any ancestor's
   overflow or stacking context. */
/* Theme-aware tooltip surface — explicit so it always feels intentional in
   both modes rather than inverting awkwardly via opaque text/bg tokens. */
:root {
  --tip-bg: #111827;        /* near-black popover in light mode */
  --tip-fg: #f9fafb;
}
html[data-theme="dark"] {
  --tip-bg: #2a3552;        /* lifted surface in dark mode (lighter than --color-bg) */
  --tip-fg: #f3f4f6;
}
.tw-tooltip {
  position: fixed;
  background: var(--tip-bg);
  color: var(--tip-fg);
  font-family: "Switzer", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
  font-size: 13px;
  font-weight: 500;
  line-height: 1.4;
  letter-spacing: 0;
  padding: var(--s-1) var(--s-cta);
  border-radius: var(--s-1);
  max-width: 280px;
  pointer-events: none;
  opacity: 0;
  transform: translateY(4px);
  transition: opacity 0.12s, transform 0.12s;
  z-index: 2147483647;
  box-shadow: 0 6px 20px rgba(0,0,0,0.32);
}
.tw-tooltip.show {
  opacity: 1;
  transform: translateY(0);
}
.tw-tooltip::after {
  /* Caret pointing at the trigger. Horizontal position is set by JS via
     --arrow-left so the arrow tracks the trigger's center even when the
     tooltip itself has been clamped to a viewport edge. -6px margin
     centers the 12px-wide arrow on that x-position. */
  content: "";
  position: absolute;
  top: 100%;
  left: var(--arrow-left, 16px);
  margin-left: -6px;
  border: 6px solid transparent;
  border-top-color: var(--tip-bg);
}
.tw-tooltip.flip-below::after {
  top: auto;
  bottom: 100%;
  border-top-color: transparent;
  border-bottom-color: var(--tip-bg);
}

.event-status {
  align-self: center;   /* center status badge vertically in card */
}

/* Mismatch flag is hidden on cards (most events have minor name/time differences,
   so showing the chip everywhere becomes visual noise). The flag still surfaces
   inside the detail modal where the differences are explained side-by-side. */
.mismatch-flag { display: none !important; }

/* ── Status badges (1:1 with Partiful) ── */
.badge {
  display: inline-flex;
  align-items: center;
  gap: var(--s-icon);
  padding: var(--s-icon) var(--s-cta);
  border: 1px solid transparent;   /* per-status border-color set by .badge-* below — matches the filter chip's outline */
  border-radius: 999px;
  font-size: 14px;
  font-weight: 600;
  white-space: nowrap;
}
/* Each badge mirrors its filter-chip active palette one-to-one (background,
   border-color, text). Update both rule blocks together when retoning. */
.badge-hosting    { background: #f5d0fe; border-color: #f0abfc; color: #86198f; }
.badge-onlist     { background: #fef08a; border-color: #fde047; color: #713f12; }
.badge-going      { background: #d9f99d; border-color: #a3e635; color: #365314; }
.badge-interested { background: #a5f3fc; border-color: #67e8f9; color: #155e75; }
.badge-invited    { background: #fbcfe8; border-color: #f9a8d4; color: #9d174d; }
.badge-waitlist   { background: #c7d2fe; border-color: #a5b4fc; color: #3730a3; }
.badge-maybe      { background: #ddd6fe; border-color: #c4b5fd; color: #5b21b6; }
.badge-pending    { background: #fed7aa; border-color: #fdba74; color: #9a3412; }
.badge-declined   { background: #fecaca; border-color: #fca5a5; color: #991b1b; }
.badge-inviteonly { background: #ede4f7; border-color: #cbb8e8; color: #6b21a8; }
.badge-cancelled  { background: #fee2e2; border-color: #fca5a5; color: #991b1b; }
.badge-none       { background: #e5e7eb; border-color: #d1d5db; color: #374151; }

.fetch-error { font-size: 14px; color: #f59e0b; margin-top: var(--s-icon); }

/* ── Calendar view ── */
#calendar-view {
  /* padding-top mirrors toolbar's padding-bottom (32px) so the divider line
     has equal space above and below it. Padding-bottom is 0 so the bottom-
     cta-row's symmetric vertical padding fully owns the gap before the
     footer. Height comes from `flex: 1 1 auto` on body's flex column. */
  padding: var(--s-4) var(--pad-x) 0;
  display: flex;
  flex-direction: column;
}

.cal-toolbar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: var(--s-2);
  margin-bottom: var(--s-4);
}
/* Agenda view: hide Today + arrows + range label */
.cal-toolbar.hide-nav .cal-nav { display: none; }
/* Pre-data state: hide the entire calendar toolbar (range chips + nav).
   Use !important to win over any other display rules cleanly. */
.cal-toolbar.no-content { display: none !important; }

.cal-range-toggle {
  display: flex;
  border: 1px solid #d1d5db;
  border-radius: var(--s-1);
  overflow: hidden;
}
.range-btn {
  padding: var(--s-icon) var(--s-cta);
  border: none;
  background: var(--color-card);
  font-size: 14px;
  /* Inactive uses page text color (readable on white card). Active state
     comes from .range-btn.active via --color-active-fg. */
  color: var(--color-text);
  cursor: pointer;
  border-right: 1px solid #d1d5db;
}
.range-btn:last-child { border-right: none; }
html[data-theme="dark"] .range-btn:not(.active) { color: var(--color-text-muted); }
/* Inter-button dividers default to the container's outline color in both
   themes (light grey or dark slate). When any button is active, every
   divider switches to the active-state amber so the whole toggle reads as
   one cohesive yellow group instead of "yellow patch in a grey frame". */
html[data-theme="dark"] .range-btn { border-right-color: var(--ctrl-outline); }
/* Dark mode only — non-selected dividers blend with the unified slate
   active group. Light mode keeps neutral dividers between non-selected
   buttons so only the active cell carries the blue outline. */
html[data-theme="dark"] .cal-range-toggle:has(.range-btn.active) .range-btn {
  border-right-color: var(--color-active-border) !important;
}
/* Color snaps instantly on .active; only bg transitions (avoids the
   mid-gray-on-mid-gray "invisible text" flicker mid-transition). */
.range-btn { transition: background-color 0.15s ease; }
.range-btn:hover { background: var(--color-border-soft); color: var(--color-text); }
html:not([data-theme="dark"]) .range-btn:not(.active):hover { background: #eff6ff; color: var(--color-blue); }
html[data-theme="dark"] .range-btn:not(.active):hover { color: var(--color-text); }
.range-btn.active {
  background: var(--color-active-bg);
  color: var(--color-active-fg);
  font-weight: 700;
}
.range-btn.active:hover { background: var(--color-active-bg-hover); color: var(--color-active-fg); }
/* Color the divider lines bracketing the active range button so the outline
   reads as continuous around it. .range-btn uses border-right between
   segments — recolor the active button's right divider AND the previous
   button's right divider. */
.range-btn.active { border-right-color: var(--color-active-border); }
.range-btn:has(+ .range-btn.active) { border-right-color: var(--color-active-border); }
html[data-theme="dark"] .cal-range-toggle:has(.range-btn.active) { border-color: var(--color-active-border); }
/* Light mode — per-segment 1px borders (see seg/view-toggle note above). */
html:not([data-theme="dark"]) .cal-range-toggle { border: none; overflow: visible; }
html:not([data-theme="dark"]) .range-btn {
  border: 1px solid #d1d5db;
  position: relative;
}
html:not([data-theme="dark"]) .range-btn:not(.active) { color: var(--color-text); }
html:not([data-theme="dark"]) .range-btn + .range-btn { margin-left: -1px; }
html:not([data-theme="dark"]) .range-btn:first-child { border-top-left-radius: var(--s-1); border-bottom-left-radius: var(--s-1); }
html:not([data-theme="dark"]) .range-btn:last-child { border-top-right-radius: var(--s-1); border-bottom-right-radius: var(--s-1); }
html:not([data-theme="dark"]) .range-btn.active { border-color: var(--color-active-border); color: var(--color-active-fg); z-index: 1; }
html:not([data-theme="dark"]) .range-btn.active:hover { background: var(--color-active-bg-hover); border-color: var(--color-active-bg-hover); color: var(--color-active-fg); }

.cal-nav {
  display: flex;
  align-items: center;
  gap: var(--s-2);
  flex-wrap: nowrap;
}
.cal-nav-arrows {
  display: flex;
  align-items: center;
  gap: var(--s-1);   /* 8px between arrow ↔ date ↔ arrow — feels grouped */
  flex-wrap: nowrap;
}
.cal-nav-arrows button {
  /* Same height as Today + segmented toggle. Square-ish icon button. */
  flex-shrink: 0;
  height: var(--ctrl-h);
  width: var(--ctrl-h);
  padding: 0;
  border: 1px solid #d1d5db;
  border-radius: var(--s-1);
  background: var(--color-card);
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  color: var(--color-text);
}
.cal-nav-arrows button svg { display: block; }
.cal-nav-arrows button:hover { border-color: var(--color-blue); color: var(--color-blue); }
.cal-today-btn {
  height: var(--ctrl-h);
  padding: 0 var(--ctrl-pad-x);
  border: 1px solid #d1d5db;
  border-radius: var(--s-1);
  background: var(--color-card);
  font-size: var(--ctrl-font);
  line-height: var(--ctrl-line);
  font-weight: 600;
  color: var(--color-text);
  cursor: pointer;
  white-space: nowrap;
  flex-shrink: 0;
  display: inline-flex;
  align-items: center;
}
.cal-today-btn:hover { border-color: var(--color-blue); color: var(--color-blue); }
#week-label {
  font-family: "Switzer", -apple-system, BlinkMacSystemFont, sans-serif;
  font-weight: 500;
  font-size: 14px;
  white-space: nowrap;
  flex-shrink: 0;
  color: var(--color-text);
}
/* Fixed width sized to the longest possible date string ("Wed, Sep 10")
   so Today and the arrow buttons never shift as the label text changes. */
#week-label {
  min-width: 96px;
  text-align: center;
}

/* When the screen is too narrow to fit range buttons + nav side-by-side,
   stack: range buttons first, Today + arrows + date below. Both left-aligned. */
@media (max-width: 720px) {
  .cal-toolbar {
    flex-direction: column;
    align-items: flex-start;
    gap: var(--s-2);
  }
  .cal-nav { justify-content: flex-start; }
}

/* Multi-day calendar grid (Day / 2-day / 3-day / Week) */
.cal-grid {
  display: grid;
  gap: var(--s-1);
  /* minmax(0, 1fr) lets columns shrink below their children's intrinsic
     min-content width — without this, a long unbreakable string (URL,
     hashtag, etc.) inside an event name forces the column wider than the
     viewport and the whole grid overflows. */
  grid-template-columns: minmax(0, 1fr);
}
.cal-grid.range-1 { grid-template-columns: minmax(0, 1fr); }
.cal-grid.range-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.cal-grid.range-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.cal-grid.range-7 { grid-template-columns: repeat(7, minmax(0, 1fr)); }
/* Match the agenda's visual breathing room above its first date header.
   Agenda gap = toolbar margin-bottom (32px) + date-separator padding-top
   (24px). The day/2-day/3-day/week views skip that 24px because their
   first child (.cal-day) has no top padding — adding it here brings the
   gap to parity across every range so switching feels consistent. */
.cal-grid.range-1,
.cal-grid.range-2,
.cal-grid.range-3,
.cal-grid.range-7 { padding-top: var(--s-3); }
/* Agenda sorted by name has no date-separator headers — the date-separator's
   own 24px padding-top normally provides the toolbar→first-element gap.
   Without headers, the first card sits flush against the toolbar's margin.
   Re-add the matching 24px here so name sort feels the same as date/RSVP. */
.cal-agenda.cal-agenda-no-headers { padding-top: var(--s-3); }

.cal-day {
  background: var(--color-card);
  border: 1px solid var(--color-border);
  border-radius: var(--s-1);
  overflow: hidden;
  display: flex;
  flex-direction: column;
}
.cal-day-header {
  padding: var(--s-1) var(--s-cta);
  border-bottom: 1px solid var(--color-border-soft);
  background: var(--color-bg);
  display: flex;
  flex-direction: column;
  justify-content: space-between;   /* weekday top, date bottom */
  min-height: 56px;                 /* same height across all columns */
  gap: var(--s-icon);
}
/* `align-self: flex-start` shrinks each label to its text width so the
   today-pulse scale animation grows symmetrically from the text's own
   center — without that, the element fills the column width and scaling
   from the wide box's center makes left-aligned text appear to drift. */
.cal-day-name { font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--color-text); align-self: flex-start; }
.cal-day-num  { font-size: 16px; font-weight: 700; color: var(--color-text); line-height: 1.1; align-self: flex-start; }

html[data-theme="light"] .cal-day.today .cal-day-header { background: #eff6ff; }
html[data-theme="dark"]  .cal-day.today .cal-day-header { background: #142036; }
.cal-day.today .cal-day-num,
.cal-day.today .cal-day-name { color: var(--color-accent-text); }

/* Day-summary view (used in 7-day, 3-day, 2-day) */
.cal-day-summary {
  padding: var(--s-1);
  display: flex;
  flex-direction: column;
  gap: var(--s-icon);
  min-height: 80px;
}
.cal-pill {
  all: unset;
  padding: var(--s-icon) var(--s-1);
  border-radius: var(--s-icon);
  font-size: 14px;
  font-weight: 500;
  cursor: pointer;
  overflow: hidden;
  display: block;
  line-height: 1.3;
}
/* Name first, wraps up to 2 lines + ellipsis; time below, single-line ellipsis. */
.cal-pill .cal-name {
  font-weight: 600;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
  word-break: break-word;
  overflow-wrap: anywhere;
}
.cal-pill .cal-time {
  font-size: 13px;
  font-weight: 400;
  opacity: 0.8;
  display: block;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.cal-pill-hosting    { background: #f5d0fe; color: #86198f; }
.cal-pill-onlist     { background: #fef08a; color: #713f12; }
.cal-pill-going      { background: #d9f99d; color: #365314; }
.cal-pill-interested { background: #a5f3fc; color: #155e75; }
.cal-pill-invited    { background: #fbcfe8; color: #9d174d; }
.cal-pill-waitlist   { background: #c7d2fe; color: #3730a3; }
.cal-pill-maybe      { background: #ddd6fe; color: #5b21b6; }
.cal-pill-pending    { background: #fed7aa; color: #9a3412; }
.cal-pill-declined   { background: #fecaca; color: #991b1b; }
.cal-pill-inviteonly { background: #ede4f7; color: #6b21a8; }
.cal-pill-cancelled  { background: #fee2e2; color: #991b1b; }
.cal-pill-none       { background: #e5e7eb; color: #374151; }
.cal-pill.conflict { box-shadow: inset 0 0 0 2px #f97316; }

/* Day view: time grid with side-by-side conflict columns */
.cal-day-grid {
  position: relative;
  display: grid;
  grid-template-columns: 88px 1fr;
  min-height: 1600px;                  /* 24 hours × 64px + padding */
  padding-top: var(--s-3);             /* space between day header and first time row */
  padding-bottom: var(--s-2);          /* space below last hour */
  /* Hint to the browser that this is a touch-pannable area but DOES handle its
     own pinch — pairs with our touchmove preventDefault. */
  touch-action: pan-x pan-y;
}
.cal-time-label.next-day { color: var(--color-text-soft); font-style: italic; }

/* Day divider — sits at the midnight boundary, marks the transition into the
   next calendar day so late-night events read clearly. The negative `left`
   makes the line span back over the 88px time column, so it reaches all the
   way across the day grid even though it's anchored inside `cal-events-col`. */
.cal-day-divider {
  position: absolute;
  left: -88px;
  right: 0;
  height: 0;
  border-top: 1px dashed var(--color-text-soft);
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--color-text-soft);
  pointer-events: none;
  z-index: 2;
}
.cal-day-divider-label {
  position: absolute;
  left: var(--s-2);
  top: -8px;
  background: var(--color-card);
  padding: 0 var(--s-1);
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--color-text-soft);
}

/* Floating zoom controls — segmented control in the day-grid's top-right
   corner. Same visual language as .seg-toggle: single rounded outer border,
   1px dividers between the three buttons, 32px tall to match other CTAs. */
.cal-zoom-controls {
  position: absolute;
  top: var(--s-1);
  right: var(--s-1);
  z-index: 5;
  display: inline-flex;
  align-items: stretch;
  height: var(--ctrl-h);
  background: var(--color-card);
  border: 1px solid var(--color-border);
  border-radius: var(--s-1);
  overflow: hidden;
  box-shadow: 0 2px 8px rgba(0,0,0,0.08);
}
html[data-theme="dark"] .cal-zoom-controls {
  border-color: var(--ctrl-outline, var(--color-border));
  box-shadow: 0 2px 8px rgba(0,0,0,0.4);
}
.cal-zoom-btn {
  min-width: 36px;
  padding: 0 var(--s-1);
  background: transparent;
  border: none;
  color: var(--color-text);
  font-family: inherit;
  font-size: 13px;
  font-weight: 500;
  line-height: 1;
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  transition: background 0.12s, color 0.12s;
}
.cal-zoom-btn svg { display: block; }
.cal-zoom-btn + .cal-zoom-btn { border-left: 1px solid var(--color-border); }
html[data-theme="dark"] .cal-zoom-btn + .cal-zoom-btn { border-left-color: var(--ctrl-outline); }
.cal-zoom-btn:hover { background: var(--color-bg); color: var(--color-blue); }
.cal-zoom-btn:active { background: var(--color-border-soft); }
.cal-zoom-level {
  min-width: 52px;
  font-variant-numeric: tabular-nums;
  font-size: 13px;
  font-weight: 500;
  color: var(--color-text-muted);
}
.cal-time-col {
  border-right: 1px solid var(--color-border-soft);
  position: relative;
}
.cal-time-label {
  position: absolute;
  right: var(--s-1);
  font-size: 12px;
  color: var(--color-text-soft);
  transform: translateY(-50%);
  white-space: nowrap;
}
.cal-events-col {
  position: relative;
  /* The hour-line gradient must track the current zoom level. JS sets
     --cal-px-per-hour on the day grid; this rule reads it so hour lines
     always sit on the hour, regardless of zoom. */
  --_h: var(--cal-px-per-hour, 64px);
  background:
    repeating-linear-gradient(
      to bottom,
      transparent 0,
      transparent calc(var(--_h) - 1px),
      var(--color-border-soft) calc(var(--_h) - 1px),
      var(--color-border-soft) var(--_h)
    );
}
.cal-block {
  all: unset;
  position: absolute;
  left: 4px;
  right: 4px;
  padding: var(--s-icon) var(--s-1);
  border-radius: var(--s-icon);
  font-size: 14px;
  cursor: pointer;
  overflow: hidden;
  display: flex;
  flex-direction: column;
  gap: var(--s-icon);   /* 4px — icon↔text exception */
  border: 1px solid rgba(0,0,0,0.05);
  box-sizing: border-box;
}
/* Title takes priority — holds its space; time gives up space first. With the
   parent's overflow:hidden, title sits at the top and remains visible while
   time gets clipped/hidden when the block is too short to fit both. */
.cal-block .block-title {
  font-weight: 600;
  line-height: 1.2;
  overflow: hidden;
  display: -webkit-box;
  -webkit-line-clamp: 4;
  -webkit-box-orient: vertical;
  word-break: break-word;
  overflow-wrap: anywhere;
  flex: 0 0 auto;       /* don't shrink — title keeps its space */
}
.cal-block .block-time  {
  font-size: 14px;
  opacity: 0.85;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  flex: 0 1 auto;       /* shrinks first when the block is short */
  min-height: 0;
}
.cal-block-hosting    { background: #f5d0fe; color: #86198f; }
.cal-block-onlist     { background: #fef08a; color: #713f12; }
.cal-block-going      { background: #d9f99d; color: #365314; }
.cal-block-interested { background: #a5f3fc; color: #155e75; }
.cal-block-invited    { background: #fbcfe8; color: #9d174d; }
.cal-block-waitlist   { background: #c7d2fe; color: #3730a3; }
.cal-block-maybe      { background: #ddd6fe; color: #5b21b6; }
.cal-block-pending    { background: #fed7aa; color: #9a3412; }
.cal-block-declined   { background: #fecaca; color: #991b1b; }
.cal-block-inviteonly { background: #ede4f7; color: #6b21a8; }
.cal-block-cancelled  { background: #fee2e2; color: #991b1b; }
.cal-block-none       { background: #e5e7eb; color: #374151; }
.cal-block.conflict { box-shadow: inset 0 0 0 2px #f97316; }

/* Dark mode — desaturated tints (less pastel, lower contrast than light mode);
   tuned to ~5.5–6.5:1 AA contrast against their tinted dark backgrounds. */
html[data-theme="dark"] .cal-block,
html[data-theme="dark"] .cal-pill { border-color: rgba(255,255,255,0.06); }

html[data-theme="dark"] .cal-block-hosting,
html[data-theme="dark"] .cal-pill-hosting     { background: #4a044e; color: #f0abfc; }
html[data-theme="dark"] .cal-block-onlist,
html[data-theme="dark"] .cal-pill-onlist      { background: #422006; color: #fde047; }
html[data-theme="dark"] .cal-block-going,
html[data-theme="dark"] .cal-pill-going       { background: #1a2e05; color: #bef264; }
html[data-theme="dark"] .cal-block-interested,
html[data-theme="dark"] .cal-pill-interested  { background: #083344; color: #67e8f9; }
html[data-theme="dark"] .cal-block-invited,
html[data-theme="dark"] .cal-pill-invited     { background: #500724; color: #f9a8d4; }
html[data-theme="dark"] .cal-block-waitlist,
html[data-theme="dark"] .cal-pill-waitlist    { background: #1e1b4b; color: #a5b4fc; }
html[data-theme="dark"] .cal-block-maybe,
html[data-theme="dark"] .cal-pill-maybe       { background: #2e1065; color: #c4b5fd; }
html[data-theme="dark"] .cal-block-pending,
html[data-theme="dark"] .cal-pill-pending     { background: #431407; color: #fdba74; }
html[data-theme="dark"] .cal-block-declined,
html[data-theme="dark"] .cal-pill-declined    { background: #450a0a; color: #fca5a5; }
/* Invite-only — distinct purple-slate hue + lighter shading so it reads as
   "gated" and clearly different from "Not RSVP'd" (cool plain slate). */
html[data-theme="dark"] .cal-block-inviteonly,
html[data-theme="dark"] .cal-pill-inviteonly  { background: #2c2440; color: #cbb8e8; }
html[data-theme="dark"] .cal-block-cancelled,
html[data-theme="dark"] .cal-pill-cancelled   { background: #450a0a; color: #fca5a5; }
/* Not RSVP'd — flatter neutral that recedes; lower contrast. */
html[data-theme="dark"] .cal-block-none,
html[data-theme="dark"] .cal-pill-none        { background: #1e2438; color: #d1d5db; }

/* ── Empty state ── */
.empty-msg {
  text-align: center;
  padding: var(--s-4) var(--s-3);
  color: var(--color-text-soft);
  line-height: 1.6;
}

.hidden { display: none !important; }

/* ── Footer ── always pinned to bottom of viewport (visible whether or not
   content fills the page). Body gets matching padding so content isn't hidden. */
.footer {
  position: fixed;
  bottom: 0;
  left: 0;
  right: 0;
  /* Match the header — above the filter drawer (300) so primary chrome
     always wins. Tooltips/modals still float above. */
  z-index: 400;
  display: flex;
  align-items: center;
  /* © Mel Lent on the left, donate link on the right — same baseline. */
  justify-content: space-between;
  gap: var(--s-2);
  padding: 0 var(--pad-x);
  height: 56px;
  background: var(--color-nav-bg);
  color: var(--color-nav-fg);
  border-top: 1px solid var(--color-border);
}
html:not([data-theme="dark"]) .footer {
  border-top-color: transparent;
  background: #eff6ff;             /* pale blue — matches today's calendar cell */
  color: var(--color-accent-text); /* today's date text color */
}
html:not([data-theme="dark"]) .footer-text,
html:not([data-theme="dark"]) .footer-link { color: var(--color-accent-text); }
body { padding-bottom: 56px; }    /* reserve space so footer doesn't cover content */
@media (max-width: 640px) {
  .footer { height: 48px; }
  body { padding-bottom: 48px; }
}
/* Footer text + link sit on the blue nav (light) or slate card (dark) —
   inherit the nav fg so they stay readable against either bg. */
.footer-text { font-size: 14px; color: var(--color-nav-fg); opacity: 0.9; }
.footer-link {
  color: var(--color-nav-fg);
  font-weight: 600;
  text-decoration: none;
}
.footer-link:hover { text-decoration: underline; }
.footer-version { font-variant-numeric: tabular-nums; opacity: 0.75; }

/* Donate link on the right side of the footer. Same font / size / color as
   the © Mel Lent text on the left so the two read as a single horizontal
   line. The link itself is always underlined so it's discoverable as a
   tap/click target. At Tailwind's sm (≤640px) the prompt copy is hidden
   and the right side becomes a single underlined "Donate" word. */
.footer-donate {
  font-size: 14px;
  text-align: right;
  white-space: nowrap;
}
.footer-donate-link {
  text-decoration: underline;
  text-underline-offset: 3px;
}
.footer-donate-short { display: none; }
@media (max-width: 640px) {
  .footer-donate-long  { display: none; }
  .footer-donate-short { display: inline; }
}

/* ── Modal ── */
.modal {
  position: fixed;
  inset: 0;
  z-index: 1000;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: var(--s-2);
  /* Open: backdrop fades + card slides up. Close: reverse, driven by a
     `.modal-closing` class added in JS for the duration of the close animation
     before the modal is hidden. */
  animation: modalIn 0.28s ease-out both;
}
.modal.modal-closing { animation: modalOut 0.22s ease-in both; }
.modal.modal-closing .modal-card { animation: modalCardOut 0.22s ease-in both; }
.modal-card { animation: modalCardIn 0.32s cubic-bezier(0.22, 0.61, 0.36, 1) both; }

@keyframes modalIn       { from { opacity: 0; } to { opacity: 1; } }
@keyframes modalOut      { from { opacity: 1; } to { opacity: 0; } }
@keyframes modalCardIn   { from { opacity: 0; transform: translateY(16px) scale(0.985); } to { opacity: 1; transform: none; } }
@keyframes modalCardOut  { from { opacity: 1; transform: none; } to { opacity: 0; transform: translateY(8px) scale(0.99); } }
@media (prefers-reduced-motion: reduce) {
  .modal, .modal-card, .modal.modal-closing, .modal.modal-closing .modal-card { animation: none; }
}

.modal-backdrop {
  position: absolute;
  inset: 0;
  background: rgba(15, 23, 42, 0.55);
  backdrop-filter: blur(6px);
  -webkit-backdrop-filter: blur(6px);
}
/* Darker shade in dark mode so the modal pops against the inky background */
html[data-theme="dark"] .modal-backdrop {
  background: rgba(0, 0, 0, 0.72);
}
.modal-card {
  position: relative;
  background: var(--color-card);
  border-radius: var(--s-2);
  box-shadow: 0 16px 48px rgba(0,0,0,0.25);
  width: min(720px, 100%);
  /* Flex column layout: title pinned at top, .modal-scroll fills middle and
     scrolls when content exceeds viewport, .modal-actions pinned at bottom.
     max-height ties to viewport (with 32px breathing room) and auto-resizes
     when the browser height changes. */
  max-height: calc(100vh - 32px);
  display: flex;
  flex-direction: column;
  padding: var(--s-3);
  overflow: hidden;
}
.modal-name { flex-shrink: 0; }
.modal-scroll {
  flex: 1 1 auto;
  overflow-y: auto;
  /* Negative side margins + matching padding so the scrollbar sits right at
     the card edge instead of inset awkwardly. */
  margin: 0 calc(-1 * var(--s-3));
  padding: 0 var(--s-3);
}
.modal-actions { flex-shrink: 0; }
.modal-close {
  position: absolute;
  top: var(--s-2);
  right: var(--s-2);
  background: none;
  border: none;
  font-size: 28px;
  line-height: 1;
  color: var(--color-text-muted);
  cursor: pointer;
  /* Bigger hit target — 36×36 click zone with the × visually proportional */
  width: 36px;
  height: 36px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  border-radius: var(--s-1);
  transition: background 0.12s, color 0.12s;
}
.modal-close:hover {
  color: var(--color-text);
  background: var(--color-bg);
}

.modal-name {
  /* Right margin must clear the absolutely-positioned .modal-close button:
     X is 36px wide + 16px from the right edge, plus a 16px buffer so long
     titles never crash into the X. Total reserved space = 36 + 16 + 16. */
  margin: 0 calc(36px + var(--s-2) + var(--s-2)) var(--s-2) 0;
  font-size: 18px;
  font-weight: 700;
  line-height: 1.4;
  color: var(--color-text);
}
.modal-grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: var(--s-2);
  align-items: stretch;
}
.modal-source {
  display: flex;
  flex-direction: column;
  border: 1px solid var(--color-border);
  border-radius: var(--s-1);
  padding: var(--s-2);
}
.modal-source h3 {
  margin: 0 0 var(--s-1);
  font-size: 14px;
  font-weight: 700;
  letter-spacing: -0.01em;
  color: var(--color-blue);
}
.modal-rows { display: flex; flex-direction: column; gap: 0; }
.modal-row {
  display: flex;
  justify-content: space-between;
  gap: var(--s-1);
  padding: var(--s-1) 0;
  border-bottom: 1px solid var(--color-border-soft);
  font-size: 14px;
}
.modal-row:last-of-type { border-bottom: none; }
.modal-key { color: var(--color-text-muted); font-weight: 500; }
.modal-val { color: var(--color-text); text-align: right; word-break: break-word; }
.modal-val.missing { color: #cbd5e1; }
/* Modal full-width CTA below the two source boxes */
.modal-actions {
  margin-top: var(--s-3);
  display: flex;
  flex-direction: column;
  gap: var(--s-1);
}
.modal-cta {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: var(--s-1);
  width: 100%;
  padding: var(--s-cta) var(--s-2);
  background: var(--color-cta-bg);    /* token guarantees AA contrast w/ white text in both themes */
  color: var(--color-cta-fg);
  font-family: "Unbounded", "Switzer", sans-serif;
  font-weight: 600;
  letter-spacing: -0.01em;
  text-decoration: none;
  border-radius: var(--s-1);
  border: none;
  font-size: 14px;
  line-height: 1;
  cursor: pointer;
}
.modal-cta .cta-arrow {
  /* SVG arrow renders consistently across fonts — flex centers it on the
     same baseline as the text. */
  display: inline-flex;
  align-items: center;
  flex-shrink: 0;
}
.modal-cta:hover { background: var(--color-cta-bg-hover); }
.modal-cta.disabled {
  background: var(--color-bg);
  color: var(--color-text-soft);
  cursor: default;
  pointer-events: none;
  border: 1px solid var(--color-border);
}

/* Warning + mismatch banners */
.modal-warning,
.modal-mismatch {
  margin-top: var(--s-2);
  padding: var(--s-cta) var(--s-2);
  background: var(--color-amber-bg);
  color: var(--color-amber-fg);
  border-radius: var(--s-1);
  font-size: 14px;
  font-weight: 600;
}

/* Conflicting fields: highlight wraps tightly around each line individually.
   The highlight applies to an inner span (.hl) inside the flex .modal-val,
   because flex items themselves can't get per-line decoration cloning. */
.modal-row.conflict .modal-val .hl {
  background: var(--color-amber-bg);
  color: var(--color-amber-fg);
  /* Inline highlight — vertical room comes from line-height, not padding. */
  padding: 0 var(--s-1);
  border-radius: var(--s-icon);
  display: inline;
  box-decoration-break: clone;
  -webkit-box-decoration-break: clone;
}

/* ── Responsive ── */

/* Calendar always shows columns side-by-side at all breakpoints — no collapse. */

/* Small tablet / large phone — apply mobile card layout from here too so the
   RSVP status badge always sits under the location, never beside it. */
@media (max-width: 640px) {
  :root {
    --label-w: 64px;
    --pad-x: var(--pad-x-mobile);
  }
  .header { height: 48px; }
  .header-title { font-size: 18px; }
  .modal-grid { grid-template-columns: 1fr; }

  /* Card grid: fixed min-height so every card looks identical regardless of
     whether the title wraps to 2 lines or the location is missing. The two
     `1fr` spacer rows above and below the body/status group center that group
     vertically as a single unit, with consistent spacing between body and chip. */
  button.event-card {
    grid-template-columns: 88px 1fr;
    grid-template-rows: 1fr auto auto 1fr;
    gap: 0 var(--s-cta);
    align-items: start;
    min-height: 112px;
  }
  .event-card .event-date   { grid-column: 1; grid-row: 1 / -1; align-self: center; }
  .event-card .event-body   { grid-column: 2; grid-row: 2;     align-self: end;   }
  .event-card .event-status { grid-column: 2; grid-row: 3;     align-self: start; margin-top: var(--s-1); }

  /* Hide Week button on phones — 7 columns is unreadable below this width */
  .cal-range-toggle [data-range="7"] { display: none; }
  /* Hide header progress + sync label and the count bar on small screens */
  .sync-label, .fetch-bar { display: none; }
  .count-bar { display: none !important; }
}

/* True mobile (down to 330px) */
@media (max-width: 480px) {
  :root {
    --label-w: 56px;
    --pad-x: var(--pad-x-mobile);
  }
  body { font-size: 14px; }
  /* Hide elements that crowd the header / toolbar at this width */
  .sync-label, .fetch-bar { display: none; }
  .count-bar { display: none !important; }
  /* Hide Week button on phones — 3-day max */
  .cal-range-toggle [data-range="7"] { display: none; }
  .filter-row {
    grid-template-columns: 1fr;
    gap: var(--s-icon);
  }
  .filter-label { padding-bottom: 2px; }
  .modal-card { padding: var(--s-2); }
  .modal-name { font-size: 16px; }

  /* On true mobile, just slim the date column further; row layout inherits
     from the 640px rule above. */
  button.event-card { grid-template-columns: 72px 1fr; padding: var(--s-cta); }
  .event-date .day-num { font-size: 22px; }
  /* Time grid: wider time column so "10:00 AM" / "11:00 AM" never wrap.
     Time label has its own padding handled inside .cal-time-label. */
  .cal-day-grid { grid-template-columns: 88px 1fr; min-height: 850px; }
  .cal-time-label { font-size: 11px; }
}
/* Failsafe: never let a time label wrap or shrink to two lines */
.cal-time-label {
  white-space: nowrap;
  overflow: visible;
}

/* ─── Mobile-specific (≤640px) refinements ─────────────────────────────────
   Centralized so the breakpoint changes stay readable.
*/

/* Year suffix on agenda date headers — wrapped in .date-year by
   formatPartyPalDateHTML so we can drop it on mobile. Context already
   makes the year obvious during Tech Week. */
@media (max-width: 640px) {
  .date-year { display: none; }
}

/* Two-text variant on the Tech Week refresh CTA — long form on tablet+,
   short form on mobile so the button stops wrapping. */
.cta-text-short { display: none; }
@media (max-width: 640px) {
  .cta-text-long  { display: none; }
  .cta-text-short { display: inline; }
}

/* Notification page action CTAs (Clear all / Mark all as read) follow
   the same "topmost-visible separator" rule on every breakpoint —
   handled by the :has() selector earlier in the file, no per-breakpoint
   override needed. */

/* Header pinned in place on mobile so URL-bar collapse animations don't
   cause it to drift on scroll. .app already accounts for the 48px height
   in min-height calc, but we also need padding-top so content doesn't
   slide under the now-absolute header. */
/* Mobile-only: standard body scroll (so pull-to-refresh works) with the
   header + footer pinned via `position: fixed`. Body gets matching
   padding-top/bottom so content doesn't slide under them. `100dvh` on
   the body's min-height tracks the live visible area as the URL bar
   animates, eliminating the phantom-scroll that previously dragged
   fixed elements during URL-bar transitions. */
@media (max-width: 640px) {
  body {
    padding-top: 48px;     /* room for fixed header (48px tall on mobile) */
    padding-bottom: 48px;  /* room for fixed footer (48px tall) */
  }
  .header {
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    z-index: 200;
  }
  .footer {
    position: fixed;
    bottom: 0;
    left: 0;
    right: 0;
    z-index: 200;
  }
  /* Force-reveal cards on mobile so the IntersectionObserver's quirks
     during viewport changes can never leave content stuck at opacity:0. */
  .notif-item.card-pending,
  .notif-item.card-revealed,
  .event-card-wrap.card-pending,
  .event-card-wrap.card-revealed,
  .date-separator.card-pending,
  .date-separator.card-revealed {
    opacity: 1 !important;
    transform: none !important;
    animation: none !important;
  }
}

/* Phone-only horizontal padding override — see breakpoint convention at the
   top of this file. Reduces --pad-x-mobile from 24px → 16px so cards and
   header/footer content sit closer to the screen edges on phones, which
   matches one-handed reach patterns. Tablets in portrait (480–640px) keep
   the 24px from --pad-x-mobile. */
/* Mobile (≤640px): override the tablet's 32px horizontal padding down to
   24px. Notif-dismiss alignment auto-tracks whatever padding is set, so
   the X glyph still lands at the card's padding line. */
/* ── Tablet + mobile (≤1024px) notif card layout ───────────────────────
   On tablet and below the IN/N/DAYS column is hidden and the bell + time
   moves above the title — single-column body with the X still on the
   right. Stacking order top → bottom inside the body:
     1. bell + notification time
     2. event title
     3. detail line ("Location moved from … to …")
     4. kind chip ("📍 Location changed")
   `display: contents` lifts .time out of .event-date so it becomes a
   direct grid child and we can place it above the body. .day-row stays
   inside .event-date but is hidden.
   Padding tiers (tighter as the screen shrinks):
     • Tablet (641–1024px): 32px L/R   — set in this block
     • Mobile (481–640px):  24px L/R   — overridden below
     • Phone  (≤480px):     16px L/R   — overridden in the phone block
*/
@media (max-width: 1024px) {
  .notif-card {
    grid-template-columns: 1fr auto;
    grid-template-rows: auto auto;
    column-gap: var(--s-2);
    row-gap: var(--s-1);
    /* Tablet default: 32px horizontal padding (var(--s-4)). Mobile and
       phone override this to 24 and 16 respectively in their own blocks. */
    padding: var(--s-2) var(--s-4);
  }
  /* Lift .time out of .event-date so it's a direct grid item we can place. */
  .notif-card .event-date {
    display: contents;
  }
  /* Hide the IN/N/DAYS stack — time is what carries forward. */
  .notif-card .event-date .day-row {
    display: none !important;
  }
  /* Time sits in row 1, body column. Bell + time aligned to the start. */
  .notif-card .event-date .time {
    grid-column: 1;
    grid-row: 1;
    justify-self: start;
    align-self: start;
    flex-direction: row;     /* override the column layout the bell rule used */
    align-items: center;
    gap: var(--s-icon);
    color: var(--color-text-muted);
    font-size: 13px;         /* a touch tighter than card body so it reads as metadata */
  }
  /* Body fills row 2, body column. */
  .notif-card .event-body {
    grid-column: 1;
    grid-row: 2;
  }
  /* Dismiss spans both rows on the right edge. Two compensations stack
     to align the X glyph's visible right edge with the card's 24px
     padding line:
       1. justify-content: flex-end — pulls the 20×20 svg to the right
          edge of the 32×32 button (8px shift right vs. centered).
       2. margin-right: -5px — pulls the entire button right by 5px,
          compensating for the optical inset of the X path inside the
          svg (the lines span 5→15 of the 20-wide viewBox, so the
          visible X is 5px inside the svg's right edge).
     Net: visible X right edge sits at exactly 24px from the card's
     outer right edge — same as the title's left edge from card outer
     left. 32×32 hit-target preserved (extra space hangs off the right
     into the card padding zone, still tappable). */
  .notif-card .notif-dismiss {
    grid-column: 2;
    grid-row: 1 / span 2;
    align-self: center;
    justify-content: flex-end;
    margin-right: -5px;
  }
}

/* Mobile (≤640px): override the tablet's 32px horizontal padding down to
   24px. Must come AFTER the 1024 block so it actually wins on mobile-
   width matches (CSS cascade — same specificity, later rule wins). */
@media (max-width: 640px) {
  .notif-card { padding: var(--s-2) var(--s-3); }
}

/* Phone (xs, ≤480px): tightens further to 16px on all four sides — the
   only padding distinction between phone and mobile vs. tablet's 32. */
@media (max-width: 480px) {
  :root { --pad-x-mobile: 16px; }
  .notif-card { padding: var(--s-2); }
}

/* ── Phone (xs, ≤480px) agenda event-card layout ────────────────────────
   Same pattern as the notif card on phone: hide the MON / 1 / JUN
   date column, lift the time onto its own row above the title, body
   fills the freed space. Status badge stays on the right.
   Padding mirrors the notif phone card (24px left/right) so left-aligned
   content lines up identically across the two views.
*/
@media (max-width: 480px) {
  button.event-card {
    /* Single column on phone — every element stacks vertically.
       time → body (title + location) → status badge. */
    grid-template-columns: 1fr;
    grid-template-rows: auto auto auto;
    gap: var(--s-1);
    padding: var(--s-2) var(--s-3);              /* 16/24 — same as notif card on phone */
  }
  /* Lift .time out of .event-date so it's a direct grid item we can place. */
  button.event-card .event-date {
    display: contents;
  }
  /* Hide the MON/N/MONTH stack and the no-date placeholder — only the time
     carries forward on phone. */
  button.event-card .event-date .day-row,
  button.event-card .event-date .no-date {
    display: none !important;
  }
  /* Time sits in row 1. Horizontal layout for start–end times. */
  button.event-card .event-date .time {
    grid-column: 1;
    grid-row: 1;
    justify-self: start;
    align-self: start;
    flex-direction: row;
    align-items: center;
    gap: var(--s-icon);
    color: var(--color-text-muted);
    font-size: 13px;
  }
  /* Body sits in row 2. */
  button.event-card .event-body {
    grid-column: 1;
    grid-row: 2;
  }
  /* Status badge sits at the bottom (row 3), left-aligned — same vertical
     stacking pattern the existing tablet/mobile (≤870px) layout uses, just
     without the left date column. Keeps the phone reading order: when, what,
     RSVP. */
  button.event-card .event-status {
    grid-column: 1;
    grid-row: 3;
    justify-self: start;
    margin-top: var(--s-1);
  }
}

/* ───────────────────────────────────────────────────────────────────────
   Generated mirrors of mobile (≤640) and phone (≤480) @media rules,
   filtered to agenda + toolbar selectors only. Header / footer /
   modals / notifications keep their existing viewport-based
   responsive behavior so they stay desktop-sized when the drawer
   shrinks the content area on a wide viewport.
   JS toggles body.eff-mobile / body.eff-phone based on the
   effective content width = innerWidth − drawer-w (drawer open).
   ─────────────────────────────────────────────────────────────────── */

/* ── effective content width ≤ 640px (drawer open) ── */
  body.eff-mobile #filter-toggle { display: none;
  }  body.eff-mobile .cal-agenda { gap: var(--s-1);
  }  /* Note: .date-separator { top: 48px } is intentionally NOT mirrored to
       eff-mobile. The page header stays 56px on a desktop viewport regardless
       of drawer state, so the separator must keep its 56px sticky-top — the
       48px mirror would pin it 8px under the real header and clip its top
       padding. The true @media (max-width: 640px) rule at line ~2544 still
       handles real mobile viewports where the header itself is 48px. */
  body.eff-mobile button.event-card {
    grid-template-columns: 88px 1fr;
    grid-template-rows: 1fr auto auto 1fr;
    gap: 0 var(--s-cta);
    align-items: start;
    min-height: 112px;
  }
  body.eff-mobile .event-card .event-date { grid-column: 1; grid-row: 1 / -1; align-self: center;
  }
  body.eff-mobile .event-card .event-body { grid-column: 2; grid-row: 2;     align-self: end;
  }
  body.eff-mobile .event-card .event-status { grid-column: 2; grid-row: 3;     align-self: start; margin-top: var(--s-1);
  }
  body.eff-mobile .cal-range-toggle [data-range="7"] { display: none;
  }
  body.eff-mobile .sync-label,
  body.eff-mobile .fetch-bar { display: none;
  }
  body.eff-mobile .count-bar { display: none !important;
  }  body.eff-mobile .date-year { display: none;
  }  body.eff-mobile .event-card-wrap.card-pending,
  body.eff-mobile .event-card-wrap.card-revealed,
  body.eff-mobile .date-separator.card-pending,
  body.eff-mobile .date-separator.card-revealed {
    opacity: 1 !important;
    transform: none !important;
    animation: none !important;
  }

/* ── effective content width ≤ 480px (drawer open) ── */
  body.eff-phone .event-date .day-num { font-size: 22px;
  }  body.eff-phone .segmented-row { gap: var(--s-1);
  }  body.eff-phone .sync-label,
  body.eff-phone .fetch-bar { display: none;
  }
  body.eff-phone .count-bar { display: none !important;
  }
  body.eff-phone .cal-range-toggle [data-range="7"] { display: none;
  }
  body.eff-phone .filter-row {
    grid-template-columns: 1fr;
    gap: var(--s-icon);
  }
  body.eff-phone .filter-label { padding-bottom: 2px;
  }
  body.eff-phone button.event-card { grid-template-columns: 72px 1fr; padding: var(--s-cta);
  }
  body.eff-phone .event-date .day-num { font-size: 22px;
  }
  body.eff-phone .cal-day-grid { grid-template-columns: 88px 1fr; min-height: 850px;
  }
  body.eff-phone .cal-time-label { font-size: 11px;
  }  body.eff-phone button.event-card {
    /* Single column on phone — every element stacks vertically.
       time → body (title + location) → status badge. */
    grid-template-columns: 1fr;
    grid-template-rows: auto auto auto;
    gap: var(--s-1);
    padding: var(--s-2) var(--s-3);              /* 16/24 — same as notif card on phone */
  }
  body.eff-phone button.event-card .event-date {
    display: contents;
  }
  body.eff-phone button.event-card .event-date .day-row,
  body.eff-phone button.event-card .event-date .no-date {
    display: none !important;
  }
  body.eff-phone button.event-card .event-date .time {
    grid-column: 1;
    grid-row: 1;
    justify-self: start;
    align-self: start;
    flex-direction: row;
    align-items: center;
    gap: var(--s-icon);
    color: var(--color-text-muted);
    font-size: 13px;
  }
  body.eff-phone button.event-card .event-body {
    grid-column: 1;
    grid-row: 2;
  }
  body.eff-phone button.event-card .event-status {
    grid-column: 1;
    grid-row: 3;
    justify-self: start;
    margin-top: var(--s-1);
  }
