/* ---------- design tokens ----------
   Bright island daylight. Everything below reads from these, so the whole
   mood is retunable from one block. */
:root{
  /* sky, top to horizon */
  --sky-top:   #6cc5e8;
  --sky-mid:   #b6e6f3;
  --sky-haze:  #ffeac6;

  /* water and land */
  --sea:       #35b0cd;
  --sea-deep:  #1b7f9e;
  --palm:      #58a374;
  --palm-dark: #3d7d57;

  /* surfaces.

     --sand and --plate used to be the same colour, which meant the board and
     the shore it sits on were within a few percent of each other and the
     board had no visible edge at all. They are separate tokens now: the shore
     stays pale, the plate is a deeper tray the tiles sit in. Everything else
     reads off that contrast — light cards float above it, light tiles sit on
     it, dark sockets sink into it. */
  --sand:      #f2e1c0;   /* the shore, behind everything */
  --plate:     #cfae74;   /* the board tray — deliberately darker than the shore */
  --cell:      #bf9a5c;   /* an empty socket, darker again */
  --card:      #fffdf8;   /* chips, orders, shop buttons */

  /* ink */
  --ink:       #2c3c44;   /* primary text — darkened for legibility at 9-12px */
  --dim:       #6b828b;   /* only for text that must recede */
  --ink-soft:  #4c626c;   /* secondary text that still has to be readable */

  /* Every card, chip and button shares one edge and one lift, so the whole
     UI layer reads as the same material floating above the board. The old
     borders were at .10 alpha, which is invisible against a pale card. */
  --edge:      rgba(44,60,68,.22);

  /* The HUD capsules read at a glance because the outline is a real stroke,
     not a hairline: a cream fill, a white inner ring, then a definite blue
     edge. Same trick the genre uses everywhere — the ring is what separates
     the pill from whatever sky, sea or sand happens to be behind it. */
  --pill:      #fdf6e4;
  --pill-edge: #5cb4d8;
  --lift:      0 3px 10px rgba(27,60,75,.26), 0 1px 2px rgba(27,60,75,.14);

  /* accents — amber does the same job it always did */
  --amber:     #e08a1e;
  /* The same accent, dark enough to be readable as 10-12px text on white.
     #e08a1e is 2.6:1 there, which fails at any size; this is ~6:1. Use
     --amber for fills, bars and large glyphs, --amber-ink for small text. */
  --amber-ink: #96570c;
  --rose:      #dd5f74;
  /* Same story as --amber-ink: the accent is for borders and fills, this is
     the version that survives being 10px text on a pale rose card. */
  --rose-ink:  #a52f47;

  --gap: 6px;
  --radius: 14px;

  /* Height of the shore strip the town is drawn in. The app reserves exactly
     this much at its bottom, so the two can never collide. One number, both
     places. */
  --town-band: 74px;
}

/* On a short screen the board is height-limited, so every pixel the shore
   takes comes straight out of the cells. The town gets less room there —
   it is scenery, and the board is the game. */
@media (max-height: 740px){ :root{ --town-band: 56px; } }

/* touch-action does NOT inherit, so setting it on body alone left every
   descendant at `auto` — which is what kept iOS double-tap-to-zoom alive.
   `manipulation` allows scroll and pinch but kills double-tap zoom. */
*{ box-sizing:border-box; -webkit-tap-highlight-color:transparent; touch-action:manipulation; }

/* The floor behind everything. Sand, not sky: on iOS the fixed scene resolves
   against the small viewport, so a strip along the bottom safe area can go
   uncovered — and a sand strip under the shore is invisible where a sky-blue
   one read as a broken band across the bottom of the screen. */
html{ background:#ecdcb6; }

/* The page itself must not scroll. overscroll-behavior stops pull-to-refresh
   and Chrome's overscroll navigation, but neither prevents an ordinary scroll
   when the content is genuinely taller than the screen — which is what made
   an iOS double tap slide the board up and show bare background. #app is
   sized to exactly one screen, so there is nothing to scroll to. */
html,body{
  margin:0; height:100%; overflow:hidden;
  color:var(--ink);
  font-family:"Bricolage Grotesque", system-ui, -apple-system, sans-serif;
  overscroll-behavior:none;      /* kill pull-to-refresh while dragging */
  user-select:none; -webkit-user-select:none;
}

/* ============================================================
   THE ISLAND — drawn in CSS, no image files.

   Fixed to the viewport and deliberately ignoring the safe area so it
   bleeds under the notch and home indicator; only #app pads away from
   those edges. Entirely decorative: aria-hidden in the markup, and
   pointer-events:none so it can never eat a touch.

   This whole block is throwaway when the game moves to Godot — it exists
   to make the loop judgeable now, not to be ported.
   ============================================================ */
/* The horizon sits high on purpose. The board is opaque and covers roughly
   y=15%..70%, so anything drawn behind it is wasted — the only bands the
   player ever sees are the strip above the board and the shore below it. */
/* 100lvh rather than inset:0 — on iOS a fixed element resolves against the
   SMALL viewport, which left the strip along the bottom safe area uncovered
   and showing bare page background. lvh is the largest the viewport ever
   gets, so the scene always reaches past the bottom edge. */
#scene{
  position:fixed; top:0; left:0; right:0; bottom:0;
  height:100lvh;
  z-index:-1; overflow:hidden;
  pointer-events:none;
  background:linear-gradient(
    to bottom,
    var(--sky-top)  0%,
    var(--sky-mid)  7%,
    var(--sky-haze) 12%);
}

