/* ── Theme tokens ───────────────────────────── */
@property --bg     { syntax: '<color>'; inherits: true; initial-value: oklch(88% 0.038 78); }
@property --accent { syntax: '<color>'; inherits: true; initial-value: oklch(57% 0.195 62); }
@property --ink    { syntax: '<color>'; inherits: true; initial-value: oklch(9%  0.040 58); }

:root {
  --bg:     oklch(88% 0.038 78);
  --accent: oklch(57% 0.195 62);
  --ink:    oklch(9%  0.040 58);
  transition: --bg .25s, --accent .25s, --ink .25s;

  --bg-alt:     oklch(from var(--bg)     calc(l - 0.04)       c              h);
  --surface:    oklch(from var(--bg)     calc(l + 0.04)       c              h);
  --border:     oklch(from var(--bg)     calc(l - 0.09)       c              h);
  --border-2:   oklch(from var(--bg)     calc(l - 0.15)       c              h);
  --amber:      var(--accent);
  --amber-pale: oklch(from var(--accent) calc(l + 0.28)       calc(c * 0.5)  h);
  --amber-dark: oklch(from var(--accent) calc(l - 0.14)       c              h);
  --text-1:     var(--ink);
  --text-2:     oklch(from var(--ink)    calc(l + 0.25)       c              h);
  --text-3:     oklch(from var(--ink)    calc(l + 0.44)       calc(c * 0.7)  h);
  --terra:      oklch(from var(--accent) calc(l - 0.08)       calc(c * 1.1)  calc(h + 12));
  --terra-pale: oklch(from var(--accent) calc(l + 0.22)       calc(c * 0.6)  calc(h + 12));
  --green:      oklch(46% 0.14 145);

  --header-h: 54px;
  --tab-h:    84px;
  --r:   4px;
  --mono: 'Courier Prime', 'Courier New', monospace;
  --cn:   'Noto Serif TC', serif;
  --disp: 'Fraunces', Georgia, serif;

  /* Type scale (px @ 16px root): 12 / 13 / 14 / 16 / 18 / 22 / 28.
     --fs-2xs is the readability floor — never go smaller than this. */
  --fs-2xs:  0.75rem;
  --fs-xs:   0.8125rem;
  --fs-sm:   0.875rem;
  --fs-base: 1rem;
  --fs-md:   1.125rem;
  --fs-lg:   1.375rem;
  --fs-xl:   1.75rem;
}
[data-theme="folad"] {
  --bg:     oklch(14%  0.020  55);
  --accent: oklch(68%  0.120  72);
  --ink:    oklch(91%  0.018  80);
}
[data-theme="riyar"] {
  --bg:     oklch(95%  0.010 200);
  --accent: oklch(44%  0.170 205);
  --ink:    oklch(11%  0.030 218);
}
[data-theme="fois"] {
  --bg:     oklch(11%  0.030 272);
  --accent: oklch(72%  0.190 288);
  --ink:    oklch(97%  0.006  80);
}

/* ── Reset ──────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { font-size: 16px; -webkit-text-size-adjust: 100%; }
body {
  font-family: var(--cn);
  background: var(--bg);
  color: var(--text-1);
  line-height: 1.6;
  -webkit-font-smoothing: antialiased;
  height: 100dvh;
  overflow: hidden;
}

/* ── Phase C prerender hook ─────────────────── */
/* #festival-data contains static festival HTML for crawlers (injected at build
   time by scripts/prerender.js). Once JS loads it is visually hidden so users
   see the interactive UI instead. The content stays in the DOM for crawlers. */
html.js-ready #festival-data {
  position: absolute;
  width: 1px; height: 1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
}

/* ── Shared header ──────────────────────────── */
.app-header {
  position: fixed;
  inset: 0 0 auto 0;
  height: var(--header-h);
  background: var(--bg);
  border-bottom: 1px solid var(--border);
  z-index: 1000;
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 0 1rem;
  gap: .75rem;
}
.header-left  { display: flex; align-items: center; gap: .5rem; }
.logo-img     { width: 32px; height: 32px; border-radius: 50%; object-fit: cover; flex-shrink: 0; }
.header-title { font-family: var(--disp); font-size: var(--fs-md); font-weight: 600; letter-spacing: -.02em; white-space: nowrap; }
.header-right { display: flex; align-items: center; gap: .5rem; }

.theme-btn {
  font-size: var(--fs-sm);
  color: var(--text-3);
  border: 1px solid var(--border);
  background: var(--surface);
  padding: 3px 8px;
  border-radius: var(--r);
  cursor: pointer;
  line-height: 1.4;
  transition: border-color .15s, color .15s, background .15s;
}
.theme-btn:hover { border-color: var(--amber); color: var(--amber-dark); background: var(--bg); }

/* Design rule: every icon-only button anchored to a header/logo (mobile
   .app-header's overview/name/saved-filter/theme row, the sidebar's
   saved-filter/theme pair next to the Pokoh logo, the desktop timeline
   header's overview/name-toggle pair) is a fixed-size circle, not a
   padding-based rounded rectangle — .theme-btn alone stays padding-based
   auto-width since it's also reused where a circle isn't wanted (the
   overview PNG/HTML export buttons), so this is an additive class rather
   than a change to .theme-btn itself. */
.header-icon-btn,
.sb-theme-btn,
.tl-overview-btn,
.tl-name-toggle-desktop {
  width: 34px;
  height: 34px;
  padding: 0;
  flex-shrink: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 50%;
}
.header-icon-btn svg { display: block; }
.saved-filter-btn.active svg path { fill: currentColor; }

.install-btn {
  font-family: var(--mono);
  font-size: var(--fs-2xs);
  letter-spacing: .07em;
  text-transform: uppercase;
  color: var(--amber);
  border: 1px solid var(--amber);
  background: transparent;
  padding: 3px 8px;
  border-radius: var(--r);
  cursor: pointer;
  white-space: nowrap;
  transition: background .15s, color .15s;
}
.install-btn:hover { background: var(--amber); color: var(--bg); }
body.tab-info .app-header { display: none; }
/* Map tab: header bar itself goes transparent so the map shows through
   (requires #panel-map to actually extend under it too — see its own
   inset override below; a transparent background alone would just reveal
   blank space, not the map, since panels are normally inset below the
   header). Individual elements get their own opaque floating-chip
   backdrop instead of one shared rectangular bar — same visual language
   as the map's own corner toggles (opaque var(--surface), not an alpha
   frosting; that's reserved for things that sit directly on a poster
   photo, like the floater's close button) — so logo/icons stay legible
   over whatever's on the map beneath them, in any theme. */
body.tab-map .app-header {
  background: none;
  border-bottom: none;
  /* Transparent background doesn't affect hit-testing — without this the
     header's full-width, 54px-tall box still intercepts taps in its empty
     middle (confirmed: #mapSatelliteToggle, moved into that gap below,
     was unclickable until this was added — same dead zone would've
     silently swallowed a tap on any marker rendered near the top of the
     map too). auto is restored on .header-left/.header-right below so the
     logo and icon buttons stay clickable; #mapSatelliteToggle itself isn't
     a descendant of .app-header (separate element, lower z-index) so it
     needs no rule of its own — none here just lets clicks pass through to
     whatever's actually underneath at that point, map or toggle alike. */
  pointer-events: none;
}
body.tab-map .header-left,
body.tab-map .header-right {
  pointer-events: auto;
}
body.tab-map .header-left {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 99px;
  padding: 4px 12px 4px 4px;
  box-shadow: 0 2px 10px oklch(from var(--ink) l c h / 0.14);
}
body.tab-map .header-icon-btn {
  box-shadow: 0 2px 10px oklch(from var(--ink) l c h / 0.14);
}
/* 全覽/名稱切換 only act on the timeline (js/timeline.js) — hide the mobile
   .app-header copies on other tabs rather than leaving inert buttons visible. */
body:not(.tab-timeline) #tlOverviewBtnMobile,
body:not(.tab-timeline) #tlNameToggleMobile { display: none !important; }

/* ── Tab bar ────────────────────────────────── */
.tab-bar {
  position: fixed;
  inset: auto 0 0 0;
  height: var(--tab-h);
  background: var(--bg);
  border-top: 1px solid var(--border);
  z-index: 1000;
  display: flex;
  align-items: stretch;
  padding-bottom: env(safe-area-inset-bottom, 0px);
}
.tab-btn {
  flex: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 3px;
  background: none;
  border: none;
  cursor: pointer;
  color: var(--text-3);
  padding: 0;
  transition: color .15s;
  -webkit-tap-highlight-color: transparent;
}
.tab-btn:hover  { color: var(--text-2); }
.tab-btn.active { color: var(--accent); }
.tab-icon  { width: 22px; height: 22px; display: flex; align-items: center; justify-content: center; }
.tab-icon svg { display: block; }
.tab-label {
  font-family: var(--mono);
  font-size: var(--fs-2xs);
  letter-spacing: .1em;
  text-transform: uppercase;
  padding-bottom: 2px;
  border-bottom: 2px solid transparent;
  transition: border-color .15s;
}
.tab-btn.active .tab-label { border-color: var(--accent); }

/* ── Panels base ────────────────────────────── */
.panel {
  display: none;
  position: fixed;
  inset: var(--header-h) 0 var(--tab-h) 0;
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
  background: var(--bg);
}
.panel.active { display: block; }

#panel-info { inset: 0 0 var(--tab-h) 0; }

/* ── Timeline panel ─────────────────────────── */
#panel-timeline          { display: none; flex-direction: column; overflow: hidden; }
#panel-timeline.active   { display: flex; }

.tl-header {
  display: flex;
  align-items: center;
  border-bottom: 1px solid var(--border);
  background: var(--bg);
  flex-shrink: 0;
}
.tl-month-tabs {
  display: flex;
  align-items: center;
  justify-content: center;
  justify-content: safe center;
  flex: 1;
  min-width: 0;
  overflow-x: auto;
  scrollbar-width: none;
}
.tl-month-tabs::-webkit-scrollbar { display: none; }
.tl-name-toggle-desktop { display: none; }
.tl-overview-btn       { display: none; }
/* The desktop overview button is absolutely positioned against .tl-header,
   which only gets position:relative at the ≥768px breakpoint below — on
   mobile it has no positioned ancestor to anchor to, so force-hide this
   specific instance there regardless of the dev-flag JS that would
   otherwise show it (js/timeline.js sets an inline display, which this
   !important beats). #tlOverviewBtnMobile in .app-header is the mobile
   equivalent, laid out normally with no absolute positioning needed. */
@media (max-width: 767px) {
  .tl-overview-btn { display: none !important; }
}

.theme-btn.active { border-color: var(--accent); color: var(--accent); }

