/* build 64b5447 · main · 2026-09-13T19:36:40Z · e6e70bfa */
/* The layered board (layered-board spec §4).
 *
 * `.cel` sits on top of `.board` inside `#stage` and never hides it — the
 * sponsor page render stays on screen underneath for the whole run. Two
 * layers become visible together, instantly, the moment a mark is touched:
 * `.cel__bg` (the deck's own background page, no marks or heading) and
 * `.cel__marks` (one frame per placement at its board position, holding the
 * same artwork the render shows). Because both layers reproduce exactly what
 * is already on screen, that swap is invisible — the check that matters is
 * in the spec, not in this file: a screenshot at that instant must match the
 * board within 1% (§5).
 *
 * From there, exactly two things animate: `.cel__marks-others` (everything
 * but the chosen mark, moved as one composited layer per v1.1 §4) and the
 * one frame inside `.cel__mark-chosen`. Both move on `transform` and
 * `opacity` only.
 *
 * 🔴 Every value a transition ends at is a plain CSS rule keyed off
 * `[data-phase]`, never a `@keyframes` percentage. Finding AA: a hidden page
 * renders no animation frames, so a `@keyframes` animation parks wherever its
 * *active* frame happens to be, and that shipped as a config panel stuck
 * off-screen. A `transition` has no such frame to park at — its target value
 * is a real style the moment `data-phase` changes, so a page that skips every
 * intermediate paint still ends up showing the correct final state. */

.cel {
  position: absolute;
  inset: 0;
  pointer-events: none;     /* a closed celebration must let taps reach the board */
}
.cel[data-phase] { pointer-events: auto; }

/* ---- the instant swap: layer 2 (background) + layer 3 (marks) ---------- */

.cel__bg {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  object-fit: fill;         /* the render IS the page, exactly like .board__render */
  opacity: 0;
  transition: none;         /* an instant snap, never an animated fade (spec §4, t=0) */
}
.cel__marks {
  position: absolute; inset: 0;
  opacity: 0;
  transition: none;
}
.cel[data-phase] .cel__bg,
.cel[data-phase] .cel__marks { opacity: 1; }

/* ---- idle breathing (decisions 62, 63) -------------------------------------
   While nothing is open, main.js sets data-attract from the board's idle state
   and celebration-board.js marks ONE frame (and its cover) data-breathing at a
   time, from the top rows of the board. The layer becomes visible but every
   frame in it is transparent except that one, so the board still reads as the
   client's render with a single mark breathing over it. Transform and opacity
   only. Any data-phase suppresses all of it, so the FLIP owns the frames the
   moment a mark is tapped — and the `:not([data-phase])` scoping is what
   restores every frame to opacity 1 for the t=0 swap. */
.cel[data-attract]:not([data-phase]) .cel__marks { opacity: 1; }
.cel[data-attract]:not([data-phase]) .cel__mark { opacity: 0; }
.cel[data-attract]:not([data-phase]) .cel__mark[data-breathing] {
  opacity: 1;                          /* "ready": shown, at rest */
  will-change: transform, opacity;     /* composited 600 ms BEFORE the pulse animation starts (WebKit, see .cel__mark-img) */
}
.cel[data-attract]:not([data-phase]) .cel__mark[data-breathing="pulse"] {
  animation: cel-breathe var(--breath-ms, 1500ms) ease-in-out 1;                 /* the tap fired it */
}

/* The covers: card colour at each placement rect, under the frames — the
   breathing mark grows over a blank patch of card instead of over the render's
   own copy of itself (Chris, 2026-09-08: "a double logo"). Only the breathing
   mark's cover shows. Not part of the FLIP or the open/return sequence at all. */
.cel__covers { position: absolute; inset: 0; transition: none; }
.cel__mark-cover { position: absolute; opacity: 0; }
.cel[data-attract]:not([data-phase]) .cel__mark-cover[data-breathing] { opacity: 1; }