#scene .sun{
  position:absolute; top:1.5%; left:8%;
  width:56px; height:56px; border-radius:50%;
  background:radial-gradient(circle at 50% 50%, #fff6d8 0%, #ffd977 55%, #ffc247 100%);
  box-shadow:0 0 40px 16px rgba(255,214,120,.5);
}

/* Each cloud is a blurred pill plus two blobs. Soft enough that the lack of
   detail reads as haze rather than as a missing sprite. */
#scene .cloud{
  position:absolute;
  width:120px; height:34px; border-radius:99px;
  background:rgba(255,255,255,.88);
  filter:blur(7px);
  animation:drift linear infinite;
}
#scene .cloud::before,
#scene .cloud::after{
  content:""; position:absolute; border-radius:50%;
  background:rgba(255,255,255,.95);
}
#scene .cloud::before{ width:58px; height:52px; left:18px; top:-22px; }
#scene .cloud::after{  width:42px; height:38px; right:20px; top:-14px; }

#scene .c1{ top:6%;  animation-duration:78s;  animation-delay:-10s; }
#scene .c2{ top:12%; animation-duration:112s; animation-delay:-56s; opacity:.8;  scale:.68; }
#scene .c3{ top:17%; animation-duration:146s; animation-delay:-96s; opacity:.65; scale:.48; }

@keyframes drift{
  from{ left:-30%; }
  to  { left:130%; }
}

/* Two headlands at the horizon. The lopsided border-radius corners are what
   stop them reading as plain rounded rectangles. */
#scene .headland{ position:absolute; }
#scene .headland.far{
  left:-8%; top:7.5%; width:52%; height:5.5%;
  background:var(--palm); opacity:.5;
  border-radius:60% 90% 0 0 / 100% 100% 0 0;
}
#scene .headland.near{
  right:-12%; top:9%; width:46%; height:4.5%;
  background:var(--palm-dark); opacity:.7;
  border-radius:90% 55% 0 0 / 100% 100% 0 0;
}

#scene .sea{
  position:absolute; left:0; right:0; top:13%; height:6%;
  background:linear-gradient(to bottom, var(--sea) 0%, var(--sea-deep) 100%);
  overflow:hidden;
}
/* Slow bands reading as light moving on water. */
#scene .shimmer{
  position:absolute; left:-50%; width:200%; height:2px;
  background:rgba(255,255,255,.4);
  filter:blur(1.5px);
  animation:swell ease-in-out infinite;
}
#scene .s1{ top:18%; animation-duration:7s; }
#scene .s2{ top:46%; animation-duration:11s; animation-delay:-3s; opacity:.7; }

@keyframes swell{
  0%,100%{ transform:translateX(-6%) scaleY(1);   opacity:.35; }
  50%    { transform:translateX( 6%) scaleY(2.2); opacity:.75; }
}

#scene .shore{
  position:absolute; left:0; right:0; top:18%; bottom:0;
  background:linear-gradient(to bottom, #ffeec9 0%, var(--sand) 14%, #ecdcb6 100%);
  border-radius:44% 56% 0 0 / 3% 3% 0 0;
}

/* Placeholder greenery, same spirit as the emoji items: stand-ins that read
   correctly and cost nothing, swapped for real art later. They live on the
   lower shore, which is the empty band below the footer — the obvious place
   for a town to grow if renovation ends up building into the scene. */
#scene .palms{
  position:absolute; left:0; right:0; bottom:2%;
  display:flex; justify-content:space-between; align-items:flex-end;
  padding:0 4%;
  font-size:40px; line-height:1;
  filter:drop-shadow(0 4px 6px rgba(120,96,56,.28));
  opacity:.9;
}
#scene .palms span:nth-child(2){ font-size:30px; opacity:.85; }
#scene .palms span:nth-child(3){ font-size:46px; }

