/**
 * Shoreline — memory-games/shoreline/shoreline.css
 *
 * A stretch of moonlit beach seen from above. A wave washes up, changes a few
 * things while the water is over them, and draws back. You say what changed.
 *
 * The beach is the page and everything else stays out of its way: a thin bar
 * of state above it, one sentence and one button below it, and nothing at all
 * drawn on top of the sand except the water.
 *
 * Font families are NOT restated here. /shared/styles/fonts.css owns
 * --font-display and --font-body and loads last, so this sheet consumes them
 * and a metric-matched fallback comes for free.
 *
 * One visual world on purpose: every Ponoki game is glow on black by
 * construction, and the light theme is deferred project wide.
 */

:root {
  --bg-deep:    hsl(230, 35%, 7%);
  --text:       hsl(220, 20%, 92%);
  --muted:      hsl(220, 15%, 55%);
  --accent:     hsl(185, 70%, 55%);
  --accent-dim: hsl(185, 60%, 40%);
  --ease-out:   cubic-bezier(0.16, 1, 0.3, 1);
  --wave-ms:    1900ms;   /* set from shoreline.js; this is only the fallback */

  /* The canonical Ponoki colourway, the same five tokens every game uses. */
  --pk-accent: hsl(248, 38%, 72%);
  --pk-band:   hsl(250, 44%, 81%);
  --pk-eye:    hsl(24, 34%, 19%);
  --pk-body:   hsl(28, 66%, 80%);
  --pk-cheek:  hsl(8, 66%, 72%);
}

* { box-sizing: border-box; }

html, body {
  margin: 0;
  background: var(--bg-deep);
  color: var(--text);
  font-family: var(--font-body, system-ui, -apple-system, "Segoe UI", sans-serif);
  -webkit-tap-highlight-color: transparent;
  overscroll-behavior-x: none;
}

/* THE FRAME.
 * fixed + inset:0 + an explicit width + margin-inline:auto. Three things fall
 * out of that one rule:
 *   - the game is a full-viewport layer, so the prose below the fold scrolls
 *     over it (seo-block.css) without the game moving,
 *   - the width is derived from the HEIGHT, so a desktop viewer sees a handset
 *     shape (about 9:19.5) rather than a stretched panel, and a phone gets the
 *     whole screen because min() picks the viewport,
 *   - centring this way does NOT create a containing block for fixed
 *     descendants, unlike transform or contain. See desktop-frame.css, which
 *     this game does not load precisely because it frames itself, the way
 *     home and Zen do.
 */
#game {
  position: fixed;
  inset: 0;
  z-index: 1;
  width: min(100vw, calc(100dvh * 9 / 19.5));
  margin-inline: auto;
  /* see the handset rule below: on a real phone this becomes 100vw */
  display: grid;
  /* minmax(0,...) lets the beach row SHRINK. A bare 1fr has an automatic
     minimum of its content, so an oversized row pushes the game past its own
     frame instead of being clipped by it. */
  grid-template-rows: auto minmax(0, 1fr) auto;
  background: var(--bg-deep);
  overflow: hidden;
  touch-action: manipulation;
}

/* On an actual handset, fill the screen. The shape lock exists so a desktop
   viewer sees a phone, not so a short phone (a 360x640 Android, say) gets
   letterboxed inside itself: there, height * 9/19.5 is 295px and the game
   would sit in a column with 32px of dead black either side. */
@media (max-width: 559px) {
  #game { width: 100vw; }
}

/* At or above that width there is a real gutter beside the frame, so show it
   as a handset. The two rules share the 560px line on purpose: below it the
   game is full bleed with no chrome, above it it is a framed phone. */
@media (min-width: 560px) {
  #game {
    border-radius: 26px;
    box-shadow: 0 18px 60px hsla(230, 45%, 2%, 0.75),
                0 0 0 1px hsla(210, 25%, 60%, 0.10);
  }
}