/* ── Overview strip (全覽) ──────────────────────── */
.tlo-wrap {
  overflow-x: auto;
  overflow-y: hidden;
  scrollbar-width: none;
  border-bottom: 2px solid var(--border-2);
  background: var(--bg);
  flex-shrink: 0;
  padding: .75rem .85rem;
}
.tlo-wrap::-webkit-scrollbar { display: none; }
#panel-timeline.ov-active #tlOverviewWrap { flex: 1; border-bottom: none; }
#panel-timeline.ov-active #tlCounty       { display: none; }
#panel-timeline.ov-active .tl-month-tab  { color: var(--text-1); pointer-events: none; cursor: default; }
#tloExportWrap {
  position: absolute; bottom: .75rem; right: .75rem;
  z-index: 20; display: none; gap: 6px;
}
.tlo-inner  { position: relative; display: inline-block; }
.tlo-band {
  position: absolute;
  border-radius: 3px;
  background: oklch(from var(--accent) calc(l + 0.22) calc(c * 0.55) h);
}
.tlo-tribe-row  { position: relative; height: 22px; margin-top: 4px; }
.tlo-tribe-bar {
  position: absolute;
  height: 100%;
  display: flex;
  align-items: stretch;
  overflow: visible;
  border-radius: 5px;
  background: oklch(from var(--accent) calc(l + 0.22) calc(c * 0.4) h / 0.001);
  border: 1px solid oklch(from var(--accent) calc(l + 0.05) calc(c * 0.55) h / 0.25);
  cursor: pointer;
}
.tlo-band-section { margin-top: 4px; margin-bottom: 2px; }
.tlo-grid-week, .tlo-grid-month {
  position: absolute; top: 0; height: 1500px; width: 1px; pointer-events: none;
}
.tlo-grid-week  { background: oklch(from var(--ink) l c h / 0.02); }
.tlo-grid-month { background: oklch(from var(--ink) l c h / 0.05); }
.tlo-tribe-lbl {
  position: absolute;
  right: calc(100% + 6px);
  top: 50%;
  transform: translateY(-50%);
  font-family: var(--mono);
  font-size: var(--fs-2xs);
  font-weight: bold;
  letter-spacing: .04em;
  white-space: nowrap;
  color: oklch(from var(--accent) calc(l - 0.22) calc(c * 0.9) h);
  z-index: 1;
  pointer-events: none;
}
.tlo-t-cells { display: flex; height: 100%; width: 100%; filter: blur(8px); clip-path: inset(0 round 5px); }
.tlo-t-day   { flex-shrink: 0; height: 100%; background: var(--accent); }
.tl-month-tab {
  flex: 0 0 auto;
  padding: .55rem 1.25rem;
  text-align: center;
  font-family: var(--mono);
  font-size: var(--fs-base);
  letter-spacing: .1em;
  color: var(--text-3);
  border: none;
  background: none;
  cursor: pointer;
  border-bottom: 2px solid transparent;
  transition: color .15s, border-color .15s;
  -webkit-tap-highlight-color: transparent;
  white-space: nowrap;
}
.tl-month-tab.active { color: var(--accent); border-color: var(--accent); }
.tl-month-tab:disabled { opacity: .35; cursor: default; }

/* Overview mode (全覽, dev-only) on mobile: renderOverviewMonthTabs() lays
   out all 12 months proportionally via flex, but .tl-month-tab's normal
   padding/font-size assume desktop width — at mobile widths their combined
   min-content size exceeds the viewport, so .tl-month-tabs' own
   overflow-x:auto (needed for the *normal*, non-overview month strip)
   quietly turns into a horizontally-scrollable header instead of the
   intended one-screen proportional calendar bar. Shrink text/padding and
   allow tabs to shrink past their content size so the row always fits
   exactly, rather than scroll. Also pulls .tlo-tribe-lbl inside its bar
   instead of to the left of it — externally-left placement assumes room in
   .tlo-wrap's small side padding, which a tribe whose season starts near
   day 1 doesn't have, bleeding the label past the scrollable panel's own
   left edge. Desktop is untouched (has enough width for both as-is). */
@media (max-width: 767px) {
  #panel-timeline.ov-active .tl-month-tabs { overflow-x: hidden; }
  #panel-timeline.ov-active .tl-month-tab {
    min-width: 0;
    padding: .35rem .1rem;
    font-size: 9px;
    letter-spacing: 0;
  }
  .tlo-tribe-lbl { right: auto; left: 4px; }
}


/* Date strip */
.tl-strip-scroll {
  overflow-x: auto;
  overflow-y: hidden;
  scrollbar-width: none;
  border-bottom: 2px solid var(--border-2);
  user-select: none;
  -webkit-user-select: none;
  flex-shrink: 0;
  background: var(--bg);
}
.tl-strip-scroll::-webkit-scrollbar { display: none; }
.tl-strip-inner { position: relative; display: inline-block; }

/* Multi-day bands */
.tl-bands-layer {
  position: relative;
  width: 100%;
  max-height: 30vh;
  overflow-y: auto;
  scrollbar-width: thin;
  scrollbar-color: var(--accent) transparent;
}
.tl-band {
  position: absolute;
  display: flex;
  align-items: center;
  overflow: hidden;
  border-radius: 6px;
  background: oklch(from var(--accent) calc(l + 0.22) calc(c * 0.55) h);
  padding: 0 5px;
}
/* Small corner dot, not a label — bands can be as narrow as one day, no
   room for welcome-day text alongside .tl-band-label. Full info lives in
   the existing hover tooltip (data-tip) instead. */
.tl-band-welcome::after {
  content: '';
  position: absolute;
  top: 2px;
  right: 2px;
  width: 5px;
  height: 5px;
  border-radius: 50%;
  background: var(--terra);
}
.tl-band-label {
  width: 100%;
  font-family: var(--mono);
  font-size: var(--fs-xs);
  letter-spacing: .03em;
  color: oklch(from var(--accent) calc(l - 0.18) calc(c * 0.9) h);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  text-align: center;
}
/* Same badge as .card-multi (cards/detail overlay), recolored to the band
   label's own derived accent tone instead of --text-3 — --text-3 reads as
   muddy against the band's pale accent-tinted background. */
.tl-band-label .card-multi { color: currentColor; margin-right: .2rem; }

/* Band tooltip */
#tl-tip {
  position: fixed;
  transform: translate(-50%, calc(-100% - 8px));
  background: var(--ink);
  color: var(--bg);
  font-size: var(--fs-xs);
  font-family: var(--mono);
  padding: 3px 9px;
  border-radius: 6px;
  white-space: nowrap;
  pointer-events: none;
  opacity: 0;
  transition: opacity .12s;
  z-index: 200;
}
#tl-tip::after {
  content: '';
  position: absolute;
  top: 100%;
  left: 50%;
  transform: translateX(-50%);
  border: 5px solid transparent;
  border-top-color: var(--ink);
}
#tl-tip.visible { opacity: 1; }

/* Date cells */
.tl-cells-layer { display: flex; border-bottom: 2px solid var(--border-2); }
.tl-cell {
  flex-shrink: 0;
  width: 44px;
  margin: 0 2px;
  border-radius: 10px;
  display: flex;
  flex-direction: column;
  align-items: center;
  padding: .35rem .2rem .4rem;
  cursor: pointer;
  transition: background .12s;
  -webkit-tap-highlight-color: transparent;
}
.tl-cell:hover       { background: var(--bg-alt); }
.tl-cell.active      { background: var(--accent); }
.tl-cell.today:not(.active) .tl-daynum { color: var(--accent); }

.tl-weekday {
  font-family: var(--mono);
  font-size: var(--fs-2xs);
  letter-spacing: .06em;
  color: var(--text-3);
  margin-bottom: .1rem;
}
.tl-cell.active .tl-weekday { color: oklch(from var(--bg) calc(l - 0.06) c h); }
.tl-daynum {
  font-family: var(--disp);
  font-size: var(--fs-md);
  font-weight: 600;
  color: var(--text-1);
  line-height: 1;
}
.tl-cell.active .tl-daynum { color: var(--bg); }
.tl-count {
  font-family: var(--mono);
  font-size: var(--fs-2xs);
  background: var(--accent);
  color: var(--bg);
  border-radius: 99px;
  padding: 1px 5px;
  margin-top: .18rem;
  min-width: 20px;
  text-align: center;
  line-height: 1.4;
}
.tl-cell.active .tl-count { background: var(--bg); color: var(--accent); }
.tl-count-empty { height: 19px; margin-top: .18rem; }

/* County chips */
.tl-county {
  display: flex;
  gap: .4rem;
  padding: .45rem .85rem;
  border-bottom: 1px solid var(--border);
  flex-shrink: 0;
  background: var(--bg);
  overflow-x: auto;
  -webkit-overflow-scrolling: touch;
  scrollbar-width: none;
}
.tl-county::-webkit-scrollbar { display: none; }
.tl-chip {
  font-family: var(--mono);
  font-size: var(--fs-2xs);
  letter-spacing: .07em;
  padding: 3px 10px;
  border-radius: 99px;
  border: 1px solid var(--border);
  background: transparent;
  color: var(--text-3);
  cursor: pointer;
  flex-shrink: 0;
  transition: background .12s, color .12s, border-color .12s;
  -webkit-tap-highlight-color: transparent;
}
.tl-chip.active { background: var(--accent); border-color: var(--accent); color: var(--bg); }
.tl-chip:hover:not(.active) { border-color: var(--amber); color: var(--amber-dark); }
.tl-county-sep {
  width: 1px;
  background: var(--border);
  flex-shrink: 0;
  align-self: stretch;
  margin: 2px .25rem;
}
.tl-chip-twp {
  font-family: var(--mono);
  font-size: var(--fs-2xs);
  letter-spacing: .07em;
  padding: 3px 8px;
  border-radius: 99px;
  border: 1px solid transparent;
  background: transparent;
  color: var(--text-3);
  white-space: nowrap;
  flex-shrink: 0;
  cursor: pointer;
  transition: background .12s, color .12s, border-color .12s;
  -webkit-tap-highlight-color: transparent;
}
.tl-chip-twp:hover:not(.active) { border-color: var(--border); color: var(--text-2); }
.tl-chip-twp.active { background: var(--accent); border-color: var(--accent); color: var(--bg); }

/* Day card list */
.tl-day-list {
  flex: 1;
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
  overscroll-behavior: contain;
  /* ::-webkit-scrollbar only (no scrollbar-width/-color) — declaring both
     together is inconsistent across Chrome builds, sometimes falling back to
     the full native scrollbar (buttons, default width) and ignoring the
     custom thumb entirely. Same reasoning as .detail-scroll. */
}
.tl-day-list::-webkit-scrollbar       { width: 6px; }
.tl-day-list::-webkit-scrollbar-thumb { background: var(--accent); border-radius: 4px; }
.tl-day-list::-webkit-scrollbar-track { background: transparent; }
.tl-empty {
  padding: 2rem .85rem;
  font-family: var(--mono);
  font-size: var(--fs-xs);
  color: var(--text-3);
  letter-spacing: .08em;
  text-align: center;
}