/* ---------- the town the renovation track builds ----------
   Same band as the palms — the shore below the footer, the only part of the
   scene the board doesn't cover. Everything is positioned absolutely and
   never moves, so planting the flowers cannot shove the café sideways; the
   only thing a stage changes is which pieces are visible.

   The band is SHORT — measured at 375x812 there are about 75px between the
   bottom of the footer and the bottom of the screen, shared with the palms.
   Everything here is sized for that, and laid out around the three palm
   emoji rather than on top of them. */
/* Pinned to the same band #app reserves for it, in px rather than a
   percentage so the two numbers are the same number. */
#scene .town{
  position:absolute; left:0; right:0; bottom:0;
  height:calc(var(--town-band) + max(0px, env(safe-area-inset-bottom)));
}

/* Every piece starts absent. The stage rules below are the only thing that
   brings one into existence, which is why a single integer is enough state
   for this whole layer. */
#scene .town > *{
  position:absolute;
  opacity:0; transform:translateY(10px) scale(.9);
  transition:opacity .5s ease, transform .5s cubic-bezier(.2,1.35,.4,1);
}

/* Cumulative on purpose: a pier built at stage 2 is still standing at stage
   5. Spelling the stages out beats a clever selector — this table can be read
   straight against RENOVATION_TASKS in config.js. Driftwood is the odd one
   out: stage 1 REMOVES it, so it lists only stage 0. */
#scene.stage-0 .driftwood,
#scene:is(.stage-2,.stage-3,.stage-4,.stage-5) .pier,
#scene:is(.stage-3,.stage-4,.stage-5) .sign,
#scene:is(.stage-4,.stage-5) .flowers,
#scene.stage-5 .cafe{
  opacity:1; transform:none;
}

/* Emoji stand-ins, same spirit as the items on the board. Tilt uses the
   standalone `rotate` property rather than transform, because the reveal
   rule above resets transform and would flatten them back out. */
#scene .town .prop{
  line-height:1;
  filter:drop-shadow(0 3px 5px rgba(120,96,56,.3));
}
#scene .town .driftwood{ bottom:26%; font-size:19px; }
#scene .town .d1{ left:18%; rotate:-14deg; }
#scene .town .d2{ left:40%; rotate:8deg; font-size:16px; }
#scene .town .d3{ left:70%; rotate:-4deg; font-size:21px; }

#scene .town .flowers{ bottom:19%; font-size:15px; }
#scene .town .f1{ left:24%; }
#scene .town .f2{ left:40%; font-size:13px; }
#scene .town .f3{ left:80%; }

/* A deck on pilings. Two flat bars and four legs read as a pier at this
   size — detail here would only fight the emoji everywhere else. */
#scene .town .pier{
  left:16%; bottom:56%; width:22%; height:5px;
  background:linear-gradient(to bottom, #d8ae74 0%, #d8ae74 55%, #a87c48 100%);
  border-radius:2px;
  box-shadow:0 3px 5px rgba(120,96,56,.3);
}
#scene .town .pier i{
  position:absolute; top:3px; width:3px; height:12px;
  background:#a87c48; border-radius:0 0 2px 2px;
}
#scene .town .pier i:nth-child(1){ left:5%; }
#scene .town .pier i:nth-child(2){ left:34%; }
#scene .town .pier i:nth-child(3){ left:63%; }
#scene .town .pier i:nth-child(4){ right:5%; }

/* A painted board on two posts. */
#scene .town .sign{
  left:30%; bottom:44%;
  padding:2px 6px;
  background:#f9ead0; border:2px solid #b98a4e; border-radius:4px;
  font-family:"DM Mono", monospace; font-size:7px; font-weight:500;
  letter-spacing:.16em; color:#8d5f28;
  box-shadow:0 3px 6px rgba(120,96,56,.3);
}
#scene .town .sign::before,
#scene .town .sign::after{
  content:""; position:absolute; top:100%;
  width:3px; height:12px; background:#b98a4e;
}
#scene .town .sign::before{ left:14%; }
#scene .town .sign::after{ right:14%; }

/* The last task, so it is the biggest shape: a box, a rose awning for a
   roof, and two lit windows — the light is what makes it read as OPEN
   rather than as one more building. */
#scene .town .cafe{
  left:56%; bottom:22%; width:66px; height:38px;
  background:linear-gradient(to bottom, #fffaf0, #f2e2c8);
  border:2px solid #d8b482; border-radius:3px;
  box-shadow:0 5px 10px rgba(120,96,56,.3);
  display:flex; align-items:flex-start; justify-content:center;
  padding-top:2px;
}
#scene .town .cafe b{ font-size:15px; line-height:1; }
#scene .town .cafe::before{           /* roof */
  content:""; position:absolute; left:-7px; right:-7px; bottom:100%; height:10px;
  background:var(--rose);
  clip-path:polygon(13% 0, 87% 0, 100% 100%, 0 100%);
}
#scene .town .cafe::after{            /* two windows, one box plus its shadow */
  content:""; position:absolute; left:9px; bottom:5px;
  width:13px; height:11px; background:#ffdf9a; border-radius:2px;
  box-shadow:22px 0 0 #ffdf9a, 0 0 12px rgba(255,213,120,.95);
}