/* ── top bar ── */
#top { padding: 9px 16px 7px; display: grid; gap: 7px; }
#top-row { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; }
#title { font-family: var(--font-display, inherit); font-size: 1rem; font-weight: 600; margin: 0; }
#best  { font-size: 0.62rem; color: var(--accent-dim); letter-spacing: 0.05em; text-transform: uppercase; white-space: nowrap; }

/* Shells: the count of the night. It only ever goes up, so it can sit in the
   corner without ever being a thing to dread looking at. */
#score-box { position: relative; display: flex; align-items: baseline; gap: 7px; }
#score {
  font-family: var(--font-display, inherit); font-size: 0.95rem; font-weight: 600;
  color: var(--text); font-variant-numeric: tabular-nums; white-space: nowrap;
  transition: color 300ms linear;
}
#score.is-best { color: var(--accent); }
/* Beside the count, not under it: below would put it over the tally. */
#gain {
  position: absolute; right: 100%; top: 0; margin-right: 7px;
  font-size: 0.72rem; font-weight: 600; color: var(--accent);
  opacity: 0; pointer-events: none; white-space: nowrap;
}
#gain.show { animation: gainUp 900ms var(--ease-out) both; }
@keyframes gainUp {
  0%   { opacity: 0; transform: translateY(5px); }
  18%  { opacity: 1; transform: translateY(0); }
  66%  { opacity: 1; transform: translateY(0); }
  100% { opacity: 0; transform: translateY(-6px); }
}

#shore-row { display: flex; align-items: center; gap: 10px; min-height: 16px; }

/* Three chances a beach. Drops of water rather than hearts or crosses:
   running out brings the tide back in, it does not end anything.
   They sit next to the tally, which is also a row of small shapes, so they are
   deliberately a different shape AND a different colour: foam-white drops for
   this beach, accent bars for the night. A hairline keeps the two groups from
   reading as one row. */
#chances { display: flex; align-items: center; gap: 5px; flex: none; }
.chance {
  width: 7px; height: 8px;
  border-radius: 50% 50% 50% 50% / 62% 62% 38% 38%;
  background: hsl(190, 32%, 88%); opacity: 0.85;
  transition: opacity 320ms linear, transform 320ms var(--ease-out), background-color 320ms linear;
}
.chance.is-spent {
  background: transparent; opacity: 0.55; transform: scale(0.82);
  box-shadow: inset 0 0 0 1px hsl(200, 14%, 45%);
}
#depth {
  font-size: 0.7rem; color: var(--muted); letter-spacing: 0.05em;
  text-transform: uppercase; white-space: nowrap; flex: none;
}

/* Every beach read tonight, still there. A record of the night, never a set
   to complete, so it carries no target and no gaps. */
#tally {
  display: flex; align-items: flex-end; gap: 4px;
  overflow-x: auto; overflow-y: hidden;
  scrollbar-width: none; flex: 1; min-height: 14px;
  /* The hairline between this beach's chances and the night's tally lives on
     the tally, so an empty tally leaves no rule hanging in mid air. */
  padding-left: 9px; border-left: 1px solid hsla(200, 25%, 65%, 0.16);
}
#tally:empty { padding-left: 0; border-left: 0; }
#tally::-webkit-scrollbar { display: none; }
.mark {
  flex: none; width: 7px;
  height: calc(5px + var(--s, 1) * 1.8px);
  border-radius: 999px;
  background: hsl(200, 18%, 34%);
  transform-origin: 50% 100%;
  animation: markIn 420ms var(--ease-out) both;
}
.mark.is-clean { background: var(--accent); box-shadow: 0 0 7px hsla(185, 70%, 55%, 0.5); }
@keyframes markIn { from { transform: scaleY(0.2); opacity: 0; } to { transform: scaleY(1); opacity: 1; } }

/* ── the beach ── */
#beach-wrap { position: relative; overflow: hidden; min-height: 0; background: hsl(210, 20%, 18%); }
#stage { position: absolute; inset: 0; cursor: pointer; touch-action: manipulation; }
#stage svg { width: 100%; height: 100%; display: block; overflow: visible; }