/* ── Map panel ──────────────────────────────── */
/* Extends under the header (same idea as #panel-info's own override
   below) so the transparent .app-header (body.tab-map rules above) has
   actual map tiles behind it instead of blank space. Desktop is
   unaffected — .app-header doesn't exist there (sidebar nav instead), so
   this override is harmless dead space above the sidebar-relative layout. */
#panel-map          { display: none; overflow: hidden; inset: 0 0 var(--tab-h) 0; }
#panel-map.active   { display: block; }
#map                { position: absolute; inset: 0; isolation: isolate; }

/* Mobile-first: county stays the original pill-segmented 3-way toggle
   (top-left), just rotated to a vertical stack; its township sub-list
   flies out to its right rather than dropping below. Time collapses
   behind a trigger that deploys its options downward (top-right).
   Desktop (>=768px, see override below) restores the original single
   centered horizontal row with everything always visible.
   top offset is header-height-aware (not just 12px) since #panel-map now
   extends under the header — without this the chips would render right
   behind the header's floating icons instead of clearing them. */
#mapFilters {
  position: absolute;
  top: calc(var(--header-h) + 12px); left: 12px; right: 12px;
  z-index: 500;
}
#mapTopRow {
  display: flex;
  justify-content: space-between;
  align-items: flex-start;
  gap: 8px;
}
.map-loc { position: relative; }
.map-dd { position: relative; }
.map-dd-trigger {
  display: flex;
  align-items: center;
  gap: 4px;
  font-family: var(--mono);
  font-size: var(--fs-xs);
  letter-spacing: .06em;
  padding: 6px 12px;
  border-radius: 99px;
  border: 1px solid var(--border);
  background: var(--surface);
  color: var(--text-2);
  box-shadow: 0 2px 10px oklch(from var(--ink) l c h / 0.14);
  cursor: pointer;
  white-space: nowrap;
  -webkit-tap-highlight-color: transparent;
}
.map-dd-caret { font-size: .65em; transition: transform .15s; }
.map-dd.open .map-dd-caret { transform: rotate(180deg); }
/* Specificity has to beat the base .map-chips rule below (also display:flex,
   same class-selector weight) or the panel renders open by default
   regardless of source order — .map-dd .map-dd-panel (two classes) does. */
.map-dd .map-dd-panel {
  position: absolute;
  top: calc(100% + 6px);
  right: 0;
  display: none;
  flex-direction: column !important;
  align-items: stretch;
}
.map-dd.open .map-dd-panel { display: flex; }
.map-chips {
  display: flex;
  gap: 3px;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 14px;
  padding: 5px;
  box-shadow: 0 2px 10px oklch(from var(--ink) l c h / 0.14);
  white-space: nowrap;
}
#mapCountyChips {
  flex-direction: column;
  align-items: stretch;
}
.map-chips--sub {
  position: absolute;
  top: 0;
  left: calc(100% + 8px);
  flex-direction: column;
  align-items: stretch;
  width: fit-content;
  max-height: 40vh;
  overflow-y: auto;
  overflow-x: hidden;
  scrollbar-width: none;
}
.map-chips--sub::-webkit-scrollbar { display: none; }
/* Same specificity problem as above: .map-chips's display:flex otherwise
   beats the UA [hidden] rule, so the county:'all' hide never took visually. */
.map-chips--sub[hidden] { display: none; }
.map-chip {
  font-family: var(--mono);
  font-size: var(--fs-xs);
  letter-spacing: .06em;
  padding: 6px 12px;
  border-radius: 10px;
  border: none;
  background: transparent;
  color: var(--text-2);
  cursor: pointer;
  text-align: left;
  transition: background .12s, color .12s;
  -webkit-tap-highlight-color: transparent;
}
#mapCountyChips .map-chip { text-align: center; }
.map-dd-panel .map-chip { text-align: right; }
.map-chip.active             { background: var(--accent); color: var(--bg); }
.map-chip:hover:not(.active) { background: var(--bg-alt); }

/* Detached from .map-dd-panel's own flex flow (position:absolute, own
   fixed-to-content width) so the date inputs never force the chip list's
   panel — which would otherwise size itself to whatever's widest inside it
   — to balloon out sideways just because 自訂 got picked. */
#mapCustomDateRow {
  position: absolute;
  top: 100%;
  right: 0;
  margin-top: 6px;
  width: fit-content;
  display: flex;
  flex-direction: column;
  align-items: stretch;
  gap: 6px;
  padding: 8px;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 14px;
  box-shadow: 0 2px 10px oklch(from var(--ink) l c h / 0.14);
}
/* Same specificity trap as the other [hidden] fixes above: the unconditional
   display:flex on #mapCustomDateRow itself otherwise beats the UA [hidden]
   rule, so the picker showed even when 自訂 wasn't the active time filter. */
#mapCustomDateRow[hidden] { display: none; }
.map-custom-field {
  display: flex;
  align-items: center;
  gap: 6px;
  font-family: var(--mono);
  font-size: var(--fs-xs);
  color: var(--text-3);
}
.map-custom-field input[type="date"] {
  flex: 1;
  min-width: 0;
  font-family: var(--mono);
  font-size: var(--fs-xs);
  color: var(--text-2);
  background: var(--bg-alt);
  border: 1px solid var(--border);
  border-radius: 6px;
  padding: 3px 4px;
}
#mapCustomApply {
  font-family: var(--mono);
  font-size: var(--fs-xs);
  letter-spacing: .06em;
  padding: 4px 10px;
  border-radius: 99px;
  border: none;
  background: var(--accent);
  color: var(--bg);
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}

@media (min-width: 768px) {
  /* Reset back to a plain 12px — the base rule's header-height-aware top
     offset exists only for the mobile map tab's transparent-header
     treatment (.app-header doesn't exist on desktop at all, sidebar nav
     instead), so left un-reset here it would push desktop's filter row
     down by the full header height for no reason. */
  #mapFilters {
    top: 12px;
    left: 50%; right: auto;
    transform: translateX(-50%);
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 5px;
  }
  #mapTopRow { display: flex; gap: 6px; align-items: flex-start; justify-content: center; }
  .map-loc { display: flex; flex-direction: column; align-items: center; gap: 5px; }
  #mapCountyChips { flex-direction: row; }
  .map-dd-trigger { display: none; }
  /* Must match .map-dd .map-dd-panel's specificity (two classes) — the
     mobile-first rule below is that specific to beat .map-chips, so a
     plain .map-dd-panel here (one class) loses position/flex-direction to
     it regardless of media-query order, leaving the panel stuck absolute
     and column-stacked on desktop. */
  .map-dd .map-dd-panel {
    position: static;
    display: flex !important;
    flex-direction: row !important;
    flex-wrap: wrap;
    justify-content: center;
  }
  .map-chips { border-radius: 99px; padding: 5px 8px; }
  .map-chip { padding: 4px 12px; border-radius: 99px; text-align: center; }
  .map-dd-panel .map-chip { text-align: center; }
  .map-chips--sub {
    position: static;
    flex-direction: row;
    align-items: center;
    width: auto;
    max-height: none;
    overflow-x: auto;
    overflow-y: hidden;
    max-width: min(92vw, 420px);
  }
  #mapCustomDateRow {
    position: static;
    width: auto;
    margin-top: 0;
    flex-direction: row;
    align-items: center;
    flex-basis: 100%;
    justify-content: center;
    padding: 2px 6px;
    background: none;
    border: none;
    box-shadow: none;
  }
  .map-custom-field { flex: 0 0 auto; }
  .map-custom-field input[type="date"] { flex: 0 0 auto; }
}

/* Bottom-right 2-segment icon toggle between every event's exact position
   (default) and clustered pins (one big circle per group) — no labels,
   both icons stroke-only/empty-filled to match the marker style in
   makeIcon(). Leaflet's own zoom control shares that corner but only on
   desktop (hidden under 768px, see .leaflet-control-zoom above), and the
   collapsed bottom sheet always peeks up ~28px, hence the base offset
   clearing both.
   Hidden (not removed) — the underlying feature (clusterModeOn,
   setClusterMode(), the "off" button already being the active default)
   is untouched in js/map.js; only the UI to switch back to clustered mode
   is gone. Delete this rule to bring it back. */
#mapClusterToggle {
  display: none;
  position: absolute;
  right: 10px;
  bottom: 40px;
  z-index: 500;
}
.map-cluster-btn {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 32px;
  height: 32px;
  padding: 0;
}
@media (min-width: 768px) {
  #mapClusterToggle { bottom: 96px; }
}

/* DOM home is now inside #mapTopRow, right after #mapTimeDD (moved
   2026-07-15 so desktop can lay it out in-flow — see the desktop rule
   below). Mobile still wants it pinned near the header's icon-button
   cluster regardless of where it sits in the DOM, so it uses `position:
   fixed` (viewport-relative, ignores the new #mapFilters/#mapTopRow
   ancestors entirely) rather than `absolute` — the right:108px/top:10px
   offsets below are unchanged from when this was a direct #panel-map
   child positioned `absolute`, and stay correct under `fixed` for the
   same reason: no ancestor between here and the viewport sets a
   transform on mobile (desktop's #mapFilters does, but that breakpoint
   uses `position: static` instead, see below, so it never reaches this
   fixed-positioning code path). right:108px gives a ~16px gap before
   .header-right (wider than that cluster's own internal 8px gap,
   deliberately — a bit more breathing room between the toggle and the
   icon group than within it) — .header-right's 2 buttons (34px each) +
   their 8px gap + .app-header's own 1rem right padding are all fixed
   pixel sizes regardless of viewport width (confirmed .header-right is
   always exactly these 2 buttons on the map tab specifically — the
   timeline-only overview/name-toggle buttons that also live in the
   shared header are display:none outside body.tab-timeline), so
   anchoring from the right edge holds on any phone width. */
#mapSatelliteToggle {
  z-index: 500;
}
.map-sat-btn {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 32px;
  height: 32px;
  padding: 0;
}
/* Resized to match the header row's height/vertical position — NOT split
   into 2 independent circular buttons (that was tried and corrected: the
   toggle must stay one linked control, same as every other map-tab toggle
   group). Still just one .map-chips pill, inheriting its own background/
   border/box-shadow unchanged — only the size, radius, and inner button
   size are overridden here, scoped to mobile since .map-chips/.map-sat-btn
   are shared with county/time/cluster's own toggle groups too. Active/
   hover coloring is untouched — .map-chip.active/:hover's shared rules
   already do the right thing here, same as every other toggle. */