/* Everything you can touch lives in here, and it keeps clear of the notch,
   the home indicator, and the rounded corners in landscape. */
/* The app is exactly one screen, always. It must never be taller than the
   viewport: the moment it is, the page becomes scrollable and on iOS a
   double tap slides the whole thing up to reveal bare background. Desktop
   browsers report every safe-area inset as 0, which is why that was invisible
   here — a real iPhone adds ~59px at the top and ~34px at the bottom, and
   that alone was enough to push the layout past the screen.

   100dvh, not 100%, because iOS resizes the viewport as its chrome comes and
   goes; dvh follows that, % does not. The 100% first is the fallback for
   anything without dvh support. */
#app{
  max-width:520px; margin:0 auto;
  padding-top:    max(12px, env(safe-area-inset-top));
  padding-left:   max(12px, env(safe-area-inset-left));
  padding-right:  max(12px, env(safe-area-inset-right));

  /* Reserve the shore band so the footer can never sit on top of the town.
     The scenery is fixed to the bottom of the viewport, so the app has to
     stop short of it rather than the other way round. */
  padding-bottom: calc(max(12px, env(safe-area-inset-bottom)) + var(--town-band));

  /* 8px, not 12. Five gaps between six rows, so every pixel here costs four
     off the board — and the board reaching full width is worth more than the
     breathing room. */
  display:flex; flex-direction:column; gap:8px;
  height:100%;
  height:100dvh;
}

/* ---------- top bar: compact chips, so the board keeps the screen ---------- */
.hud{ display:flex; gap:8px; align-items:center; }
.chip{
  position:relative;
  display:flex; align-items:center;
  background:var(--pill);
  border:2px solid var(--pill-edge);
  border-radius:99px;
  /* Left padding leaves the icon its own space; it is absolutely positioned
     so it can hang past the edge without pushing the number around. */
  padding:3px 14px 3px 34px;
  min-height:38px;
  box-shadow:inset 0 0 0 2px #fff, var(--lift);
}
.chip .ico{
  position:absolute; left:-5px; top:50%;
  transform:translateY(-50%);
  font-size:27px; line-height:1;
  filter:drop-shadow(0 2px 2px rgba(27,60,75,.35));
}
.chip .stack{ display:flex; flex-direction:column; align-items:flex-start; gap:0; }
/* Big, heavy and dark. The old 14px monospace was the single biggest reason
   the counters did not read at a glance. */