/* One breath: in over the first half, out over the second, resting where it
   started. .cel__mark has transform-origin 0 0 for the FLIP, so a bare scale
   would grow the frame down and to the right — translate(-5%, -5%) of the
   frame's own box is half the 10 % growth and recentres it. The old 2.4 s loop
   spiked to 1.06 for 340 ms — a heartbeat; this is the breath Chris asked for
   (1.5 s and 1.1x since 2026-09-08 evening; first shipped as 2 s and 1.05x). */
@keyframes cel-breathe {
  0%, 100% { transform: translate(0, 0) scale(1);         opacity: 1; }
  50%      { transform: translate(-5%, -5%) scale(1.1);   opacity: .9; }
}
@media (prefers-reduced-motion: reduce) {
  .cel[data-attract]:not([data-phase]) .cel__mark[data-breathing="pulse"] { animation: none; }
}

/* ---- the hand (decision 64) ------------------------------------------------
   One element, moved by celebration-board.js onto whichever mark is breathing.
   Three poses, keyed by data-tapping and reached by TRANSITIONS to explicit end
   states (finding AA): raised (the base — offset along the finger, larger, and
   invisible), "down"/"contact" (on the mark, full size, visible), "up" (back to
   raised, faster). The scheduler sets the pose; the durations here match its
   FINGER_TAP_MS / FINGER_LIFT_MS. The contact ripple is the one keyframe, fired
   by data-tapping="contact" and resting at opacity 0 both ends. Transform and
   opacity only. The glyph is painted dark with a light stroke under it
   (paint-order) so it reads on any logo colour without a filter.
   transform-origin is the fingertip, so scaling reads as the hand coming toward
   the glass, not sliding across it. Any data-phase hides it — the FLIP owns the
   board from the first frame of an open. */
.cel__finger {
  position: absolute;
  opacity: 0;
  pointer-events: none;       /* a tap on the hand reaches the mark beneath */
  transform-origin: 35% 5%;   /* FINGER_TIP in celebration-board.js */
  transform: translate(14%, 22%) scale(1.25);           /* raised */
  transition: transform 300ms ease-in, opacity 300ms ease-in;   /* the lift (FINGER_LIFT_MS) */
  z-index: 4;                 /* above the marks layer, below Home (5) */
}
.cel[data-attract]:not([data-phase]) .cel__finger[data-tapping="down"],
.cel[data-attract]:not([data-phase]) .cel__finger[data-tapping="contact"] {
  transform: translate(0, 0) scale(1);                  /* on the mark */
  opacity: .3;                                          /* Chris, 2026-09-08: 50 % then "try 30 percent" — a hint, not a stamp */
  transition: transform 600ms cubic-bezier(0.4, 0, 0.6, 1), opacity 350ms ease-out;   /* the approach (FINGER_TAP_MS) */
}
.cel__finger svg {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  overflow: visible;
}
.cel__finger path {
  fill: #14141a;
  stroke: #ffffff;
  stroke-width: 56;
  stroke-linejoin: round;
  paint-order: stroke fill;
}
.cel__finger-ripple {
  position: absolute;
  left: 35%; top: 5%;
  width: 60%; height: 60%;
  margin-left: -30%; margin-top: -30%;
  border-radius: 50%;
  border: calc(var(--u) * 6) solid #14141a;
  opacity: 0;
  transform: scale(0.3);
}
.cel[data-attract]:not([data-phase]) .cel__finger[data-tapping="contact"] .cel__finger-ripple {
  animation: cel-finger-ripple 700ms ease-out 1;        /* fires at the moment the pulse starts */
}
@keyframes cel-finger-ripple {
  0%        { opacity: .55; transform: scale(0.3); }
  100%      { opacity: 0;   transform: scale(1.8); }
}
@media (prefers-reduced-motion: reduce) {
  .cel__finger { display: none; }   /* a motionless hand is noise, not a cue */
}

/* ---- layer 3a: every mark but the chosen one, as one composited layer -- */

.cel__marks-others {
  position: absolute; inset: 0;
  opacity: 1;
  transform: none;
  transition: opacity 400ms ease-out, transform 400ms ease-out;
}
.cel[data-phase="open"] .cel__marks-others { opacity: 0; transform: scale(0.96); }
.cel[data-phase="closing"] .cel__marks-others {
  opacity: 1; transform: none;
  transition: opacity 600ms ease-in, transform 600ms ease-in;
}