@media (max-width: 767px) {
  #mapSatelliteToggle {
    position: fixed;
    right: 108px;
    top: 10px;
    height: 34px;
    padding: 3px;
    gap: 2px;
    border-radius: 99px;
  }
  #mapSatelliteToggle .map-sat-btn {
    width: 28px;
    height: 28px;
    border-radius: 50%;
  }
}
@media (min-width: 768px) {
  /* In-flow, right of the time filter — it's the last child of
     #mapTopRow now, which is a flex row (see the desktop #mapTopRow rule
     above), so plain `static` positioning is all this needs. */
  #mapSatelliteToggle { position: static; }
  /* Match the sibling filter chips' height exactly rather than a guessed
     px value: a desktop .map-chip's rendered height is its line-height
     (fs-xs's 13px × body's 1.6) plus its own 4px top/bottom padding — the
     same formula .map-sat-btn needs since #mapSatelliteToggle wraps it in
     the same .map-chips pill (5px top/bottom padding) that #mapTimeChips/
     #mapCountyChips use. Keeps the 3 chip groups' pills visually identical
     in height without hardcoding a number that'd drift if fs-xs ever
     changes. */
  .map-sat-btn {
    width: calc(var(--fs-xs) * 1.6 + 8px);
    height: calc(var(--fs-xs) * 1.6 + 8px);
  }
}

/* ── Bottom sheet ───────────────────────────── */
.bs {
  position: absolute;
  bottom: 0; left: 0; right: 0;
  height: 92%;
  background: var(--surface);
  border-radius: 16px 16px 0 0;
  box-shadow: 0 -4px 24px oklch(from var(--ink) l c h / 0.12);
  display: flex;
  flex-direction: column;
  overflow: hidden;
  z-index: 400;
  transform: translateY(calc(100% - 28px));
  transition: transform .35s cubic-bezier(.4, 0, .2, 1), height .35s cubic-bezier(.4, 0, .2, 1);
  will-change: transform;
}
/* z-index above #mapFilters (500) — at full height the sheet's handle bar
   sits within the filter row's vertical span, and without this the filter
   row would win hit-testing and swallow the drag gesture entirely. */
.bs[data-state="full"]      { transform: translateY(0); z-index: 600; }
.bs[data-state="half"]      { transform: translateY(0); height: 50%; }
.bs[data-state="collapsed"] { transform: translateY(calc(100% - 28px)); }

.bs-handle-bar {
  flex-shrink: 0;
  height: 28px;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: grab;
  touch-action: none;
  -webkit-tap-highlight-color: transparent;
}
.bs-handle-bar:active { cursor: grabbing; }
.bs-handle-pill {
  width: 36px; height: 4px;
  border-radius: 2px;
  background: var(--border-2);
}

.bs-content {
  flex: 1;
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
  overscroll-behavior: contain;
  scrollbar-width: thin;
  scrollbar-color: var(--accent) transparent;
}

.bs-empty {
  padding: .75rem .85rem 1rem;
  font-family: var(--mono);
  font-size: var(--fs-xs);
  color: var(--text-3);
  letter-spacing: .06em;
  font-style: italic;
}

/* Retired below 768px in favor of #mapFloatCard (one floating card for the
   selected village, not a scroll-to-card list) — kept, not deleted, so the
   desktop side panel above is completely untouched and this is a one-line
   revert if the floating card doesn't work out. */
@media (max-width: 767px) {
  .bs { display: none; }
}

/* ── Map floating card (mobile only) ───────────
   Mobile's replacement for .bs's "half" state — a single card for whichever
   village is currently selected, instead of scrolling a shared list to it.
   Desktop keeps using .bs (see its own mobile-hide rule above); the two are
   mutually exclusive per viewport, driven by the same bsState/activeId via
   setSheetState()/activateVillage() in js/map.js, not a separate state
   machine. */
/* Shadow recipe (three soft layers, not one hard drop-shadow) borrows
   Airbnb's card-elevation approach — a thin 1px definition line plus two
   widening, lightening blur layers reads as a much gentler "lift" than a
   single heavy shadow at the same visual weight. oklch(from var(--ink)...)
   keeps it theme-correct (correctly inverts on the dark themes) rather than
   a flat black rgba, which the ink-based convention elsewhere in this file
   already establishes. */
.map-float-card {
  position: absolute;
  left: 10px; right: 10px;
  bottom: 96px;
  z-index: 550;
  background: var(--surface);
  border-radius: 14px;
  overflow: hidden;
  box-shadow:
    0 0 0 1px oklch(from var(--ink) l c h / 0.05),
    0 2px 8px oklch(from var(--ink) l c h / 0.10),
    0 8px 20px oklch(from var(--ink) l c h / 0.18);
  animation: map-float-in .25s cubic-bezier(.34, 1.56, .64, 1);
}
/* Embedded left thumbnail, not a full-bleed banner. Absolutely positioned
   with top+bottom (not a flex sibling with height:100%) so it can never
   dictate the card's own height. Two things had to change to get here,
   both confirmed by measuring computed styles, not guessed:
   (1) A flex/height:100% approach was tried first and failed outright —
   .map-float-card's height is auto (sized by content), and percentage
   heights can't resolve against an auto-height containing block; the
   *hypothetical* size flexbox uses to compute that auto height still fell
   back to the image's own intrinsic aspect ratio, so a portrait-shaped
   poster silently re-dictated the row height anyway (measured: still 153px
   card vs. ~112px of actual text — no visible change at all). Taking the
   image out of flow (position:absolute) sidesteps that circularity: an
   abspos element doesn't contribute to its containing block's auto-height
   calculation, so .village-card (still in normal flow) alone determines the
   card's height now. (2) Even after that, top:0/bottom:0 with no explicit
   height *still* didn't stretch the image — CSS2.1's abspos-replaced-
   element algorithm computes height from width + intrinsic ratio whenever
   height itself is literally 'auto', regardless of top/bottom (measured
   value matched width×naturalHeight/naturalWidth exactly). height:100% —
   an explicit, non-auto value — is what actually forces it to fill the
   (now genuinely definite) container height instead.
   object-fit:cover crops rather than distorts. Borderless: no radius/border
   of its own, it just fills its corner of the already-clipped
   (overflow:hidden) card. Only present when getScheduleDetail(v) resolves
   one (see renderFloatCard() in js/map.js) — .has-poster is added in JS
   alongside it so the text column below only reserves left space when
   there's actually an image to clear; most entries have none, so the card
   falls back to full-width text with no gap left behind. */
.map-float-poster {
  position: absolute;
  top: 0; left: 0;
  width: 100px;
  height: 100%;
  object-fit: cover;
  cursor: pointer;
}
/* Text column: a header (indigenous name + share/save, divider beneath)
   above a body that mirrors the detail overlay's own body structure
   (name+date row, a row2 pairing a Google-maps link with the welcome
   badge, then venue) — not the shared .village-card list-row shape. The
   has-poster divider (below) runs the column's full height, header
   included, which is why it's on .map-float-text rather than just the
   body part. */
.map-float-text {
  flex: 1;
  min-width: 0;
  display: flex;
  flex-direction: column;
}
/* Ticket-stub divider — dashed rather than solid, at the exact image/text
   boundary (same 100px the image itself is width:100px at), so it reads as
   a perforation between the "stub" (image) and the "ticket" (info) rather
   than a generic column rule. */
.map-float-card.has-poster .map-float-text {
  margin-left: 100px;
  border-left: 1.5px dashed var(--border-2);
}
/* Header: share (left) / indigenous name (center) / save (right) — same
   grouping as .detail-header, but the title is flex:1 + text-align:center
   rather than that header's fixed-rem absolute-centering trick (tuned for
   the much wider detail overlay panel; flex:1 auto-adapts to the floater's
   narrower, variable width instead of needing its own re-measured
   constant). */
/* cursor:pointer for the header background/title — clicking it opens the
   detail overlay (see renderFloatCard()'s onclick), same as the image and
   body below. The share/save buttons inside stay their own clickable
   targets; they stopPropagation() in their own handlers so a tap on either
   doesn't also fire this. */
.map-float-header {
  display: flex;
  align-items: center;
  gap: .3rem;
  padding: .55rem .5rem;
  border-bottom: 1px solid var(--border);
  cursor: pointer;
}
/* .card-share/.card-save's own 14×14 SVGs (shared with the much smaller
   list-card icons elsewhere) read as noticeably smaller than
   .detail-header-btn's 20×20 icons — scaled up here to match, via CSS
   rather than touching SHARE_SVG/BOOKMARK_SVG themselves (those still
   need to stay small for every other card usage). CSS width/height on the
   svg element overrides its own width/height attributes cleanly since the
   viewBox is unitless — no distortion. Button padding bumped to match
   .detail-header-btn's (.3rem .5rem) too, for a consistent, more spacious
   header tap target rather than the list-icon's tighter one. */