.chip .num{
  font-family:"Bricolage Grotesque", system-ui, sans-serif;
  font-size:19px; font-weight:800; line-height:1.05;
  color:var(--ink); letter-spacing:-.01em;
}
.chip .num.amber{ color:#b06a12; }
.chip .sub{
  font-family:"DM Mono", monospace; font-size:9.5px; font-weight:500;
  color:var(--amber-ink); letter-spacing:.04em; line-height:1.1;
}
/* the old energy bar, reduced to a hairline along the chip's bottom edge */
.chip .fill{
  position:absolute; left:3px; bottom:2px; height:3px;
  background:var(--amber); border-radius:99px;
  transition:width .35s cubic-bezier(.2,.8,.3,1);
  /* inset from the border so the rounded corners don't clip it */
  max-width:calc(100% - 6px);
}

/* ---------- orders ---------- */
.orders{ display:flex; gap:8px; }
/* Deliberately short. Every pixel this strip takes comes straight out of the
   board, and the board is where the game is played — so the character reads
   in a single row: who is asking, and what for. */
/* Same capsule language as the HUD chips: cream fill, white inner ring, a
   real blue stroke, and the portrait hanging off the left edge as its own
   object. Consistency is most of why a HUD like this scans — every counter
   and every request is visibly the same kind of thing. */
.order{
  position:relative;
  flex:1; min-width:0;
  background:var(--pill);
  border:2px solid var(--pill-edge);
  border-radius:99px;
  padding:4px 8px 4px 30px;
  min-height:44px;
  display:flex; align-items:center; justify-content:center; gap:4px;
  box-shadow:inset 0 0 0 2px #fff, var(--lift);
  transition:border-color .18s, background .18s;
}
/* Portrait disc. The circle does the work, not the emoji: it reads as a
   person's frame, and it is what a real sprite will sit in later without any
   of this layout changing. */
.order .who{
  position:absolute; left:-6px; top:50%;
  transform:translateY(-50%);
  width:34px; height:34px; border-radius:50%;
  display:flex; align-items:center; justify-content:center;
  font-size:19px; line-height:1;
  border:2px solid #fff;
  box-shadow:0 2px 4px rgba(27,60,75,.3);
}

/* What they are asking for, in a tailed bubble. The tail is the whole trick —
   it is what makes an emoji read as someone REQUESTING a thing rather than
   as a sticker sitting on a card. */
/* The request, in a bubble beside them rather than under them. The tail now
   points left, at the person — which is what makes the emoji read as someone
   ASKING rather than as a sticker on a card, and costs no vertical space. */
.order .ask{
  position:relative; min-width:0;
  display:flex; flex-direction:column; align-items:center; gap:1px;
  background:#fff; border:1px solid rgba(44,60,68,.14);
  border-radius:9px; padding:2px 6px;
  box-shadow:0 1px 2px rgba(27,60,75,.12);
}
.order .ask::before{
  content:""; position:absolute; left:-4px; top:50%; margin-top:-4px;
  width:7px; height:7px; background:#fff;
  border-left:1px solid var(--edge);
  border-bottom:1px solid var(--edge);
  transform:rotate(45deg);
}
.order .face{ font-size:21px; line-height:1; }
.order .pay{
  font-family:"Bricolage Grotesque", system-ui, sans-serif;
  font-size:12px; font-weight:800;
  color:var(--amber-ink); line-height:1;
}

/* The name is what turns a face into a person, so it stays — but it is the
   least important thing here, so it gets the smallest type and never wraps. */
.order .name{
  flex:none;
  font-size:10px; font-weight:800; letter-spacing:.01em;
  color:var(--ink); line-height:1;
  max-width:52px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
}
/* The nudge moves the BUBBLE, not the card. A tap target that is drifting up
   and down under the finger is the classic reason a browser declines to
   synthesise a click on touch — and the card is the tap target. Same visual
   cue, stationary hit area. */
.order.ready{
  border-color:var(--rose); background:#fff4f5;
}
.order.ready .ask{ animation:nudge 1.6s ease-in-out infinite; }
.order.ready .pay{ color:var(--rose-ink); }
@keyframes nudge{ 0%,100%{transform:translateY(0)} 50%{transform:translateY(-3px)} }

/* ---------- the board ---------- */
/* The board takes whatever height is left and derives its width from that,
   rather than taking the full width and pushing its own height off the
   screen. That inversion is what makes the layout fit a short phone: on a
   tall screen it is still width-limited and looks identical, on a short one
   it shrinks instead of overflowing.

   Rows are 1fr rather than the cells being aspect-ratio:1, because the cells
   can no longer decide the board's size — the board decides theirs. */
.board{
  /* Width first: the board should always be as wide as the screen allows,
     because cell size is the whole game. max-height only kicks in on a screen
     too short to afford that, and because aspect-ratio is set, clamping the
     height pulls the width in with it rather than squashing the cells. */
  flex:0 1 auto; min-height:0;
  width:100%; aspect-ratio:6 / 8; max-width:100%; max-height:100%;
  margin-inline:auto;

  background:linear-gradient(168deg, #d8b881, var(--plate) 55%, #c4a068);
  border-radius:20px; padding:var(--gap);
  display:grid;
  grid-template-columns:repeat(6, 1fr);
  grid-template-rows:repeat(8, 1fr);
  gap:var(--gap);
  /* A bright top lip and a dark base give the tray thickness; the wide drop
     shadow lifts the whole thing off the shore. Without these the board was a
     flat rectangle the same colour as its background. */
  box-shadow: inset 0 2px 0 rgba(255,255,255,.45),
              inset 0 -2px 0 rgba(120,84,30,.30),
              0 10px 28px rgba(96,66,24,.34);
  /* `manipulation` still lets the browser pan, which on a phone turns a drag
     into a page scroll and stops our pointermove firing. `none` gives the
     board exclusive ownership; the browser intersects this with the cells'
     value, so everything inside inherits the stricter rule in practice. */
  touch-action:none;
}
.cell{
  min-width:0; min-height:0;   /* the grid sizes these now, not their content */
  background:var(--cell); border-radius:10px;
  display:flex; align-items:center; justify-content:center;
  line-height:1; position:relative;
  /* An empty cell is a socket pressed INTO the plate: shadow on the inside,
     a hint of light on its lower lip. */
  box-shadow:inset 0 3px 4px rgba(90,62,20,.38),
             inset 0 -1px 0 rgba(255,255,255,.16);
  /* makes the cell a sizing reference for the glyph inside it */
  container-type:size;
  transition:background .15s, transform .15s;
}
/* 74cqmin = 74% of the cell's shorter side. A fixed px size looks smaller
   and smaller as the board grows; this keeps the item filling its tile at
   any board size, so what you aim at is the whole cell, not a small mark. */
/* Everything that moves, moves the GLYPH — never the tile.

   The grid has to stay dead still. A tile that scales pushes against its
   neighbours' shadows and makes the whole board look like it is breathing
   at the wrong moment, and worse, it changes the hit area: the drop target
   grows out from under the finger while document.elementFromPoint() is
   still deciding what you are over. Keeping the cell fixed means the thing
   you aim at and the thing that reacts are never the same element. */
/* An occupied cell sits ON the plate instead of in it: the shadow flips from
   inset to outset, and the top edge catches the light. Same geometry, opposite
   depth cue — which is the whole trick. */
.cell.filled{
  background:linear-gradient(168deg, #fffaf0, #f6e8cc);
  box-shadow:0 2px 4px rgba(90,62,20,.38),
             inset 0 1px 0 rgba(255,255,255,.95);
}

.cell .glyph{
  font-size:74cqmin; line-height:1;
  transition:transform .15s cubic-bezier(.2,.8,.3,1);
}
/* A contact shadow is what stops an emoji looking printed onto the tile.
   Static filter, rasterised once per element — animating a filter is the
   expensive case, and this never animates. */
.cell.filled .glyph{
  filter:drop-shadow(0 1.5px 1px rgba(120,96,56,.35));

  /* The idle. Only occupied cells breathe — typically 5 to 15 elements, never
     48. That cap is the design, not an optimisation: empty cells breathing
     would turn the whole board into one pulsing surface.

     Amplitude is the entire risk here. At a 56px cell, -2.5% is about 1.4px
     of travel: enough that the board is never quite still, small enough that
     nothing appears to drift out of its socket. Do not exceed 4% scale or 3%
     translate — past that it stops reading as breathing and starts reading as
     a rendering fault. Percentages, not pixels, so it scales with the board
     for the same reason the glyph is sized in cqmin.

     render.js gives each glyph a negative animation-delay derived from the
     wall clock, which both staggers neighbours and lets a freshly rebuilt
     element resume mid-breath instead of snapping back to 0%. */
  animation:idleBreathe 2800ms ease-in-out infinite;
}
@keyframes idleBreathe{
  0%,100%{ transform:translateY(0)     scale(1);     }
  50%    { transform:translateY(-2.5%) scale(1.035); }
}

/* Anything that needs to own the glyph's transform has to switch the idle off
   first — a running animation outranks a plain transform declaration, so
   without `animation:none` these would silently do nothing. */
.cell.over{ background:#c9e9c0; }                              /* drop target: tint only */
.cell.over .glyph{ animation:none; transform:scale(1.1); }     /* the lift lives on the art */

/* Where the item came from reads as an empty socket with a dashed outline,
   not as a faded copy of itself. A 25%-opacity duplicate meant the item
   existed twice at once — faded in its hole and solid under the finger —
   which is what made the drag feel like a UI trick rather than like picking
   something up. iOS leaves a hole; so do we. */
.cell.dragging.filled{
  background:var(--cell);
  box-shadow:inset 0 3px 4px rgba(90,62,20,.38);
}
.cell.dragging .glyph{ opacity:0; }
.cell.dragging::after{
  content:""; position:absolute; inset:3px;
  border:1.5px dashed rgba(255,255,255,.45); border-radius:8px;
}

/* Every tile this drag could merge with. Turns "scan 48 tiles" into
   "glance", and gives the board its most legible moment of motion.

   Animates opacity and scale on a pseudo-element carrying a STATIC border.
   Never animate box-shadow or border-color in a loop — those repaint; these
   composite. 900ms is deliberately quicker than any ambient idle: this is an
   active hint during an active gesture and should read as urgent. */
.cell.match::after{
  content:""; position:absolute; inset:-2px;
  border:2px solid var(--amber); border-radius:12px;
  opacity:0; pointer-events:none;
  animation:matchRing 900ms ease-in-out infinite;
}
@keyframes matchRing{
  0%,100%{ opacity:0;  scale:.94; }
  50%    { opacity:.8; scale:1.04; }
}

/* Two entrances, because the two events should not feel the same. A merge
   punches outward from where you dropped; a fresh item falls into its slot. */
.cell.pop .glyph{ animation:pop .3s cubic-bezier(.2,1.4,.4,1); }
@keyframes pop{ 0%{transform:scale(.6)} 60%{transform:scale(1.22)} 100%{transform:scale(1)} }

.cell.drop .glyph{ animation:drop .34s cubic-bezier(.2,1.5,.4,1); }
@keyframes drop{
  0%  { transform:translateY(-40%) scale(.7); opacity:0 }
  55% { transform:translateY(0) scale(1.14);  opacity:1 }
  100%{ transform:translateY(0) scale(1) }
}

/* the generator sits on the board like any item, but reads as a machine */
.cell.gen{
  background:linear-gradient(160deg,#f8dcaa,#dfb475);
  border:1px solid rgba(200,130,40,.5);
  cursor:pointer;
}
/* Same rule as above: the press squashes the machine, not its tile. */
.cell.gen:active .glyph{ animation:none; transform:scale(.9); }
.cell.gen .cost{
  position:absolute; bottom:2px; right:4px;
  font-family:"DM Mono", monospace; font-size:8px; color:#7d4409;
}
/* a merged machine reads as better: brighter frame, and its rank in the corner */
.cell.gen .rank{
  position:absolute; top:2px; left:4px;
  font-family:"DM Mono", monospace; font-size:8px; font-weight:600; color:#7d4409;
}
.cell.gen-t2{ border-color:rgba(200,130,40,.85); }
.cell.gen-t3{
  border-color:#c8822a;
  box-shadow:0 0 10px rgba(224,138,30,.5);
}

/* ---------- shop: the first thing coins are actually for ---------- */
.shop{ display:flex; gap:8px; }
.buy{
  flex:1; display:flex; align-items:center; justify-content:center; gap:7px;
  background:var(--card); border:1px solid rgba(224,138,30,.55);
  border-radius:var(--radius); padding:8px 10px; color:var(--ink);
  font-family:"DM Mono", monospace; font-size:12px;
  box-shadow:var(--lift);
  transition:opacity .18s, border-color .18s, transform .12s;
}
.buy .face{ font-size:18px; line-height:1; }
.buy .price{ color:var(--amber-ink); }
.buy:active{ transform:scale(.97); }
.buy.broke{ border-color:var(--edge); opacity:.62; }

/* ---------- renovation: the other thing coins are for ----------
   One wide bar, because there is only ever one next task. It reads as a
   statement of what the town needs, not as a menu. It shares the footer row
   with Reset so the meta-layer costs the board no height. */
.renovate{ flex:1; display:flex; }
.fix{
  flex:1; display:flex; align-items:center; gap:8px;
  background:var(--card); border:1px solid rgba(221,95,116,.45);
  border-radius:var(--radius); padding:7px 11px; color:var(--ink);
  font-family:"Bricolage Grotesque", system-ui, -apple-system, sans-serif;
  font-size:12.5px; font-weight:700; text-align:left;
  box-shadow:var(--lift);
  transition:opacity .18s, border-color .18s, transform .12s;
}
.fix .face{ font-size:17px; line-height:1; }
.fix .label{ flex:1; }
.fix .price{
  font-family:"DM Mono", monospace; font-size:12px; font-weight:500;
  color:var(--amber-ink);
}
.fix .price::before{ content:"💰 "; }
.fix:active{ transform:scale(.985); }
/* Same treatment the shop uses for an unaffordable button: dimmed, still
   tappable, so a tap can explain why rather than doing nothing. */
.fix.broke{ border-color:var(--edge); opacity:.66; }
.fix.done{ border-color:var(--edge); color:var(--ink-soft); }

/* floating copy that follows your finger */
/* Position and lift both live in one `transform` string, written by input.js.

   The important change is that it is transform at all: this used to be
   `left`/`top`, which are LAYOUT properties, so every pointermove forced
   style, layout and paint — up to 120 times a second on a modern phone,
   during the most tactile interaction in the game. Transform composites.

   The two are combined rather than using the standalone `translate` and
   `scale` properties because those did not take effect here; one string is
   what actually works, and input.js owns it. Only `left`/`top` are ever
   at rest, both at 0. */
#ghost{
  position:fixed; left:0; top:0; z-index:99;
  font-size:34px; pointer-events:none; display:none;
  transform:translate(-50%,-50%);
  transition:transform 160ms cubic-bezier(.3,.9,.4,1);
  will-change:transform;
  filter:drop-shadow(0 8px 12px rgba(27,60,75,.45));
}
/* While the finger is moving, position must track it with no easing at all —
   the lift transition would otherwise make the ghost lag behind the touch. */
#ghost.tracking{ transition:none; }

/* ---------- footer ---------- */
.foot{ display:flex; flex-direction:column; gap:7px; }
.footrow{ display:flex; align-items:stretch; gap:8px; }
.hint{
  font-family:"DM Mono",monospace; font-size:10px; line-height:1.5;
  color:var(--ink-soft); font-weight:500;
  text-shadow:0 1px 0 rgba(255,255,255,.6);
}
button.reset{
  background:rgba(255,255,255,.92); border:1px solid var(--edge); color:var(--ink-soft);
  font-family:"DM Mono",monospace; font-size:10px; letter-spacing:.1em;
  text-transform:uppercase; padding:7px 11px; border-radius:8px;
}

#toast{
  position:fixed; left:50%; bottom:14%; transform:translateX(-50%);
  background:var(--ink); color:#fff6e4; font-weight:700; font-size:13px;
  padding:8px 16px; border-radius:99px; opacity:0; pointer-events:none;
  box-shadow:0 6px 18px rgba(27,60,75,.35);
  transition:opacity .2s, transform .2s;
}
#toast.show{ opacity:1; transform:translateX(-50%) translateY(-8px); }

/* ---------- effects overlay ----------
   Screen-space, above the game, deaf to input. src/effects.js positions
   everything in here with left/top and animates it with the Web Animations
   API, so these rules only describe what a particle looks like at rest. */
#fx{ position:fixed; inset:0; z-index:80; pointer-events:none; overflow:hidden; }
#fx > i{ position:absolute; display:block; will-change:transform, opacity; }

/* The merge, in two beats. No ring: an expanding circle read as a halo laid
   over the board rather than as anything happening to the items. */

/* Beat one — copies of the outgoing pair, rushing inward and crushing down.
   They carry the same contact shadow the real tiles do, so they read as the
   actual items being consumed rather than as effects. */
.fx-merge{
  line-height:1; transform:translate(-50%,-50%);
  filter:drop-shadow(0 2px 2px rgba(90,62,20,.4));
}

/* Beat two — a few small warm points thrown off as the new thing appears.
   Fast and few: energy released, not an aura. */
.fx-star{
  font-size:13px; line-height:1;
  color:#ffd27a; transform:translate(-50%,-50%);
  text-shadow:0 0 6px rgba(224,138,30,.9);
}
.fx-star.big{ font-size:17px; color:#fff3d0; text-shadow:0 0 10px rgba(224,138,30,1); }

/* starts invisible: the first keyframe fades it in as it leaves the card */
.fx-coin{ font-size:18px; line-height:1; opacity:0; transform:translate(-50%,-50%); }

@media (prefers-reduced-motion: reduce){ *{ animation:none !important; transition:none !important; } }

/* ---------- a new item arriving from its machine ----------
   The item is thrown by effects.js as a flying copy in the #fx overlay. The
   real cell renders with its glyph hidden so the item is never in two places
   at once, then reveals with an impact when the throw lands. */
.cell.arriving .glyph{ animation:none; opacity:0; }

/* The squash now happens on the flying copy, during its skid into the centre
   (see `toss` in effects.js) — impact belongs at the moment of contact, not
   after the item has already come to rest. This class is kept only so a
   redraw mid-flight has something harmless to land on. */
.cell.land .glyph{ animation:land 420ms cubic-bezier(.3,1.1,.4,1); }
/* Starts at exactly rest, because that is the pose the flying copy hands over
   in. Any offset in the first frame is a visible jump at the handoff, which
   is what made the landing read as jerky even once the arc was smooth. */
@keyframes land{
  0%   { transform:translateY(0)   scale(1, 1); }
  22%  { transform:translateY(0)   scale(1.20, .80); }   /* impact — widest, flattest */
  48%  { transform:translateY(-7%) scale(.93, 1.10); }   /* rebound — narrow and tall */
  74%  { transform:translateY(0)   scale(1.05, .96); }
  100% { transform:translateY(0)   scale(1, 1); }
}

/* The machine shoves as it lets go — equal and opposite to the item leaving.
   On the tile rather than the glyph, because the whole machine should kick. */
.cell.recoil .glyph{ animation:recoil 300ms cubic-bezier(.3,1.2,.4,1); }
@keyframes recoil{
  0%  { transform:scale(1, 1); }
  30% { transform:scale(1.14, .84) translateY(4%); }   /* compress, as if pushing off */
  65% { transform:scale(.95, 1.06); }
  100%{ transform:scale(1, 1); }
}

/* The flying copy itself. Sized to match the destination cell's glyph so it
   arrives at exactly the size the real one appears at. */
.fx-toss{
  line-height:1; transform:translate(-50%,-50%);
  filter:drop-shadow(0 6px 5px rgba(90,62,20,.4));
}

/* The bolt replaces the old "-1": same symbol as the energy chip, so it says
   "costs energy" without needing to be read. */
.cell.gen .cost{ font-size:9px; filter:saturate(1.2); }