/* Resonance: clean beaches bring the moon out. Reward only, never difficulty. */
#moon {
  position: absolute; inset: 0; pointer-events: none; opacity: 0;
  transition: opacity 1000ms ease;
  background:
    radial-gradient(46% 26% at 50% 6%, hsla(196, 70%, 78%, 0.20), transparent 72%),
    radial-gradient(120% 80% at 50% 20%, hsla(200, 50%, 70%, 0.09), transparent 76%);
  mix-blend-mode: screen;
}

/* Sand the wave just covered, drying back out. */
#wet {
  position: absolute; inset: 0; pointer-events: none; opacity: 0;
  background: linear-gradient(to bottom, hsla(200, 40%, 16%, 0.55), hsla(202, 36%, 18%, 0.30));
}
#wet.soak { transition: opacity 220ms ease; opacity: 1; }
#wet.dry  { transition: opacity 2600ms ease; opacity: 0; }

/* ── the wave ── */
#wave {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  pointer-events: none;
  opacity: 0;
  display: block;
  overflow: visible;
}
#wave.run { opacity: 1; }
#wave-g { transform: translateY(16px); }

/* Up the beach fast, a beat at the top of its reach, then a long slow drain.
   The asymmetry is the whole thing: water arrives quicker than it leaves. */
#wave.run #wave-g { animation: wash var(--wave-ms) both; }
@keyframes wash {
  0%   { transform: translateY(14px); }
  38%  { transform: translateY(196px); animation-timing-function: cubic-bezier(0.18, 0.72, 0.3, 1); }
  50%  { transform: translateY(200px); }
  100% { transform: translateY(14px); animation-timing-function: cubic-bezier(0.5, 0, 0.4, 1); }
}
/* The foam layers slide against each other so the edge never reads as one
   rigid sheet being dragged down the screen. */
#wave.run #foam-a { animation: driftA var(--wave-ms) ease-in-out both; }
#wave.run #foam-b { animation: driftB var(--wave-ms) ease-in-out both; }
@keyframes driftA {
  0% { transform: translateX(0) } 40% { transform: translateX(-3.5px) } 100% { transform: translateX(1.5px) }
}
@keyframes driftB {
  0% { transform: translateX(0) } 45% { transform: translateX(4.5px) } 100% { transform: translateX(-2px) }
}
#wave.run #sheen-streaks { animation: streak var(--wave-ms) ease-in-out both; }
@keyframes streak {
  0% { transform: translateY(0) } 50% { transform: translateY(26px) } 100% { transform: translateY(4px) }
}
#wave.run #bubbles { animation: bubbleFade var(--wave-ms) ease-out both; }
@keyframes bubbleFade {
  0% { opacity: 0 } 30% { opacity: 0.8 } 70% { opacity: 0.5 } 100% { opacity: 0 }
}

/* ── the change, played out by the water ──
   Each object changes as the wave's trailing edge uncovers it (timing lives in
   play.js), so the beach changes in the order the water leaves it.

   transform-box: fill-box makes rotate and scale pivot on the object itself;
   the default reference box is the whole viewBox, which would swing the object
   around the top-left corner of the beach. */
.anim { transform-box: fill-box; transform-origin: center; }
.anim.settle {
  transition: transform 620ms cubic-bezier(0.22, 0.78, 0.3, 1),
              opacity 420ms ease;
}
/* Spun by the water: it carries past a little and swings back. */
.anim.settle.turn { transition-timing-function: cubic-bezier(0.33, 1.34, 0.5, 1); }
/* Bedding into the sand, or being scoured out of it. */
.anim.settle.settle-in { transition-timing-function: cubic-bezier(0.25, 1.2, 0.4, 1); }

/* A colour change has no geometry to animate, so the OLD colour is held on top
   and wiped away from the bottom up, following the water off the beach. The
   opacity fade is a late safety net: if clip-path is unavailable the ghost
   still clears instead of sitting there hiding the change. */