/* ---- layer 3b: the chosen mark ------------------------------------------ */

.cel__mark-chosen { position: absolute; inset: 0; }

/* Position/size are set per-mark as inline styles (percentages of the page,
 * exactly like board.js's targets) so the frame starts at its BOARD rect
 * whichever container currently holds it. `transform-origin: 0 0` is what
 * makes the FLIP math in celebration-board.js (translate + scale from the
 * top-left corner) match what actually renders. */
.cel__mark {
  position: absolute;
  transform-origin: 0 0;
}
.cel__mark-chosen .cel__mark {
  transition: transform 900ms cubic-bezier(0.16, 1, 0.3, 1);   /* ease-out */
  will-change: transform;              /* the travelling mark: same WebKit precaution as the breathing frame */
}
.cel[data-phase="open"] .cel__mark-chosen .cel__mark {
  transform:
    translate(calc(var(--u) * var(--cel-dx, 0)), calc(var(--u) * var(--cel-dy, 0)))
    scale(var(--cel-sx, 1), var(--cel-sy, 1));
}
.cel[data-phase="closing"] .cel__mark-chosen .cel__mark {
  transform: none;
  transition: transform 600ms ease-in;
}

/* backface-visibility: WebKit (iPhone, 2026-09-08, Chris on DuckDuckGo — "after the
   hand approaches the logo it goes all white and the logo disappears during the
   pulse"): when a CSS animation starts on the frame, WebKit promotes it to a
   compositing layer and can leave the <img> unpainted in the new layer, so the
   white cover behind shows through. Keeping the image on its own composited
   surface from the start is the long-standing fix for that class of bug. NOT
   reproduced here — no WebKit engine on this machine (Chromium showed the logo at
   every phase, and Playwright's WebKit download timed out) — so this is the
   textbook remedy applied narrowly, and the phone is the test. Harmless in
   Chromium: a second surface per frame, no paint change. */
.cel__mark-img {
  position: absolute; object-fit: fill;
  -webkit-backface-visibility: hidden;
  backface-visibility: hidden;
}

.cel__mark-plate {
  position: absolute; inset: 0;
  opacity: 0;
  transition: opacity 900ms ease-out;
}
.cel[data-phase="open"] .cel__mark-chosen .cel__mark-plate { opacity: 1; }
.cel[data-phase="closing"] .cel__mark-chosen .cel__mark-plate {
  opacity: 0;
  transition: opacity 600ms ease-in;
}

.cel__mark-text {
  position: absolute; inset: 0;
  display: flex; align-items: center; justify-content: center;
  text-align: center;
  overflow-wrap: anywhere;
  padding: 4%;
  color: #14141a;
  font-weight: 700;
  font-size: calc(var(--u) * var(--cel-size, 60));
}

/* ---- layer 4: the live heading ------------------------------------------ */

/* The board's title is set in a heavy geometric sans that Canva never releases (spec
   §8.12). Montserrat Bold (OFL) is the closest open face; self-hosted, because a
   kiosk fetches nothing at runtime. Latin subset. Was ExtraBold (800) until
   2026-09-07: Chris found the celebration heading heavier than the all-sponsor
   page's own title at the same nominal size — swapped to Bold (700) same day. */
@font-face {
  font-family: "Kiosk Display";
  src: url("../fonts/montserrat-700-latin.woff2") format("woff2");
  font-weight: 700;
  font-style: normal;
  font-display: block;
}

