/* deploy/site.css — the ONLY stylesheet on this site.
   Live pages (index/ranking/activity/town.html) and the admin preview
   (preview.html) all link this same file. There is no second copy anywhere;
   preview.html loads the real page's markup instead of duplicating it.

   Served `max-age=0, must-revalidate` (vercel.json) on purpose — the whole admin
   UX is live style tweaking, and an immutable stylesheet once froze the
   predecessor site in every returning visitor's browser for two sessions. `?v=N`
   on the link is still bumped on edits, but a missed bump now costs one
   conditional request, not a year of stale CSS. */

@import url('/cursor.css?v=3');

/* DB HelvethaicaMon X, Bold only — the one weight the licence file ships. The 100–900 range
   is deliberate: it makes the browser use this face at every weight instead of synthesising a
   faux-bold on top of it, which is what "ตัวหนาทั้งเว็บ" asks for.
   Checked before shipping: macStyle=1 / fsSelection=32 (bold set, italic clear), so this file
   is not affected by the foundry bug that made the predecessor site render fake obliques in
   Safari and the LINE in-app browser. Re-check those bits if the font is ever re-sourced. */
@font-face{
  font-family:'DBHelvethaicaMonX';
  /* Percent-encoded because the licence file keeps its original spaced filename. If it is ever
     renamed to something without spaces, update this line to match. */
  src:url('/fonts/DB%20HelvethaicaMon%20X%20Bd%20v3.2.ttf') format('truetype');
  font-weight:100 900;
  font-style:normal;
  font-display:swap;
}

/* ============================================================ tokens */

:root{
  --bg:#e7f4fb;            /* light blue page ground */
  --cream:#fdf7ea;         /* the cloud band at the top and bottom of every page */
  --scallop-line:#e3d9c2;  /* soft outline hugging the cloud edge */
  --paper:#ffffff;
  --paper-2:#fdf9f0;
  --line:#141414;          /* the hand-drawn black outline everything wears */
  --line-soft:#b9c6d0;
  --ink:#141414;
  --ink-soft:#6b7883;
  --grape:#43b7d9;         /* logo blue */
  --grape-deep:#1c8fb5;
  --grape-soft:#dcf2fa;
  --pink:#ff8fb1;
  --pink-soft:#ffe3ec;
  --yellow:#ffd45e;
  --orange:#ff9f43;
  --mint:#7fd6bd;
  --lilac:#b58cff;
  --tape:rgba(214,214,206,.72);
  --tape-line:rgba(178,178,168,.55);
  --shadow-hard:4px 4px 0 var(--line);
  --radius:20px;
  --bw:3px;                /* standard outline width */

  /* How every admin-set font size is rescaled for narrower screens. render.js writes each
     --fs-* as calc(<slider px> * var(--ui-shrink) + var(--ui-lift)), so these two numbers
     are the only place the phone type scale lives — see the responsive section at the
     bottom of this file for the per-breakpoint values. 1 / 0px = leave the slider alone. */
  --ui-shrink:1;
  --ui-lift:0px;

  --font-body:'DBHelvethaicaMonX','Noto Sans Thai','Kanit',system-ui,sans-serif;
  --font-pixel:'Press Start 2P','DBHelvethaicaMonX',monospace;
}

:root[data-theme="dark"]{
  --bg:#0f1a22;
  --cream:#182430;
  --scallop-line:#2b3b48;
  --paper:#1a242f;
  --paper-2:#16202a;
  --line:#e8f4fa;
  --line-soft:#3a4a58;
  --ink:#eaf4fa;
  --ink-soft:#a3b6c4;
  --grape:#3fa7c6;
  --grape-deep:#9fdcef;
  --grape-soft:#1e3a48;
  --pink:#ff9ebc;
  --pink-soft:#3a2431;
  --tape:rgba(90,102,112,.55);
  --tape-line:rgba(120,132,142,.45);
}

*{box-sizing:border-box;}
html{scroll-behavior:smooth;}

body{
  margin:0;
  cursor:var(--cursor-url), auto;
  /* The admin UI-scale slider applies CSS zoom here; below 1 the body would stop
     short of the fold and leak the bare <html> behind it, so render.js
     divides 100vh back out to compensate. */
  min-height:100vh;
  color:var(--ink);
  font-family:var(--font-body);
  /* DB Helvethaica draws noticeably smaller than a generic UI face at the same px, so the
     base size is set larger than it would be for, say, Noto. Fine-tune sitewide with the
     admin's UI-scale slider rather than editing this number. */
  font-size:1.3rem;
  font-weight:700;
  line-height:1.45;
  /* Flat light blue, no pattern — the cream cloud bands at the page edges and the outlined
     cards carry the decoration on their own. */
  background-color:var(--bg);
  -webkit-font-smoothing:antialiased;
}

img{max-width:100%;}

/* ============================================================ page frame */

/* The cream cloud band that tops and tails every page. Built from two background layers
   rather than an SVG so it recolours with the theme: a solid cream slab, plus a repeating
   half-circle tile along its lower edge that makes the bumps. The drop-shadow filter traces
   the alpha edge, which is what draws the soft outline around the bumps for free. */
.scallop{
  --bump-w:46px;   /* half the width of one cloud bump */
  --bump-h:22px;   /* how far it hangs below the slab */
  height:54px;
  background-color:transparent;
  background-image:
    linear-gradient(var(--cream), var(--cream)),
    radial-gradient(var(--bump-w) var(--bump-h) at var(--bump-w) 0, var(--cream) 99%, transparent 100%);
  background-size:100% calc(100% - var(--bump-h)), calc(var(--bump-w) * 2) var(--bump-h);
  background-position:0 0, 0 100%;
  background-repeat:no-repeat, repeat-x;
  filter:drop-shadow(0 2px 0 var(--scallop-line));
}
.scallop.bottom{transform:scaleY(-1);}

@media (max-width:560px){
  .scallop{--bump-w:30px; --bump-h:15px; height:38px;}
}

.wrap{max-width:1120px; margin:0 auto; padding:0 20px 70px;}

.theme-toggle{
  position:fixed; top:14px; right:14px; z-index:60;
  width:46px; height:46px; border-radius:50%;
  border:var(--bw) solid var(--line); background:var(--paper); color:var(--ink);
  font-size:1.25rem; line-height:1; display:flex; align-items:center; justify-content:center;
  cursor:var(--cursor-pointer-url), pointer; padding:0; box-shadow:var(--shadow-hard);
  transition:transform .12s ease;
}
.theme-toggle:active{transform:translate(3px,3px); box-shadow:1px 1px 0 var(--line);}

/* ============================================================ nav */