.ghost { pointer-events: none; }
.wipe-rect { transition: transform 620ms cubic-bezier(0.38, 0, 0.3, 1); }
.wipe-rect.go { transform: translateY(var(--wipe-h, -30px)); }
/* Late fade as a safety net: if the clip ever fails to apply, the old colour
   still clears instead of sitting on top and hiding the change entirely. */
.ghost.wipe { opacity: 0; transition: opacity 140ms ease 640ms; }

@media (prefers-reduced-motion: reduce) {
  .anim.settle { transition: none; }
  .ghost { display: none; }
}

/* The rotation eddy. Rotation turns on the spot and keeps its colour, so
   nothing about it moves across the sand for the eye to catch. Arrivals used
   to get a matching ring of foam and no longer do: an arrival already travels,
   so the ring was not making the change readable, it was marking the answer. */
.cue { pointer-events: none; }
.cue > g { transform-box: fill-box; transform-origin: center; }
/* Faint on purpose. The shape itself now carries the turn (each kind is held
   to angles it can actually express, and the whelk has an aperture to read it
   by), so this only has to suggest water moving round the object. Anything
   brighter stops being a hint and starts being a marker. */
.cue > .spin { animation: cueSpin 640ms cubic-bezier(0.3, 0.8, 0.3, 1) both; }
@keyframes cueSpin {
  0%   { opacity: 0;    transform: rotate(0deg) scale(0.9); }
  26%  { opacity: 0.34; }
  100% { opacity: 0;    transform: rotate(112deg) scale(1.06); }
}
@media (prefers-reduced-motion: reduce) { .cue { display: none; } }

/* ── taps ── */
.ripple {
  fill: none; stroke: hsl(190, 55%, 82%); stroke-width: 1.1;
  animation: rip 640ms ease-out forwards; pointer-events: none;
}
@keyframes rip { from { r: 4; opacity: 0.5; } to { r: 15; opacity: 0; } }

.found-ring {
  fill: none; stroke: var(--accent); stroke-width: 1.6;
  animation: found 900ms var(--ease-out) forwards; pointer-events: none;
}
@keyframes found {
  from { r: 3; opacity: 0; stroke-width: 3; }
  40%  { opacity: 0.95; }
  to   { r: 14; opacity: 0.7; stroke-width: 1.2; }
}

.breathe-ring {
  fill: none; stroke: hsl(45, 80%, 70%); stroke-width: 1.3;
  opacity: 0.75; pointer-events: none;
  animation: breathe 2.1s ease-in-out infinite;
}
@keyframes breathe {
  0%, 100% { r: 11; opacity: 0.4; } 50% { r: 15; opacity: 0.85; }
}

#toast {
  position: absolute; left: 50%; top: 14px; translate: -50% 0;
  margin: 0; padding: 8px 15px; border-radius: 999px;
  background: hsla(210, 30%, 10%, 0.86);
  border: 1px solid hsla(185, 40%, 55%, 0.28);
  color: var(--text); font-size: 0.8rem; text-align: center;
  max-width: calc(100% - 32px);
  opacity: 0; transform: translateY(-6px);
  transition: opacity 320ms ease, transform 320ms ease;
  pointer-events: none;
}
#toast.show { opacity: 1; transform: translateY(0); }