.cel__heading-line {
  position: absolute;
  display: flex;
  align-items: center;
  white-space: nowrap;
  overflow: hidden;
  /* --cel-fit shrinks a line that would overrun its rect (set by the player, 1 otherwise). */
  font-size: calc(var(--u) * var(--cel-size, 60) * var(--cel-fit, 1));
  /* Same size as the board's title (--cel-size is its measured font size), and the
     same weight class: Canva reports that face as "normal" because it is heavy by
     design, so the weight is fixed here, never taken from the bundle (Chris, 2026-09-07). */
  font-family: "Kiosk Display", "Montserrat", "Segoe UI", Roboto, system-ui, sans-serif;
  font-weight: 700;
  letter-spacing: 0.01em;
  line-height: 1;
  opacity: 0;
  transition: opacity 400ms ease-out;
}
.cel[data-phase="open"] .cel__heading-line--top { opacity: 1; }
.cel[data-phase="open"] .cel__heading-line--bottom {
  opacity: 1;
  transition-duration: 600ms;
  transition-delay: 300ms;
}
.cel[data-phase="closing"] .cel__heading-line {
  opacity: 0;
  transition: opacity 600ms ease-in;
}

/* ---- layer 5: confetti --------------------------------------------------- */

.cel__confetti { position: absolute; inset: 0; width: 100%; height: 100%; }

/* ---- layer 6: the ripple ------------------------------------------------- */

/* One reusable element, moved to the touch point and replayed — never a fresh
 * element per tap, which is what would leak on a kiosk that runs for days. */
.cel__ripple {
  position: absolute;
  width: calc(var(--u) * 160);
  height: calc(var(--u) * 160);
  margin-left: calc(var(--u) * -80);
  margin-top: calc(var(--u) * -80);
  border-radius: 50%;
  background: radial-gradient(circle, rgb(255 255 255 / .5), rgb(255 255 255 / 0) 70%);
  opacity: 0;
  transform: scale(0.3);
  pointer-events: none;
  transition: opacity 500ms ease-out, transform 500ms ease-out;
}
.cel__ripple.cel__ripple--active { opacity: 1; transform: scale(1); }

/* ---- layer 7: home (the flick-through shortcut, Chris 2026-09-08) ------- */

/* Always top-LEFT — the config gesture owns the top-right 12% x 12% (spec
 * §... kiosk.js) and this must never compete with it. Sized off the page's
 * own height (~4%), not a fixed pixel count — any event's page size, per the
 * pipeline-not-an-event rule — but the touch target is floored at 96px
 * regardless of how small `--u` makes the glyph on a modest preview window. */
.cel__home {
  position: absolute;
  top: calc(var(--u) * var(--page-h) * 0.02);
  left: calc(var(--u) * var(--page-h) * 0.02);
  width: max(96px, calc(var(--u) * var(--page-h) * 0.04));
  height: max(96px, calc(var(--u) * var(--page-h) * 0.04));
  display: grid;
  place-items: center;
  padding: 0;
  border: none;
  border-radius: 50%;
  background: transparent;
  color: #fff;
  cursor: pointer;
  opacity: 0;
  pointer-events: none;
  transition: opacity 150ms ease-out;
  z-index: 5;   /* above the marks layer and confetti, both unclipped here */
}
.cel__home svg { width: 55%; height: 55%; }
.cel[data-phase] .cel__home { opacity: 0.35; pointer-events: auto; }
.cel__home:hover, .cel__home:active, .cel__home:focus-visible { opacity: 0.7; }

@media (prefers-reduced-motion: reduce) {
  .cel__marks-others,
  .cel__mark-chosen .cel__mark,
  .cel__mark-plate,
  .cel__heading-line,
  .cel__ripple,
  .cel__home { transition: none; }
}

/* ---- the review sweep's caption (v1.1, `?sweep=<seconds>`) -------------- */

/* Fixed to the stage's own top edge (`.stage` is `position: fixed`, so this
 * positions against the whole page, not the card) and centred — over the
 * deck's header art, never over the card itself, so it never occludes the
 * mark Chris is judging. Toggled by main.js's sweep driver, per sponsor;
 * `hidden` by default in index.html so an unmodified kiosk never shows it. */
.sweep-caption {
  position: absolute;
  top: 0;
  left: 50%;
  transform: translateX(-50%);
  white-space: nowrap;
  font-size: calc(var(--u) * 28);
  color: #fff;
  background: rgb(0 0 0 / .55);
  padding: calc(var(--u) * 10) calc(var(--u) * 24);
  pointer-events: none;   /* review-only chrome; never a hit target */
  z-index: 6;             /* above the celebration's Home button (5) */
}