.site-nav{
  display:flex; flex-wrap:wrap; gap:10px; justify-content:center;
  padding:20px 0 6px;
}
.nav-pill{
  display:inline-flex; align-items:center; gap:7px;
  padding:8px 20px; border-radius:999px;
  border:var(--bw) solid var(--line); background:var(--paper);
  color:var(--ink); text-decoration:none; font-weight:700; font-size:var(--fs-nav, 1rem);
  box-shadow:var(--shadow-hard);
  transition:transform .12s ease, box-shadow .12s ease, background .12s ease;
}
.nav-pill:hover{transform:translate(-1px,-1px); box-shadow:5px 5px 0 var(--line);}
.nav-pill:active{transform:translate(3px,3px); box-shadow:1px 1px 0 var(--line);}
.nav-pill.active{background:var(--grape); color:#fff;}
.nav-pill.rank.active{background:var(--yellow); color:var(--line);}
.nav-pill.act.active{background:var(--pink); color:var(--line);}
.nav-pill.town.active{background:var(--mint); color:var(--line);}
.nav-pill.part.active{background:var(--lilac); color:var(--line);}
/* pet.html's own pill. Its label is hardcoded in the markup rather than carrying an id for
   renderShell() to fill, because that page's title is admin-editable on its own screen
   (content.pet.pageTitle) and a second copy in texts.* would be one more thing to keep in
   step for no gain. */
.nav-pill.pet.active{background:var(--orange); color:var(--line);}
.nav-pill.ocean.active{background:var(--lilac); color:var(--line);}
/* Grape House's own tab (owner, 2026-10-25): lands on the cart, shop.html; lit on catalog/shop/village. */
.nav-pill.house.active{background:var(--pink); color:var(--line);}
.nav-emoji{font-size:1.05em; line-height:1;}

/* ACTIVITY's sub-tabs (2026-10-02): Grape Universe moved out of the main nav and lives under
   ACTIVITY as an event of its own; Grape Ocean took its slot up top. The whole row carries
   id="navUniversePill", so renderShell() hides it while the universe is switched off. */
.act-subnav{display:flex; flex-wrap:wrap; gap:8px; justify-content:center; margin:0 0 18px;}
.act-subnav a{
  display:inline-flex; align-items:center; gap:6px; padding:5px 15px; border-radius:999px;
  border:2px dashed var(--line); background:var(--paper); color:var(--ink);
  text-decoration:none; font-weight:700; font-size:.88rem;
  transition:transform .12s ease, background .12s ease;
}
.act-subnav a:hover{transform:translateY(-2px);}
.act-subnav a.on{border-style:solid; background:var(--pink); color:var(--line); box-shadow:2px 2px 0 var(--line);}
.act-subnav a.uni.on{background:#a47cf0; color:#fff;}

/* ============================================================ shared bits */

.card{
  background:var(--paper);
  border:var(--bw) solid var(--line);
  border-radius:var(--radius);
  box-shadow:var(--shadow-hard);
  padding:22px;
  position:relative;
}

/* black sticker pill — "กิจการที่สร้างเสร็จแล้ว", section headers */
.sticker{
  display:inline-flex; align-items:center; gap:8px;
  background:var(--line); color:var(--paper);
  border:var(--bw) solid var(--line);
  border-radius:999px; padding:6px 20px;
  font-weight:700; font-size:var(--fs-sectionLabel, 1.05rem); line-height:1.25;
  box-shadow:3px 3px 0 rgba(20,20,20,.18);
}
.sticker.grape{background:var(--grape); color:#fff;}
.sticker.pink{background:var(--pink); color:var(--line);}
.sticker.orange{background:var(--orange); color:var(--line);}
.sticker.yellow{background:var(--yellow); color:var(--line);}
.sticker.mint{background:var(--mint); color:var(--line);}
.sticker.gray{background:#6f7a83; color:#fff;}

/* outlined display text, like the caption sitting on the hero photo */
.outlined{
  font-weight:700;
  color:var(--paper);
  -webkit-text-stroke:6px var(--line);
  paint-order:stroke fill;
}

.tape{
  position:absolute; z-index:3;
  width:112px; height:34px;
  background:var(--tape);
  border-left:1px dashed var(--tape-line);
  border-right:1px dashed var(--tape-line);
  opacity:.95;
}
.tape.tl{top:-14px; left:-26px; transform:rotate(-32deg);}
.tape.br{bottom:-10px; right:-24px; transform:rotate(-30deg);}

/* taped photo frame */
.photo-frame{
  position:relative;
  background:var(--paper);
  border:var(--bw) solid var(--line);
  border-radius:14px;
  padding:12px;
  box-shadow:var(--shadow-hard);
}
.photo-frame img{display:block; width:100%; border-radius:6px;}
.photo-frame .photo-empty{
  display:flex; align-items:center; justify-content:center;
  aspect-ratio:4/3; border-radius:6px; background:var(--grape-soft);
  color:var(--ink-soft); font-weight:600; font-size:var(--fs-empty, 1.3rem);
}
.photo-caption{
  position:absolute; left:50%; bottom:26px; transform:translateX(-50%);
  white-space:nowrap;
  font-size:var(--fs-heroCaption, 2.4rem); line-height:1;
  pointer-events:none;
}

/* pixel decorations */
.pixdeco{position:absolute; pointer-events:none; user-select:none; background-repeat:no-repeat; background-size:contain; z-index:2;}
.pixdeco.heart{
  width:26px; height:22px;
  background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 7 6' shape-rendering='crispEdges'%3E%3Cg fill='%23ff8fb1'%3E%3Crect x='1' y='0' width='2' height='1'/%3E%3Crect x='4' y='0' width='2' height='1'/%3E%3Crect x='0' y='1' width='7' height='2'/%3E%3Crect x='1' y='3' width='5' height='1'/%3E%3Crect x='2' y='4' width='3' height='1'/%3E%3Crect x='3' y='5' width='1' height='1'/%3E%3C/g%3E%3C/svg%3E");
  animation:bob 3.4s ease-in-out infinite;
}
.pixdeco.spark{
  width:24px; height:24px;
  background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 7 7' shape-rendering='crispEdges'%3E%3Cg fill='%232f8fd6'%3E%3Crect x='3' y='0' width='1' height='7'/%3E%3Crect x='0' y='3' width='7' height='1'/%3E%3Crect x='2' y='2' width='3' height='3'/%3E%3C/g%3E%3C/svg%3E");
  animation:twinkle 2.6s ease-in-out infinite;
}
@keyframes bob{0%,100%{transform:translateY(0) rotate(-4deg);}50%{transform:translateY(-7px) rotate(4deg);}}
@keyframes twinkle{0%,100%{opacity:.35; transform:scale(.85);}50%{opacity:1; transform:scale(1.1);}}
@media (prefers-reduced-motion:reduce){
  .pixdeco{animation:none !important;}
  /* Entrances too — a reader who has asked their system for less movement should get the
     page already in place, not a cascade with the motion merely shortened. */
  #page-content > *, .badge-item, .town-tile, .town-plot,
  .part-groups.anim .part-group, .past-card,
  .tube-fill, .vs-fill, .vs-crown, .part-badge, .sticker:hover,
  .nav-pill:hover .nav-emoji, .town-tile:hover .town-icon, .part-chip:hover .part-badge,
  .home-photo, .hero-logo, .cnum.tick{
    animation:none !important;
  }
  .tube-fill, .vs-fill{transition:none !important;}
  .badge-item .badge-photo, .past-card{transition:none !important;}
}

.pill-btn{
  display:inline-flex; align-items:center; justify-content:center; gap:8px;
  padding:13px 34px; border-radius:999px;
  border:var(--bw) solid var(--line);
  background:var(--grape); color:#fff;
  font-family:var(--font-body); font-weight:700; font-size:var(--fs-supportBtn, 1.15rem);
  text-decoration:none; cursor:var(--cursor-pointer-url), pointer;
  box-shadow:var(--shadow-hard);
  transition:transform .12s ease, box-shadow .12s ease;
}
.pill-btn:hover{transform:translate(-1px,-1px); box-shadow:5px 5px 0 var(--line);}
.pill-btn:active{transform:translate(3px,3px); box-shadow:1px 1px 0 var(--line);}
.pill-btn.pink{background:var(--pink); color:var(--line);}

/* the "หลอด %" tube — a fat outlined pill with a fill inside */
.tube{
  position:relative;
  height:64px;
  border:var(--bw) solid var(--line);
  border-radius:999px;
  background:var(--paper);
  overflow:hidden;
  box-shadow:var(--shadow-hard);
}
.tube-fill{
  position:absolute; top:0; bottom:0; left:0; width:0;
  background:
    repeating-linear-gradient(115deg, rgba(255,255,255,.42) 0 10px, transparent 10px 22px),
    linear-gradient(90deg, var(--grape-soft), var(--grape));
  transition:width 1s cubic-bezier(.2,.7,.3,1);
}
.tube-text{
  position:absolute; top:0; right:0; bottom:0; left:0;
  display:flex; align-items:center; justify-content:center; gap:10px;
  font-weight:700; font-size:var(--fs-tubeText, 1.7rem); color:var(--ink);
  letter-spacing:.5px;
}

/* the head-to-head bars an activity shows instead of the tube when two or more teams are
   racing. One row per team; the fill is that team's share of every vote cast, so the bars
   across a race always add up to the whole board. */
.versus{display:flex; flex-direction:column; gap:14px;}
.vs-top{display:flex; flex-wrap:wrap; align-items:center; justify-content:space-between; gap:10px;}
.vs-lead{font-weight:700; font-size:var(--fs-vsLead, .95rem); color:var(--grape-deep);}
.vs-bars{display:flex; flex-direction:column; gap:14px;}
.vs-row{display:flex; flex-direction:column; gap:6px;}
.vs-head{display:flex; align-items:center; gap:10px;}
.vs-avatar{
  width:var(--vs-avatar, 46px); height:var(--vs-avatar, 46px); flex:none;
  border-radius:50%; overflow:hidden; border:var(--bw) solid var(--line); background:var(--grape-soft);
}
.vs-avatar img{width:100%; height:100%; object-fit:cover;}
.vs-name{
  flex:1; min-width:0; display:flex; align-items:center; gap:6px;
  font-weight:700; font-size:var(--fs-vsName, 1.3rem); overflow-wrap:anywhere;
}
.vs-score{font-weight:700; font-size:var(--fs-vsScore, 1.1rem); color:var(--ink-soft); white-space:nowrap;}
.vs-track{
  height:30px; border:var(--bw) solid var(--line); border-radius:999px;
  background:var(--paper); overflow:hidden; box-shadow:var(--shadow-hard);
}
.vs-fill{
  height:100%; width:0;
  background:
    repeating-linear-gradient(115deg, rgba(255,255,255,.42) 0 10px, transparent 10px 22px),
    var(--vs-color, var(--grape));
  transition:width 1s cubic-bezier(.2,.7,.3,1);
}
.vs-row.leading .vs-score{color:var(--ink);}
.vs-total{text-align:right; font-weight:700; color:var(--ink-soft); font-size:var(--fs-actStatLabel, .85rem);}
.vs-empty{text-align:center; color:var(--ink-soft); padding:18px 0; font-size:var(--fs-empty, 1.3rem);}
/* the same bars squeezed into the home page's activity card, where they replace the tube */
.vs-bars.compact{gap:9px;}
.vs-bars.compact .vs-row{gap:3px;}
.vs-bars.compact .vs-name{font-size:1rem;}
.vs-bars.compact .vs-score{font-size:.85rem;}
.vs-bars.compact .vs-track{height:20px; box-shadow:2px 2px 0 var(--line);}

.note{color:var(--ink-soft); font-size:var(--fs-note, .95rem);}
.center{text-align:center;}
.hidden{display:none !important;}

footer.site-footer{
  margin-top:44px; text-align:center;
  font-size:var(--fs-footer, .98rem); color:var(--ink-soft);
}

/* ============================================================ home */

.hero{
  position:relative;
  padding:26px 0 10px;
}
.hero-logo{display:block; margin:0 auto 6px; width:100%; max-width:230px;}
.hero-logo-fallback{
  text-align:center; font-family:var(--font-pixel); font-size:var(--fs-siteName, 1.5rem); color:var(--grape-deep);
  margin-bottom:8px;
}
.hero-tagline{
  text-align:center; margin:0 0 22px;
  font-size:var(--fs-tagline, 1.2rem); font-weight:600; color:var(--ink-soft);
}

/* The hero photo stands alone, centred. Width comes from the admin's own slider
   (imageSizes.hero → --hero-w) because "how big is the photo" is a taste decision that
   changes with every photo uploaded, and the previous two-column hero decided it by
   accident — whatever was left after the activity card took its share. */
.home-photo{
  position:relative; transform:rotate(var(--hero-tilt, -2deg));
  width:100%; max-width:var(--hero-w, 480px); margin:8px auto 0;
}

/* Follow links, directly under the photo. */
.hero-socials{text-align:center; margin-top:26px;}

/* Activity | donate. Equal columns on purpose: these two carry the same weight, and any
   other ratio is what made the old hero look lopsided. align-items:start so the shorter
   card keeps its own height instead of stretching to match. */
.home-main{
  display:grid; grid-template-columns:1fr 1fr; gap:30px; align-items:start;
  margin-top:34px;
}
.home-act{display:flex; flex-direction:column; gap:16px; align-items:center;}
/* The frame takes the shape of whatever poster is uploaded — these are portrait-ish
   graphics with text near the edges, and the old fixed 16/10 + object-fit:cover cropped
   the top line clean off. Only the empty placeholder keeps a fixed box. */
.act-frame{
  width:100%;
  border:var(--bw) solid var(--line);
  border-radius:10px;
  background:var(--paper);
  display:flex; align-items:center; justify-content:center;
  overflow:hidden;
  box-shadow:var(--shadow-hard);
}
.act-frame.no-img{aspect-ratio:16/10;}
.act-frame img{display:block; width:100%; height:auto;}
.act-frame .empty{color:var(--line-soft); font-weight:600; font-size:var(--fs-empty, 1.3rem);}
.act-name{font-weight:700; text-align:center; font-size:var(--fs-homeActName, 1.15rem);}
.home-act .tube{width:100%;}
.home-act .vs-bars{width:100%;}

.badges-block{margin-top:34px;}
.badges-row{
  display:flex; flex-wrap:wrap; gap:22px; justify-content:center; margin-top:10px;
}
.badge-item{display:flex; flex-direction:column; align-items:center; gap:8px; width:calc(var(--badge-size, 92px) + 30px);}
.badge-photo{
  width:var(--badge-size, 92px); height:var(--badge-size, 92px);
  border-radius:50%; border:var(--bw) solid var(--line); overflow:hidden;
  background:var(--grape-soft); box-shadow:var(--shadow-hard);
}
.badge-photo img{width:100%; height:100%; object-fit:cover;}
.badge-item .sticker{font-size:var(--fs-badgeLabel, .92rem); padding:4px 14px; text-align:center;}

/* QR / donate panel
   One centred header (title + dashed strip) spanning the whole card, then two EQUAL
   columns underneath — the old 1.1fr/.9fr split left the short right-hand column
   floating against a much taller QR stack, which read as lopsided. Both columns are
   vertically centred against each other and separated by a dashed rule. */
.donate-panel{margin-top:38px;}
.donate-grid{
  display:grid; grid-template-columns:1fr 1fr; gap:30px; align-items:center;
  margin-top:20px;
}
/* Inside the home page's half-width column there is no room for the card's own two
   columns, so it runs as one: QRs, bank box, then the picture and the button below.
   (The activity page still uses the wide two-column version of this card.) */
.home-main .donate-panel{margin-top:0;}
.home-main .donate-grid{grid-template-columns:1fr; gap:22px;}
.qr-col{display:flex; flex-direction:column; justify-content:center; gap:16px;}
.donate-title{
  margin:0 0 6px; font-size:var(--fs-donateTitle, 1.5rem); font-weight:700; text-align:center;
}
.donate-strip{
  display:block; margin:0 auto 16px; width:max-content; max-width:100%;
  background:var(--grape-soft); border:2px dashed var(--grape-deep); color:var(--grape-deep);
  border-radius:999px; padding:4px 18px; font-weight:600; font-size:var(--fs-donateStrip, .95rem);
}
.qr-row{display:flex; flex-wrap:wrap; gap:18px; justify-content:center;}
.qr-box{
  width:var(--qr-width, 150px);
  background:var(--paper); border:var(--bw) solid var(--line); border-radius:12px;
  padding:10px; text-align:center; box-shadow:var(--shadow-hard);
}
.qr-box img{display:block; width:100%; border-radius:4px;}
.qr-box .qr-label{
  margin-top:6px; font-weight:700; font-size:var(--fs-donateLabel, .95rem); line-height:1.2;
}
.qr-box .qr-caption{font-size:var(--fs-qrCaption, .85rem); color:var(--ink-soft);}
.bank-box{
  margin-top:0; text-align:center;
  border:var(--bw) solid var(--line); border-radius:14px; background:var(--paper);
  padding:12px 16px; box-shadow:var(--shadow-hard);
}
.bank-title{font-weight:700; font-size:var(--fs-bankLabel, .95rem); color:var(--ink-soft);}
.bank-name{
  display:inline-block; margin:4px 0;
  background:var(--line); color:var(--paper); border-radius:8px; padding:2px 12px;
  font-weight:700; font-size:var(--fs-bankLabel, .95rem);
}
.bank-number{font-family:var(--font-pixel); font-size:var(--fs-bankNumber, 1.15rem); letter-spacing:1px; margin:6px 0 2px;}
.bank-owner{font-size:var(--fs-bankLabel, .95rem); color:var(--ink-soft);}

/* The SUPPORT NOW block — picture, button, thank-you line, trust badges — was removed from
   the home page, and its styles went with it. The donate card is the QR codes and the bank
   details now. Note that button was the only link to content.links.line anywhere on the
   site: the social row lists facebook/instagram/tiktok/x and has no LINE entry. */
.social-row{display:flex; gap:10px; flex-wrap:wrap; justify-content:center;}
.social-link{
  display:inline-flex; align-items:center; gap:6px;
  border:var(--bw) solid var(--line); border-radius:999px; background:var(--paper);
  color:var(--ink); text-decoration:none; font-weight:700; font-size:var(--fs-socialLink, .95rem);
  padding:6px 16px; box-shadow:3px 3px 0 var(--line);
}
.social-title{font-weight:700; font-size:var(--fs-socialTitle, 1.3rem); margin-bottom:8px;}

/* total progress + countdown */
.home-stats{display:grid; grid-template-columns:1.2fr .8fr; gap:26px; margin-top:34px;}
.stat-card{text-align:center;}
.stat-card h4{margin:0 0 10px; font-size:var(--fs-statTitle, 1.15rem); font-weight:700;}
.stat-big{
  font-family:var(--font-pixel); font-size:var(--fs-progressNum, 1.9rem); line-height:1.5;
  color:var(--grape-deep); word-break:break-word;
}
.stat-sub{color:var(--ink-soft); font-size:var(--fs-statSub, .95rem);}
.countdown-grid{display:grid; grid-template-columns:repeat(4,1fr); gap:8px; margin:10px 0 8px;}
.countdown-grid div{
  border:var(--bw) solid var(--line); border-radius:12px; background:var(--paper-2); padding:8px 2px;
}
.cnum{display:block; font-family:var(--font-pixel); font-size:var(--fs-countdownNum, 1.25rem); color:var(--grape-deep);}
.clabel{display:block; font-size:var(--fs-countdownLabel, .85rem); color:var(--ink-soft);}

/* ============================================================ ranking */

.page-hero{position:relative; text-align:center; padding:26px 0 10px;}
.page-hero h1{
  margin:0; font-size:var(--fs-rankTitle, 2.3rem); line-height:1.25;
}
.page-hero .subtitle{
  margin:2px 0 0; color:var(--pink); font-weight:700;
  font-size:var(--fs-rankSubtitle, 1.5rem);
}
.page-hero .updated{margin:8px 0 0; color:var(--ink-soft); font-size:var(--fs-rankUpdated, .92rem);}

/* the spiral-bound notebook page the ranking list is written on */
.notebook{
  position:relative;
  margin:26px auto 0; max-width:840px;
  background:var(--paper);
  border:var(--bw) solid var(--line);
  border-radius:6px;
  padding:52px 30px 30px;
  box-shadow:var(--shadow-hard);
  transform:rotate(-.5deg);
}
/* the punched holes along the top edge */
.notebook::before{
  content:''; position:absolute; top:16px; left:28px; right:28px; height:20px;
  background-image:radial-gradient(circle at 10px 10px, var(--bg) 0 7px, transparent 7.5px),
                   radial-gradient(circle at 10px 10px, transparent 0 6.4px, var(--line) 6.4px 8px, transparent 8.5px);
  background-size:74px 20px; background-repeat:repeat-x;
  pointer-events:none;
}
.rank-list{display:flex; flex-direction:column;}
.rank-row{
  display:flex; align-items:center; gap:14px;
  padding:10px 4px;
  border-bottom:2px dashed var(--line-soft);
}
.rank-row:last-child{border-bottom:0;}
.rank-num{
  font-weight:700; font-size:var(--fs-rankNum, 1.6rem); min-width:2.2em;
  color:var(--ink);
}
.rank-row.top1 .rank-num{color:var(--yellow); -webkit-text-stroke:2px var(--line); paint-order:stroke fill;}
.rank-row.top2 .rank-num{color:#c9d3da; -webkit-text-stroke:2px var(--line); paint-order:stroke fill;}
.rank-row.top3 .rank-num{color:var(--orange); -webkit-text-stroke:2px var(--line); paint-order:stroke fill;}
.rank-photo{
  width:var(--rank-photo, 52px); height:var(--rank-photo, 52px); flex:none; border-radius:50%; overflow:hidden;
  border:var(--bw) solid var(--line); background:var(--grape-soft);
}
.rank-photo img{width:100%; height:100%; object-fit:cover;}
.rank-name-col{flex:1; min-width:0; display:flex; flex-direction:column; gap:1px;}
.rank-name{
  font-weight:700; font-size:var(--fs-rankName, 1.4rem);
  overflow-wrap:anywhere;
}
.rank-gap{
  font-weight:700; font-size:.78rem; color:var(--ink-soft);
  overflow-wrap:anywhere;
}
.rank-score{
  font-weight:700; font-size:var(--fs-rankScore, 1.4rem); color:var(--grape-deep);
  white-space:nowrap;
}
/* Amount + share button, kept as one flex item so they wrap together on a phone. */
.rank-tail{display:flex; align-items:center; gap:10px; flex:none;}
.rank-move{
  font-size:var(--fs-rankMove, .85rem); font-weight:700; border-radius:999px;
  padding:2px 8px; border:2px solid var(--line); white-space:nowrap;
  display:inline-flex; align-items:center; gap:3px; flex:none;
}
/* The ▲▼ is an inline SVG, not a character — see ARROW_SVG in render.js for why. */
.rank-arrow{width:.7em; height:.56em; flex:none;}
.rank-move.up{background:#d9f7e6; color:#127a4a;}
.rank-move.down{background:#ffe0e0; color:#b93232;}
.rank-move.same{background:var(--paper-2); color:var(--ink-soft);}
.rank-move.new{background:var(--yellow); color:var(--line);}
/* Per-row share. Always visible rather than hover-only: half the visitors are on a phone,
   where a control that only appears on hover simply does not exist. */
.rank-share-one{
  font-family:inherit; font-weight:700; font-size:1rem; line-height:1;
  flex:none; width:34px; height:34px; padding:0;
  display:inline-flex; align-items:center; justify-content:center;
  border:2px solid var(--line); border-radius:50%;
  background:var(--grape-soft); color:var(--ink); cursor:var(--cursor-pointer-url), pointer;
  box-shadow:2px 2px 0 var(--line);
  transition:transform .12s ease, box-shadow .12s ease, opacity .12s ease;
}
.rank-share-one:hover{transform:translate(-1px,-1px); box-shadow:3px 3px 0 var(--line);}
.rank-share-one:active{transform:translate(1px,1px); box-shadow:1px 1px 0 var(--line);}
.rank-share-one:disabled{opacity:.45; cursor:default; transform:none; box-shadow:2px 2px 0 var(--line);}

/* ---------------------------------------- the cute notebook (Sept 2026)
   Same board as before — one list on a spiral notebook page — dressed up the way somebody
   would decorate their own notebook: ruled lines and a pink margin, little doodles in the
   corners, the rank number as a sticker (gold / silver / bronze medals with ribbon tails for the
   top three, a doodled circle for the rest), photos stuck on like stickers, and each amount
   swiped with a highlighter pen. */
.notebook{
  background:
    linear-gradient(90deg, transparent 18px, color-mix(in srgb, var(--pink) 55%, transparent) 18px 20px, transparent 20px),
    repeating-linear-gradient(transparent 0 35px, color-mix(in srgb, var(--grape) 16%, transparent) 35px 37px),
    var(--paper);
  background-position:0 0, 0 44px, 0 0;
}
/* doodles: a grape bunch, a heart and a star, drawn in ink along the bottom-right corner */
.notebook::after{
  content:''; position:absolute; right:16px; bottom:10px; width:110px; height:40px; pointer-events:none;
  opacity:.55;
  background:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 110 40' fill='none' stroke='%23b58cff' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M16 6c2-3 6-3 7 0'/%3E%3Ccircle cx='12' cy='14' r='4'/%3E%3Ccircle cx='20' cy='14' r='4'/%3E%3Ccircle cx='16' cy='21' r='4'/%3E%3Ccircle cx='8' cy='21' r='4'/%3E%3Ccircle cx='24' cy='21' r='4'/%3E%3Ccircle cx='16' cy='28' r='4'/%3E%3Cpath d='M52 30c-9-6-12-11-8-15 3-3 6-1 8 2 2-3 5-5 8-2 4 4 1 9-8 15z' stroke='%23ff8fb1'/%3E%3Cpath d='M92 8l3 7 7 1-5 5 1 7-6-3-6 3 1-7-5-5 7-1z' stroke='%23f0b43c'/%3E%3C/svg%3E") no-repeat right bottom/contain;
}
.rank-row{position:relative; border-radius:14px; padding:12px 10px; transition:background .15s ease;}
.rank-row:hover{background:color-mix(in srgb, var(--lilac) 9%, transparent);}
/* the winner's line is gently gilded */
.rank-row.top1{background:color-mix(in srgb, var(--yellow) 16%, transparent);}

/* rank sticker */
.rank-num{
  min-width:0; width:2.3em; height:2.3em; flex:none; position:relative;
  isolation:isolate;   /* keeps the ribbon tails (z-index:-1) above the row's own background */
  display:inline-flex; align-items:center; justify-content:center;
  font-size:calc(var(--fs-rankNum, 1.6rem) * .64); font-weight:800; color:var(--ink);
}
.rank-num::before{
  content:''; position:absolute; inset:0; border-radius:50%;
  border:2.5px dashed color-mix(in srgb, var(--grape) 70%, var(--line));
  transform:rotate(-12deg);
}
.rank-num span{position:relative;}
.rank-row:hover .rank-num::before{animation:rankSpin 1.2s ease-in-out;}
@keyframes rankSpin{to{transform:rotate(348deg);}}
.rank-row.top1 .rank-num, .rank-row.top2 .rank-num, .rank-row.top3 .rank-num{
  color:#141414; -webkit-text-stroke:0;
}
.rank-row.top1 .rank-num::before, .rank-row.top2 .rank-num::before, .rank-row.top3 .rank-num::before{
  border:2px solid var(--line); background:var(--medal); box-shadow:2px 2px 0 var(--line); transform:none;
}
/* ribbon tails under the medal */
.rank-row.top1 .rank-num::after, .rank-row.top2 .rank-num::after, .rank-row.top3 .rank-num::after{
  content:''; position:absolute; left:50%; bottom:-.55em; width:1.3em; height:.8em; z-index:-1;
  transform:translateX(-50%);
  background:linear-gradient(115deg, var(--pink) 0 48%, transparent 48% 52%, var(--grape) 52%);
  clip-path:polygon(0 0,100% 0,100% 100%,75% 70%,50% 100%,25% 70%,0 100%);
}
.rank-row.top1{--medal:#ffd45e;}
.rank-row.top2{--medal:#dfe6ee;}
.rank-row.top3{--medal:#f2b27c;}
.rank-row.top1 .rank-num span::before{
  content:'👑'; position:absolute; left:50%; top:-1.35em; transform:translateX(-50%) rotate(-10deg);
  font-size:.95em; animation:rankCrown 2.4s ease-in-out infinite;
}
@keyframes rankCrown{
  0%,100%{transform:translateX(-50%) rotate(-10deg);}
  50%{transform:translateX(-50%) rotate(8deg) translateY(-2px);}
}

/* photos stuck on like stickers */
.rank-photo{
  border-width:3px; box-shadow:0 0 0 3px var(--paper), 2px 3px 0 3px var(--line);
  transform:rotate(-4deg);
}
.rank-row:nth-child(even) .rank-photo{transform:rotate(4deg);}

/* the amount, swiped with a highlighter */
.rank-score{
  padding:0 6px; border-radius:4px;
  background:linear-gradient(transparent 52%, var(--hl, color-mix(in srgb, var(--pink) 45%, transparent)) 52% 92%, transparent 92%);
}
.rank-row:nth-child(3n+2){--hl:color-mix(in srgb, var(--mint) 55%, transparent);}
.rank-row:nth-child(3n){--hl:color-mix(in srgb, var(--lilac) 45%, transparent);}
.rank-row.top1{--hl:color-mix(in srgb, var(--yellow) 75%, transparent);}
.rank-row.top2{--hl:color-mix(in srgb, #c9d3da 75%, transparent);}
.rank-row.top3{--hl:color-mix(in srgb, var(--orange) 50%, transparent);}

/* Every row is a sticky note pinned to the page — yellow, pink, mint and blue in turn, each
   tipped a little, with a red pin at the top. The note colours are fixed pastels (a sticky note
   is that colour in any light), so the text on them is pinned to dark ink in both themes. */
.rank-row{
  margin:10px 0; border:0; border-radius:2px;
  background:var(--note, #fff3a8); color:#141414;
  box-shadow:2px 4px 0 rgba(0,0,0,.18);
  transform:rotate(-.6deg);
}
.rank-row:last-child{border-bottom:0;}
.rank-row:nth-child(even){transform:rotate(.6deg);}
.rank-row:nth-child(4n+2){--note:#ffd6e5;}
.rank-row:nth-child(4n+3){--note:#d6f5e8;}
.rank-row:nth-child(4n){--note:#dfe8ff;}
.rank-row.top1{background:var(--note, #fff3a8);}
.rank-row::after{
  content:''; position:absolute; top:-6px; left:50%; width:14px; height:14px; border-radius:50%;
  background:#ff6f91; border:2px solid #141414; transform:translateX(-50%);
}
.rank-row:hover{background:var(--note, #fff3a8); transform:rotate(0) scale(1.01);}
.rank-row .rank-name, .rank-row .rank-gap, .rank-row .rank-num{color:#141414;}
.rank-row .rank-num::before{border-color:color-mix(in srgb, #43b7d9 70%, #141414);}
.rank-row .rank-score{color:#0d6d91;}

@media (prefers-reduced-motion:reduce){
  .rank-row:hover .rank-num::before, .rank-row.top1 .rank-num span::before{animation:none;}
  .rank-row, .rank-row:nth-child(even), .rank-row:hover{transform:none;}
}
/* Same button, drawn as a <span>: it sits inside .part-group-head, which is itself a real
   <button> (see the seal badge's own note on this same constraint above), and a button
   nested inside a button is one the browser simply throws away. */
.part-share-one{
  font-family:inherit; font-weight:700; font-size:1rem; line-height:1;
  flex:none; width:34px; height:34px; padding:0;
  display:inline-flex; align-items:center; justify-content:center;
  border:2px solid var(--line); border-radius:50%;
  background:var(--grape-soft); color:var(--ink); cursor:var(--cursor-pointer-url), pointer;
  box-shadow:2px 2px 0 var(--line);
  transition:transform .12s ease, box-shadow .12s ease;
}
.part-share-one:hover{transform:translate(-1px,-1px); box-shadow:3px 3px 0 var(--line);}
.part-share-one:active{transform:translate(1px,1px); box-shadow:1px 1px 0 var(--line);}
/* The share modal every 𝕏 on this page opens: the finished picture, then the two things to
   do with it. Built in render.js and parked on <body>, so it needs its own paper — same
   conventions as .town-modal-backdrop above. */
.share-modal-backdrop{
  position:fixed; top:0; right:0; bottom:0; left:0; z-index:90; display:none;
  background:rgba(20,20,20,.55);
  align-items:center; justify-content:center; padding:20px;
}
.share-modal-backdrop.show{display:flex;}
.share-modal{
  position:relative; width:min(440px,100%); max-height:90vh; overflow:auto;
  background:var(--paper); border:var(--bw) solid var(--line); border-radius:22px;
  padding:24px 20px 20px; box-shadow:var(--shadow-hard); text-align:center;
}
.share-modal h3{margin:0 0 14px; font-size:1.35rem;}
.share-modal-close{
  position:absolute; top:10px; right:10px; width:38px; height:38px; border-radius:50%;
  border:var(--bw) solid var(--line); background:var(--paper); color:var(--ink);
  font-size:1.1rem; cursor:var(--cursor-pointer-url), pointer; line-height:1;
}
/* Holds its shape while the picture is still being drawn, so the buttons below do not jump
   into place under a thumb that is already on its way down. */
.share-preview{
  position:relative; min-height:180px; margin-bottom:16px;
  display:flex; align-items:center; justify-content:center;
  border:var(--bw) solid var(--line); border-radius:16px;
  background:var(--paper-2); overflow:hidden;
}
.share-preview img{display:none; width:100%; height:auto;}
.share-modal-backdrop.ready .share-preview img{display:block;}
.share-spinner{color:var(--ink-soft); font-weight:700;}
.share-modal-backdrop.ready .share-spinner{display:none;}
.share-go, .share-keep{
  font-family:inherit; font-weight:700; width:100%; cursor:var(--cursor-pointer-url), pointer;
  display:flex; align-items:center; justify-content:center; gap:10px;
  border:var(--bw) solid var(--line); border-radius:999px;
  box-shadow:3px 3px 0 var(--line);
  transition:transform .12s ease, box-shadow .12s ease, opacity .12s ease;
}
.share-go{font-size:1.1rem; padding:13px 18px; background:var(--grape-soft); color:var(--ink);}
.share-keep{font-size:1rem; padding:11px 18px; margin-top:10px; background:var(--pink-soft); color:var(--ink);}
.share-go b{font-size:1.2em; line-height:1;}
.share-go:hover, .share-keep:hover{transform:translate(-2px,-2px); box-shadow:5px 5px 0 var(--line);}
.share-go:active, .share-keep:active{transform:translate(2px,2px); box-shadow:1px 1px 0 var(--line);}
.share-go:disabled, .share-keep:disabled{opacity:.45; cursor:default; transform:none; box-shadow:3px 3px 0 var(--line);}
/* Nudges the eye to "บันทึกรูป" after the tweet popup steals focus on desktop — see
   pulseSaveButton()'s comment in render.js for why this replaced an automatic download. */
.share-keep.pulse{animation:share-keep-pulse 1s ease-in-out 3;}
@keyframes share-keep-pulse{
  0%, 100%{box-shadow:3px 3px 0 var(--line); transform:none;}
  50%{box-shadow:0 0 0 4px var(--pink-soft), 5px 5px 0 var(--line); transform:translate(-2px,-2px);}
}
.share-modal .share-msg{flex:none; margin:10px 0 0;}
/* share / save-as-picture row under the notebook. The picture itself is drawn on a canvas
   in render.js, so nothing here affects what gets exported — these are only the buttons. */
.rank-share{
  display:flex; flex-wrap:wrap; align-items:center; justify-content:center;
  gap:12px; margin:22px auto 0; max-width:840px;
}
.share-btn{
  font-family:inherit; font-weight:700; font-size:1.05rem;
  display:inline-flex; align-items:center; gap:8px;
  padding:10px 20px; border:var(--bw) solid var(--line); border-radius:999px;
  background:var(--paper); color:var(--ink); cursor:var(--cursor-pointer-url), pointer;
  box-shadow:3px 3px 0 var(--line);
  transition:transform .12s ease, box-shadow .12s ease, background .12s ease;
}
.share-btn b{font-size:1.15em; line-height:1;}
.share-btn.tweet{background:var(--grape-soft);}
.share-btn.save{background:var(--pink-soft);}
.share-btn:hover{transform:translate(-2px,-2px); box-shadow:5px 5px 0 var(--line);}
.share-btn:active{transform:translate(2px,2px); box-shadow:1px 1px 0 var(--line);}
.share-btn:disabled{opacity:.55; cursor:default; transform:none; box-shadow:3px 3px 0 var(--line);}
/* Full width so a message appearing (or clearing) never reflows the buttons above it. */
.share-msg{flex:0 0 100%; margin:2px 0 0; text-align:center; color:var(--ink-soft); font-size:.95rem; min-height:1.2em;}
/* ---------------------------------------------------------------- seal badges

   A supporter's pixel-art seal, drawn beside their name wherever that name appears. The art
   is a CSS background rather than an <img> so a seal nobody has uploaded yet degrades to
   empty space instead of a broken-image icon next to a real person's name, and pixelated
   rendering because interpolating pixel art at this size turns it to mush.

   .seal-1 … .seal-11 (which picture) live in /seals.css, because the admin needs the same
   eleven classes for its picker and admin.css does not load this file. Everything here is
   the shape and the motion, shared by both places a seal appears. */
.part-seal{
  display:inline-block; flex:none; padding:0; border:0; cursor:var(--cursor-pointer-url), pointer;
  vertical-align:middle;
  width:var(--seal-w, 44px); height:calc(var(--seal-w, 44px) * 0.612);
  background-color:transparent; background-size:contain;
  background-repeat:no-repeat; background-position:center;
  image-rendering:pixelated;
  transform-origin:50% 100%;
  /* --seal-delay is set inline per badge by sealHtml() in render.js, so a row of several
     seals doesn't bob in lockstep — kept as a custom property rather than a direct
     animation-delay in that inline style so .glow::after's own pulse (below) can read the
     same stagger via inheritance, which a real element's style="" attribute can't reach. */
  animation:seal-bob 2.6s ease-in-out infinite;
  animation-delay:var(--seal-delay,0s);
}
/* Two things on one clock: a slow float, and a squash at the bottom of it. The squash is
   what reads as "alive" rather than "sliding up and down". */
@keyframes seal-bob{
  0%,100%{ transform:translateY(0) rotate(-2.5deg) scale(1,1); }
  25%    { transform:translateY(-4px) rotate(0deg) scale(1,1); }
  50%    { transform:translateY(-5px) rotate(2.5deg) scale(1,1); }
  75%    { transform:translateY(-1px) rotate(0deg) scale(1.06,.94); }
}
/* Poked. render.js restarts this by removing the class and forcing a reflow, so a second
   poke on the same seal plays again instead of doing nothing. Applies to tank seals too
   (.tank-seal, the ones swimming in the strip) now that bindSealPokeOnce() matches them —
   same three classes either element can carry. */
.part-seal.poked, .tank-seal.poked{ animation:seal-pop .55s cubic-bezier(.3,1.6,.5,1); animation-delay:var(--seal-delay,0s); }
@keyframes seal-pop{
  0%  { transform:translateY(0) scale(1.18,.82); }
  30% { transform:translateY(-18px) rotate(-9deg) scale(.92,1.1); }
  60% { transform:translateY(0) scale(1.14,.86); }
  80% { transform:translateY(-3px) scale(.98,1.02); }
  100%{ transform:translateY(0) scale(1,1); }
}
/* The two rarer poke moves (render.js's POKE_VARIANTS picks one at random, weighted toward
   the plain bounce above) — a full spin, or a bubbly wiggle to go with the bubble-poof
   variant's droplet particles instead of hearts. */
.part-seal.poked-spin, .tank-seal.poked-spin{ animation:seal-spin .6s cubic-bezier(.4,0,.2,1); animation-delay:var(--seal-delay,0s); }
@keyframes seal-spin{
  0%  { transform:rotate(0deg) scale(1,1); }
  35% { transform:rotate(190deg) scale(.86,1.14); }
  70% { transform:rotate(370deg) scale(1.08,.92); }
  100%{ transform:rotate(360deg) scale(1,1); }
}
.part-seal.poked-bubbles, .tank-seal.poked-bubbles{ animation:seal-wiggle .6s ease-in-out; animation-delay:var(--seal-delay,0s); }
@keyframes seal-wiggle{
  0%,100%{ transform:translateY(0) rotate(0deg); }
  20% { transform:translateY(-6px) rotate(-9deg); }
  40% { transform:translateY(-11px) rotate(9deg); }
  60% { transform:translateY(-6px) rotate(-6deg); }
  80% { transform:translateY(-2px) rotate(4deg); }
}
.part-seal:focus-visible{ outline:2px solid var(--grape-deep); outline-offset:3px; }
/* Wraps sealHtml()'s output when a person owns more than one — inline-flex so the badges sit
   in a row with a gap instead of touching, and flex-wrap so a full set (up to ten) breaks onto
   more than one line instead of forcing the row past the edge of the card and over whatever
   sits next to it — the canvas share-card has its own shrink-to-fit logic (sealRowLayout in
   render.js) for exactly this problem, but a live DOM element can just wrap, which is simpler
   and reads better than shrinking the art at ten-in-a-row size. Absent (and irrelevant) for
   the common one-seal case, which stays a single bare .part-seal like before. */
.part-seals{ display:inline-flex; flex-wrap:wrap; align-items:center; gap:4px; vertical-align:middle; }
/* Owning every seal there is (the checkbox picker caps at ten) is rare enough on its own to
   be worth a decoration — originally one shimmering gold pill behind the whole row, sized for
   a single line. Once a collection is big enough to actually need `.part-seals`' flex-wrap
   (ten seals wraps to two rows on most cards), that pill's `border-radius:999px` was rounding
   a box that was no longer short-and-wide into something closer to square — a circle instead
   of a pill, overlapping the wrapped rows underneath it. Each seal glows on its own instead:
   the glow is sized to one seal's own small box regardless of how many rows the set wraps to,
   so there's no shared container shape to get wrong.

   The class lives on the badge itself (render.js's sealHtml adds `.glow` per `<span
   class="part-seal">`), not on `.part-seals`, because a full set is one of *two* reasons a
   seal glows now — the other is a supporter buying the shimmer for one specific design
   (participants.seals.assign[].glow), which can land on a single seal with no `.part-seals`
   wrapper around it at all. Pulsing sparkle after the row stays tied to `.full-set` on the
   wrapper, though — that flourish is specifically the "owns every design" achievement, not a
   per-seal effect, so a single bought-glow seal doesn't get a sparkle of its own. */
.part-seal.glow:not(.poked):not(.poked-spin):not(.poked-bubbles):not(.gold-seal){
  /* box-shadow follows the box it's cast from, not the art inside it — the seal's own box is
     a plain rectangle (139×85 art), so it threw a blurred rectangle behind the seal, and even
     rounding the box to an ellipse (border-radius:50%) still didn't trace the seal's actual
     blob silhouette. filter:drop-shadow() does: it reads the alpha channel of what's actually
     rendered (the seal art's own transparency), so the glow follows the seal's real outline
     instead of an approximation of its box. Excludes .gold-seal — that one already carries
     its own drop-shadow as part of its gold tint (see .gold-seal below); stacking a second,
     independently-animated filter on top of it fought the first one for the same CSS property
     instead of adding to it. Excludes all three poked-* classes, not just .poked — this rule
     outranks .part-seal.poked-spin/.poked-bubbles on specificity alone (four class-level
     selectors vs. two), so a glowing seal given the rarer spin/bubbles poke kept playing its
     glow's seal-bob/seal-fullset-glow pair right through the poke animation until this was
     caught by testing the two new poke variants against a glowing seal specifically.

     --glow-rgb/--glow-rgb-soft are "R,G,B" triples (no rgba() wrapper) so rgba(var(--x),alpha)
     can splice them in with the alpha this rule controls, not a fixed one baked into the var.
     render.js's sealHtml() sets them inline per-badge when a supporter picked a custom hue
     (participants.seals.assign[].glowColor); the fallback here is the original light blue, so
     everyone who never touched that admin field keeps looking exactly as before.

     The pulse used to animate this filter's own blur/alpha directly (0%→50%→100% keyframes on
     drop-shadow itself). filter is not a compositor property the way transform/opacity are —
     unlike this same rule's seal-bob (a transform, animated for free on the GPU), re-running
     the drop-shadow convolution every frame is real per-frame paint cost, for every glowing
     badge on screen at once, for as long as any one of them is visible. This base filter is
     now static (the pulse's own "resting" 0%/100% values) so the browser only ever computes it
     once; the ::after below layers a second, brighter drop-shadow on top at a fixed value and
     only animates *its* opacity — same breathing-glow look, but the only thing moving frame to
     frame is a compositor-only property. */
  position:relative;
  filter:drop-shadow(0 0 3px rgba(var(--glow-rgb,130,200,255),.65)) drop-shadow(0 0 6px rgba(var(--glow-rgb-soft,190,225,255),.35));
  animation:seal-bob 2.6s ease-in-out infinite;
  /* The shorthand above resets delay to its own initial 0s as one of its unlisted
     sub-properties, which would silently out-specificity the base .part-seal rule's
     animation-delay and un-stagger every glowing badge's bob — caught by testing this against
     a real multi-seal row rather than trusting the shorthand not to clobber it. */
  animation-delay:var(--seal-delay,0s);
}
/* sealHtml() in render.js marks the badges past its duplicate-copy cutoff `.still`. The plain
   selector below beats every un-glowing rule this cascade has (base .part-seal, the poked-*
   trio), but .part-seal.glow above carries four extra :not() clauses — each one full
   specificity weight on its own — so a still badge that also glows needed its own copy of the
   same :not() chain repeated here just to tie it, or the glow rule's animation would have kept
   winning regardless of source order. */
.part-seal.still{ animation:none; }
.part-seal.glow.still:not(.poked):not(.poked-spin):not(.poked-bubbles):not(.gold-seal){ animation:none; }
/* Traces the same badge's own art (background-image:inherit picks up whichever .seal-N class
   is on the host span) so the brighter glow lines up with the real silhouette underneath it,
   not a guessed box. inset:0 plus the host's own position:relative above means this rides
   along with seal-bob's transform automatically — it is drawn inside the transformed box, not
   beside it, so it bobs in lockstep with the seal for free without needing its own transform. */
.part-seal.glow:not(.poked):not(.poked-spin):not(.poked-bubbles):not(.gold-seal)::after{
  content:''; position:absolute; inset:0; pointer-events:none;
  background-image:inherit; background-size:contain; background-repeat:no-repeat;
  background-position:center; image-rendering:pixelated;
  filter:drop-shadow(0 0 5px rgba(var(--glow-rgb,130,200,255),.95)) drop-shadow(0 0 10px rgba(var(--glow-rgb-soft,190,225,255),.6));
  animation:seal-fullset-glow 2.2s ease-in-out infinite;
  animation-delay:var(--seal-delay,0s);
  /* Hints the compositor to promote this layer up front rather than mid-animation — with a
     dozen-plus of these pulsing on screen together (a top donor's full set), the promotion
     itself was a visible hitch the first time each one came into view. */
  will-change:opacity;
}
@keyframes seal-fullset-glow{
  0%, 100%{ opacity:0; }
  50%{ opacity:1; }
}
.part-seals.full-set::after{
  content:'✨'; font-size:.85em; margin-left:3px; display:inline-block;
  animation:seal-fullset-sparkle 1.7s ease-in-out infinite;
}
@keyframes seal-fullset-sparkle{
  0%, 100%{ opacity:.55; transform:scale(.9); }
  50%{ opacity:1; transform:scale(1.2); }
}
@media (prefers-reduced-motion: reduce){
  .part-seals.full-set::after{ animation:none; }
}

/* Golden seal for whoever render.js's topDonorName() computes as rank #1 right now — moves
   with the live leaderboard on its own, nothing for the admin to assign. Reuses seal 1's
   art (tinted gold with a filter, not a separate image) rather than a generic icon, so it
   reads as one of this site's own seals — just the one nobody can buy. The crown is a
   separate sibling span rather than a pseudo-element on .gold-seal itself: filter applies to
   an element's whole rendered subtree, so a ::before crown would get sepia-tinted along with
   the seal and lose its bright gold against the (also gold) seal beneath it. The inner
   .part-seal.gold-seal span keeps that base class, so it's the same size as a real seal and
   pokeable the same way (bindSealPokeOnce matches on the class, not a specific seal id).
   Shown beside the name on the ranking board and the participants page, same two spots a
   seal badge appears. */
.gold-seal-wrap{
  position:relative; display:inline-block; vertical-align:middle; margin-left:2px;
  animation:gold-seal-bob 1.6s ease-in-out infinite;
}
.gold-seal{
  background-image:url(/seals/1.png);
  filter:sepia(1) saturate(4.5) hue-rotate(-8deg) brightness(1.25) drop-shadow(0 0 4px #ffcb3d);
}
.gold-crown{
  position:absolute; top:-9px; left:50%; transform:translateX(-50%) rotate(-8deg);
  font-size:.62em; line-height:1; pointer-events:none;
}
@keyframes gold-seal-bob{
  0%,100%{ transform:translateY(0) rotate(-4deg); }
  50%{ transform:translateY(-3px) rotate(4deg); }
}
@media (prefers-reduced-motion:reduce){
  .gold-seal-wrap{ animation:none; }
}
/* The hearts a poke throws off. position:fixed so render.js can place them straight from
   getBoundingClientRect without worrying about which scrolling box they landed in. */
.seal-poof{
  position:fixed; z-index:99; pointer-events:none;
  font-size:15px; font-weight:400; line-height:1;
  animation:seal-poof 1s ease-out forwards;
}
@keyframes seal-poof{
  0%  { opacity:0; transform:translate(-50%,0) scale(.5); }
  15% { opacity:1; transform:translate(-50%,-8px) scale(1.1); }
  100%{ opacity:0; transform:translate(calc(-50% + var(--dx,0px)),-46px) scale(.9) rotate(var(--rot,0deg)); }
}

/* The strip of seals swimming under the board. Decorative — everything in it is already
   written above in text, so it is aria-hidden and nothing here is focusable. */
.seal-tank{
  position:relative; height:150px; margin-top:24px; overflow:hidden;
  border:var(--bw) solid var(--line); border-radius:18px; box-shadow:var(--shadow-hard);
  background:linear-gradient(#eaf7ff 0%, #cfeaf8 55%, #b6dff2 100%);
}
.seal-tank::after{
  content:""; position:absolute; left:0; right:0; bottom:0; height:26px;
  background:repeating-linear-gradient(90deg,#a9d8ee 0 14px,#bde3f4 14px 28px);
}
/* Swims on transform, not left: left is a layout property, so animating it forces the
   browser to recompute layout and repaint every frame for as long as the tank is on screen —
   the one animation on this page that never stops. transform:translateX() is compositor-only.
   The static `left:100%` below still parks each swimmer off the right edge as its resting
   position (set once, not animated, so it costs nothing per frame); render.js's
   updateSealTravel() measures the tank's actual width and writes how far translateX has to
   travel to clear the left edge into --travel on #sealTank itself, which every .swimmer
   inherits (a custom property cascades like any other) — one number to update on resize
   instead of one per swimmer. */
.swimmer{
  position:absolute; top:var(--top,20%); left:100%; transform:translateX(0);
  display:flex; flex-direction:column; align-items:center; gap:2px;
  animation:seal-swim var(--dur,20s) linear var(--delay,0s) infinite;
}
/* The art faces left, so left is the direction it swims — no flipping needed. */
@keyframes seal-swim{ to{ transform:translateX(var(--travel, -100vw)); } }
.tank-seal{
  display:block; width:var(--seal-w,44px); height:var(--seal-h,27px);
  background-size:contain; background-repeat:no-repeat; background-position:center;
  image-rendering:pixelated;
  animation:seal-bob 2.6s ease-in-out infinite;
}
.swimmer-name{
  font-size:1.1rem; font-weight:700; padding:2px 11px; border-radius:999px; white-space:nowrap;
  background:rgba(255,255,255,.92); border:1.5px solid var(--line);
  opacity:0; transition:opacity .15s;
}
.swimmer:hover .swimmer-name{opacity:1;}

/* render.js's paintSealMood() toggles mood-night on .seal-tank from the same daypartNow()
   Grape Town's sky uses (see the comment above daypartNow() in render.js) — day and dusk both
   read as the tank exactly as it always shipped, only night gets a treatment. Dims the water
   and slows every swimmer's swim and bob down together, so a seal reads as sleepy rather than
   just laggy. .swimmer-mood is an empty span with no size of its own; the 💤 lives entirely in
   ::before so an unstamped tank (before first paint) renders nothing rather than a stray box. */
.swimmer-mood{
  position:absolute; top:-15px; left:50%; transform:translateX(-50%);
  font-size:.85rem; line-height:1; opacity:0; pointer-events:none;
}
.swimmer-mood::before{content:'';}
.seal-tank.mood-night .swimmer-mood::before{content:'💤';}
.seal-tank.mood-night .swimmer-mood{opacity:1; animation:swimmer-mood-float 2.6s ease-in-out infinite;}
@keyframes swimmer-mood-float{
  0%,100%{transform:translateX(-50%) translateY(0); opacity:.55;}
  50%{transform:translateX(-50%) translateY(-5px); opacity:1;}
}
.seal-tank.mood-night .swimmer{animation-duration:calc(var(--dur,20s) * 1.6);}
/* :not(.poked...) matters here — a poke's own rule (site.css, near seal-pop) sets the same
   animation-duration sub-property through its `animation` shorthand, and this selector's
   higher specificity would otherwise win and stretch a snappy .55s pop out to a sleepy 4.2s
   one on a mid-poke seal (the same class of bug the poke-vs-glow fix a few features back
   was about — see HANDOFF.md item 13). */
.seal-tank.mood-night .tank-seal:not(.poked):not(.poked-spin):not(.poked-bubbles){animation-duration:4.2s; filter:brightness(.85);}
.seal-tank.mood-night{background:linear-gradient(#16324d 0%, #1c4463 55%, #163a58 100%);}
.seal-tank.mood-night::after{background:repeating-linear-gradient(90deg,#123049 0 14px,#17395c 14px 28px);}

/* One switch for the lot. A visitor who has asked their system for less movement gets the
   seals as still pictures — they are still badges, they just stop moving. */
@media (prefers-reduced-motion:reduce){
  .part-seal, .part-seal.poked, .part-seal.poked-spin, .part-seal.poked-bubbles,
  .tank-seal, .tank-seal.poked, .tank-seal.poked-spin, .tank-seal.poked-bubbles,
  .swimmer-mood{ animation:none !important; }
  /* The strip becomes a still row rather than a pile: taking the animation off a stack of
     position:absolute swimmers would park every one of them on top of the next. */
  .seal-tank{ display:flex; flex-wrap:wrap; align-items:center; justify-content:center; gap:14px; height:auto; padding:18px; }
  .swimmer{ position:static; animation:none; transform:none; }
  .swimmer-name{opacity:1;}
}

.rank-empty{text-align:center; color:var(--ink-soft); padding:22px 0; font-size:var(--fs-empty, 1.3rem);}
.rank-note{margin-top:20px; text-align:center; font-size:var(--fs-rankNote, .98rem); color:var(--ink-soft);}

/* ============================================================ activity */

.act-hero h1{font-size:var(--fs-actTitle, 2.2rem); margin:0;}
.act-hero .subtitle{font-size:var(--fs-actSubtitle, 1.1rem); color:var(--ink-soft); font-weight:600;}

.cur-act{margin-top:24px; display:grid; grid-template-columns:1fr 1fr; gap:26px; align-items:start;}
.cur-act-img{border:var(--bw) solid var(--line); border-radius:14px; overflow:hidden; background:var(--paper); box-shadow:var(--shadow-hard);}
.cur-act-img img{display:block; width:100%;}
.cur-act-body{display:flex; flex-direction:column; gap:12px;}
.cur-act-title{font-size:var(--fs-actName, 1.6rem); font-weight:700; margin:0;}
.cur-act-desc{font-size:var(--fs-actDesc, 1.05rem); color:var(--ink-soft); margin:0; white-space:pre-line;}
.act-stats{display:grid; grid-template-columns:repeat(3,1fr); gap:10px;}
.act-stats .stat{
  border:var(--bw) solid var(--line); border-radius:12px; background:var(--paper-2);
  padding:10px 6px; text-align:center;
}
.act-stats .stat .lbl{display:block; font-size:var(--fs-actStatLabel, .85rem); color:var(--ink-soft);}
.act-stats .stat .val{display:block; font-weight:700; font-size:var(--fs-actStatVal, 1.15rem);}
.act-stats .stat.hero{background:var(--grape);}
.act-stats .stat.hero .lbl,.act-stats .stat.hero .val{color:#fff;}
.act-updated{font-size:var(--fs-actUpdated, .9rem); color:var(--ink-soft);}

.past-title{margin:44px 0 18px; text-align:center;}
.past-grid{display:grid; grid-template-columns:repeat(auto-fill,minmax(250px,1fr)); gap:20px;}
.past-card{
  background:var(--paper); border:var(--bw) solid var(--line); border-radius:16px;
  overflow:hidden; box-shadow:var(--shadow-hard);
  animation:popIn .4s ease backwards;
}
@keyframes popIn{from{opacity:0; transform:translateY(10px) scale(.98);}to{opacity:1; transform:none;}}
.past-card-img{position:relative; aspect-ratio:16/10; background:var(--grape-soft);}
.past-card-img img{width:100%; height:100%; object-fit:cover; display:block;}
.past-badge{
  position:absolute; top:8px; right:8px;
  background:var(--mint); color:var(--line); border:2px solid var(--line); border-radius:999px;
  padding:2px 10px; font-size:var(--fs-pastBadge, .8rem); font-weight:700;
}
.past-card-body{padding:12px 14px 16px;}
.past-card-title{font-weight:700; font-size:var(--fs-pastCardTitle, 1.15rem);}
.past-card-amount{color:var(--grape-deep); font-weight:700; font-size:var(--fs-pastCardAmount, 1.05rem);}
.past-card-note{color:var(--ink-soft); font-size:var(--fs-pastCardNote, .92rem); white-space:pre-line;}
.past-empty{grid-column:1/-1; text-align:center; color:var(--ink-soft); padding:30px 0; font-size:var(--fs-empty, 1.3rem);}

/* ---------------------------------------- activity page: the scrapbook pass (Sept 2026)
   The poster is pinned up like a polaroid with a "happening now" ribbon, a grape rides the end
   of the progress tube, the three numbers are stickers, the QR cards are taped on, and past
   activities are a wall of tilted polaroids stamped "สำเร็จ!". Everything is scoped to
   .cur-act / .past-* (and the donate panel that follows .cur-act), because the tube, the stat
   boxes and the QR cards are shared with the home page, which keeps its own look. */
.cur-act-img{
  position:relative; overflow:visible; padding:10px 10px 34px; border-radius:6px;
  transform:rotate(-1.5deg);
}
.cur-act-img img{border:2px solid var(--line); border-radius:4px;}
.cur-act-img::before{
  content:''; position:absolute; top:-12px; left:50%; width:96px; height:24px; z-index:2;
  transform:translateX(-50%) rotate(-3deg); opacity:.85;
  background:repeating-linear-gradient(45deg, #ffb3cd 0 7px, #ffe3ec 7px 14px);
}
.cur-act-img::after{
  content:'✨ กำลังจัดอยู่'; position:absolute; right:-10px; bottom:12px; z-index:2;
  background:var(--pink); color:#141414; border:2px solid var(--line); border-radius:999px;
  padding:3px 12px; font-weight:800; font-size:.85rem; box-shadow:2px 2px 0 var(--line);
  transform:rotate(-4deg); animation:actNowPulse 2.2s ease-in-out infinite;
}
@keyframes actNowPulse{
  0%,100%{transform:rotate(-4deg) scale(1);}
  50%{transform:rotate(-2deg) scale(1.06);}
}
.cur-act-body{
  position:relative; overflow:visible;
  background:color-mix(in srgb, var(--lilac) 10%, var(--paper));
}
.cur-act-body::before{
  content:''; position:absolute; top:-11px; right:26px; width:70px; height:20px;
  transform:rotate(4deg); opacity:.85;
  background:repeating-linear-gradient(45deg, #9fe3cc 0 7px, #dff7ee 7px 14px);
}
.cur-act-title::before{content:'🎪 ';}
/* a grape riding the leading edge of the fill */
.cur-act .tube-fill::after{
  content:'🍇'; position:absolute; right:6px; top:50%; font-size:1.7rem; line-height:1;
  transform:translateY(-50%); animation:actGrapeBob 1.4s ease-in-out infinite;
  filter:drop-shadow(1px 1px 0 rgba(0,0,0,.25));
}
@keyframes actGrapeBob{
  0%,100%{transform:translateY(-50%) rotate(-8deg);}
  50%{transform:translateY(-62%) rotate(8deg);}
}
/* stat stickers */
.act-stats .stat{
  position:relative; box-shadow:3px 3px 0 var(--line);
  background:color-mix(in srgb, var(--mint) 30%, var(--paper));
  transform:rotate(1.2deg); padding-top:16px;
}
.act-stats .stat:nth-child(3){background:color-mix(in srgb, var(--yellow) 38%, var(--paper)); transform:rotate(-1.2deg);}
.act-stats .stat.hero{background:var(--grape); transform:rotate(-1.5deg);}
.act-stats .stat::before{
  position:absolute; top:-14px; left:50%; transform:translateX(-50%);
  width:26px; height:26px; border-radius:50%; border:2px solid var(--line); background:var(--paper);
  display:flex; align-items:center; justify-content:center; font-size:.85rem;
}
.act-stats .stat.hero::before{content:'💜';}
.act-stats .stat:nth-child(2)::before{content:'🎯';}
.act-stats .stat:nth-child(3)::before{content:'⏳';}

/* the QR cards under the current activity, taped on */
.cur-act ~ .donate-panel{
  position:relative; background:color-mix(in srgb, var(--pink) 9%, var(--paper));
}
.cur-act ~ .donate-panel .donate-title::before{content:'💌 ';}
.cur-act ~ .donate-panel .qr-box{position:relative; border-radius:6px; transform:rotate(-2deg);}
.cur-act ~ .donate-panel .qr-box:nth-child(even){transform:rotate(2deg);}
.cur-act ~ .donate-panel .qr-box::before{
  content:''; position:absolute; top:-10px; left:50%; width:56px; height:18px;
  transform:translateX(-50%) rotate(-4deg); opacity:.85;
  background:repeating-linear-gradient(45deg, #c9b3ff 0 6px, #efe7ff 6px 12px);
}
.cur-act ~ .donate-panel .qr-box:nth-child(3n+2)::before{background:repeating-linear-gradient(45deg, #ffb3cd 0 6px, #ffe3ec 6px 12px);}
.cur-act ~ .donate-panel .qr-box:nth-child(3n)::before{background:repeating-linear-gradient(45deg, #9fe3cc 0 6px, #dff7ee 6px 12px);}
.cur-act ~ .donate-panel .qr-label{
  display:inline-block; margin-top:8px; padding:1px 10px; border-radius:999px;
  border:2px solid var(--line); background:color-mix(in srgb, var(--yellow) 45%, var(--paper));
}

/* past activities: tilted polaroids with a stamp */
.past-grid{gap:26px 22px; padding-top:8px;}
.past-card{border-radius:6px; padding:10px 10px 0; position:relative; overflow:visible; transform:rotate(-1.4deg);}
.past-card:nth-child(even){transform:rotate(1.4deg);}
.past-card:nth-child(3n){transform:rotate(-.6deg);}
.past-card::before{
  content:''; position:absolute; top:-8px; left:50%; width:16px; height:16px; border-radius:50%; z-index:2;
  background:var(--pin, #ff6f91); border:2px solid var(--line); transform:translateX(-50%);
}
.past-card:nth-child(3n+2){--pin:#7fd6bd;}
.past-card:nth-child(3n){--pin:#b58cff;}
.past-card:hover{transform:rotate(0) translate(-2px,-3px);}
.past-card-img{border:2px solid var(--line); border-radius:3px; overflow:visible;}   /* visible: the stamp hangs off its edge */
.past-badge{
  top:auto; bottom:-14px; right:10px; z-index:2;
  width:62px; height:62px; padding:0; border-radius:50%;
  display:flex; align-items:center; justify-content:center; text-align:center; line-height:1.05;
  background:color-mix(in srgb, var(--mint) 85%, #fff);
  border:2px dashed var(--line); box-shadow:0 0 0 3px color-mix(in srgb, var(--mint) 85%, #fff), 0 0 0 5px var(--line);
  transform:rotate(-14deg); font-size:.7rem;
}
.past-card-body .past-badge{position:static; display:inline-flex; margin-bottom:6px;}
.past-card-body{padding:14px 6px 16px;}
.past-card-title{padding-right:62px;}

@media (prefers-reduced-motion:reduce){
  .cur-act-img::after, .cur-act .tube-fill::after{animation:none;}
  .past-card, .past-card:nth-child(even), .past-card:nth-child(3n), .past-card:hover{transform:none;}
}

/* ============================================================ grape town */

.town-hero{
  position:relative; margin-top:18px; padding:34px 24px 28px; text-align:center;
  background:var(--grape);
  border:var(--bw) solid var(--line);
  border-radius:26px;
  box-shadow:var(--shadow-hard);
  color:#fff;
  overflow:hidden;
}
.town-hero h1{
  font-family:var(--font-pixel); font-size:var(--fs-townTitle, 2.1rem); margin:0 0 12px;
  line-height:1.35; letter-spacing:1px;
  text-shadow:4px 4px 0 rgba(20,20,20,.22);
}
.town-hero .town-sub{
  font-family:var(--font-pixel); font-size:var(--fs-townSubtitle, .95rem); opacity:.95; margin:0;
}
.town-level{
  display:inline-block; margin-top:14px;
  background:var(--paper); color:var(--grape-deep);
  border:var(--bw) solid var(--line); border-radius:999px;
  padding:8px 26px; font-family:var(--font-pixel); font-size:var(--fs-townLevel, 1rem);
  box-shadow:var(--shadow-hard);
}
.town-mayor{
  display:flex; align-items:center; gap:10px; justify-content:center; margin-top:18px;
}
.town-mayor img{
  width:74px; height:74px; border-radius:50%; object-fit:cover;
  border:var(--bw) solid var(--line); background:var(--paper);
}
/* Name and role are centred on each other: the role line "( เจ้าของเมือง )" is the wider of
   the two, so left-aligning them made the short name look pushed off to one side. */
.town-mayor .who{
  background:var(--paper); color:var(--ink); border:var(--bw) solid var(--line);
  border-radius:999px; padding:8px 22px; text-align:center; line-height:1.2;
}
.town-mayor .who b{display:block; font-size:var(--fs-mayorName, 1.05rem);}
.town-mayor .who span{font-size:var(--fs-mayorRole, .85rem); color:var(--ink-soft);}

.town-legend{display:flex; gap:14px; flex-wrap:wrap; justify-content:center; margin:26px 0 16px;}
.town-legend span{
  display:inline-flex; align-items:center; gap:6px;
  font-size:var(--fs-townLegend, .9rem); font-weight:600; color:var(--ink-soft);
}
.town-legend i{width:14px; height:14px; border:2px solid var(--line); border-radius:4px; display:inline-block;}
.town-legend i.done{background:var(--mint);}
.town-legend i.building{background:var(--yellow);}
.town-legend i.locked{background:var(--line-soft);}

.town-grid{
  display:grid; grid-template-columns:repeat(auto-fill,minmax(180px,1fr)); gap:20px;
}
.town-tile{
  position:relative;
  background:var(--paper); border:var(--bw) solid var(--line); border-radius:18px;
  padding:18px 12px 16px; text-align:center; cursor:var(--cursor-pointer-url), pointer;
  box-shadow:var(--shadow-hard);
  transition:transform .12s ease, box-shadow .12s ease;
  font:inherit; color:inherit; width:100%;
}
.town-tile:hover{transform:translate(-2px,-2px); box-shadow:6px 6px 0 var(--line);}
.town-tile:active{transform:translate(3px,3px); box-shadow:1px 1px 0 var(--line);}
.town-tile.locked{background:var(--paper-2); border-style:dashed; opacity:.8;}
.town-icon{
  font-size:var(--town-icon-size, 54px); line-height:1.1; display:block;
  filter:drop-shadow(2px 2px 0 rgba(20,20,20,.18));
}
.town-icon img{width:var(--town-icon-size, 54px); height:var(--town-icon-size, 54px); object-fit:contain; image-rendering:pixelated;}
.town-tile.locked .town-icon{filter:grayscale(1); opacity:.5;}
.town-name{
  margin-top:8px; font-weight:700; font-size:var(--fs-placeName, 1.05rem);
  font-family:var(--font-pixel); line-height:1.5; word-break:break-word;
}
.town-status{
  display:inline-block; margin-top:8px; border:2px solid var(--line); border-radius:999px;
  padding:2px 12px; font-size:var(--fs-placeStatus, .82rem); font-weight:700;
}
.town-status.done{background:var(--mint); color:var(--line);}
.town-status.building{background:var(--yellow); color:var(--line);}
.town-status.locked{background:var(--line-soft); color:var(--paper);}
.town-mini-bar{
  margin-top:10px; height:12px; border:2px solid var(--line); border-radius:999px;
  background:var(--paper-2); overflow:hidden;
}
.town-mini-fill{height:100%; width:0; background:var(--grape); transition:width .8s ease;}
.town-next{
  margin:26px auto 0; max-width:460px; text-align:center;
  border:var(--bw) dashed var(--line); border-radius:18px; padding:18px;
  font-family:var(--font-pixel); font-size:var(--fs-townNext, 1rem); background:var(--paper);
}
.town-next .note{font-family:var(--font-body); margin-top:8px;}
.town-empty{grid-column:1/-1; text-align:center; color:var(--ink-soft); padding:30px 0; font-size:var(--fs-empty, 1.3rem);}

/* ---------------------------------------- Grape Town: map layout
   Same #townGrid element as the tile grid, different class (town.layout picks which).
   render.js draws the board — ground, snaking road, trees, clouds — as one inline SVG on a
   1000-wide coordinate system, then positions each place over it as a real HTML button in
   percent coordinates from that same system. Keeping the plots as HTML (not SVG text) means
   they inherit the site font, the black-outline card look, the font-size vars and keyboard
   focus for free.
   On a phone the board keeps its pixel width and scrolls sideways rather than squeezing
   four columns of buildings into 320px. */
.town-map{
  --map-ground:#7fd6bd;
  --map-ground-2:#5cc0a6;
  --map-road:#ffe08a;
  --map-road-line:#fffdf3;
  --map-tree:#3f9e7c;
  --map-tree-2:#2f7d61;
  overflow-x:auto; overflow-y:hidden; -webkit-overflow-scrolling:touch;
  background:var(--map-ground);
  border:var(--bw) solid var(--line);
  border-radius:22px;
  box-shadow:var(--shadow-hard);
}
:root[data-theme="dark"] .town-map{
  --map-ground:#2b6d60; --map-ground-2:#225a50; --map-road:#d9bd68; --map-road-line:#f4eeda;
  --map-tree:#358068; --map-tree-2:#276352;
}
/* Never below 1:1 — the plot cards are laid out in real px (site fonts, real borders), so a
   board squeezed under its natural width would pull them off their roads. Below this the
   board scrolls sideways instead. */
.town-map-inner{position:relative; min-width:1000px;}
.town-map-svg{display:block; width:100%; height:auto;}

.town-plot{
  position:absolute; transform:translate(-50%,-50%);
  width:19%;
  display:flex; flex-direction:column; align-items:center;
  background:none; border:0; padding:0; margin:0; font:inherit; color:inherit; cursor:var(--cursor-pointer-url), pointer;
}
/* the little post that pins a card to the road it sits next to */
.town-plot.above::after,
.town-plot.below::before{
  content:''; width:4px; height:24px; background:var(--line); border-radius:2px;
}
.town-plot .plot-card{
  width:100%; text-align:center;
  background:var(--paper); border:var(--bw) solid var(--line); border-radius:16px;
  padding:10px 8px 12px; box-shadow:var(--shadow-hard);
  transition:transform .12s ease, box-shadow .12s ease;
}
.town-plot:hover .plot-card{transform:translate(-2px,-2px); box-shadow:6px 6px 0 var(--line);}
.town-plot:active .plot-card{transform:translate(3px,3px); box-shadow:1px 1px 0 var(--line);}
.town-plot.locked .plot-card{background:var(--paper-2); border-style:dashed;}
.town-plot.locked .town-icon{filter:grayscale(1); opacity:.5;}
.town-plot .town-name{margin-top:4px;}
.town-plot .town-status{margin-top:6px;}
.town-plot .town-mini-bar{margin-top:8px;}
/* Only worth saying on a screen that actually has to scroll the board sideways. */
.town-map-hint{display:none;}
@media (max-width:1060px){
  .town-map-hint:not(.hidden){
    display:block; text-align:center; margin:0 0 10px;
    color:var(--ink-soft); font-weight:600; font-size:var(--fs-note, .95rem);
  }
}
.town-map-empty{
  text-align:center; color:var(--line); font-weight:700; padding:60px 20px;
  font-size:var(--fs-empty, 1.3rem);
}

.town-houses-intro{
  color:var(--ink-soft); font-size:var(--fs-note, .95rem); margin:0 0 16px;
}

/* ---------------------------------------- the village map (village.html)
   This is only the frame. The village itself is a <canvas> painted by town-map.js (Kenney
   tiny-town tiles, CC0) with the plot buttons stacked over it; everything here is the window
   you look at it through — a viewport you scroll around inside, which is what makes the board
   read as a game map rather than a very tall picture, and what lets a phone scroll the art at
   full size instead of shrinking it.
   #b4e6a0 is the pastel village's grass green, showing through until the tile atlas loads so the board
   never flashes blank white.
   --house-tint layers a flat semi-transparent wash over the scene for dark theme —
   a colour layer rather than a filter, since a filter would also soften the pixel edges. */
.house-map{
  --house-tint:rgba(0,0,0,0);
  position:relative;
  background:#b4e6a0;
  border:var(--bw) solid var(--line);
  border-radius:22px;
  box-shadow:var(--shadow-hard);
  overflow:hidden;                       /* clips the scroller to the rounded frame */
}
:root[data-theme="dark"] .house-map{ --house-tint:rgba(10,20,30,.6); }
.gtown-scroll{
  overflow:auto; -webkit-overflow-scrolling:touch;
  /* Height comes from the flex parent on village.html (see .village-body) so the map fills
     whatever room the page or the fullscreen viewport gives it; this is only the floor. */
  height:100%;
  min-height:340px;
  /* overscroll-behavior stops a flick that hits the bottom of the map from carrying on and
     scrolling the page out from under the reader mid-explore. */
  overscroll-behavior:contain;
  cursor:grab;
}
/* Click-and-drag panning with a mouse (touch already scrolls this natively) — the grabbing
   hand plus no text selection while a drag is in progress; see bindVillageDragPan() in
   render.js for the mousedown/mousemove/mouseup that toggles this class. */
.gtown-scroll.dragging{cursor:grabbing; user-select:none;}
/* Fixed pixel size (cols*16*2 x rows*16*2, set inline by town-map.js) rather than a percentage:
   on a narrow screen the art must scroll, never shrink — a fractional scale is what turns
   pixel art to mush. */
.gtown-inner{position:relative;}
.gtown-canvas{display:block; width:100%; height:100%; image-rendering:pixelated;}
/* Dusk/night/dark-theme wash. A flat colour layer rather than a filter, so it tints the scene
   without softening the pixel edges underneath. */
.gtown-inner::after{
  content:''; position:absolute; inset:0; pointer-events:none;
  background:var(--house-tint);
}
.gtown-hint{
  position:absolute; right:12px; top:12px; z-index:3; pointer-events:none;
  font-size:.68rem; font-weight:700; letter-spacing:.02em;
  padding:3px 10px; border-radius:999px;
  background:var(--paper); border:2px solid var(--line); opacity:.88;
}
/* A single 16x16 tile pulled straight out of the atlas by background-position — lets the bits
   of chrome that have to be HTML (the for-sale sign, the villagers) use the same art as the
   canvas without a second request or a second copy of the file. Size and offset are set inline
   because they depend on which tile and at what scale. */
/* The atlas itself is set inline by tileSpan() in town-map.js (it lives there as a data URI). */
.gt-tile{display:block; image-rendering:pixelated;}

/* ---- plots: real buttons stacked over the canvas ----
   No card chrome on purpose — a house should sit on the ground like a building in the scene,
   not float in a UI box. Only the nameplate and the sign's tag keep a background, and only
   because they are text that has to stay readable over grass, dirt or a tree. */
.gt-plot{
  position:absolute; transform:translate(-50%,-50%); z-index:2;
  display:flex; flex-direction:column; align-items:center; gap:3px;
  background:none; border:0; padding:0; margin:0; font:inherit; color:inherit;
  cursor:var(--cursor-pointer-url), pointer;
  transition:transform .12s ease;
  filter:drop-shadow(3px 4px 0 rgba(20,20,20,.3));
}
.gt-plot:hover{transform:translate(-50%,calc(-50% - 4px));}
.gt-plot:active{transform:translate(-50%,calc(-50% + 1px));}
.gt-plot:focus-visible{outline:3px solid var(--line); outline-offset:5px; border-radius:8px;}
/* Both dimensions come from town-map.js per zoom level: the width is the type's
   own tile width capped to the pad, the height is the pad itself. A square box
   (the old --gt-house) is taller than a five-row pad, which is what pushed houses
   out over the street. */
.gt-house{
  width:var(--gt-house-w,70px); height:var(--gt-house-h,70px);
  display:flex; align-items:flex-end; justify-content:center;
}
/* Bottom-centred: a house stands on its lot rather than floating mid-pad when its art is
   shorter than the pad. placeSmoke() in town-map.js assumes exactly this placement. */
.gt-house img{width:100%; height:100%; object-fit:contain; object-position:50% 100%; image-rendering:pixelated;}
/* The one house type currently priced highest (town-map.js's legendaryTypeId) gets a ring of
   twinkling stars — percent-positioned off .gt-house itself, not the plot, so they track the
   house's own box at every zoom level instead of a fixed offset that only lined up at one. */
.gt-house.gt-legendary{position:relative;}
.gt-legend-spark{
  position:absolute; z-index:4; color:var(--yellow); line-height:1;
  font-size:calc(var(--gt-house-w,70px) * .16);
  filter:drop-shadow(0 0 3px rgba(255,212,94,.85));
  animation:gtLegendTwinkle 1.8s ease-in-out infinite;
}
@keyframes gtLegendTwinkle{
  0%,100%{opacity:0; transform:scale(.4) rotate(0deg);}
  50%{opacity:1; transform:scale(1) rotate(20deg);}
}
/* The stand-in when a type has no art uploaded yet — sized off the same box, so
   an emoji house grows and shrinks with the zoom like a real one. */
.gt-emoji{font-size:calc(var(--gt-house-w,70px) * .7); line-height:1;}
.gt-plate{
  font-weight:800; font-size:var(--fs-plotName, .72rem); padding:2px 10px; border-radius:999px;
  background:var(--paper); border:2px solid var(--line); white-space:nowrap;
  max-width:130px; overflow:hidden; text-overflow:ellipsis;
}
/* A booking that only exists in this browser has to be impossible to mistake for a real one,
   so it never gets the solid nameplate a confirmed owner has. */
.gt-plate.demo{border-style:dashed; opacity:.9;}
.gt-plate.demo::after{content:' • ทดลอง'; font-weight:600; opacity:.6;}
.gt-signpost{filter:drop-shadow(2px 3px 0 rgba(20,20,20,.25));}
.gt-tag{
  display:flex; flex-direction:column; align-items:center; line-height:1.25;
  font-weight:800; font-size:var(--fs-plotTag, .6rem); letter-spacing:.07em; white-space:nowrap;
  padding:2px 9px; border-radius:7px;
  background:var(--paper); border:2px solid var(--line);
}
.gt-tag span{font-weight:600; font-size:var(--fs-plotTagNo, .58rem); letter-spacing:0; opacity:.65;}
/* Two badges a house can earn on top of its own art — a fresh sale and a supporter who
   shared its card. Absolutely positioned corners of .gt-plot rather than flex siblings, so
   neither one ever pushes the nameplate around. */
.gt-new-badge{
  position:absolute; top:-10px; left:-8px; z-index:3;
  font-weight:800; font-size:var(--fs-plotNewBadge, .58rem); letter-spacing:.02em; white-space:nowrap;
  padding:2px 7px; border-radius:999px;
  background:var(--yellow); border:2px solid var(--line); color:var(--line);
  transform:rotate(-8deg);
}
.gt-shared-badge{
  position:absolute; top:-11px; right:-9px; z-index:3;
  font-size:var(--fs-plotSharedBadge, .95rem); line-height:1;
  filter:drop-shadow(1px 2px 0 rgba(20,20,20,.25));
}

/* ---- ambient life ----
   Small looping decorations, all pure CSS on elements that already exist: no timers, no
   canvas repaint loop, nothing that touches layout. */
.gt-sparkle{
  position:absolute; top:-12px; left:50%; width:7px; height:7px;
  background:#ffd84d; box-shadow:0 0 0 2px rgba(255,216,77,.3);
  animation:gtTwinkle 2.6s ease-in-out infinite;
}
@keyframes gtTwinkle{
  0%,100%{opacity:0; transform:translate(-50%,2px) scale(.5);}
  50%{opacity:1; transform:translate(-50%,-5px) scale(1);}
}
/* Chimney smoke: three puffs on one 3.6s loop, a third of a cycle apart, curling off to the side
   as they rise — a steady trail rather than one dot blinking on and off. */
.gt-smoke{position:absolute; top:2px; left:34%; width:0; height:0; z-index:3; pointer-events:none;}
.gt-smoke i{
  position:absolute; left:0; top:0; width:7px; height:7px; border-radius:50%;
  background:rgba(255,255,255,.92); box-shadow:0 0 0 1px rgba(120,120,140,.25);
  opacity:0; animation:gtSmoke 3.6s ease-out infinite;
}
/* Pinned to the type's chimney by placeSmoke() in town-map.js; hidden until it has been. */
.gt-smoke.placed:not(.ready){display:none;}
.gt-smoke i:nth-child(2){animation-delay:1.2s;}
.gt-smoke i:nth-child(3){animation-delay:2.4s;}
@keyframes gtSmoke{
  0%  {opacity:0; transform:translate(-50%,0) scale(.5);}
  15% {opacity:.85;}
  55% {transform:translate(calc(-50% + 5px),-20px) scale(1.2);}
  100%{opacity:0; transform:translate(calc(-50% - 3px),-40px) scale(1.8);}
}
/* After dark the smoke catches the lamplight. */
#page-content[data-daytime="night"] .gt-smoke i,
:root[data-theme="dark"] .gt-smoke i{background:rgba(220,220,240,.55); box-shadow:none;}

/* ---- houses with their lights on ----
   A warm glow pooled behind each owned house's art (behind it: .gt-house isolates its own
   stacking, so z-index:-1 stays inside the house rather than dropping under the map) plus a soft
   halo on the art itself. Off in the day, half up at dusk, full at night and in the dark theme —
   the map around it is tinted darker at those times, and the lit houses are what make the street
   read as lived in. The house art is whatever the admin uploaded, so there are no known window
   positions to light individually; the glow is shaped like light spilling from the doorway. */
.gt-house{position:relative; isolation:isolate;}
.gt-house::before{
  content:''; position:absolute; left:4%; right:4%; bottom:-6%; height:62%; z-index:-1;
  border-radius:50%; pointer-events:none;
  background:radial-gradient(closest-side, rgba(255,206,110,.85), rgba(255,170,70,.35) 58%, transparent);
  opacity:0; transition:opacity .8s ease;
}
#page-content[data-daytime="dusk"] .gt-house::before{opacity:.55;}
#page-content[data-daytime="night"] .gt-house::before,
:root[data-theme="dark"] .gt-house::before{opacity:1; animation:gtLamp 3.4s ease-in-out infinite;}
#page-content[data-daytime="night"] .gt-house img,
:root[data-theme="dark"] .gt-house img{filter:drop-shadow(0 0 4px rgba(255,200,110,.7));}
@keyframes gtLamp{
  0%,100%{opacity:.9; transform:scale(1);}
  50%{opacity:1; transform:scale(1.05);}
}

/* The mailbox out front of a house whose owner left a note. Sits at the house's front-left
   corner, flag up, with a little wiggle now and then so it catches the eye. */
.gt-mailbox{
  position:absolute; left:-6%; bottom:0; z-index:1;
  background-repeat:no-repeat; background-size:100% 100%; image-rendering:pixelated;
  transform-origin:50% 100%;
  filter:drop-shadow(1px 2px 0 rgba(20,20,20,.28));
  animation:gtMailWiggle 4.2s ease-in-out infinite;
}
@keyframes gtMailWiggle{
  0%,78%,100%{transform:rotate(0deg);}
  82%{transform:rotate(-8deg);}
  86%{transform:rotate(7deg);}
  90%{transform:rotate(-4deg);}
  94%{transform:rotate(2deg);}
}

/* ---- critters ----
   Positioned by left/top from town-map.js; `scale` does the left/right mirror so `transform`
   is free for the peck and hop. Birds sit under the houses (a bird on the grass behind a house
   should be hidden by it) and fly over everything. */
.gt-critter{
  position:absolute; left:0; top:0; z-index:1;
  background-repeat:no-repeat; background-position:0 0;
  image-rendering:pixelated; transform-origin:50% 100%;
}
.gt-bird{cursor:var(--cursor-pointer-url), pointer;}
.gt-bird.flying{z-index:6; animation:gtFlap .22s steps(1,end) infinite; pointer-events:none;}
@keyframes gtFlap{
  0%{background-position:50% 0;}
  50%{background-position:100% 0;}
}
.gt-bird.peck{animation:gtPeck .5s ease-in-out;}
@keyframes gtPeck{
  0%,100%{transform:rotate(0deg);}
  35%,55%{transform:rotate(-38deg);}
  45%{transform:rotate(-30deg);}
}
.gt-bird.hop{animation:gtHop .3s ease-out;}
@keyframes gtHop{
  0%,100%{transform:translateY(0);}
  45%{transform:translateY(-40%);}
}
.gt-butterfly{z-index:5; pointer-events:none; animation:gtFlutter .3s steps(1,end) infinite;}
@keyframes gtFlutter{
  0%{background-position:0 0;}
  50%{background-position:100% 0;}
}
/* Night: birds roost, butterflies become fireflies — the same element, its art swapped for a
   glowing dot that breathes. */
#page-content[data-daytime="night"] .gt-bird{display:none;}
#page-content[data-daytime="night"] .gt-butterfly{
  background:none !important; border-radius:50%;
  width:5px !important; height:5px !important;
  background-color:#fff6a8 !important;
  box-shadow:0 0 6px 3px rgba(255,236,120,.75);
  animation:gtFirefly 2.2s ease-in-out infinite;
}
#page-content[data-daytime="night"] .gt-butterfly:nth-child(odd){animation-delay:1.1s;}
@keyframes gtFirefly{
  0%,100%{opacity:.15;}
  50%{opacity:1;}
}

/* The note from the owner, in the house's popup: a little letter on lined paper. */
.gt-letter{
  position:relative; width:100%; max-width:360px; box-sizing:border-box; text-align:left;
  padding:14px 18px 12px; border:2px solid var(--line); border-radius:6px 6px 14px 14px;
  background:
    repeating-linear-gradient(transparent 0 25px, rgba(150,130,200,.28) 25px 26px),
    #fffdf4;
  background-position:0 8px;
  color:#2d2640; box-shadow:3px 4px 0 var(--line);
  transform:rotate(-1.2deg);
  animation:gtLetterIn .5s cubic-bezier(.3,1.4,.5,1);
}
@keyframes gtLetterIn{
  0%{opacity:0; transform:translateY(14px) rotate(3deg) scale(.94);}
  100%{opacity:1; transform:translateY(0) rotate(-1.2deg) scale(1);}
}
.gt-letter-head{display:block; font-size:.85rem; margin-bottom:6px;}
.town-modal .gt-letter-body{margin:0; color:#2d2640; line-height:26px; white-space:pre-line;
  font-size:var(--fs-placeDesc, 1rem); overflow-wrap:anywhere;}
.gt-letter-sign{display:block; text-align:right; font-weight:800; font-size:.85rem; margin-top:4px;}
.gt-letter-stamp{
  position:absolute; right:10px; top:-12px; font-size:1.1rem; line-height:1;
  padding:4px 5px; background:#fff; border:2px dashed #d58ab0; transform:rotate(8deg);
}

@media (prefers-reduced-motion:reduce){
  .gt-sparkle,.gt-smoke,.gt-legend-spark{animation:none; display:none;}
  .gt-house::before,.gt-mailbox,.gt-letter,.gt-bird.flying,.gt-bird.peck,.gt-bird.hop{animation:none;}
  .gt-butterfly{display:none;}
}

/* ---- the walker ----
   A sprite you steer along the dirt streets (see startWalker in town-map.js).
   Size, background-size and the frame offset are all set inline per zoom level,
   because they are multiples of the map's own integer scale and nothing here can
   know which of 1x/2x/3x is showing.

   pointer-events:none matters: the plot buttons sit in the same stacking context
   and a character standing on a plot must not be able to swallow its click. */
.gt-walker{
  position:absolute; left:0; top:0; z-index:3; pointer-events:none;
  background-image:url('town/walker.png?v=1');
  background-repeat:no-repeat;
  image-rendering:pixelated;
  transform-origin:50% 100%;
  filter:drop-shadow(2px 3px 0 rgba(20,20,20,.28));
}
/* Her seal, trailing a moment behind (see the trail bookkeeping in startWalker).
   z-index below the walker so if the delay ever closes to nothing she tucks
   behind her rather than in front.

   One static pose (`/seals/11.png` — the same file every seal badge on the site
   already uses, not a copy) rather than a walk-cycle sheet: a seal has no legs to
   animate a stride with, she flops. So .gt-seal-buddy only ever carries position
   (translate + the left/right flip, both written every frame by renderSeal) and
   .gt-seal-buddy-art underneath carries the waddle itself as its own CSS
   animation — two separate transforms because a single element can't hold a
   JS-driven translate and a looping CSS keyframe at once without one wiping out
   the other on every animation frame. */
.gt-seal-buddy{
  position:absolute; left:0; top:0; z-index:2; pointer-events:none;
  transform-origin:50% 100%;
}
.gt-seal-buddy-art{
  width:100%; height:100%;
  background-image:url('/seals/11.png');
  background-repeat:no-repeat;
  background-size:contain;
  image-rendering:pixelated;
  transform-origin:50% 82%;
  filter:drop-shadow(1px 2px 0 rgba(20,20,20,.25));
}
/* Only while she's actually moving (see renderSeal's `moved` flag) — matches the
   old frame-hop's own rule of resting flat during a pause, and the class is only
   ever added when !reducedMotion() so this never has to fight that setting. */
.gt-seal-buddy-art.waddling{animation:gt-seal-waddle .5s ease-in-out infinite;}
@keyframes gt-seal-waddle{
  0%,100%{transform:scale(1,1) rotate(0deg);}
  25%{transform:scale(1.06,.92) rotate(-5deg);}
  50%{transform:scale(.95,1.05) rotate(0deg);}
  75%{transform:scale(1.06,.92) rotate(5deg);}
}
@media (prefers-reduced-motion:reduce){
  .gt-seal-buddy-art.waddling{animation:none;}
}

/* ---- the plot panel inside the town modal ---- */
.gt-panel{display:flex; flex-direction:column; align-items:center; gap:10px;}
.gt-modal-sign{margin:0 auto;}
.gt-modal-art{
  width:120px; height:120px; object-fit:contain; image-rendering:pixelated;
  filter:drop-shadow(3px 4px 0 rgba(20,20,20,.25));
}
.gt-modal-emoji{font-size:3.2rem; line-height:1;}
.gt-panel h3{margin:0;}
.gt-thanks{margin:0 !important; color:var(--ink-soft);}
.gt-note{
  margin:2px 0 0 !important; font-size:.76rem; line-height:1.5;
  color:var(--ink-soft); text-align:center;
}
.gt-stats{display:flex; flex-wrap:wrap; justify-content:center; gap:8px; margin:2px 0 0; width:100%;}
.gt-stats>div{
  flex:1 1 120px; padding:7px 10px; border-radius:12px;
  background:var(--paper-2); border:2px solid var(--line);
}
.gt-stats dt{font-size:.66rem; font-weight:700; letter-spacing:.05em; opacity:.62;}
.gt-stats dd{margin:2px 0 0; font-weight:800; font-size:.9rem; word-break:break-word;}
/* The price list a vacant plot shows. Read-only — ownership is admin-entered, so there is no
   picker and no form here, just what could be built and what it costs. */
.gt-pricelist{display:flex; flex-direction:column; gap:6px; width:100%; margin-top:2px;}
.gt-pricerow{
  display:flex; align-items:center; gap:10px;
  padding:6px 10px; border-radius:12px;
  border:2px solid var(--line); background:var(--paper-2);
}
.gt-pricerow b{flex:1 1 auto; text-align:left; font-size:.85rem;}
.gt-pricerow-art{
  flex:none; width:46px; height:34px;
  display:flex; align-items:center; justify-content:center;
}
.gt-pricerow-art img{max-width:100%; max-height:100%; object-fit:contain; image-rendering:pixelated;}
.gt-pricerow-art .gt-emoji{font-size:1.5rem;}
.gt-price{font-size:.8rem; font-weight:800; opacity:.75; white-space:nowrap;}

/* ============================================================ village.html

   The map's own page. The shell is the fullscreen target so the toolbar and the collection
   panel go fullscreen with the map — a fullscreen map you cannot search is worse than a
   windowed one you can. */
.village-back{
  display:inline-block; margin:0 0 10px; font-weight:800; font-size:.85rem;
  text-decoration:none; color:var(--ink);
}
.village-back:hover{text-decoration:underline;}
.village-title{margin:0 0 4px; font-size:var(--fs-villageTitle, 1.6rem);}
.village-intro{margin:0 0 16px; color:var(--ink-soft); font-size:var(--fs-villageIntro, .95rem);}
.village-off{
  margin:20px 0; padding:22px; text-align:center; color:var(--ink-soft);
  border:var(--bw) dashed var(--line); border-radius:22px;
}
.village-shell{
  display:flex; flex-direction:column; gap:12px;
  background:var(--bg);
}
/* In fullscreen the shell becomes the whole viewport, so it needs its own padding and has to
   stop the map growing past the screen — hence the explicit height and min-height:0 below. */
.village-shell:fullscreen{padding:14px; height:100vh;}
.village-shell:-webkit-full-screen{padding:14px; height:100vh;}

.village-bar{
  display:flex; flex-wrap:wrap; align-items:center; gap:10px;
}
.village-search{position:relative; flex:1 1 240px; min-width:0; display:flex;}
.village-search input{
  width:100%; font:inherit; font-weight:700; font-size:.9rem;
  padding:9px 34px 9px 14px; border-radius:999px;
  border:var(--bw) solid var(--line); background:var(--paper); color:var(--ink);
}
.village-search input::-webkit-search-cancel-button{display:none;}
#villageSearchClear{
  position:absolute; right:6px; top:50%; transform:translateY(-50%);
  width:26px; height:26px; border-radius:50%; line-height:1;
  border:0; background:none; color:var(--ink-soft);
  cursor:var(--cursor-pointer-url), pointer;
}
.village-search-out{font-size:.78rem; font-weight:700; color:var(--ink-soft);}
.village-search-out.none{color:#e0534f;}
.village-zoom{display:flex; border:var(--bw) solid var(--line); border-radius:999px; overflow:hidden;}
.village-zoom button{
  font:inherit; font-weight:800; font-size:.78rem; padding:7px 13px;
  border:0; background:var(--paper); color:var(--ink);
  cursor:var(--cursor-pointer-url), pointer;
}
/* A readout between the two buttons rather than one button per level: the map now
   zooms 1x-4x and a row of four fixed buttons is a lot of toolbar for something
   the reader just wants to nudge. */
.village-zoom-val{
  min-width:34px; align-self:stretch;
  display:flex; align-items:center; justify-content:center;
  font-weight:800; font-size:.8rem;
  border-left:2px solid var(--line); border-right:2px solid var(--line);
}
.village-zoom button[disabled]{opacity:.35; cursor:default;}
.village-full{
  font:inherit; font-weight:800; font-size:.8rem; padding:8px 14px; border-radius:999px;
  border:var(--bw) solid var(--line); background:var(--paper); color:var(--ink);
  cursor:var(--cursor-pointer-url), pointer;
}

/* min-height:0 on both the row and the map is what lets the map shrink to the flex height
   instead of forcing the page taller — without it a tall canvas wins and fullscreen overflows. */
.village-body{display:flex; gap:12px; align-items:stretch; min-height:0; flex:1 1 auto;}
.village-map{flex:1 1 auto; min-width:0; min-height:0; height:min(76vh, 860px);}
.village-shell:fullscreen .village-map{height:auto;}
.village-shell:-webkit-full-screen .village-map{height:auto;}
.village-side{
  flex:0 0 232px; display:flex; flex-direction:column; gap:10px;
  overflow:auto; padding:14px; border-radius:20px;
  border:var(--bw) solid var(--line); background:var(--paper);
  box-shadow:var(--shadow-hard);
}
.village-side-title{margin:2px 0 0; font-size:var(--fs-villageSideTitle, .95rem);}
/* Its own var rather than the shared --fs-note: fontSizes is one flat map for the
   whole site, so reusing noteText here would move the Participants page's text too. */
.village-side-note{margin:auto 0 0; font-size:var(--fs-villageNote, .74rem); line-height:1.5; color:var(--ink-soft);}
.gv-stats{font-size:var(--fs-villageStats, .85rem); font-weight:800;}
.gv-stats b{font-size:calc(var(--fs-villageStats, .85rem) * 1.53);}
.gv-bar{
  display:block; height:9px; margin-top:6px; border-radius:999px; overflow:hidden;
  border:2px solid var(--line); background:var(--paper-2);
}
.gv-bar>span{display:block; height:100%; background:var(--yellow);}
.gv-lowstock{margin:8px 0 0; font-size:var(--fs-villageLowStock, .78rem); font-weight:800; color:var(--pink);}
.gv-cards{list-style:none; margin:0; padding:0; display:flex; flex-direction:column; gap:8px;}
.gv-card{
  display:flex; align-items:center; gap:10px;
  padding:8px; border-radius:14px;
  border:2px solid var(--line); background:var(--paper-2);
}
.gv-card-art{flex:none; width:64px; height:46px; display:flex; align-items:center; justify-content:center;}
.gv-card-art img{max-width:100%; max-height:100%; object-fit:contain; image-rendering:pixelated;}
.gv-card-art .gt-emoji{font-size:1.7rem;}
.gv-card-body{display:flex; flex-direction:column; gap:1px; min-width:0;}
.gv-card-body b{font-size:var(--fs-villageCardName, .82rem);}
.gv-card-built{font-size:var(--fs-villageCardBuilt, .7rem); color:var(--ink-soft);}
/* Only the price inside a village collection card — .gt-price is shared with the
   Grape Town modal, which the village admin has no business resizing. */
.gv-card-body .gt-price{font-size:var(--fs-villageCardPrice, .8rem);}

/* Search feedback. Dimming rather than hiding: the map is a place, and holes punched in a
   street read as broken rather than filtered. */
.gt-plot.gt-dim{opacity:.25; filter:grayscale(1);}
.gt-plot.gt-hit{z-index:4;}
.gt-plot.gt-hit .gt-plate{
  background:var(--yellow); color:#141414;
  box-shadow:0 0 0 3px var(--line);
}

@media (max-width:820px){
  /* The side panel drops under the map rather than squeezing it — at this width a 232px
     column leaves the map too narrow to read. */
  .village-body{flex-direction:column;}
  .village-map{height:min(64vh, 560px);}
  .village-side{flex:1 1 auto; max-height:none;}
  .village-side-note{margin-top:4px;}
}

/* ---- the doorway card on Grape Town ---- */
.village-door{
  display:flex; align-items:center; gap:14px; margin:0 auto; max-width:560px;
  padding:14px 18px; border-radius:22px; text-decoration:none; color:inherit;
  border:var(--bw) solid var(--line); background:var(--paper);
  box-shadow:var(--shadow-hard);
  transition:transform .12s ease, box-shadow .12s ease;
}
.village-door:hover{transform:translate(-2px,-3px); box-shadow:6px 7px 0 var(--line);}
.village-door:active{transform:translate(1px,1px); box-shadow:2px 2px 0 var(--line);}
.village-door-art{
  flex:none; width:64px; height:64px; border-radius:14px;
  border:2px solid var(--line);
  /* one tile of the sheet — the red-roof cottage's gable — as a stamp-sized crest */
  /* the blue cottage's roof from the pastel village sheet (same data URI as ATLAS_URL in town-map.js) */
  background:#b4e6a0 url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMAAAACwCAYAAABZ/mHGAAAMcklEQVR42u2dPcsdRRTH7wfyI8RWrC3TWNhJQFv9AAELEwsrYyEklWCTBCGFKClMUgTEiBqwCCIPUSSmMAqKrMwlczN37pmZM+9nZv4HhufZ3bt7d+7+fzNnzrzs7ubZJ9vIaQeD5Viq8L57djtLuKXOxxOEdQGAmz789r2jv6XPd+Xr5Zde28wU+7vkng8DACci5qTtxv2o813i3X48O0oxIlaf/eLpzaOUAxFUtjgAMeLfHp4nIeACQIl/f90b91lipMR/8cGlfeKKWQvfhAcgDABAbXeFc74Sasz5XPFzIPCJnwsBdY2cWgTWuAZIFW/M+T5QYr8/Rvw+CDjiD0HgEz8ggAtUFLQgAKbolT3//8n711kA2KJX11R/X//8HTZAgAAANOsHOALAUfIr8XMAcJX8SvwUAGr7CJYb953XiG1PwDoBICXOf9RI3rYwAESpb4o/BEBI/DYAtvhdSRkgQA2QHiFSpiJEFgRHAARK/hAAlMtjiz8VANQCEwDQO0qkxW+eb5b+FASU+O2GsC79KTFT4jfFGwLA5w4BgAFrgBZRopjzbf/fhMAlfhcANgQu8dviTakFIP7JXKDc0j01uXp9feI397l6fX3id7lC9jmuBPFP2gZoLX4XALqEN5OrMeyK/9vnhxrDFAT6XIgfjeB2ADiiQC6XKBT/D7UHqMa0PfQBg+rQBmjTBnBEgUKRICoEakaBuOKHTQCANIFzOsJ8IVBK/BQAMSU/AIALJLMnOEH8IQgg/gUB+P2OzMQaDZogfhcEEP+CACih/Xd2t2na/vzFeezXe58dPkNBQIk4Rfw2BBD/ggBo8SvRaeHFiLUFGDYELhFTifub5J6/w5TKsQGQnDgAaAHeu/zGdvXCK8nTGVPPLzWlEiYIAF0au2qGEu4O5zPSAcCMsIkBsMWfC8NsNQAl/jvXngGCEdsANUXsu746NmoNYEaRrrz5CIPgVm4DcCM7s9QAMSNCAcHgALSMBI1aA5jJrA0g/slqAKptULONMCIAKP0XcIFaQTBCFAjzAAYGoEdkpwYAuwJLE+asKwrxL94IDpX+qe2IGAB6GnqCJwagZy0xCgCwhWoATsizJQASxgLBJgSAEnOOwGv0A5BzhI15whzxU8MYEMEBAN0TNSSaK/7U1aHtqZKxS6Sj9hgUgJBbYx7PdYFiahEXABzxx64O7ZokH4Jofy/fPDpKAGHQGsAWt7lNCTcFBnN/CIYoADosj75dvX2alD3/HxBM4gLVHA7hg4ANQIfl0SnBUwkQDAZAabH7ZpwVcYEaL4/uLf0BABrBJUOpQQAaL4/OBgBuEACo2Q/Qa3n02NIfEEwGQIlOMP05zueDYdAE8bsgiFkRztV5hk41ABB9Hd/US1ZHWIL4KQhSB7Tt7+Gvx0cJoh8cAO5w55iwaUpDeIShEABgEgBajvGZbVkUALBoDRDjJqW4SCMAsMNQCESBVp4QA5sEAAmzw+zawwag9f3UqAlz+0V0unvrySHZCwu7jgEARg0QA0IsNLGf7wlAygy3lEUDuOLXo2O1wP/5ezskU+jmsbOf/j06BgAqDIWoWXPUAICzTlEoT7F5Dv2mXGgo8SuRa6Hb4jePhQDo7UI2cUFLtwFCq77lHssFILWGyhm/VLMmcolfi5zab8LhjWQZQ7lDoqTei1bj/GUawS6xxQBQcyHf2AiWa/5E7j2GRO4TfxAAK4S73/fcTsRq7a9xfncAfJ1csQKhzuVcLwQAp6Tm5sO3ILD9t1efCUfkrv0+F8gpYEKU3M/mnj98DRDbltCfN8/zAVBylboWgvZNBOJ+h24D+ESuj1ORIC4Ah5luxL6Yz6aeLxqAWgvhlmwE1yqFU6/L/T1CQ0vMSI9L5HYYNAUA8y/n/9LndwPALqVqTI7JAUDicA1u1KjE/XFi/VRDOdYFWhYAyr3IgYA7Sb5EI5hbsraCyPc9qe5bKNYfihLBBWocBcoVVO+e4JZtIG5Y2BfrD0WJAECl16TaD9vV4DNL+9i3w7iiQLkAqtlgvec+lwqDhqJEXAB++/6rjdrn2l/6/GYAjPai7FjgWpTsqd+dEr6NDYNy+wHszinzL+f/0uc3BWCkVHLJxpS+ixouIadNomvLUBg0dCymH6Clda0BZgCgd1sldV50yhzpUBiUOpbSEQYAhAJQs4GqozPU8Z5v08wZDp3SDzAtADAYAIDBAAAMdhoB6jUtdLg51asvDDVT/l3Lu6/y/Uk3/NGD7ZB++WsLbs8Ewf6B3froRXr4dXBbcv57L+sy1LIytvhj0gwQnIg/IknNPwCIuNG3rj8+EnXs9sgQqHt//MFbR6KO3ZYMAdoAgRvkuDqc7REh2JdSDFeHsy0x/4gCVXJ7ZnCHctyeUdwhABAQf67rQ22PAIEWf67rQ21Lyj8A8Ii/lOtDbYuPjmS4OpxtKfkHAA3cnpHcoRpuj2R3CAA4xF/D9aG2xbkDBV0dznbv/AMAQvw1XR9qW0xJWMHV4Wz3zD8AaOz2SHSHWro90twhANDJ9ZHkCvVwfaS4QssD0NP1keAK9XR9JLhCSwMgwfXp6QpJcH16u0LLAmCLv5frQ223+CFs8fdyfajtHhD0dkG7ir+369N6GHXKsObW260EEbu+f2mxN3s/gGS3p6U7JNHt6ekOxazvX+S7er0fYFdoWHPr7dIPQJKrI2EYdcs1+7u+H6DksObW2yV+mJLDmltvV3cHGWt7lnJ9uq0NOorb40q5+R/F7XGlVgC4ljHP/v17L48+mutjb+fmfzTXx94GAIWqoFET8g8XqGrmpaTek7JXyj8AAAAAgLm+f+73dH8/AFcAriq4xIPlXFfCshyr5J/qnPKt45/7Hbue7wfgCECHSu1wpA5F5jx87nV7ArBa/pc3lwBcvbK5AuBcVwIAq+QfABA/vm9YAqdKp6r22Osi/zBxAuh9rzUEtHr+AQAAAAAAAAAAgHJRpFbfVaQDkfrxthv3X/ixD88f9vUWgL4X8x71sNqSAKyU/xJibzmfQF3vjy8vRyXvPdidEJ++/aoz9RaA795SO1NWz3+Sx8AYz19zPsEPH184SRcfXCL3qwQAAEAVlzkIQKX5BLbAr7z5aJ/U/+ZvEg1AKLQnob3CDTnmuEAr5T/l+znjhmqMJeIAkFUDhFJ3H7jCWJrV8x/7/dyh0zWGU/tcIF8CAAAAAEAAyP8MLlCVMKgakspJvQXAvc9YAFbO/4gAUPdlBgOiTwYAAID7/dy5AzXmEwAAANC9BthFzB0oPZ+gCgAxPpXkMGDOfa6e/9EtGwAYDADAYINDIF78dsPIjNWaXddm/HamIb6r5x+UAgAAAAAAAAAAAAAAAJz475QfrxYSmCaj5rDdGAHoVj7yL89yJwrpSUdmatF3AAEg/0XEvxetBYGaBJMqfhsCuEBwgUTnnxL/XsQBCFR+nt48503TQWCPWHQJQP2AtgAkjMZE/t2mpiRyawBfyT91TcAVAFUCrgTAKPm3G68KAs6QDAAAAIbP/+F1U57kEi/H/ZnSDbJHLHIEIGk0JvJvAZD4hstlawAAAAB8oU9fSBQAAIBpAFg2CiT1HVnIf3sAdiv2A8B2SxVo/MY0xA8DSMvOaIPBYDBY8+qmx74S14LBksV/99aTQ9LCarkv51p4grBsAM5++veQtMhc+5ToOJ9z7TOFXOL6eILzmVrVmfoLAADAEuK/c+3ZRv2tAgIAgElrhzbtAEQbACZB/EWXN8+hzy5tc/fZJbqr5E+9PuQDALLNdCnspAUs8TikAwBEA7Bdvb0/rv4CAJgPAN/L/lwvACx2A6ZPXSop0dup9HdAOqgBitcApVwWCoDSLhGkAwDEAmBDUOP6kA4AEA2AhqDW9SGduSDoNhGoRhugRYJsYMtb7zAtngAMAMBgAAAG6wQA2iGwZa330Aw8AdgQAMSG2QAAbAoAcl+UDQBgw7YBSnSY2PMP0AaADREFKtlb6HOJ8ARg4gCoMVHGBQGeAEwUADVniVEQ4AnAxLQBWkyRxJxkmChrVfq7agE8AZgIAFpOkDchwBOAwWAwGAwGy25TxIxNwrpIMAAAgwEAGGxgAFLnIgAA2HQ1QMx8BAAAmx6Aiw8uLQHAau+HhjEA0C+FmB2A/XuBiZUEfQkQLNAG0ADM3gYAAKgBlo4CqXw8vXnuJOm3xFPHAAAAmAqAn9/95CRpAKhjAAAAAADY3G2AVfoBXAD4EgBYIAq0Sj8AAAAAAAAAAICVe4LREYY2AMYCdbD/AUoT7vzV59aMAAAAAElFTkSuQmCC') no-repeat -64px -256px;
  background-size:768px 704px;
  image-rendering:pixelated;
}
.village-door-text{display:flex; flex-direction:column; gap:2px; flex:1 1 auto; min-width:0;}
.village-door-text b{font-size:1.05rem;}
.village-door-text>span{font-size:.82rem; color:var(--ink-soft);}
.village-door-count{font-size:.78rem; font-weight:800; color:var(--ink) !important;}
.village-door-count b{font-size:1rem;}
.village-door-go{flex:none; font-size:1.5rem; font-weight:800;}

/* town place modal */
.town-modal-backdrop{
  position:fixed; top:0; right:0; bottom:0; left:0; z-index:80; display:none;
  background:rgba(20,20,20,.55);
  align-items:center; justify-content:center; padding:20px;
}
.town-modal-backdrop.show{display:flex;}
.town-modal{
  position:relative; width:min(520px,100%); max-height:86vh; overflow:auto;
  background:var(--paper); border:var(--bw) solid var(--line); border-radius:22px;
  padding:26px 22px; box-shadow:var(--shadow-hard); text-align:center;
}
.town-modal img.place-img{width:100%; border:var(--bw) solid var(--line); border-radius:14px; margin-bottom:14px;}
.town-modal h3{margin:0 0 6px; font-size:var(--fs-townModalTitle, 1.4rem);}
.town-modal p{margin:8px 0; color:var(--ink-soft); white-space:pre-line; font-size:var(--fs-placeDesc, 1rem);}
.town-modal-close{
  position:absolute; top:10px; right:10px; width:38px; height:38px; border-radius:50%;
  border:var(--bw) solid var(--line); background:var(--paper); color:var(--ink);
  font-size:1.1rem; cursor:var(--cursor-pointer-url), pointer; line-height:1;
}

/* ---------------------------------------- Grape Town: time of day
   render.js's paintDaytime() stamps data-daytime="day"/"dusk"/"night" on #page-content from
   the visitor's own clock — "day" is the look above with no override needed; dusk/night
   repaint the hero sky and both boards' ground/road/tree tones the same way
   :root[data-theme="dark"] already repaints them for the manual theme toggle, just with two
   extra palettes instead of one. Every rule here is gated behind
   :root:not([data-theme="dark"]) — the dark theme already reads as night site-wide, and
   layering this on top of it read muddier than either alone, not richer. */
:root:not([data-theme="dark"]) #page-content[data-daytime="dusk"] .town-hero{
  background:linear-gradient(180deg,#ff9f5c 0%,#ff7d92 55%,#9868c9 100%);
}
:root:not([data-theme="dark"]) #page-content[data-daytime="night"] .town-hero{
  /* A handful of fixed star dots layered under the sky gradient — cheap (no images, no
     animation) and reads as "night sky" without a starfield generator. */
  background:
    radial-gradient(1.5px 1.5px at 12% 22%, #fff, transparent 100%),
    radial-gradient(1.5px 1.5px at 82% 18%, #fff, transparent 100%),
    radial-gradient(1.5px 1.5px at 68% 42%, #fff, transparent 100%),
    radial-gradient(1.5px 1.5px at 30% 55%, #fff, transparent 100%),
    radial-gradient(1.5px 1.5px at 90% 60%, #fff, transparent 100%),
    radial-gradient(1.5px 1.5px at 48% 15%, #fff, transparent 100%),
    linear-gradient(180deg,#1c2b52 0%,#0f1a38 100%);
}
:root:not([data-theme="dark"]) #page-content[data-daytime="dusk"] .town-map{
  --map-ground:#dfb96c; --map-ground-2:#c69f52; --map-road:#ffcf82; --map-road-line:#fff3d9;
  --map-tree:#6a9a63; --map-tree-2:#4d7a4d;
}
:root:not([data-theme="dark"]) #page-content[data-daytime="night"] .town-map{
  --map-ground:#2c4a5c; --map-ground-2:#213b4a; --map-road:#5f7690; --map-road-line:#9db0c4;
  --map-tree:#255246; --map-tree-2:#1a3f36;
}
:root:not([data-theme="dark"]) #page-content[data-daytime="dusk"] .house-map{
  --house-tint:rgba(255,150,70,.28);
}
:root:not([data-theme="dark"]) #page-content[data-daytime="night"] .house-map{
  --house-tint:rgba(10,20,45,.55);
}

/* ============================================================ participants

   The one page whose rows come from a live Google Sheet rather than the content document.
   Two groupings of the same rows (by activity / by person) plus a filter box, all
   client-side, so switching view never refetches. */

.part-hero h1{font-size:var(--fs-partTitle, 2.2rem); margin:0;}
.part-hero .subtitle{font-size:var(--fs-partSubtitle, 1.1rem); color:var(--ink-soft);}
.part-hero .updated{font-size:var(--fs-partUpdated, .9rem);}

.part-toolbar{
  display:flex; flex-wrap:wrap; gap:12px; align-items:center; justify-content:space-between;
  margin:24px 0 18px;
}
.part-tabs{display:flex; gap:8px; flex-wrap:wrap;}
.part-tab{
  border:var(--bw) solid var(--line); border-radius:999px; background:var(--paper);
  color:var(--ink); font-family:inherit; font-weight:700; font-size:var(--fs-partTab, 1rem);
  padding:8px 20px; cursor:var(--cursor-pointer-url), pointer; box-shadow:var(--shadow-hard);
  transition:transform .12s ease, box-shadow .12s ease, background .12s ease;
}
.part-tab:active{transform:translate(3px,3px); box-shadow:1px 1px 0 var(--line);}
.part-tab.active{background:var(--lilac); color:var(--line);}
.part-search{
  flex:1 1 220px; max-width:420px; min-width:0;
  border:var(--bw) solid var(--line); border-radius:999px; background:var(--paper);
  color:var(--ink); font-family:inherit; font-weight:700; font-size:var(--fs-partTab, 1rem);
  padding:9px 18px; box-shadow:var(--shadow-hard);
}
.part-search::placeholder{color:var(--ink-soft); font-weight:700;}

/* align-items:start or a whole row of closed cards would stretch to match the one card
   somebody just opened. */
.part-groups{
  display:grid; grid-template-columns:repeat(auto-fill,minmax(320px,1fr)); gap:20px;
  align-items:start;
}
/* By-person only (render.js's paintParticipants toggles this) — one card per row instead of
   the auto-fill grid above. That grid suits by-activity, whose cards hold a wall of chips and
   want the width; a by-person card is short (name, badge, seal row, count), and a row of
   short cards left uneven leftover columns stretching some wider than others. */
.part-groups.by-person{ grid-template-columns:1fr; max-width:640px; margin-left:auto; margin-right:auto; }
.part-groups.anim .part-group{animation:popIn .4s ease backwards;}
/* The head is the control: click the activity (or the person) to see who is inside. It is a
   real <button> so it answers to the keyboard and announces its state, but it has to shed
   every default button style first — this one has to look like the heading it replaced. */
.part-group-head{
  display:flex; align-items:center; justify-content:space-between; gap:10px; width:100%;
  padding:0; margin:0; border:0; background:none; color:inherit; font:inherit; text-align:left;
  cursor:var(--cursor-pointer-url), pointer;
}
.part-group.open .part-group-head{
  border-bottom:2px dashed var(--line-soft); padding-bottom:10px; margin-bottom:14px;
}
/* min-width:0 overrides the flex default (min-width:auto), which otherwise sizes this to its
   unbroken content — the seal row's wrap only helps once this is allowed to actually shrink
   inside .part-group-head instead of forcing the whole button (and the card under it) wider. */
.part-group-title{margin:0; flex:1 1 auto; min-width:0; font-size:var(--fs-partGroupTitle, 1.4rem); overflow-wrap:anywhere;}
.part-caret{
  flex:none; width:0; height:0; border-left:7px solid transparent; border-right:7px solid transparent;
  border-top:9px solid var(--ink-soft); transform:rotate(-90deg);
  transition:transform .18s ease;
}
.part-group.open .part-caret{transform:rotate(0deg);}
.part-group-head:hover .part-caret{border-top-color:var(--lilac);}
.part-count{
  flex:none; margin-left:auto; white-space:nowrap;
  color:var(--ink-soft); font-size:var(--fs-partCount, .9rem);
  border:2px solid var(--line-soft); border-radius:999px; padding:2px 10px;
}
.part-chips{display:flex; flex-wrap:wrap; gap:8px;}
.part-chip{
  display:inline-flex; align-items:center; gap:8px;
  border:2px solid var(--line); border-radius:999px; padding:5px 14px;
  background:var(--paper-2); font-weight:700; font-size:var(--fs-partName, 1.15rem);
  overflow-wrap:anywhere;
}
/* Bare span, no rules of its own otherwise — which meant it inherited overflow-wrap:anywhere
   from .part-chip above with no min-width floor to stop there. A chip next to a big .part-seals
   row (a full ten-seal set is 500px+) has to shrink to fit the card, and by default a flex
   item's automatic minimum size under overflow-wrap:anywhere is about one character — so the
   shrink math starved the name down to that floor while .part-seals, which can freely reflow
   onto more rows, absorbed almost none of it. Result: a two-letter name like "Ch" rendered as
   "C" then "h" stacked one letter per line. flex-shrink:0 takes the name out of that shrink pool
   entirely — it always renders at its natural width, and .part-seals does 100% of the giving. */
.part-chip-name{flex-shrink:0;}
/* ป้ายขั้น. Sized in em throughout, so both shapes scale with whichever name they sit beside
   and neither needs a slider of its own.
   .rosette — the full award beside a person's heading: a disc on two ribbon tails, drawn in
   SVG so it keeps the hand-inked outline everything else on the page wears.
   .plain — the same tier inside a name chip, stripped back to the bare emoji. Forty of these
   can share one activity group, so it carries no disc, no border and no extra row height. */
.part-badge{
  display:inline-flex; align-items:center; justify-content:center; flex:none;
  line-height:1; cursor:help;
  --tier-bg:var(--paper-2); --tier-tail:var(--pink);
}
/* Same six colours the .sticker labels use, so a tier can never be a shade that belongs to
   nothing else on the site. gold/silver are the retired two-tier names, kept so a document
   saved before the tier list existed still paints. */
.part-badge.grape{--tier-bg:var(--grape);}
.part-badge.pink{--tier-bg:var(--pink); --tier-tail:var(--grape);}
.part-badge.orange{--tier-bg:var(--orange);}
.part-badge.yellow, .part-badge.gold{--tier-bg:var(--yellow);}
.part-badge.mint{--tier-bg:var(--mint);}
.part-badge.gray, .part-badge.silver{--tier-bg:var(--paper-2);}

.part-badge.rosette{margin-left:.3em; vertical-align:middle; transform:rotate(-4deg);}
.part-badge.rosette svg{width:1.8em; height:2.2em; display:block; overflow:visible;}
.part-badge.rosette .disc{fill:var(--tier-bg); stroke:var(--line); stroke-width:2.6;}
.part-badge.rosette .tail{fill:var(--tier-tail); stroke:var(--line); stroke-width:2.4; stroke-linejoin:round;}

.part-badge.plain{
  font-size:1.1em; transform:rotate(-8deg);
  filter:drop-shadow(1.5px 1.5px 0 rgba(0,0,0,.22));
}
:root[data-theme="dark"] .part-badge.plain{filter:drop-shadow(1.5px 1.5px 0 rgba(0,0,0,.55));}
.part-chip-amount{color:var(--grape-deep); font-size:var(--fs-partMeta, .9rem);}
.part-chip-note{color:var(--ink-soft); font-size:var(--fs-partMeta, .9rem);}
.part-total{margin-top:14px; text-align:right; font-weight:700; color:var(--grape-deep); font-size:var(--fs-partMeta, .9rem);}
.part-state{
  grid-column:1/-1; text-align:center; color:var(--ink-soft);
  padding:36px 0; font-size:var(--fs-empty, 1.3rem);
}
.part-state.err{color:var(--pink);}

/* ---------------------------------------- participants: the pastel pass (Sept 2026)
   Every card carries one of six tones (tone-0..5, set in turn by paintParticipants) as --tone,
   and everything soft on the card is mixed from it against --paper — so the same rules read
   as pastel in the light theme and as a gentle tint in the dark one without a second palette. */
.tone-0{--tone:var(--lilac);}
.tone-1{--tone:var(--pink);}
.tone-2{--tone:var(--mint);}
.tone-3{--tone:var(--yellow);}
.tone-4{--tone:var(--grape);}
.tone-5{--tone:var(--orange);}

/* stats bubbles under the title */
.part-stats{display:flex; flex-wrap:wrap; justify-content:center; gap:10px; margin-top:14px;}
.part-stats:empty{display:none;}
.part-stat{
  display:inline-flex; align-items:center; gap:6px;
  padding:5px 14px 5px 6px; border-radius:999px;
  border:2px solid var(--line); box-shadow:3px 3px 0 var(--line);
  background:color-mix(in srgb, var(--tone) 28%, var(--paper));
  font-weight:700; font-size:var(--fs-partMeta, .95rem);
  animation:popIn .5s cubic-bezier(.34,1.45,.64,1) backwards;
}
.part-stat:nth-child(2){animation-delay:.08s; transform:rotate(1.5deg);}
.part-stat:nth-child(1){transform:rotate(-1.5deg);}
.part-stat b{font-size:1.15em;}
.part-stat-icon{
  width:1.9em; height:1.9em; border-radius:50%; display:inline-flex; align-items:center; justify-content:center;
  background:var(--paper); border:2px solid var(--line);
}

/* tabs: one pill holding both, the active one filled */
.part-tabs{
  gap:4px; padding:4px; border-radius:999px;
  border:var(--bw) solid var(--line); background:var(--paper); box-shadow:var(--shadow-hard);
}
.part-tab{border:0; box-shadow:none; padding:7px 18px; background:transparent;}
.part-tab:active{transform:scale(.96); box-shadow:none;}
.part-tab.active{background:var(--lilac); color:#141414; box-shadow:inset 0 -3px 0 rgba(0,0,0,.14);}
.part-tab[data-view="activity"]::before{content:'🎉 ';}
.part-tab[data-view="person"]::before{content:'🙋 ';}

/* search: a magnifier drawn in, no extra markup */
.part-search{
  padding-left:44px;
  background:var(--paper) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23b58cff' stroke-width='3' stroke-linecap='round'%3E%3Ccircle cx='10' cy='10' r='6.5'/%3E%3Cpath d='M15 15l5.5 5.5'/%3E%3C/svg%3E") no-repeat 16px 50%;
  background-size:20px 20px;
}
.part-search:focus{outline:none; box-shadow:0 0 0 4px color-mix(in srgb, var(--lilac) 45%, transparent), var(--shadow-hard);}

/* the cards */
/* An open card now takes a whole row to itself (below), so closed cards can share a row's
   height again — stretch keeps a row of stickers level when one title wraps. */
.part-groups{grid-auto-flow:row dense; align-items:stretch;}
.part-groups.by-person{align-items:start;}
.part-group{
  position:relative;
  background:color-mix(in srgb, var(--tone, var(--lilac)) 13%, var(--paper));
  transition:transform .15s ease, box-shadow .15s ease;
}
/* a strip of washi tape holding each card to the page */
.part-group::before{
  content:''; position:absolute; top:-11px; left:22px; width:62px; height:20px; z-index:1;
  background:repeating-linear-gradient(45deg,
    color-mix(in srgb, var(--tone) 75%, #fff) 0 6px, color-mix(in srgb, var(--tone) 50%, #fff) 6px 12px);
  opacity:.85; border-radius:3px; transform:rotate(-5deg);
  box-shadow:0 1px 2px rgba(0,0,0,.12);
  pointer-events:none;
}
.part-group:nth-child(even)::before{left:auto; right:26px; transform:rotate(4deg);}
.part-group:not(.open):hover{transform:translate(-2px,-3px) rotate(-.4deg); box-shadow:6px 7px 0 var(--line);}
/* An opened activity spreads across the whole row, instead of stretching one column into a
   tall tower next to its closed neighbours; `dense` lets the rest close up around it. */
.part-groups:not(.by-person) .part-group.open{grid-column:1/-1;}

.part-head-main{display:flex; flex-direction:column; gap:6px; flex:1 1 auto; min-width:0;}
.part-no{
  flex:none; width:2.1em; height:2.1em; border-radius:50%;
  display:inline-flex; align-items:center; justify-content:center;
  background:var(--tone); color:#141414; border:2px solid var(--line);
  font-weight:800; font-size:.95rem; transform:rotate(-8deg);
  box-shadow:2px 2px 0 var(--line);
}
.part-group:nth-child(odd) .part-no{transform:rotate(6deg);}
/* top three, by person: gold / silver / bronze, the winner with a crown */
.part-no.r1{background:#ffd45e; position:relative;}
.part-no.r2{background:#dfe6ee;}
.part-no.r3{background:#f2b27c;}
.part-no.r1::after{content:'👑'; position:absolute; top:-1.05em; left:50%; transform:translateX(-50%) rotate(-6deg); font-size:1em;}

.part-count{
  color:#141414; font-weight:700; border:2px solid var(--line);
  background:color-mix(in srgb, var(--tone) 45%, #fff);
}
/* the caret becomes a round button that turns when the card opens */
.part-caret{
  width:26px; height:26px; border:2px solid var(--line); border-radius:50%;
  background:var(--paper); transform:none; position:relative;
  transition:transform .25s cubic-bezier(.34,1.56,.64,1), background .15s ease;
}
.part-caret::before{
  content:''; position:absolute; left:50%; top:50%; width:7px; height:7px;
  border-right:2.5px solid var(--line); border-bottom:2.5px solid var(--line);
  transform:translate(-65%,-50%) rotate(-45deg);
}
.part-group.open .part-caret{transform:rotate(90deg); background:var(--tone);}
.part-group-head:hover .part-caret{border-top-color:var(--line); background:var(--tone);}

/* progress under a person's name: how much of the programme they came to */
.part-meter{
  display:block; height:10px; max-width:260px; border-radius:999px; overflow:hidden;
  border:2px solid var(--line); background:var(--paper);
}
.part-meter i{
  display:block; height:100%; border-radius:999px;
  background:repeating-linear-gradient(45deg, var(--tone) 0 6px, color-mix(in srgb, var(--tone) 70%, #fff) 6px 12px);
}

.part-group.open .part-group-head{border-bottom-color:color-mix(in srgb, var(--tone) 70%, var(--line-soft));}
.part-chip{
  background:var(--paper);
  box-shadow:2px 2px 0 color-mix(in srgb, var(--tone) 80%, var(--line));
  transition:transform .15s ease;
}
.part-chip:hover{transform:translateY(-2px) rotate(-1deg);}
.part-group.open .part-chip{animation:popIn .35s ease backwards;}
.part-group.open .part-chip:nth-child(3n+2){animation-delay:.04s;}
.part-group.open .part-chip:nth-child(3n){animation-delay:.08s;}

@media (prefers-reduced-motion:reduce){
  .part-stat, .part-group.open .part-chip{animation:none;}
  .part-group:not(.open):hover, .part-chip:hover{transform:none;}
  .part-caret{transition:none;}
}
@media (max-width:520px){
  .part-group::before{width:48px; height:16px;}
  .part-no{font-size:.8rem;}
}

/* ============================================================ entrances

   Every page opens the same way: its top-level blocks rise in one after another, a beat
   apart. It is keyed on the children of #page-content because that is exactly the region
   the router swaps on a client-side navigation — so the cascade plays on a first load and
   on every nav pill click, with no JavaScript hook and nothing per-page to maintain.
   Deliberately not applied to anything that repaints on a timer: the ranking table redraws
   every 45 seconds from the sheet, and popping its rows on each poll would make the page
   twitch at a visitor who is only reading it. */
/* fill-mode is `backwards`, never `both`. `both` leaves the final keyframe applied for good,
   and since an animation outranks ordinary declarations that means a keyframe ending in
   `transform:none` permanently cancels whatever transform the element's own rules set — it
   silently broke the map cards' translate(-50%,-50%) centring and every :hover lift on the
   page. `backwards` covers the delay and then hands the element back to its own styles. */
#page-content > *{animation:riseIn .46s cubic-bezier(.34,1.4,.64,1) backwards;}
#page-content > *:nth-child(2){animation-delay:.06s;}
#page-content > *:nth-child(3){animation-delay:.12s;}
#page-content > *:nth-child(4){animation-delay:.18s;}
#page-content > *:nth-child(5){animation-delay:.24s;}
#page-content > *:nth-child(n+6){animation-delay:.3s;}
@keyframes riseIn{from{opacity:0; transform:translateY(14px);}to{opacity:1; transform:none;}}

/* Grid items inside a block get their own small cascade once the block itself has landed.
   Both of these are drawn from the content document, which only changes when the admin
   saves — unlike the sheet-fed tables, they are not rebuilt under the reader. */
.badge-item, .town-tile, .town-plot{animation:popIn .44s cubic-bezier(.34,1.45,.64,1) backwards;}
/* A map card is placed by its centre, so its entrance has to carry the centring translate in
   every keyframe — popIn's plain translateY would slide it half a card off the road while it
   plays. */
.town-plot{animation-name:popInCentered;}
@keyframes popInCentered{
  from{opacity:0; transform:translate(-50%,-50%) translateY(10px) scale(.98);}
  to{opacity:1; transform:translate(-50%,-50%);}
}
.badge-item:nth-child(2), .town-tile:nth-child(2), .town-plot:nth-child(2){animation-delay:.05s;}
.badge-item:nth-child(3), .town-tile:nth-child(3), .town-plot:nth-child(3){animation-delay:.1s;}
.badge-item:nth-child(4), .town-tile:nth-child(4), .town-plot:nth-child(4){animation-delay:.15s;}
.badge-item:nth-child(5), .town-tile:nth-child(5), .town-plot:nth-child(5){animation-delay:.2s;}
.badge-item:nth-child(n+6), .town-tile:nth-child(n+6), .town-plot:nth-child(n+6){animation-delay:.25s;}

/* ---- ดุ้กดิ้ก ----
   Two shapes of motion, kept apart on purpose. `wobble` is a reaction: it fires once, on
   hover, so the thing under the cursor answers back. `sway` is idle, and only ever given to
   small decorative items — a crown, an award rosette — the same licence the pixel hearts in
   the margins already have. Body copy, buttons and section headings never move on their own;
   a page that fidgets while it is being read is a page that is hard to read. */
@keyframes wobble{0%,100%{transform:rotate(-4deg);}25%{transform:rotate(4deg);}50%{transform:rotate(-3deg);}75%{transform:rotate(2deg);}}
@keyframes sway{0%,100%{transform:rotate(-5deg) translateY(0);}50%{transform:rotate(-1deg) translateY(-3px);}}
@keyframes swayPlain{0%,100%{transform:rotate(-9deg);}50%{transform:rotate(-3deg) translateY(-2px);}}
/* The fill carries a striped gradient, so drifting it sideways reads as the bar flowing
   rather than sitting there. The stripes repeat every 22px *along a 115deg axis*, so a
   seamless loop needs a horizontal step of 22 / sin(115deg) = 24.27px — stepping a flat 22px
   would leave the pattern short of a full period and visibly jump every cycle. */
@keyframes stripeFlow{from{background-position:0 0, 0 0;}to{background-position:24.27px 0, 0 0;}}

.tube-fill, .vs-fill{animation:stripeFlow 1.1s linear infinite;}

/* ---- the home page's own idle motion ----
   The pieces a visitor lands on first, given a slow drift each so the page is alive before
   anything is scrolled or hovered. The photo keeps whatever tilt the admin set — the keyframe
   is written around var(--hero-tilt) rather than replacing it, or every home page would snap
   to level the moment the animation started. */
@keyframes heroFloat{
  0%,100%{transform:rotate(var(--hero-tilt, -2deg)) translateY(0);}
  50%{transform:rotate(calc(var(--hero-tilt, -2deg) + 1deg)) translateY(-9px);}
}
@keyframes logoBob{0%,100%{transform:translateY(0) rotate(-.8deg);}50%{transform:translateY(-7px) rotate(.8deg);}}
@keyframes tick{0%{transform:scale(1);}30%{transform:scale(1.22);}100%{transform:scale(1);}}

.home-photo{animation:heroFloat 5.4s ease-in-out infinite;}
.hero-logo{animation:logoBob 4.2s ease-in-out infinite;}
.cnum.tick{display:inline-block; animation:tick .5s cubic-bezier(.34,1.6,.64,1);}
.vs-crown{display:inline-block; animation:sway 2.8s ease-in-out infinite;}
.part-badge.rosette{animation:sway 3.4s ease-in-out infinite;}
.part-badge.plain{animation:swayPlain 3.1s ease-in-out infinite;}

.sticker:hover, .part-chip:hover .part-badge{animation:wobble .55s ease-in-out 1;}
.nav-pill:hover .nav-emoji, .town-tile:hover .town-icon{display:inline-block; animation:wobble .5s ease-in-out 1;}
.badge-item .badge-photo{transition:transform .22s cubic-bezier(.34,1.5,.64,1);}
.badge-item:hover .badge-photo{transform:translateY(-5px) rotate(-4deg);}
.past-card{transition:transform .2s ease, box-shadow .2s ease;}
.past-card:hover{transform:translate(-2px,-2px); box-shadow:6px 6px 0 var(--line);}

/* ============================================================ responsive */

@media (max-width:900px){
  /* Tablet: the columns have already stacked, so the type only needs taking off the boil. */
  :root{--ui-shrink:.86; --ui-lift:2px;}
  .home-main,.donate-grid,.home-stats,.cur-act{grid-template-columns:1fr;}
  .home-photo{transform:rotate(0deg);}
  .notebook{padding:48px 16px 24px; transform:none;}
  .qr-col{align-items:center;}
  .bank-box{max-width:420px; margin-left:auto; margin-right:auto; width:100%;}
}
@media (max-width:560px){
  /* Phone. Sizes chosen against the live content document: the 60px ranking title lands at
     43px and the 19px footer at 18px, so headings stop overflowing without the small print
     turning to dust. */
  :root{--ui-shrink:.6; --ui-lift:7px;}
  body{font-size:1.05rem;}
  .wrap{padding:0 14px 50px;}
  .photo-caption{font-size:calc(var(--fs-heroCaption, 2.4rem) * .6);}
  .outlined{-webkit-text-stroke-width:4px;}
  .act-stats{grid-template-columns:1fr;}
  .town-grid{grid-template-columns:repeat(auto-fill,minmax(140px,1fr));}
  .rank-photo{width:42px; height:42px;}
  /* still var()-first, or the admin's nav slider would silently stop working on phones */
  .nav-pill{font-size:var(--fs-nav, .9rem); padding:6px 12px; gap:5px;}
  .tube{height:54px;}
  .tape{width:86px; height:26px;}
  .vs-track{height:24px;}
  .part-groups{grid-template-columns:1fr;}

  /* The theme button is fixed on desktop, where the layout has margins for it to sit in.
     On a phone it hovers over whatever is under the top-right corner — the ranking title,
     the first row of the list — so it scrolls away with the page instead, parked on the
     cloud band above the nav. */
  .theme-toggle{
    position:absolute; top:8px; right:10px;
    width:38px; height:38px; font-size:1.05rem;
  }
  .site-nav{padding:46px 0 4px; gap:8px;}

  /* Rotated tape hanging off the notebook was dragging the whole page 8px sideways. */
  .tape.tl{top:-10px; left:-8px;}
  .tape.br{bottom:-8px; right:-8px;}

  .page-hero{padding:16px 0 8px;}
  .page-hero h1{overflow-wrap:break-word;}
  .card{padding:16px;}
  .notebook{padding:44px 12px 20px;}

  /* Ranking rows. Four items on one line only fits while the name is short: with
     `flex:1` and `overflow-wrap:anywhere` a long name was being crushed to 33px wide and
     spelled out one letter per line, 479px tall. Wrapping lets the amount drop to its own
     right-aligned line exactly when the name needs the room, and short names keep the
     compact single-line row. */
  .rank-row{flex-wrap:wrap; gap:4px 10px; padding:9px 2px;}
  .rank-num{min-width:1.6em;}
  .rank-name-col{flex:1 1 auto; min-width:76px;}
  .rank-tail{margin-left:auto;}
  /* The ▲▼ badge is a fourth thing competing for the same line. Trimmed here (and the name's
     minimum with it) so a row with a short name still fits on one line on a phone. */
  .rank-move{padding:1px 6px; gap:2px;}
  /* Kept at 30px rather than shrunk with the type — below that it stops being a reliable
     tap target, and it is the last thing on the line either way. */
  .rank-share-one{width:30px; height:30px; font-size:.9rem;}

  /* .part-toolbar turns into a column here, which made `flex:1 1 220px` on the search box
     a 220px *height* — a giant empty pill the width of the screen. */
  .part-toolbar{flex-direction:column; align-items:stretch; gap:10px;}
  .part-tabs{gap:8px;}
  /* flex:1 1 auto, not `1 1 0`: a <button> with a zero flex-basis is measured by Chrome
     against its own min-content width first, so "ดูตามกิจกรรม" wrapped internally and left
     the pill 60px tall with the second line blank. */
  .part-tab{flex:1 1 auto; text-align:center; padding:8px 10px;}
  .part-search{
    flex:none; width:100%; max-width:none;
    /* iOS zooms the whole page in on a focused input under 16px */
    font-size:max(16px, var(--fs-partTab, 1rem));
  }
}
@media (max-width:359px){
  /* Narrower than the 360/375/390 mainstream — old SE, small Androids. One notch further
     down; kept below 360 so the common phone widths all share the same type scale. */
  :root{--ui-shrink:.54; --ui-lift:7px;}
  .wrap{padding:0 10px 50px;}
  .rank-photo{width:38px; height:38px;}
}

/* ============================================================ site-wide little delights
   All three are wired up at the bottom of render.js (loaded on every public page). */

/* 1. Tap burst — tiny grapes / hearts / dots thrown off wherever you click or tap. Fixed-
   positioned at the pointer, --dx/--dy set per particle by render.js. */
.tap-bit{
  position:fixed; z-index:9999; pointer-events:none; width:12px; height:12px; margin:-6px 0 0 -6px;
  animation:tap-bit .62s cubic-bezier(.2,.7,.3,1) forwards;
}
.tap-bit svg{display:block; width:100%; height:100%;}
.tap-bit.dot{width:7px; height:7px; margin:-3.5px 0 0 -3.5px; border-radius:50%; background:var(--c);
  box-shadow:0 0 0 1.5px var(--line);}
@keyframes tap-bit{
  0%{opacity:1; transform:translate(0,0) scale(.4) rotate(0);}
  60%{opacity:1;}
  100%{opacity:0; transform:translate(var(--dx),var(--dy)) scale(1) rotate(var(--rot));}
}

/* 2. Jelly buttons. Uses the standalone `scale` property, not `transform`, on purpose: every
   button here already owns `transform` for its hard-shadow press (translate(3px,3px) etc.), and
   `scale` composes with that instead of replacing it. Held down = squashed; on release render.js
   adds .jelly for the wobble back. */
:is(button, .nav-pill, .pill-btn, .town-tile, .village-door, .share-btn, [role="button"]):not(:disabled):active{scale:1.06 .9;}
.jelly{animation:jelly .5s cubic-bezier(.3,1.6,.5,1);}
@keyframes jelly{
  0%{scale:1.06 .9;}
  35%{scale:.94 1.07;}
  65%{scale:1.03 .97;}
  100%{scale:1 1;}
}

/* 3. Back-to-top seal. Sits on the bottom edge, tucked out of sight until you've scrolled a
   screen or so down, then peeks up; a click makes it hop and carries the page back up. Art is
   the same cream body PNG the booth uses (it faces left, toward the page). */
.seal-top{
  position:fixed; right:18px; bottom:-4px; z-index:55;
  width:92px; aspect-ratio:1000/664; padding:0; border:0; background:none;
  cursor:var(--cursor-pointer-url), pointer;
  transform:translateY(115%); transition:transform .45s cubic-bezier(.3,1.5,.5,1);
  -webkit-tap-highlight-color:transparent;
}
.seal-top.show{transform:translateY(10%);}
.seal-top.show:hover{transform:translateY(2%);}
.seal-top img{display:block; width:100%; height:100%; object-fit:contain; pointer-events:none;
  transform-origin:50% 100%;}
.seal-top.show:hover img{animation:seal-top-wiggle .6s ease-in-out;}
.seal-top.hop img{animation:seal-top-hop .55s cubic-bezier(.3,.7,.4,1);}
.seal-top-tag{
  position:absolute; left:-6px; top:-14px; white-space:nowrap;
  padding:2px 8px; border:2px solid var(--line); border-radius:999px;
  background:var(--paper); color:var(--ink); font:700 16px/1.3 var(--font-body);
  box-shadow:2px 2px 0 var(--line); pointer-events:none;
}
@keyframes seal-top-wiggle{0%,100%{rotate:0deg;} 25%{rotate:-6deg;} 60%{rotate:4deg;}}
@keyframes seal-top-hop{
  0%{transform:scale(1,1);}
  20%{transform:scale(1.12,.84);}
  50%{transform:translateY(-38%) scale(.94,1.08);}
  80%{transform:translateY(0) scale(1.08,.9);}
  100%{transform:scale(1,1);}
}
@media (max-width:640px){
  .seal-top{width:70px; right:10px;}
  .seal-top-tag{font-size:14px; top:-12px;}
}
@media (prefers-reduced-motion:reduce){
  .jelly, .seal-top img{animation:none !important;}
  :is(button, .nav-pill, .pill-btn, .town-tile, .village-door, .share-btn, [role="button"]):active{scale:none;}
  .seal-top{transition:none;}
}

/* ============================================================ home: notebook page

   The home page's activity/donate row and its totals row each sit on a notebook page —
   ruled lines, ring holes, a pink margin — with the cards pinned onto it as pastel post-its.
   Scoped to .home-main / .home-stats / .home-photo, which only the home page has. The
   post-its keep dark ink in both themes; the page itself goes dark with the theme. */
.home-main, .home-stats{
  --nb-paper:#fffdf6;
  position:relative; padding:34px 24px 26px 56px;
  border:var(--bw) solid var(--line); border-radius:18px; box-shadow:var(--shadow-hard);
  background:
    linear-gradient(90deg, transparent 40px, color-mix(in srgb, var(--pink) 70%, transparent) 40px 42px, transparent 42px),
    radial-gradient(circle at 20px 50%, var(--bg) 0 7px, var(--line) 7.5px 9px, transparent 9.5px) 0 0/40px 60px repeat-y,
    repeating-linear-gradient(transparent 0 31px, color-mix(in srgb, var(--grape) 28%, transparent) 31px 32px),
    var(--nb-paper);
}
:root[data-theme="dark"] .home-main, :root[data-theme="dark"] .home-stats{--nb-paper:#1f2d38;}
.home-main::before, .home-stats::before{
  content:'📌 แปะไว้ตรงนี้'; position:absolute; top:-16px; left:22px; z-index:2;
  background:var(--paper); border:2px solid var(--line); border-radius:8px; padding:2px 12px;
  font-weight:800; transform:rotate(-3deg); box-shadow:2px 2px 0 var(--line);
}
.home-stats::before{content:'📊 ยอดรวม';}
.home-main::after, .home-stats::after{
  content:''; position:absolute; top:-12px; right:170px; width:90px; height:24px;
  transform:rotate(5deg); opacity:.85;
  background:repeating-linear-gradient(45deg, #9fe3cc 0 7px, #dff7ee 7px 14px);
}
.home-stats::after{right:auto; left:55%; background:repeating-linear-gradient(45deg, #ffb3cd 0 7px, #ffe3ec 7px 14px);}

/* pinned post-its */
.home-main .card, .home-stats .card, .home-act .act-frame{
  position:relative; overflow:visible; border-radius:3px; border-width:0;
  box-shadow:3px 5px 10px rgba(0,0,0,.25);
}
.home-main .card::after, .home-stats .card::after, .home-act .act-frame::after{
  content:''; position:absolute; top:-9px; left:50%; width:20px; height:20px; border-radius:50%; z-index:3;
  transform:translateX(-50%); border:2px solid var(--line); box-shadow:1px 3px 0 rgba(0,0,0,.3);
  background:radial-gradient(circle at 35% 35%, #fff9 0 3px, transparent 4px), var(--pin, #ff6f91);
}
.home-act .act-frame{padding:10px; background:#fff; transform:rotate(-1.5deg);}
.home-act .act-frame img{border-radius:0;}
.home-act .sticker{transform:rotate(-3deg);}
.home-main .donate-panel{background:#fff7b8; color:#141414; transform:rotate(1.2deg); --pin:#7fd6bd;}
.home-main .donate-strip{background:#fffbe0;}
.home-stats .stat-card{background:#ffd6e5; color:#141414; transform:rotate(-1deg);}
.home-stats .stat-card:nth-child(2){background:#cdeffd; transform:rotate(1.4deg); --pin:#b58cff;}
.home-stats .stat-sub, .home-stats .note,
.home-main .bank-title, .home-main .bank-owner, .home-main .qr-caption, .home-stats .clabel{color:#555;}
.home-stats .stat-big, .home-stats .cnum{color:#141414;}
.home-main .qr-box, .home-main .bank-box{box-shadow:2px 2px 0 var(--line); background:#fff; color:#141414;}
.home-main .qr-box:nth-child(odd){transform:rotate(-2deg);}
.home-main .bank-name{background:#141414; color:#fff;}
.home-stats .countdown-grid div{background:#fff; border-width:2px;}
/* the hero photo, pinned the same way */
.home-photo .photo-frame{background:#fff; border-radius:3px; padding:14px 14px 50px;}
.home-photo .tape{display:none;}
.home-photo .photo-frame::after{
  content:''; position:absolute; top:-10px; left:50%; width:22px; height:22px; border-radius:50%; z-index:3;
  transform:translateX(-50%); border:2px solid var(--line);
  background:radial-gradient(circle at 35% 35%, #fff9 0 3px, transparent 4px), #ffd45e;
}
.home-photo .photo-caption{bottom:8px; font-size:calc(var(--fs-heroCaption, 2.4rem) * .8);}

/* a grape riding the leading edge of the activity tube */
.home-act .tube-fill::after{
  content:'🍇'; position:absolute; right:6px; top:50%; font-size:1.7rem; line-height:1;
  transform:translateY(-50%); animation:actGrapeBob 1.4s ease-in-out infinite;
  filter:drop-shadow(1px 1px 0 rgba(0,0,0,.25));
}
/* countdown seconds flip like a calendar page */
.home-stats .cnum.tick{animation:homeFlip .45s ease;}
@keyframes homeFlip{
  0%{transform:perspective(120px) rotateX(-70deg);}
  100%{transform:perspective(120px) rotateX(0);}
}

/* copy-the-account button + its "คัดลอกแล้ว!" pop */
.home-copy{
  display:inline-flex; align-items:center; gap:4px; margin-top:6px;
  font-family:inherit; font-weight:700; font-size:.85rem; color:#141414;
  border:2px solid var(--line); border-radius:999px; background:#fff; padding:3px 12px;
  box-shadow:2px 2px 0 var(--line); cursor:var(--cursor-pointer-url), pointer;
}
.home-copied{
  position:absolute; left:50%; top:-8px; transform:translate(-50%,-100%); pointer-events:none;
  background:var(--mint); color:#141414; border:2px solid var(--line); border-radius:999px;
  padding:2px 10px; font-weight:800; font-size:.85rem; white-space:nowrap;
  animation:homeCopied 1.4s ease forwards;
}
@keyframes homeCopied{
  0%{opacity:0; transform:translate(-50%,-60%) scale(.6);}
  15%{opacity:1; transform:translate(-50%,-100%) scale(1.08);}
  80%{opacity:1;}
  100%{opacity:0; transform:translate(-50%,-140%);}
}
/* "เพิ่งอัปเดต!" beside the updated time */
.home-new{
  display:inline-block; margin-left:6px; padding:0 8px; border-radius:999px;
  background:var(--pink); color:#141414; border:2px solid var(--line);
  font-weight:800; font-size:.75rem; transform:rotate(-5deg);
  animation:actNowPulse 2.2s ease-in-out infinite;
}
/* the seal peeking over the donate post-it */
.home-seal{
  position:absolute; right:22px; top:3px; width:112px; height:66px; z-index:4;
  overflow:hidden; transform:translateY(-100%); cursor:var(--cursor-pointer-url), pointer;
}
.home-seal img{position:absolute; left:0; bottom:-70px; width:112px; transition:bottom .5s cubic-bezier(.34,1.5,.64,1);}
.home-seal.up img{bottom:-12px;}
.home-seal-say{
  position:absolute; right:130px; top:-48px; z-index:5; pointer-events:none;
  background:#fff; color:#141414; border:2px solid var(--line); border-radius:14px; padding:3px 10px;
  font-weight:800; font-size:.85rem; white-space:nowrap; box-shadow:2px 2px 0 var(--line);
  opacity:0; transition:opacity .2s;
}
.home-seal-say.on{opacity:1;}
/* confetti, once, when the total reaches its goal */
.home-confetti{
  position:fixed; top:-30px; z-index:90; font-size:1.6rem; pointer-events:none;
  animation:homeFall linear forwards;
}
@keyframes homeFall{to{transform:translateY(110vh) rotate(540deg);}}

@media (max-width:700px){
  /* minmax(0,…): the page margin eats width the QR row would otherwise force the column to keep */
  .home-main, .home-stats{padding:30px 12px 20px 46px; grid-template-columns:minmax(0,1fr);}
  .home-main::after{right:120px;}
}
@media (prefers-reduced-motion:reduce){
  .home-act .tube-fill::after, .home-new, .home-stats .cnum.tick{animation:none;}
  .home-seal img{transition:none;}
}

/* ============================================================ activity: "เป้าวันนี้" tag

   A paper tag tied on under the activity's stat boxes: a % ring for today's share of
   activity.dailyGoal and the time left until Bangkok midnight. Dark ink in both themes. */
.day-tag{
  position:relative; margin:18px auto 4px; width:min(300px,calc(100% - 30px)); text-align:left;
  background:#fff; color:#141414; border:2px solid #141414; border-radius:6px 22px 22px 6px;
  padding:12px 14px 12px 40px; box-shadow:2px 3px 0 #141414; transform:rotate(-2deg);
}
.day-tag::before{ /* the eyelet */
  content:''; position:absolute; left:12px; top:50%; width:14px; height:14px; border-radius:50%;
  border:2px solid #141414; background:#fff7b8; transform:translateY(-50%);
}
.day-tag::after{ /* its string */
  content:''; position:absolute; left:-26px; top:50%; width:40px; height:2px; background:#b08968; transform:rotate(-20deg);
}
.day-tag-top{display:flex; align-items:center; gap:12px;}
.day-tag-ring{
  --p:0; flex:none; width:64px; height:64px; border-radius:50%; display:grid; place-items:center;
  border:2px solid #141414; background:conic-gradient(#ff8fb1 calc(var(--p) * 1%), #ffe3ec 0);
}
.day-tag.done .day-tag-ring{background:#7fd6bd;}
.day-tag-ring span{
  width:44px; height:44px; border-radius:50%; background:#fff; border:2px solid #141414;
  display:grid; place-items:center; font-weight:900; font-size:.9rem;
}
.day-tag-title{font-weight:900; font-size:1.05rem;}
.day-tag-left{margin-top:2px; font-weight:800; font-size:.9rem;}
.day-tag-left b{background:#fff7b8; border:2px solid #141414; border-radius:999px; padding:0 8px; white-space:nowrap;}
.day-tag-clock{margin-top:8px; font-weight:700; color:#555; font-size:.9rem;}
.day-tag-clock span{font-family:var(--font-pixel); font-size:.8rem; color:#141414;}
.day-tag-stamp{
  position:absolute; right:-12px; top:-16px; z-index:4; transform:rotate(12deg);
  border:3px solid #e0558a; color:#e0558a; border-radius:10px; background:rgba(255,255,255,.9);
  padding:1px 8px; font-weight:900; font-size:.95rem; animation:dayTagStamp .4s cubic-bezier(.34,1.8,.64,1);
}
@keyframes dayTagStamp{from{transform:rotate(12deg) scale(2); opacity:0;}}
@media (prefers-reduced-motion:reduce){ .day-tag-stamp{animation:none;} }