/* ── bottom ── */
#bottom {
  /* FIXED height, never min-height. The message is the most variable thing on
     screen and it must not be able to move the beach.
     The safe area is ADDED to the bar rather than taken out of it: with the
     inset inside a fixed height, a notched phone was quietly stealing a line
     and a half from the message instead of from the beach. */
  height: calc(156px + env(safe-area-inset-bottom, 0px));
  padding: 8px 16px calc(14px + env(safe-area-inset-bottom, 0px));
  display: grid;
  /* The button row is a FIXED 48px at the bottom and the message takes the
     rest, so the button never moves. Sizing the message row to its content and
     centring the pair looked better on a still frame and was wrong in motion:
     the message changes on every tap during a beach, and the control under it
     shifted by up to 24px as it did. */
  grid-template-rows: minmax(0, 1fr) 48px;
  gap: 14px;
  justify-items: center;
  align-items: center;
  /* The bar was a flat black slab butted against the sand. A lift at its top
     edge and a hairline give it an edge of its own, without a gradient over
     the beach: anything painted on the sand would dim the objects lying at
     the bottom of it, which are the ones you have to find. */
  background: linear-gradient(to bottom, hsl(224, 26%, 11%) 0%, var(--bg-deep) 70%);
  border-top: 1px solid hsla(200, 30%, 72%, 0.09);
}
#say {
  grid-row: 1;
  /* Sat to the BOTTOM of its row. The message and the button then read as one
     block at the foot of the bar, with the slack above them where it is just
     air under the beach, rather than adrift at either end. */
  margin: 0; align-self: end; text-align: center;
  font-size: 1.06rem; line-height: 1.4; max-width: 26em;
  /* Clamp rather than grow: an over-long line is trimmed, never allowed to
     resize the layout. Three lines at this size is what the bar holds; the
     longest line in the game is 71 characters, which is two.
     Clamped by HEIGHT, not by -webkit-box. As a grid item the box display was
     being blockified to flow-root, so -webkit-line-clamp was quietly doing
     nothing at all and an over-long message would have grown the row and
     pushed the button. A max-height holds whatever the display resolves to. */
  max-height: calc(1.4em * 3);
  overflow: hidden;
}
/* The line arrives whole and lit, then settles. No per-character reveal: a
   sentence that is still being written is work to read, and the highlight does
   the job of drawing the eye without asking it to track anything. Colour and
   text-shadow only, so the worst case if the animation never runs is the line
   simply being there in its final colour. */
/* Light only: no transform. The box now sizes to the sentence exactly, so any
   movement would be clipped by the overflow above, and motion is the thing
   that made the old typed line tiring in the first place. */
#say .line {
  display: block;
  animation: sayIn 700ms var(--ease-out) both;
}
@keyframes sayIn {
  0%   { opacity: 0;
         color: hsl(186, 80%, 78%); text-shadow: 0 0 16px hsla(186, 85%, 62%, 0.55); }
  30%  { opacity: 1; }
  55%  { color: hsl(186, 70%, 85%); text-shadow: 0 0 12px hsla(186, 85%, 62%, 0.32); }
  100% { opacity: 1;
         color: var(--text); text-shadow: 0 0 0 hsla(186, 85%, 62%, 0); }
}
@media (prefers-reduced-motion: reduce) {
  #say .line { animation: sayFade 220ms linear both; }
  @keyframes sayFade { from { opacity: 0; } to { opacity: 1; } }
}
/* Both buttons share one fixed-height cell. Only ever one is shown, so they
   never collide, and hiding one cannot change the bar's height. */
#act, #hint { grid-row: 2; grid-column: 1; }

button {
  touch-action: manipulation;
  font-family: inherit; font-size: 0.95rem; font-weight: 600;
  letter-spacing: 0.01em;
  border-radius: 999px; border: 0;
  padding: 13px 32px; min-height: 48px;
  color: hsl(200, 62%, 9%);
  /* Lit rather than flat: a slight top-down gradient, a highlight along the
     top edge and a pool of its own colour under it, so the button reads as
     something on the page instead of a solid block of accent cut out of it. */
  background: linear-gradient(180deg, hsl(186, 72%, 62%), hsl(185, 66%, 50%));
  box-shadow: 0 8px 20px -8px hsla(186, 75%, 48%, 0.55),
              inset 0 1px 0 hsla(190, 90%, 92%, 0.38);
  cursor: pointer;
  transition: transform 140ms ease, filter 140ms ease, box-shadow 140ms ease;
}
button:active { transform: translateY(1px) scale(0.985); box-shadow: 0 3px 10px -6px hsla(186, 75%, 48%, 0.5), inset 0 1px 0 hsla(190, 90%, 92%, 0.25); }
@media (hover: hover) and (pointer: fine) { button:hover { filter: brightness(1.08); } }
button:focus-visible { outline: 3px solid hsl(45, 90%, 65%); outline-offset: 3px; }