.map-float-header .card-share,
.map-float-header .card-save {
  padding: .3rem .5rem;
}
.map-float-header .card-share svg,
.map-float-header .card-save svg {
  width: 20px;
  height: 20px;
}
.map-float-header-title {
  flex: 1;
  min-width: 0;
  text-align: center;
  font-family: var(--mono);
  /* Same size as .detail-header-title (calc(--fs-base * 1.16), not a
     token) — matching the detail overlay exactly, per its own comment,
     rather than reusing a nearby --fs-* step that just happens to be close. */
  font-size: calc(var(--fs-base) * 1.16);
  font-weight: 700;
  color: var(--terra);
  letter-spacing: .03em;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* Body: reuses .village-card only for its click-to-open-detail affordance
   and status color hook (.card-confirmed/tbd/cancelled) — padding/layout
   is fully overridden below, this isn't the list-row shape. */
.map-float-card .village-card,
.map-float-card .village-card.active {
  padding: .6rem .85rem .75rem;
  display: flex;
  flex-direction: column;
  gap: .35rem;
  border: none;
}
/* background only — NOT border-left-color: the has-poster divider is on
   .map-float-text now, not this element, so there's nothing here for a
   shared-hover-accent reset to clobber; kept simple rather than removed
   outright since .village-card:hover's shared rule still applies its own
   background/border-left changes that need canceling regardless. */
.map-float-card .village-card:hover { background: none; }
.map-float-row { display: flex; align-items: center; gap: .4rem; }
/* Name+date: space-between (name at the left edge, date at the right) —
   same convention .card-top uses elsewhere. */
.map-float-namedate { justify-content: space-between; }
/* Row2: venue link + welcome badge share one row — .card-venue-wrap is
   already flex:1 (see its own rule below), so it grows to fill the space
   and pushes the badge to the right edge on its own; space-between is
   here mainly for parity with .map-float-namedate/.detail-row2's
   convention, and as a no-op safety net if venue-wrap's flex:1 is ever
   changed. */
.map-float-row2 { justify-content: space-between; }
/* .card-date defaults to text-align:right (it normally sits opposite the
   name in one flex row) — on its own row here that would push it away from
   the names/venue rows' shared left edge, so override to left. */
.map-float-row .card-date { text-align: left; }
/* .card-date's shared color is --amber-dark (accent, unshifted hue) and
   .card-amis's is --terra (accent, hue+12°) — 12° apart reads as "two
   distinct colors" when they're side by side in a list row, but stacked as
   two full rows here they collapse into "the same purple" (confirmed in
   the fois theme, where accent is already a saturated purple). Scoped to
   the floater only — .card-date is shared by every other card usage in the
   app and shouldn't change there. Specificity (2 classes) still loses to
   .card-cancelled/.card-tbd .card-date (defined later, same specificity),
   so those keep their own muted+strikethrough treatment untouched. */
.map-float-card .card-date { color: var(--text-2); }
@keyframes map-float-in {
  from { opacity: 0; transform: translateY(12px); }
  to   { opacity: 1; transform: translateY(0); }
}
@media (min-width: 768px) {
  .map-float-card { display: none; }
}

/* ── Shared village cards ───────────────────── */
.village-card {
  padding: .6rem .85rem;
  border-left: 3px solid transparent;
  border-bottom: 1px solid var(--border);
  cursor: pointer;
  transition: border-left-color .15s, background .15s;
  -webkit-tap-highlight-color: transparent;
}
.village-card:last-child { border-bottom: none; }
.village-card:hover  { border-left-color: var(--amber-pale); background: var(--bg); }
.village-card.active { border-left-color: var(--amber);      background: var(--bg); }

/* align-items: center (not flex-start) — .card-names and .card-date sit at
   different font-sizes/line-heights, so their box tops don't put the glyphs
   at the same visual height; centering the row is what actually lines them
   up, not padding/font-size math (see also .card-chinese vs .card-amis below). */
.card-top   { display: flex; justify-content: space-between; align-items: center; gap: .5rem; }
/* Single-line, ellipsis-truncated — long bilingual/joint names (e.g. two
   merged buluo's chinese+amis names together) used to wrap and blow the
   card past its fixed 2-row height. Full name is always one tap away in
   the detail overlay, so truncating here loses nothing permanently. */
.card-names {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* Flags a buluo_ids-merged or joint:true entry — not one buluo's own event.
   Sits first in .card-names, ahead of the (possibly truncated) name text, so
   it never gets clipped by the row's ellipsis overflow. Muted --text-3, same
   as the alt-name spans below — informational, not a primary-attention cue. */
.card-multi {
  display: inline-flex;
  align-items: center;
  vertical-align: middle;
  margin-right: .3rem;
  color: var(--text-3);
}
.card-chinese { font-family: var(--cn);   font-size: var(--fs-base); font-weight: 600; line-height: 1.25; }
.card-chinese-alt { font-family: var(--cn); font-size: var(--fs-sm); font-weight: 400; color: var(--text-3); margin-left: .2rem; }
/* Courier Prime cap-height (~0.56em) sits well below CJK glyph height (~0.88em),
   so the amis label needs a larger font-size to read as visually equal-height. */
.card-amis    { font-family: var(--mono); font-size: calc(var(--fs-base) * 1.16); font-weight: 700; color: var(--terra); letter-spacing: .03em; display: inline; margin-left: .4rem; }
.card-amis-alt { font-family: var(--mono); font-size: var(--fs-sm); font-weight: 400; color: var(--text-3); letter-spacing: .03em; margin-left: .25rem; }
.card-date    { flex-shrink: 0; font-family: var(--mono); font-size: var(--fs-xs); color: var(--amber-dark); font-weight: 700; white-space: nowrap; text-align: right; }
/* Inverse of the .card-amis fix above: the weekday character (五/六/日…) is
   CJK inside an otherwise-Latin/numeral mono string, so at the same
   font-size it reads larger than the digits. Shrink by the same ratio
   (1/1.16) instead of touching the digits, which must stay put. */
.card-date-weekday { font-size: 0.86em; }
.card-cancelled .card-date { color: var(--text-3); text-decoration: line-through; }
.card-tbd .card-date       { color: var(--text-3); }
/* Same terra palette as .detail-welcome, sized down to sit inline. Right-most
   in .card-meta so it lands under .card-date above it — same column, just a
   row down — reading as "the date row's pill", not an unrelated new element. */
.card-welcome {
  flex-shrink: 0;
  display: inline-block;
  font-size: var(--fs-2xs);
  font-weight: 600;
  color: var(--terra);
  border: 1px solid var(--terra-pale);
  border-radius: 99px;
  padding: 1px 7px;
  white-space: nowrap;
}
/* Share+save grouped at the meta row's left edge (icons, venue, pill order) —
   fixed-width like .card-welcome, so .card-venue-wrap is the only flexible
   child (see below). Gap widened to .5rem (8px) specifically so the two
   buttons' *invisible*
   hit-area extensions below (3px each side, via ::after) can't overlap each
   other — 3+3=6px needed at minimum, 8px leaves a small buffer. */
.card-icons { display: flex; align-items: center; gap: .5rem; flex-shrink: 0; }
/* Outline bookmark by default; fills solid amber once saved — same
   currentColor-driven convention as .card-pin, just filled instead of a
   color swap, so "saved" reads as a distinct shape state, not just a tint. */
.card-save {
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
  padding: 2px;
  border: none;
  background: none;
  cursor: pointer;
  color: var(--text-3);
  transition: color .15s;
}
.card-save:hover  { color: var(--amber-dark); }
.card-save.saved  { color: var(--amber); }
.card-save.saved svg path { fill: currentColor; }
/* Same icon-button treatment as .card-save. */
.card-share {
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
  padding: 2px;
  border: none;
  background: none;
  cursor: pointer;
  color: var(--text-3);
  transition: color .15s;
}
.card-share:hover { color: var(--amber-dark); }
/* Invisible tap-target extension: the visible button box is 18×18px (14px
   icon + 2px padding) — under even WCAG's 24px floor. Rather than growing
   the box itself (which would grow the fixed-height meta row, and risks
   overlapping the card's own click-to-open-detail area if pushed further),
   extend the *hit area* only, via a transparent pseudo-element, up to a
   24×24px target. Deliberately NOT sized to the fuller 44/48px Apple/
   Material recommendation — at that size the two icons' hit areas would
   overlap each other even with the widened gap above. */
.card-save::after, .card-share::after {
  content: '';
  position: absolute;
  inset: -3px;
}
.card-meta   { display: flex; align-items: center; margin-top: .25rem; gap: .4rem; }
/* .card-venue-wrap (not the link itself) takes the row's flexible space, so
   the welcome pill after it still lands at the row's right edge. .card-venue
   is a plain (non-flex-grow) child inside it — it shrink-wraps to just its
   own icon+text content, so the clickable area is the link's visible
   content, not the whole flexible gap the wrap reserves. */
.card-venue-wrap { flex: 1; min-width: 0; display: flex; }
.card-venue  {
  font-size: var(--fs-sm);
  color: var(--text-2);
  min-width: 0;
  max-width: 100%;
  display: inline-flex;
  align-items: center;
  gap: .3rem;
  text-decoration: none;
}
.card-pin    { flex-shrink: 0; color: var(--terra); }
.card-venue:hover .card-pin { color: var(--amber); }
.card-venue-text {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  text-decoration: underline;
  text-decoration-color: var(--border-2);
  text-underline-offset: 2px;
}
.card-venue:hover .card-venue-text { color: var(--text-1); text-decoration-color: var(--amber); }
/* Mobile shows a short county/township(/village) string (bounded length,
   rarely truncates); desktop shows the full venue name + county/township
   instead, since it has the width to spare and the venue name is the more
   useful glance-info there. See `shortAdmin()`/`cardBodyHtml()` in app.js. */
.card-loc-desktop { display: none; }
.card-loc-mobile  { display: inline; }
@media (min-width: 768px) {
  .card-loc-desktop { display: inline; }
  .card-loc-mobile  { display: none; }
}

/* ── Village detail overlay ─────────────────── */
/* A backdrop (.detail-overlay) behind a floating, centered card
   (.detail-panel) — deliberately not edge-to-edge and not bottom-anchored
   (that's #bottomSheet's job on the map tab, a distinct component), so this
   reads as a popup window over the dimmed app regardless of how much content
   it holds. A sparse entry (just the header card) used to anchor to the
   bottom edge via align-items:flex-end and look like a broken stub of a
   bottom sheet; centering unconditionally (2026-07-14) fixes that, since a
   short card centers naturally instead of looking stuck to an edge.
   max-height/.detail-scroll below are unchanged, so a content-heavy entry
   still opens to roughly the same height and stays internally scrollable.
   Backdrop click and the close button both dismiss it (wired in
   js/detail.js). */
.detail-overlay {
  position: fixed;
  inset: 0;
  z-index: 1100;
  background: oklch(from black l c h / 0.5);
  display: flex;
  align-items: center;
  justify-content: center;
  opacity: 0;
  pointer-events: none;
  transition: opacity .2s ease;
}
.detail-overlay[hidden] { display: none; }
.detail-overlay.open { opacity: 1; pointer-events: auto; }

.detail-panel {
  /* Guarantees a real side gap on every viewport width — not just once the
     viewport grows past max-width. `100% - 2rem` reserves 1rem on each side
     (the flex container centers it), `min()` still caps it at 560px on
     larger screens. */
  width: min(100% - 2rem, 560px);
  max-height: 85vh;
  display: flex;
  flex-direction: column;
  background: var(--bg);
  border-radius: 16px;
  box-shadow: 0 8px 32px oklch(from black l c h / 0.3);
  /* Scrolling lives on .detail-scroll below, not here — a native scrollbar
     on the same element as border-radius isn't clipped to the curve (square
     corner even with overflow:hidden's own clip-mask; verified in Chrome).
     Splitting the scroll from the rounded shell fixes it. */
  overflow: hidden;
  /* Fade + slight scale reads as a centered dialog appearing in place;
     the previous translateY slide-up was a leftover of the old
     bottom-sheet-style anchoring and looked like it was arriving from an
     edge that no longer exists. */
  transform: scale(.96);
  transition: transform .2s ease;
}
.detail-overlay.open .detail-panel { transform: scale(1); }

.detail-scroll {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
  /* ::-webkit-scrollbar only (no scrollbar-width/-color) — verified in Chrome
     that declaring both on the same element reverts to the unclipped native
     scrollbar, squaring off .detail-panel's corner again. Firefox falls back
     to its default scrollbar here since it doesn't support ::-webkit-scrollbar,
     but it also doesn't have the corner-clipping bug this works around. */
}
.detail-scroll::-webkit-scrollbar       { width: 6px; }
.detail-scroll::-webkit-scrollbar-thumb { background: var(--accent); border-radius: 4px; }
.detail-scroll::-webkit-scrollbar-track { background: transparent; }

.detail-header {
  position: sticky;
  top: 0;
  z-index: 1;
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: .6rem .85rem;
  background: var(--bg);
  border-bottom: 1px solid var(--border);
}
/* Two icon buttons on each side (2026-07-14: share+calendar left,
   save+close right — was 3 left / 1 right before the map-link button moved
   into the body as a labeled link, see .detail-pokohmap-link). */
.detail-header-left, .detail-header-right { display: flex; align-items: center; }
/* Absolutely positioned so it centers on the header's full width regardless
   of the side buttons' widths, rather than being centered in the leftover
   flex space between them — position:absolute within the (sticky, so
   positioned) .detail-header, immune to sibling width changes, unlike
   flex's justify-content:center which shifts if the two side clusters
   differ in width. EQUAL left/right insets is what actually guarantees
   this: with left==right, the title box is geometrically centered on the
   header regardless of any width difference between the two button
   clusters (only the *larger* cluster's width sets the shared inset both
   sides use) — asymmetric insets (the old 8.1rem/2.6rem, sized to very
   differently-sized clusters) only approximate centering, and would drift
   off-center again the moment either cluster's width changes. Value
   re-measured 2026-07-14 for the new 2-button-each-side layout via a real
   rendered check (Playwright), not computed from padding/font-size — see
   CLAUDE.md's "measure, don't guess" convention for row alignment. */
.detail-header-title {
  position: absolute;
  left: 5.9rem;
  right: 5.9rem;
  text-align: center;
  font-family: var(--mono);
  font-size: calc(var(--fs-base) * 1.16);
  font-weight: 700;
  color: var(--terra);
  letter-spacing: .03em;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  pointer-events: none;
}
/* Shared by all 4 header icon buttons — icon-only, no text, same visual
   weight as the tab bar's own icons. */
.detail-header-btn {
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: var(--fs-md);
  color: var(--text-2);
  background: none;
  border: none;
  cursor: pointer;
  line-height: 1;
  padding: .3rem .5rem;
  z-index: 1;
}
.detail-header-btn:hover { color: var(--amber); }
/* Save button's "saved" state — same outline-to-filled convention as
   .card-save.saved, just living in the header instead of a card row. */
.detail-header-btn.saved       { color: var(--amber); }
.detail-header-btn.saved svg path { fill: currentColor; }
.detail-body { padding: 0 1rem 2rem; }

/* Bottom-centered confirmation toast (share link copied) — sits above the
   mobile tab bar so it's never clipped behind it, above .detail-overlay
   (1100) too since sharing can be triggered from inside that overlay. */
.toast {
  position: fixed;
  bottom: calc(var(--tab-h) + 1rem + env(safe-area-inset-bottom, 0px));
  left: 50%;
  transform: translateX(-50%) translateY(12px);
  background: var(--ink);
  color: var(--bg);
  font-family: var(--mono);
  font-size: var(--fs-xs);
  padding: .5rem 1rem;
  border-radius: 99px;
  opacity: 0;
  pointer-events: none;
  transition: opacity .18s, transform .18s;
  z-index: 1200;
  max-width: calc(100vw - 2rem);
  text-align: center;
}
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }

/* Reuses .village-card so the header reads as the same card, expanded in
   place — but it isn't itself tappable, so the hover/active affordances and
   left accent border are switched off. Bled out to the panel's edge via
   negative margin to match how cards sit edge-to-edge in the list views. */
.detail-header-card {
  margin: 0 -1rem;
  padding-left: 1rem;
  padding-right: 1rem;
  cursor: default;
}
.detail-header-card:hover { border-left-color: transparent; background: none; }
/* Rows 2–3 below the name/date row (2026-07-14 rework). Row 2: the in-app
   map link + welcome-day pill share one row (2026-07-14 — both short, no
   need for separate rows) — space-between puts the map link at the left
   edge and the pill at the right edge, landing it under the date above
   (row 1's date is the right-hand side of .card-top's
   justify-content:space-between), same "pill sits under the date"
   convention .card-welcome already follows in list cards. When there's no
   welcome day, the map link just sits alone at the left. Row 3: venue. */
.detail-row2 { display: flex; align-items: center; justify-content: space-between; gap: .5rem; margin-top: .5rem; }
.detail-venue-row { margin-top: .5rem; }
.detail-welcome {
  display: inline-block;
  font-size: var(--fs-xs);
  font-weight: 600;
  color: var(--terra);
  border: 1px solid var(--terra-pale);
  border-radius: 99px;
  padding: 2px 10px;
}
/* "顯示於 Pokoh 地圖" — relocated from the header's icon-only button
   (2026-07-14); a labeled link reads clearer than a bare pin icon did. */
.detail-pokohmap-link {
  display: inline-flex;
  align-items: center;
  gap: .35rem;
  font-size: var(--fs-sm);
  color: var(--text-2);
  text-decoration: none;
}
.detail-pokohmap-link svg { flex-shrink: 0; color: var(--terra); }
.detail-pokohmap-link:hover { color: var(--text-1); }
.detail-pokohmap-link:hover svg { color: var(--amber); }
/* Small trailing hint on the venue link, added 2026-07-14 so the link's
   destination (Google Maps) isn't only implied by the pin icon. */
.detail-venue-hint {
  margin-left: .4rem;
  font-size: var(--fs-2xs);
  color: var(--text-3);
  text-decoration: none;
  white-space: nowrap;
}
.detail-poster { margin: .85rem 0; }
.detail-poster img { display: block; width: 100%; border-radius: 8px; border: 1px solid var(--border); }
.detail-poster-credit { display: inline-block; margin-top: .3rem; font-size: var(--fs-2xs); color: var(--text-3); text-decoration: none; }
a.detail-poster-credit:hover { color: var(--amber); text-decoration: underline; }
.detail-section-title { font-size: var(--fs-xs); font-weight: 700; color: var(--text-2); letter-spacing: .04em; margin: 1.1rem 0 .5rem; }
.detail-days { display: flex; flex-direction: column; gap: .5rem; }
/* One container per date, not per sub-event — a day with several named
   sub-events (e.g. Palaylay + Malikoda, both 7/9) shows the date once on
   the left and stacks the events as rows on the right, instead of each
   event getting its own separate boxed row. */
.detail-day-row {
  display: flex;
  gap: .6rem;
  padding: .45rem .6rem;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 6px;
  font-size: var(--fs-sm);
}
.detail-day-date { flex-shrink: 0; padding-top: .15rem; font-family: var(--mono); font-size: var(--fs-xs); color: var(--amber-dark); white-space: nowrap; }
.detail-day-events { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: .3rem; }
.detail-day-event { display: flex; align-items: baseline; flex-wrap: wrap; column-gap: .6rem; row-gap: .1rem; }
.detail-day-name { font-family: var(--mono); font-weight: 700; color: var(--terra); white-space: nowrap; }
.detail-day-zh   { font-family: var(--cn); color: var(--text-1); }
.detail-day-desc { flex-basis: 100%; font-size: var(--fs-xs); color: var(--text-3); }
.detail-history  { font-size: var(--fs-sm); color: var(--text-2); line-height: 1.6; }
.detail-contacts { display: flex; flex-direction: column; gap: .4rem; }
.detail-contact-row {
  display: flex;
  align-items: baseline;
  gap: .5rem;
  padding: .4rem .6rem;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 6px;
  font-size: var(--fs-sm);
}
.detail-contact-role  { flex-shrink: 0; font-size: var(--fs-xs); color: var(--text-3); }
.detail-contact-name  { color: var(--text-1); }
.detail-contact-phone { margin-left: auto; font-family: var(--mono); color: var(--terra); text-decoration: none; white-space: nowrap; }
a.detail-contact-phone:hover { text-decoration: underline; }
.detail-address { margin-top: .3rem; font-size: var(--fs-xs); color: var(--text-3); }
.detail-shuttle { display: flex; flex-direction: column; gap: .4rem; }
.detail-shuttle-row {
  display: flex;
  align-items: baseline;
  gap: .5rem;
  padding: .4rem .6rem;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 6px;
  font-size: var(--fs-sm);
}
.detail-shuttle-label { flex-shrink: 0; font-size: var(--fs-xs); color: var(--text-3); }
.detail-shuttle-value { color: var(--text-1); }

/* ── Search panel ──────────────────────────── */
#panel-search          { display: none; flex-direction: column; overflow: hidden; }
#panel-search.active   { display: flex; }

.sr-input-row {
  flex-shrink: 0;
  display: flex;
  align-items: center;
  gap: .5rem;
  padding: .45rem .85rem;
  border-bottom: 1px solid var(--border);
  background: var(--bg);
}
.sr-icon { font-size: var(--fs-base); color: var(--text-3); flex-shrink: 0; line-height: 1; }
.sr-input {
  flex: 1;
  background: none;
  border: none;
  outline: none;
  font-family: var(--cn);
  font-size: var(--fs-base);
  color: var(--text-1);
  caret-color: var(--accent);
  min-width: 0;
}
.sr-input::placeholder { color: var(--text-3); font-size: var(--fs-sm); }
.sr-clear {
  background: none;
  border: none;
  color: var(--text-3);
  cursor: pointer;
  font-size: var(--fs-xs);
  padding: 3px 5px;
  border-radius: var(--r);
  line-height: 1;
  transition: color .12s;
  -webkit-tap-highlight-color: transparent;
}
.sr-clear:hover { color: var(--text-1); }

.sr-chips {
  flex-shrink: 0;
  display: flex;
  gap: .4rem;
  padding: .45rem .85rem;
  border-bottom: 1px solid var(--border);
  background: var(--bg);
  overflow-x: auto;
  scrollbar-width: none;
}
.sr-chips::-webkit-scrollbar { display: none; }
.sr-chip {
  font-family: var(--mono);
  font-size: var(--fs-2xs);
  letter-spacing: .07em;
  padding: 3px 10px;
  border-radius: 99px;
  border: 1px solid var(--border);
  background: transparent;
  color: var(--text-3);
  cursor: pointer;
  white-space: nowrap;
  flex-shrink: 0;
  transition: background .12s, color .12s, border-color .12s;
  -webkit-tap-highlight-color: transparent;
}
.sr-chip.active              { background: var(--accent); border-color: var(--accent); color: var(--bg); }
.sr-chip:hover:not(.active)  { border-color: var(--amber); color: var(--amber-dark); }

.sr-results {
  flex: 1;
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
  overscroll-behavior: contain;
}
.sr-hint {
  padding: 2.5rem .85rem;
  font-family: var(--mono);
  font-size: var(--fs-xs);
  color: var(--text-3);
  letter-spacing: .08em;
  text-align: center;
}
.sr-count {
  padding: .4rem .85rem .3rem;
  font-family: var(--mono);
  font-size: var(--fs-2xs);
  color: var(--text-3);
  letter-spacing: .07em;
  border-bottom: 1px solid var(--border);
  position: sticky;
  top: 0;
  background: var(--bg);
  z-index: 5;
}

/* ── Recent searches ───────────────────────── */
.sr-recent-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: .55rem .85rem .3rem;
  flex-shrink: 0;
}
.sr-recent-title {
  font-family: var(--mono);
  font-size: var(--fs-2xs);
  color: var(--text-3);
  letter-spacing: .07em;
}
.sr-clear-all {
  font-family: var(--mono);
  font-size: var(--fs-2xs);
  color: var(--accent);
  background: none;
  border: none;
  cursor: pointer;
  padding: 0;
}
.sr-recent-row {
  display: flex;
  align-items: center;
  gap: .5rem;
  padding: .55rem .85rem;
  cursor: pointer;
  border-bottom: 1px solid var(--border);
}
.sr-recent-row:hover { background: var(--bg-alt); }
.sr-recent-icon { font-size: var(--fs-2xs); color: var(--text-3); flex-shrink: 0; }
.sr-recent-text {
  flex: 1;
  font-family: var(--cn);
  font-size: var(--fs-sm);
  color: var(--text-1);
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.sr-recent-del {
  font-size: var(--fs-xs);
  color: var(--text-3);
  background: none;
  border: none;
  cursor: pointer;
  padding: 0 .1rem;
  flex-shrink: 0;
}
.sr-recent-del:hover { color: var(--text-1); }
.sr-show-more {
  display: block;
  width: 100%;
  padding: .65rem .85rem;
  font-family: var(--mono);
  font-size: var(--fs-xs);
  color: var(--accent);
  background: none;
  border: none;
  border-top: 1px solid var(--border);
  cursor: pointer;
  text-align: left;
  letter-spacing: .04em;
}
.sr-show-more:hover { background: var(--bg-alt); }

/* ── Info panel ─────────────────────────────── */
#panel-info { overflow-y: auto; -webkit-overflow-scrolling: touch; }
.info-wrap {
  max-width: 480px;
  margin: 0 auto;
  padding: 2rem .85rem 3rem;
}
.info-logo-block {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: .55rem;
  margin-bottom: 2rem;
}
.info-logo { width: 72px; height: 72px; object-fit: contain; border-radius: 50%; }
.info-appname {
  font-family: var(--disp);
  font-size: var(--fs-lg);
  color: var(--text-1);
  letter-spacing: .02em;
  text-align: center;
}
.info-appsub {
  font-family: var(--mono);
  font-size: var(--fs-xs);
  color: var(--text-3);
  letter-spacing: .1em;
  text-align: center;
  margin-top: -.2rem;
}
.info-tiles {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: .55rem;
  margin-bottom: 1.5rem;
}
.info-tile {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: calc(var(--r) * 2);
  padding: .75rem .85rem;
}
.info-tile-value {
  font-family: var(--mono);
  font-size: var(--fs-md);
  color: var(--accent);
  letter-spacing: .02em;
}
.info-tile-label {
  font-family: var(--mono);
  font-size: var(--fs-2xs);
  color: var(--text-3);
  letter-spacing: .08em;
  margin-top: .2rem;
}
.info-section-title {
  font-family: var(--mono);
  font-size: var(--fs-2xs);
  color: var(--text-1);
  letter-spacing: .1em;
  text-transform: uppercase;
  margin-bottom: .6rem;
}
.info-warn {
  border: 1px solid var(--amber);
  border-left: 3px solid var(--amber);
  border-radius: var(--r);
  padding: .7rem .85rem;
  margin-bottom: 1.5rem;
}
.info-warn-title {
  font-family: var(--mono);
  font-size: var(--fs-xs);
  color: var(--amber-dark);
  letter-spacing: .06em;
  margin-bottom: .3rem;
}
.info-warn-body {
  font-family: var(--cn);
  font-size: var(--fs-sm);
  color: var(--text-2);
  line-height: 1.6;
}
.info-sources {
  display: flex;
  flex-direction: column;
  gap: .1rem;
  margin-bottom: 1.5rem;
}
.info-source-link {
  display: flex;
  align-items: center;
  gap: .5rem;
  padding: .5rem 0;
  border-bottom: 1px solid var(--border);
  text-decoration: none;
  color: var(--text-2);
}
.info-source-link:last-child { border-bottom: none; }
.info-source-label { flex: 1; font-family: var(--cn); font-size: var(--fs-sm); }
.info-source-arrow { font-family: var(--mono); font-size: var(--fs-2xs); color: var(--text-3); }
.info-sources-details { margin-bottom: 0; }
.info-sources-details .info-sources { margin-bottom: 0; padding-top: .5rem; }
.info-sources-summary {
  display: flex;
  align-items: center;
  gap: .4rem;
  cursor: pointer;
  list-style: none;
  padding: .4rem 0;
  margin-bottom: .25rem;
}
.info-sources-summary::-webkit-details-marker { display: none; }
.info-chevron {
  font-family: var(--mono);
  font-size: var(--fs-2xs);
  color: var(--text-3);
  transition: transform .2s;
}
.info-sources-details[open] .info-chevron { transform: rotate(180deg); }
.info-about-body {
  font-family: var(--cn);
  font-size: var(--fs-sm);
  color: var(--text-2);
  line-height: 1.75;
}

/* ── Contribution form ──────────────────────── */
.contrib-cta-row { display: flex; gap: .6rem; margin-bottom: 1.5rem; }
.contrib-cta {
  flex: 1;
  font-family: var(--mono);
  font-size: var(--fs-sm);
  font-weight: 600;
  letter-spacing: .04em;
  padding: .7rem .5rem;
  background: var(--accent);
  color: var(--bg);
  border: none;
  border-radius: var(--r);
  cursor: pointer;
  text-align: center;
  transition: opacity .12s;
}
.contrib-cta:active { opacity: .8; }
.contrib-details { margin-top: 1.75rem; }
.contrib-summary {
  list-style: none;
  display: flex;
  align-items: center;
  justify-content: space-between;
  cursor: pointer;
  user-select: none;
  -webkit-tap-highlight-color: transparent;
}
.contrib-summary::-webkit-details-marker { display: none; }
.contrib-summary .info-section-title { margin-bottom: 0; }
.contrib-chevron {
  font-family: var(--mono);
  font-size: var(--fs-2xs);
  color: var(--text-3);
  transition: transform .2s;
}
.contrib-details[open] .contrib-chevron { transform: rotate(90deg); }
.contrib-body { padding-top: .85rem; }
.contrib-tabs {
  display: flex;
  gap: 0;
  border-bottom: 1px solid var(--border);
  margin-bottom: 1rem;
}
.contrib-tab {
  font-family: var(--mono);
  font-size: var(--fs-xs);
  letter-spacing: .07em;
  padding: .42rem 1rem;
  background: none;
  border: none;
  border-bottom: 2px solid transparent;
  color: var(--text-3);
  cursor: pointer;
  transition: color .12s, border-color .12s;
  margin-bottom: -1px;
}
.contrib-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
.contrib-form { display: none; flex-direction: column; gap: .75rem; }
.contrib-form.active { display: flex; }
.form-field { display: flex; flex-direction: column; gap: .28rem; }
.form-label {
  font-family: var(--mono);
  font-size: var(--fs-2xs);
  color: var(--text-3);
  letter-spacing: .07em;
}
.form-label .req { color: var(--accent); margin-left: 2px; }
.form-input,
.form-select,
.form-textarea {
  background: var(--bg-alt);
  border: 1px solid var(--border);
  border-radius: var(--r);
  padding: .4rem .6rem;
  font-family: var(--cn);
  font-size: var(--fs-base);
  color: var(--text-1);
  outline: none;
  width: 100%;
  transition: border-color .15s;
}
.form-input:focus,
.form-select:focus,
.form-textarea:focus { border-color: var(--accent); }
.form-input::placeholder,
.form-textarea::placeholder { color: var(--text-3); font-size: var(--fs-sm); }
.form-select { appearance: none; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%23666'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right .6rem center; padding-right: 1.8rem; }
[data-theme="folad"] .form-select,
[data-theme="fois"]  .form-select { background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%23aaa'/%3E%3C/svg%3E"); }
.form-textarea { resize: vertical; min-height: 72px; }
.form-radio-group { display: flex; flex-wrap: wrap; gap: .4rem; }
.form-radio-label {
  display: flex;
  align-items: center;
  gap: .3rem;
  font-family: var(--mono);
  font-size: var(--fs-xs);
  color: var(--text-2);
  cursor: pointer;
  padding: 3px 8px;
  border: 1px solid var(--border);
  border-radius: 99px;
  transition: border-color .12s, color .12s, background .12s;
}
.form-radio-label input { display: none; }
.form-radio-label:has(input:checked) { border-color: var(--accent); color: var(--accent); background: var(--amber-pale); }
.form-conditional { display: none; }
.form-conditional.visible { display: flex; flex-direction: column; gap: .28rem; }
.form-submit {
  align-self: flex-start;
  font-family: var(--mono);
  font-size: var(--fs-xs);
  letter-spacing: .08em;
  padding: .45rem 1.2rem;
  background: var(--accent);
  color: var(--bg);
  border: none;
  border-radius: var(--r);
  cursor: pointer;
  transition: opacity .15s;
  margin-top: .25rem;
}
.form-submit:disabled { opacity: .45; cursor: not-allowed; }
.form-status {
  font-family: var(--mono);
  font-size: var(--fs-xs);
  letter-spacing: .05em;
  padding: .4rem .7rem;
  border-radius: var(--r);
  display: none;
}
.form-status.ok  { display: block; background: oklch(from var(--green) l c h / 0.12); color: var(--green); border: 1px solid oklch(from var(--green) l c h / 0.3); }
.form-status.err { display: block; background: oklch(from var(--terra) l c h / 0.1); color: var(--terra); border: 1px solid oklch(from var(--terra) l c h / 0.25); }

/* Per-field validation state — border color + a small inline icon, so an
   invalid field is visible at a glance rather than only via the aggregate
   .form-status banner above. Icon reuses the same background-image-on-input
   trick as .form-select's own dropdown caret just below. */
.form-input.invalid, .form-textarea.invalid { border-color: var(--terra); }
.form-input.valid,   .form-textarea.valid   { border-color: var(--green); }
.form-input.invalid, .form-textarea.invalid {
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24'%3E%3Ccircle cx='12' cy='12' r='10' fill='none' stroke='%23c0392b' stroke-width='2'/%3E%3Cline x1='12' y1='7' x2='12' y2='13' stroke='%23c0392b' stroke-width='2' stroke-linecap='round'/%3E%3Ccircle cx='12' cy='16.5' r='1.1' fill='%23c0392b'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: right .6rem center;
  padding-right: 1.9rem;
}
.form-input.valid, .form-textarea.valid {
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24'%3E%3Cpath d='M4 12.5l5 5L20 6' fill='none' stroke='%232e7d32' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: right .6rem center;
  padding-right: 1.9rem;
}
/* .form-select already owns background-image (dropdown caret) — the
   validation icon has to sit in the same property, so both selectors
   redeclare the caret alongside the added icon rather than losing it. */
.form-select.invalid, .form-select.valid { background-repeat: no-repeat, no-repeat; background-position: right .6rem center, right 1.9rem center; padding-right: 3.1rem; }
.form-select.invalid { background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%23666'/%3E%3C/svg%3E"), url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24'%3E%3Ccircle cx='12' cy='12' r='10' fill='none' stroke='%23c0392b' stroke-width='2'/%3E%3Cline x1='12' y1='7' x2='12' y2='13' stroke='%23c0392b' stroke-width='2' stroke-linecap='round'/%3E%3Ccircle cx='12' cy='16.5' r='1.1' fill='%23c0392b'/%3E%3C/svg%3E"); border-color: var(--terra); }
.form-select.valid { background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%23666'/%3E%3C/svg%3E"), url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24'%3E%3Cpath d='M4 12.5l5 5L20 6' fill='none' stroke='%232e7d32' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); border-color: var(--green); }
/* Radio "問題類型" group has no single input to put a border/icon on — an
   outline around the whole pill row is the equivalent affordance. */
.form-radio-group.invalid { outline: 1px solid var(--terra); outline-offset: 4px; border-radius: 8px; }

/* ── Contribution modal header (simpler than .detail-header: one title,
   one close button, no left icon cluster to center against) ── */
.contrib-header {
  position: sticky;
  top: 0;
  z-index: 1;
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: .6rem .85rem;
  background: var(--bg);
  border-bottom: 1px solid var(--border);
}
.contrib-header-title {
  font-family: var(--mono);
  font-size: calc(var(--fs-base) * 1.16);
  font-weight: 700;
  color: var(--terra);
  letter-spacing: .03em;
}
.contrib-header-btn {
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: var(--fs-md);
  color: var(--text-2);
  background: none;
  border: none;
  cursor: pointer;
  line-height: 1;
  padding: .3rem .5rem;
}
.contrib-header-btn:hover { color: var(--amber); }

/* ── Submission success state — replaces the form in place with a summary
   of what was submitted, rather than just a one-line status message. ── */
.form-success {
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
  gap: .3rem;
  padding: 1rem 0 .5rem;
}
.form-success-icon {
  width: 44px;
  height: 44px;
  border-radius: 50%;
  background: oklch(from var(--green) l c h / 0.15);
  color: var(--green);
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 1.3rem;
  margin-bottom: .3rem;
}
.form-success-title { font-family: var(--cn); font-weight: 700; font-size: var(--fs-md); color: var(--text-1); }
.form-success-fields {
  width: 100%;
  display: flex;
  flex-direction: column;
  gap: .3rem;
  margin: .75rem 0 1rem;
  text-align: left;
}
.form-success-row {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: .6rem;
  padding: .4rem .6rem;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 6px;
  font-size: var(--fs-sm);
}
.form-success-label { flex-shrink: 0; font-family: var(--mono); font-size: var(--fs-2xs); color: var(--text-3); }
.form-success-value { color: var(--text-1); text-align: right; overflow-wrap: anywhere; }

/* ── Placeholder panels ─────────────────────── */
.panel-placeholder {
  height: 100%;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: .6rem;
  color: var(--text-3);
  font-family: var(--mono);
  font-size: var(--fs-xs);
  letter-spacing: .12em;
  text-transform: uppercase;
}
.panel-placeholder .ph-icon { font-size: var(--fs-xl); }

/* ── Leaflet overrides ──────────────────────── */
.leaflet-container { background: var(--bg-alt); }
.leaflet-control-zoom a { color: var(--text-2) !important; background: var(--surface) !important; border-color: var(--border) !important; }
@media (max-width: 767px) { .leaflet-control-zoom { display: none; } }
.marker-cluster-small,
.marker-cluster-medium,
.marker-cluster-large  { background-color: oklch(from var(--accent) l c h / 0.22) !important; }
.marker-cluster-small  div,
.marker-cluster-medium div,
.marker-cluster-large  div {
  background-color: oklch(from var(--accent) l c h / 0.72) !important;
  color: var(--bg) !important; font-family: var(--mono) !important; font-size: var(--fs-xs) !important;
}

/* County/township boundary overlay (addBorderLayers() in js/map.js) —
   fill:none so the polygons never obscure the basemap/markers, stroke
   color/opacity via theme tokens (not a flat hardcoded color) so it stays
   correct across all 4 themes with no JS theme-change listener needed,
   unlike the tile layer itself. Leaflet sets its own default path
   presentation attributes (blue stroke, filled) directly on each SVG
   path; author stylesheet rules like these win over presentation
   attributes in the CSS cascade, so no !important needed to override them.
   --text-3 (ink-relative), not --border-2 (bg-relative), for the town
   stroke — measured and caught before shipping: --border-2 computed to
   oklch(0 ...) (pure black, clipped) in the fois theme, since it's
   `bg's lightness - 0.15` and fois's --bg is already only 11% lightness.
   --text-3 is derived from --ink instead, which the theme system already
   keeps legible against --bg in every theme, so it can't clip the same way. */
.border-town {
  fill: none;
  stroke: var(--text-3);
  stroke-width: 0.5;
  stroke-opacity: 0.6;
}
.border-county {
  fill: none;
  stroke: var(--accent);
  stroke-width: 1;
  stroke-opacity: 0.55;
}

/* Teardrop event pins (makeIcon() in js/map.js). transform-origin is the
   drawn tip, not the box center, so zoom-tier scaling and the pop-in below
   both grow/shrink from the point touching the coordinate, not the middle
   of the pin. Scale tiers are a plain CSS attribute selector on #map (see
   updateZoomTier()) rather than re-creating every marker's icon on zoom. */
.marker-pin-wrap {
  transform-origin: 50% 100%;
  transition: transform .15s ease;
  filter: drop-shadow(0 1px 3px oklch(from var(--ink) l c h / 0.35));
}
#map[data-zoom-tier="far"]  .marker-pin-wrap { transform: scale(0.8); }
#map[data-zoom-tier="near"] .marker-pin-wrap { transform: scale(1.25); }
/* One continuous animation, not the old one-shot pop chained with a new
   infinite pulse — both would target `transform`, and a later-listed
   animation in a comma-separated list wins that property outright rather
   than blending with an earlier one, so trying to run both at once
   wouldn't actually combine them. The pulse's own resting size (1.15) already
   reads as "larger than normal" the instant it's selected, so it doubles as
   the old pop-in's job without a separate animation. */
.marker-pin-wrap.is-active {
  filter: drop-shadow(0 2px 5px oklch(from var(--ink) l c h / 0.45));
  animation: pin-pulse 1.8s ease-in-out infinite;
}
@keyframes pin-pulse {
  0%, 100% { transform: scale(1.15); }
  50%      { transform: scale(1.3); }
}

/* ── Sidebar (desktop only) ──────────────────── */
.sidebar {
  display: none;
  position: fixed;
  left: 0; top: 0; bottom: 0;
  width: 240px;
  flex-direction: column;
  background: var(--bg);
  border-right: 1px solid var(--border);
  z-index: 600;
}
.sb-logo-block {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: .6rem;
  height: 54px;
  padding: 0 1.2rem;
  border-bottom: 1px solid var(--border);
  flex-shrink: 0;
}
.sb-brand  { display: flex; align-items: center; gap: .6rem; }
.sb-logo   { width: 34px; height: 34px; border-radius: 50%; object-fit: contain; flex-shrink: 0; }
.sb-appname { font-family: var(--disp); font-size: var(--fs-lg); color: var(--text-1); letter-spacing: -.01em; }
.sb-header-actions { display: flex; align-items: center; gap: .4rem; flex-shrink: 0; }
.sb-nav {
  flex: 1;
  display: flex;
  flex-direction: column;
  padding: .6rem 0;
  overflow-y: auto;
}
.sb-link {
  display: flex;
  align-items: center;
  padding: .7rem 1.2rem;
  font-family: var(--mono);
  font-size: var(--fs-sm);
  letter-spacing: .07em;
  color: var(--text-2);
  background: none;
  border: none;
  border-left: 2px solid transparent;
  cursor: pointer;
  text-align: left;
  transition: color .12s, background .12s, border-color .12s;
  -webkit-tap-highlight-color: transparent;
}
.sb-link:hover { color: var(--text-1); background: var(--bg-alt); }
.sb-link.active { color: var(--accent); border-left-color: var(--accent); background: var(--bg-alt); }
.sb-link-icon { width: 16px; height: 16px; margin-right: .6rem; flex-shrink: 0; display: flex; align-items: center; justify-content: center; }
.sb-link-icon svg { display: block; }
/* Close button (desktop detail panel) */
.bs-close-btn {
  display: none;
  margin-left: auto;
  background: none;
  border: none;
  cursor: pointer;
  font-family: var(--mono);
  font-size: var(--fs-xs);
  color: var(--text-3);
  padding: .2rem .4rem;
}
.bs-close-btn:hover { color: var(--text-1); }

/* ── Desktop breakpoint ≥768px ───────────────── */
@media (min-width: 768px) {
  .sidebar { display: flex; }
  .app-header { display: none; }
  .tab-bar { display: none; }
  .bs-handle-pill { display: none; }
  .bs-close-btn { display: block; }

  .panel { inset: 0 0 0 240px; }
  #panel-info { inset: 0 0 0 240px; }
  /* Re-correct back to the desktop layout — #panel-map's own ID-level
     mobile override (inset: 0 0 var(--tab-h) 0, added for the map tab's
     transparent-header treatment, which doesn't exist on desktop) would
     otherwise beat this block's .panel class rule on specificity alone,
     same reason #panel-info needs its own re-correction right above. */
  #panel-map { inset: 0 0 0 240px; }

  .tl-header { height: 54px; position: relative; }
  .tl-name-toggle-desktop {
    display: inline-block;
    position: absolute; right: .5rem; top: 50%; transform: translateY(-50%);
  }

  .bs {
    position: fixed;
    top: 0; bottom: 0;
    left: auto; right: 0;
    width: 440px;
    height: 100%;
    border-radius: 0;
    border-top: none;
    border-left: 1px solid var(--border);
    box-shadow: -4px 0 24px oklch(from var(--ink) l c h / 0.08);
    transform: translateX(100%);
  }
  .bs[data-state="half"],
  .bs[data-state="full"] { transform: translateX(0); height: 100%; }
  .bs[data-state="collapsed"] { transform: translateX(100%); }

  #panel-map:has(.bs[data-state="half"]),
  #panel-map:has(.bs[data-state="full"]) { right: 440px; }

  .bs-handle-bar {
    height: 48px;
    justify-content: flex-start;
    padding: 0 .85rem;
    border-bottom: 1px solid var(--border);
    cursor: default;
  }
  .bs-handle-bar:active { cursor: default; }

  #panel-timeline { display: none; }
  #panel-timeline.active { display: flex; }
}