#hint {
  background: none; box-shadow: none; color: var(--muted);
  font-weight: 500; font-size: 0.84rem;
  padding: 9px 18px; min-height: 44px;
  text-decoration: underline; text-underline-offset: 3px;
}
@media (hover: hover) and (pointer: fine) { #hint:hover { color: var(--text); filter: none; } }

/* ── end card ── */
/* The two cards that sit over the beach: the one you arrive on and the one you
   leave on. Same box, so the night opens and closes in the same place. */
#start, #end {
  position: absolute; inset: 0; z-index: 3;
  background: hsla(210, 30%, 8%, 0.93);
  display: grid; place-content: center; gap: 16px; justify-items: center;
  padding: 32px; text-align: center;
}
/* The opening card is a scrim, not a blackout: it is dense enough to read the
   copy off in the middle band and clears at both ends, so the beach it is
   introducing is actually visible behind it. The end card stays flat, because
   by then the beach is finished and the card is the whole message. */
#start {
  background: linear-gradient(to bottom,
    hsla(210, 30%, 8%, 0.38) 0%,
    hsla(210, 30%, 8%, 0.90) 28%,
    hsla(210, 30%, 8%, 0.90) 70%,
    hsla(210, 30%, 8%, 0.38) 100%);
}
#start h2, #end h2 { font-family: var(--font-display, inherit); margin: 0; font-size: 1.3rem; font-weight: 600; }
#start p, #end-msg { margin: 0; color: var(--muted); max-width: 24em; line-height: 1.5; }

/* Ponoki, waiting on the sand. Mounted by the game so the rig lives in one
   place; companion-stage.css reserves any #pk-*-stage box by pattern. */
#pk-hero { width: 96px; }
#pk-hero .pk-svg, #pk-end .pk-svg { width: 100%; height: auto; display: block; }
#pk-end { width: 78px; }

/* Back to the rest of Ponoki. On the opening card only, where it cannot be
   mistaken for a game control. */
.nav-back {
  position: absolute;
  top: calc(10px + env(safe-area-inset-top, 0px));
  left: calc(10px + env(safe-area-inset-left, 0px));
  width: 40px; height: 40px; border-radius: 50%;
  display: grid; place-items: center;
  color: var(--muted); background: hsla(215, 25%, 18%, 0.75);
  text-decoration: none;
}
@media (hover: hover) and (pointer: fine) { .nav-back:hover { color: var(--text); } }

/* Metadata, not prose: it reads as a fact about the game rather than another
   line of Ponoki's voice, so it is set as a chip rather than a sentence. */
#start .note {
  font-size: 0.72rem; letter-spacing: 0.06em; text-transform: uppercase;
  color: hsl(200, 16%, 52%);
  padding: 5px 12px; border-radius: 999px;
  border: 1px solid hsla(200, 30%, 70%, 0.16);
  background: hsla(210, 30%, 14%, 0.5);
}

/* A quiet second action beside the loud one. An anchor, so it restates the
   few button properties it needs rather than inheriting them. */
.link-btn {
  display: inline-block; font-family: inherit; border: 0; cursor: pointer;
  background: none; color: var(--muted); font-weight: 500;
  font-size: 0.88rem; padding: 8px 12px;
  text-decoration: underline; text-underline-offset: 3px;
}
@media (hover: hover) and (pointer: fine) { .link-btn:hover { color: var(--text); filter: none; } }

[hidden] { display: none !important; }

/* A wave that rushes is the point, so reduced motion gets a still swap rather
   than a slower version of the same sweep. */
@media (prefers-reduced-motion: reduce) {
  .mark { animation: none; }
  #wave.run #wave-g,
  #wave.run #foam-a,
  #wave.run #foam-b,
  #wave.run #bubbles,
  #wave.run #sheen-streaks { animation: none; }
  #wave.run { opacity: 0; }
  .breathe-ring { animation: none; opacity: 0.85; }
}
