/* Ported from BoothTheme.swift.
 *
 * The governing rule is the same: black is invisible, everything else floats.
 * The mirror only reveals pixels that emit light, so there are no surfaces
 * here — a dark grey card is a glowing rectangle hanging in the glass. Only
 * type, hairlines and bloom.
 */
:root {
  --primary: #fff;
  --secondary: rgba(255,255,255,.62);
  --tertiary: rgba(255,255,255,.32);
  --hairline: rgba(255,255,255,.18);
  --accent: #fff;
  /* 96px, well above the 44px floor: guests hold drinks, stand at arm's
     length, and aim through glass. */
  --hit: 96px;
  --margin: clamp(28px, 6vw, 72px);
}

*,::before,::after { box-sizing: border-box; }
html, body {
  margin: 0; height: 100%; background: #000; color: var(--primary);
  font: 400 17px/1.4 ui-rounded, -apple-system, BlinkMacSystemFont, sans-serif;
  overflow: hidden;
  -webkit-user-select: none; user-select: none;
  -webkit-touch-callout: none;
  -webkit-tap-highlight-color: transparent;
  overscroll-behavior: none;
}

#stage { position: fixed; inset: 0; background: #000; }

#preview {
  position: absolute; inset: 0; width: 100%; height: 100%;
  object-fit: cover; opacity: 0; transition: opacity .6s ease;
  background: #000;
}
#preview.mirrored { transform: scaleX(-1); }

#flash {
  position: absolute; inset: 0; background: #fff; opacity: 0;
  pointer-events: none;
}
#flash.fire { animation: flash .45s ease-out; }
@keyframes flash { from { opacity: 1 } to { opacity: 0 } }

/* Centre glow. Sits above the preview, below the text.
 *
 * `--glow` is set per state from JS. It stays low on the review screen on
 * purpose: the QR's white card has to remain the brightest object on the panel
 * or it stops scanning through the mirror (ADR 0002). */
#glow {
  position: absolute; inset: 0; z-index: 1;
  display: grid; place-items: center; pointer-events: none;
  opacity: var(--glow, 0.5);
  transition: opacity 1.2s ease;
}
/* Two offset layers on mutually prime periods rather than one disc on a
   timer. A single pulsing circle has a period the eye locks onto in about
   fifteen seconds, and once you see the loop it stops being light and starts
   being an animation. */
#glow::before, #glow::after {
  content: '';
  position: absolute;
  border-radius: 50%;
  mix-blend-mode: screen;
}
#glow::before {
  width: min(92vmin, 940px); aspect-ratio: 1;
  background: radial-gradient(circle,
    rgba(255,255,255,.20) 0%,
    rgba(255,255,255,.09) 32%,
    rgba(255,255,255,.03) 54%,
    transparent 74%);
  animation: glowBreathe 43s ease-in-out infinite;
}
#glow::after {
  width: min(64vmin, 660px); aspect-ratio: 1;
  background: radial-gradient(circle,
    color-mix(in srgb, var(--accent, #fff) 60%, transparent) 0%,
    color-mix(in srgb, var(--accent, #fff) 20%, transparent) 26%,
    transparent 56%);
  opacity: .12;
  animation: glowDrift 53s ease-in-out infinite;
}
@keyframes glowBreathe {
  0%   { transform: scale(.94) translate(-1%, 1%);  opacity: .74; }
  43%  { transform: scale(1.08) translate(1%, -1%); opacity: 1; }
  100% { transform: scale(.94) translate(-1%, 1%);  opacity: .74; }
}
@keyframes glowDrift {
  0%   { transform: scale(1.04) translate( 4%, -3%); }
  38%  { transform: scale(1.24) translate(-5%,  4%); }
  73%  { transform: scale(1.12) translate( 2%,  3%); }
  100% { transform: scale(1.04) translate( 4%, -3%); }
}
@media (prefers-reduced-motion: reduce) {
  #glow::before, #glow::after { animation: none; }
}

#screen {
  position: absolute; inset: 0; z-index: 2;
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: 40px; padding: var(--margin); text-align: center;
}

/* Bloom. Through the mirror a hard edge looks printed on the glass; a bloomed
   edge looks suspended in it. This is what sells "floating". */
.glow { text-shadow: 0 0 24px rgba(255,255,255,.55), 0 0 60px rgba(255,255,255,.22); }

h1 { font-size: clamp(38px, 7vw, 76px); font-weight: 300; letter-spacing: .04em; margin: 0; }
h2 { font-size: clamp(22px, 3.4vw, 34px); font-weight: 400; color: var(--secondary); margin: 0; }
.count {
  font-size: clamp(140px, 34vw, 260px); font-weight: 200;
  font-variant-numeric: tabular-nums; line-height: .9; margin: 0;
}
.caption { font-size: clamp(18px, 2.4vw, 28px); color: var(--secondary); margin: 0; }
/* Sits under the bloom, so it should not compete with it. */
.caption.soft { color: rgba(255,255,255,.45); letter-spacing: .03em; font-weight: 300; }
.hint { font-size: 15px; color: var(--tertiary); letter-spacing: .18em; text-transform: uppercase; }

/* The only control. A hairline capsule — a filled button is a glowing slab in
   the glass. */
button.booth {
  appearance: none; background: transparent; color: var(--accent);
  border: 1px solid rgba(255,255,255,.45); border-radius: 999px;
  min-height: var(--hit); min-width: 260px; padding: 0 52px;
  font: 500 clamp(18px,2.2vw,26px)/1 inherit;
  letter-spacing: .14em; text-transform: uppercase;
  transition: transform .18s ease, background .18s ease, color .18s ease;
  box-shadow: 0 0 18px rgba(255,255,255,.28);
}
button.booth.prominent { border-width: 2px; box-shadow: 0 0 26px rgba(255,255,255,.4); }
button.booth:active { background: #fff; color: #000; transform: scale(.97); }
button.booth.quiet { color: var(--secondary); border-color: rgba(255,255,255,.25); box-shadow: none; }

.row { display: flex; gap: 20px; flex-wrap: wrap; justify-content: center; }

/* Countdown ring */
.ringwrap { width: min(52vh, 420px); aspect-ratio: 1; position: relative; }
.ringwrap svg { position: absolute; inset: 0; width: 100%; height: 100%; transform: rotate(-90deg); }
.ringwrap .track { fill: none; stroke: var(--hairline); stroke-width: 2; }
.ringwrap .value { fill: none; stroke: var(--accent); stroke-width: 3; stroke-linecap: round;
  transition: stroke-dashoffset .9s ease; filter: drop-shadow(0 0 12px rgba(255,255,255,.7)); }
.ringwrap .num { position: absolute; inset: 0; display: grid; place-items: center; }

/* Delivery. Deliberately off-theme — see ADR 0002. */
.delivery { display: flex; gap: var(--margin); align-items: center; justify-content: center;
  width: 100%; height: 100%; flex-wrap: wrap; }
.shot { max-width: min(46vw, 520px); max-height: 72vh; border-radius: 8px;
  border: 1px solid var(--hairline); box-shadow: 0 24px 60px rgba(0,0,0,.7); }
.qr-card {
  display: grid; place-items: center; text-decoration: none;
  background: #fff; border-radius: 20px; padding: 20px;
  width: min(38vw, 340px); aspect-ratio: 1;
  display: grid; place-items: center;
  box-shadow: 0 0 30px rgba(255,255,255,.35);
}
.qr-card svg { width: 100%; height: 100%; display: block; }
.qr-card:active { transform: scale(.985); }
.deliver-col { display: flex; flex-direction: column; gap: 24px; align-items: center; }

/* Recording */
.rec-dot { width: 18px; height: 18px; border-radius: 50%; background: #ff3b30;
  animation: pulse .8s ease-in-out infinite alternate; }
@keyframes pulse { from { opacity: .25 } to { opacity: 1 } }
.timebar { width: min(70vw, 640px); height: 4px; background: var(--hairline); border-radius: 999px; }
.timebar > i { display: block; height: 100%; background: var(--accent); border-radius: 999px;
  transition: width 1s linear; box-shadow: 0 0 10px rgba(255,255,255,.6); }

/* ── Bloom ─────────────────────────────────────────────────────────────────
 *
 * Replaces the loading ring. A stroked circle with a progress arc is a
 * *loading control*: hard edge, a start and an end, and it reads as software
 * working. Through a half-silvered mirror a hard edge is the one thing that
 * gives the screen away — real light in glass has no outline.
 *
 * PERFORMANCE, learned the hard way: the first version put `filter: blur(60px)`
 * on five animated layers. A blur filter is recomputed on *every frame* of a
 * transform animation, and blurring a 660px layer at 60fps is far beyond what
 * an iPad will do — it drops to a few frames a second, which reads as lag.
 *
 * A radial gradient is already soft. The blur was doing nothing the gradient
 * could not do for free, at enormous cost. So: no filters, softer colour
 * stops, and only `transform` and `opacity` animate — the two properties the
 * compositor can handle without touching the main thread.
 */
.bloom {
  position: relative;
  width: min(74vmin, 660px);
  aspect-ratio: 1;
  pointer-events: none;
  /* Promote once, up front, rather than letting the browser guess per frame. */
  will-change: transform;
  transform: translateZ(0);
}

.bloom i {
  position: absolute;
  inset: -20%;
  border-radius: 50%;
  mix-blend-mode: screen;
  will-change: transform, opacity;
  /* No `filter` anywhere in here. That is the point. */
}

/* Core — softest possible falloff, doing the work blur used to do. */
.bloom i:nth-child(1) {
  background: radial-gradient(circle at 50% 50%,
    rgba(255,255,255,.42) 0%,
    rgba(255,255,255,.26) 12%,
    rgba(255,255,255,.10) 28%,
    rgba(255,255,255,.03) 44%,
    transparent 62%);
  animation: bloomCore 19s ease-in-out infinite;
}

/* Accent veil — carries the client colour, drifting off-centre. */
.bloom i:nth-child(2) {
  background: radial-gradient(circle at 42% 58%,
    color-mix(in srgb, var(--accent, #fff) 55%, transparent) 0%,
    color-mix(in srgb, var(--accent, #fff) 22%, transparent) 22%,
    transparent 52%);
  opacity: .34;
  animation: bloomDriftA 23s ease-in-out infinite;
}

/* Cool counter-veil drifting the other way. The two crossing is what shifts
   the colour, without animating colour at all. */
.bloom i:nth-child(3) {
  background: radial-gradient(circle at 62% 40%,
    rgba(120,200,255,.42) 0%,
    rgba(120,200,255,.16) 24%,
    transparent 50%);
  opacity: .32;
  animation: bloomDriftB 29s ease-in-out infinite;
}

/* Outer haze — the part that reads as glow *in* the glass rather than on it. */
.bloom i:nth-child(4) {
  background: radial-gradient(circle at 50% 50%,
    rgba(255,255,255,.14) 0%,
    rgba(255,255,255,.06) 30%,
    rgba(255,255,255,.02) 52%,
    transparent 74%);
  animation: bloomHaze 31s ease-in-out infinite;
}

/* One slow sweep, like something turning over inside it. */
.bloom i:nth-child(5) {
  background: radial-gradient(ellipse 58% 38% at 32% 68%,
    rgba(255,255,255,.20) 0%,
    rgba(255,255,255,.07) 30%,
    transparent 58%);
  animation: bloomSweep 37s linear infinite;
}

/* Closed loops: 0% and 100% are identical, so the animation returns to its own
 * start and never plays backwards. The first version used `alternate`, which
 * runs the keyframes in reverse every other cycle — on a gradient that reads
 * as the bloom stopping and rewinding. Light does not rewind.
 *
 * Intermediate keyframes sit at different fractions per layer so the layers
 * never reach their extremes together. */
@keyframes bloomCore {
  0%   { transform: scale(.94) translate(0, 0);          opacity: .78; }
  37%  { transform: scale(1.05) translate(1.6%, -1.2%);  opacity: 1; }
  64%  { transform: scale(.99) translate(-1.1%, 1.4%);   opacity: .88; }
  100% { transform: scale(.94) translate(0, 0);          opacity: .78; }
}
@keyframes bloomDriftA {
  0%   { transform: scale(1.02) translate(-5%,  3%); }
  29%  { transform: scale(1.18) translate( 3%, -5%); }
  71%  { transform: scale(1.10) translate( 5%,  2%); }
  100% { transform: scale(1.02) translate(-5%,  3%); }
}
@keyframes bloomDriftB {
  0%   { transform: scale(1.12) translate( 5%, -2%); }
  41%  { transform: scale(.97)  translate(-4%,  5%); }
  68%  { transform: scale(1.06) translate(-2%, -4%); }
  100% { transform: scale(1.12) translate( 5%, -2%); }
}
@keyframes bloomHaze {
  0%   { transform: scale(.97); opacity: .62; }
  45%  { transform: scale(1.16); opacity: 1; }
  100% { transform: scale(.97); opacity: .62; }
}
@keyframes bloomSweep {
  from { transform: rotate(0deg)   scale(1.08); }
  to   { transform: rotate(360deg) scale(1.08); }
}

/* Motes. Three, not thirty — a particle field reads as a screensaver. These
   read as dust caught in a beam, which is the difference. */
.motes { position: absolute; inset: 0; pointer-events: none; }
.motes b {
  position: absolute; width: 3px; height: 3px; border-radius: 50%;
  background: #fff; opacity: 0;
  box-shadow: 0 0 8px 2px rgba(255,255,255,.6);
  animation: moteRise linear infinite;
}
.motes b:nth-child(1) { left: 38%; animation-duration: 14s; animation-delay: 0s; }
.motes b:nth-child(2) { left: 55%; animation-duration: 19s; animation-delay: -6s; }
.motes b:nth-child(3) { left: 47%; animation-duration: 23s; animation-delay: -13s; }

@keyframes moteRise {
  0%   { transform: translateY(62%) translateX(0);    opacity: 0; }
  18%  { opacity: .9; }
  82%  { opacity: .7; }
  100% { transform: translateY(-58%) translateX(14px); opacity: 0; }
}

@media (prefers-reduced-motion: reduce) {
  .bloom i, .motes b { animation: none; }
  .bloom i:nth-child(1) { opacity: .9; }
}

/* Above #screen (z-index 2), or the screen swallows every tap and the gesture
   silently does nothing — which is exactly what it did. An explicit z-index
   always beats `auto`, regardless of DOM order. */
#hotcorner { position: absolute; top: 0; left: 0; width: 140px; height: 140px; z-index: 6; }

/* Debug drawer — never visible to a guest. Five taps top-left. */
#rig {
  position: fixed; inset: auto 0 0 0; background: #111; color: #eee;
  padding: 20px; font: 13px/1.5 ui-monospace, monospace;
  border-top: 1px solid #333; max-height: 55vh; overflow: auto; z-index: 10;
}
#rig h2 { color: #eee; font-size: 14px; text-transform: uppercase; letter-spacing: .12em; }
#rig label { display: block; margin: 8px 0; }
#rig button { margin-top: 12px; background: #eee; color: #111; border: 0;
  border-radius: 6px; padding: 10px 18px; font: inherit; }
#rig hr { border: 0; border-top: 1px solid #333; margin: 14px 0; }

/* ── Simulated LED ring ────────────────────────────────────────────────────
 *
 * Stands in for the physical ring the Pi drives. Worth having on screen rather
 * than only in the portal: the whole design rests on lighting being a pure
 * function of booth state, and the cheapest way to check that is to watch both
 * change at once behind the glass.
 *
 * Sits *outside* #stage so it frames the screen edge — roughly where the real
 * ring sits relative to the panel.
 */
#ring {
  position: fixed; inset: 0; pointer-events: none; z-index: 5;
  border: 10px solid transparent; border-radius: 2px;
  transition: border-color .6s ease, box-shadow .6s ease;
}
#ring[data-scene="boot"]          { border-color: rgba(255,147,41,.35);  box-shadow: inset 0 0 40px rgba(255,147,41,.25); }
#ring[data-scene="attract"]       { border-color: rgba(180,120,255,.5);  box-shadow: inset 0 0 60px rgba(180,120,255,.3);
                                    animation: ringChase 6s linear infinite; }
#ring[data-scene="call_to_action"]{ border-color: rgba(255,255,255,.55); box-shadow: inset 0 0 50px rgba(255,255,255,.3);
                                    animation: ringPulse 1.4s ease-in-out infinite alternate; }
/* Flat, full, static — this is what the sensor sees. Any animation here would
   band across the exposure. */
#ring[data-scene="capture_key"]   { border-color: #fff; box-shadow: inset 0 0 90px rgba(255,255,255,.55); animation: none; }
#ring[data-scene="processing"]    { border-color: rgba(0,220,255,.6); box-shadow: inset 0 0 50px rgba(0,220,255,.3);
                                    animation: ringPulse .7s ease-in-out infinite alternate; }
#ring[data-scene="delivery"]      { border-color: rgba(255,240,220,.6); box-shadow: inset 0 0 50px rgba(255,240,220,.28); }
#ring[data-scene="fault"]         { border-color: rgba(255,40,60,.75); box-shadow: inset 0 0 60px rgba(255,40,60,.4);
                                    animation: ringPulse .9s ease-in-out infinite alternate; }
#ring[data-scene="service"]       { border-color: rgba(255,255,255,.9); }

@keyframes ringPulse { from { opacity: .45 } to { opacity: 1 } }

/* ── Client-chosen ring movement ───────────────────────────────────────────
 *
 * Reported from a venue: clients want to choose the animation, not just the
 * colour. `data-style` comes from the brand page and overrides the scene's own
 * default; `capture_key` never gets one, because the light on someone's face
 * at the moment of the shot is not a branding surface.
 *
 * `--brand-1` / `--brand-2` are set from the configuration at boot.
 */
#ring[data-style]                 { border-color: var(--ring-c, var(--brand-1, #fff));
                                    box-shadow: inset 0 0 55px
                                      color-mix(in srgb, var(--ring-c, var(--brand-1,#fff)) 32%, transparent); }
#ring[data-scene="attract"][data-style]        { --ring-c: var(--brand-2, #b478ff); }
#ring[data-scene="call_to_action"][data-style],
#ring[data-scene="processing"][data-style],
#ring[data-scene="delivery"][data-style]       { --ring-c: var(--brand-1, #fff); }

#ring[data-style="chase"]   { animation: ringChase 6s linear infinite; }
#ring[data-style="breathe"] { animation: ringBreathe 5.2s ease-in-out infinite; }
#ring[data-style="pulse"]   { animation: ringBeat 1.4s ease-in-out infinite; }
#ring[data-style="orbit"]   { animation: ringChase 2.2s linear infinite; }
#ring[data-style="sparkle"] { animation: ringSparkle 1.6s steps(4, end) infinite; }
#ring[data-style="steady"]  { animation: none; }

/* Closed loops, not `alternate`. `animation-direction: alternate` literally
 * plays the keyframes backwards, which is what made the earlier version look
 * like it was boomeranging on a laggy connection. */
@keyframes ringBreathe { 0%, 100% { opacity: .35 } 50% { opacity: 1 } }
@keyframes ringBeat    { 0%, 100% { opacity: .5 } 22% { opacity: 1 } 44% { opacity: .62 } }
@keyframes ringSparkle { 0%, 100% { opacity: 1 } 25% { opacity: .3 } 50% { opacity: .85 } 75% { opacity: .45 } }
@keyframes ringChase { 0% { filter: hue-rotate(0deg) } 100% { filter: hue-rotate(360deg) } }

#rig label span { color: #888; }
#rig a { color: #b9ff66; }

#rig select { width: 100%; padding: 8px; margin: 6px 0 2px; background: #0e0e10;
  color: #eee; border: 1px solid #333; border-radius: 6px; font: inherit; }
#rig #opticsNote { color: #8b8b95; margin: 4px 0 10px; font-size: 12px; }
#rig input[type=checkbox] { width: auto; margin-right: 6px; }

#rig input[type=text], #rig input[type=password] {
  width: 100%; padding: 8px 10px; margin: 4px 0 8px; background: #0e0e10;
  color: #eee; border: 1px solid #333; border-radius: 6px; font: inherit; }
#rig #pairStatus { color: #8b8b95; font-size: 12px; margin: 8px 0 0; }

/* ── Setup ─────────────────────────────────────────────────────────────────
 *
 * Staff-facing, so it drops the black-void rules: this is read at arm's length
 * under work lights by someone typing a token, not by a guest through glass.
 */
.setup { width: min(92vw, 520px); text-align: left; }
.setup h1 { font-size: clamp(28px, 5vw, 40px); margin: 0 0 8px; }
.setup p.lead { color: var(--secondary); font-size: 17px; margin: 0 0 28px; }
.setup label { display: block; font-size: 14px; color: var(--secondary);
  margin: 18px 0 6px; letter-spacing: .06em; text-transform: uppercase; }
.setup input { width: 100%; min-height: 60px; padding: 0 18px; font: 400 19px/1 inherit;
  background: rgba(255,255,255,.06); color: #fff; border: 1px solid var(--hairline);
  border-radius: 12px; }
.setup input:focus { outline: none; border-color: var(--accent); }
.setup .actions { margin-top: 28px; display: flex; gap: 12px; flex-wrap: wrap; }
.setup .status { margin-top: 20px; font-size: 15px; color: var(--secondary); }
.setup .status.bad { color: #ff8095; }
.setup .status.good { color: #b9ff66; }
.setup .hintline { margin-top: 26px; font-size: 13px; color: var(--tertiary); line-height: 1.7; }

/* ── Materialising plate ───────────────────────────────────────────────────
 *
 * The guest's photo condensing out of light, revealed from the centre outward
 * through a soft glowing boundary.
 *
 * The first version cross-faded a flat white veil. It read as progress and
 * nothing else — a loading bar wearing a photograph. This uses a radial mask
 * whose radius is the progress value, so the image *arrives* rather than
 * fades, and the boundary itself glows and drifts so the edge never looks
 * like a hard wipe.
 *
 * Cheap on purpose: `mask-image` and `opacity` composite on the GPU. No
 * filters anywhere near an animated layer — that mistake cost us a frame rate
 * once already.
 */
.developing {
  position: relative;
  width: min(70vmin, 560px);
  aspect-ratio: 3 / 4;
  display: grid;
  place-items: center;
  --develop: 0;
  /* Slightly ahead of the mask, so the glow leads the reveal. */
  --edge: calc(var(--develop) * 118%);
}

.developing .bloom {
  position: absolute;
  inset: -22%;
  width: auto;
  opacity: calc(.35 + .35 * (1 - var(--develop)));
  transition: opacity 1.2s ease-out;
}

/* The image, masked to a growing disc with a long soft edge. */
.developing .plate {
  position: absolute;
  inset: 0;
  width: 100%; height: 100%;
  object-fit: cover;
  border-radius: 10px;
  -webkit-mask-image: radial-gradient(circle at 50% 46%,
    #000 0%,
    #000 calc(var(--edge) - 26%),
    rgba(0,0,0,.55) calc(var(--edge) - 10%),
    transparent var(--edge));
  mask-image: radial-gradient(circle at 50% 46%,
    #000 0%,
    #000 calc(var(--edge) - 26%),
    rgba(0,0,0,.55) calc(var(--edge) - 10%),
    transparent var(--edge));
  transition: -webkit-mask-image 1.1s ease-out, mask-image 1.1s ease-out,
              transform 1.4s ease-out;
  transform: scale(calc(.985 + .015 * var(--develop)));
}

/* The boundary itself: a ring of light sitting exactly where the image ends,
   which is what makes it read as condensing rather than wiping. */
.developing .rim {
  position: absolute;
  inset: 0;
  border-radius: 10px;
  pointer-events: none;
  mix-blend-mode: screen;
  background: radial-gradient(circle at 50% 46%,
    transparent calc(var(--edge) - 24%),
    rgba(255,255,255,.16) calc(var(--edge) - 12%),
    var(--accent, #fff) calc(var(--edge) - 4%),
    rgba(255,255,255,.10) calc(var(--edge) + 3%),
    transparent calc(var(--edge) + 16%));
  opacity: calc(.30 + .45 * (1 - var(--develop)));
  animation: rimBreathe 6.5s ease-in-out infinite;
  transition: background 1.1s ease-out, opacity 1.2s ease-out;
}

/* Cool counter-glow drifting across the boundary, so the rim is never one
   flat colour. */
.developing .rim::after {
  content: '';
  position: absolute;
  inset: -10%;
  border-radius: 50%;
  background: radial-gradient(ellipse 50% 34% at 34% 62%,
    rgba(140,200,255,.30) 0%, transparent 62%);
  mix-blend-mode: screen;
  animation: rimDrift 17s ease-in-out infinite;
}

@keyframes rimBreathe {
  0%   { filter: none; transform: scale(1); }
  45%  { transform: scale(1.012); }
  100% { transform: scale(1); }
}
@keyframes rimDrift {
  0%   { transform: translate(-4%, 3%) scale(1); }
  38%  { transform: translate(5%, -4%) scale(1.18); }
  71%  { transform: translate(2%, 4%) scale(1.05); }
  100% { transform: translate(-4%, 3%) scale(1); }
}

.developing .motes { position: absolute; inset: -8%; }

@media (prefers-reduced-motion: reduce) {
  .developing .rim { animation: none; }
  .developing .rim::after { animation: none; }
  .developing .plate { transition: none; }
}

/* ── Particle field ────────────────────────────────────────────────────────
 *
 * The canvas is the whole effect; CSS only sizes it. Rendering writes into an
 * ImageData buffer (see particles.js) rather than issuing a draw call per
 * particle, which is what keeps nine thousand of them at 60fps on an iPad.
 */
.field {
  position: relative;
  width: min(72vmin, 560px);
  /* Height has to be constrained too. A 3:4 box sized only by width overflows
     any landscape-ish viewport and pushes the caption off the bottom — which
     it did, on the 13" panel this is built for. `aspect-ratio` then narrows the
     width to match rather than distorting. */
  max-height: 64vh;
  aspect-ratio: 3 / 4;
}
.field canvas {
  width: 100%;
  height: 100%;
  display: block;
  border-radius: 10px;
  /* Black is invisible behind the mirror, so the field appears to hang in the
     glass with no frame around it. */
  background: #000;
}

/* ── Bug reporting ─────────────────────────────────────────────────────────
 *
 * Off unless a device has been marked as under test. A floating button on a
 * booth at a wedding is a guest tapping it to see what it does.
 */
#bugFab {
  position: fixed; right: 20px; bottom: 20px; z-index: 40;
  width: 52px; height: 52px; border-radius: 50%;
  background: #000; color: #fff; font-size: 22px;
  border: 1px solid var(--hairline); cursor: pointer; padding: 0; line-height: 1;
}
#bugPanel {
  position: fixed; right: 20px; bottom: 84px; z-index: 41;
  width: min(92vw, 420px); padding: 22px;
  background: #000; border: 1px solid var(--hairline); border-radius: 16px;
}
#bugPanel h2 { margin: 0 0 12px; font-size: 18px; font-weight: 600; }
#bugPanel textarea {
  width: 100%; font: inherit; font-size: 16px; padding: 12px;
  background: #000; color: #fff; border: 1px solid var(--hairline);
  border-radius: 10px; resize: vertical;
}
#bugPanel .bugNote { color: var(--tertiary); font-size: 12px; margin: 10px 0 0; }
#bugPanel .bugRow { display: flex; gap: 10px; justify-content: flex-end; margin-top: 16px; }
#bugPanel .bugRow .booth { min-width: 0; min-height: 48px; padding: 0 24px; font-size: 15px; }
#bugStatus { color: var(--secondary); font-size: 13px; margin: 12px 0 0; }

/* ── Standby screen ─────────────────────────────────────────────────────────
 *
 * The client's own film or key visual, behind the words. Dimmed and inset:
 * through a one-way mirror a full-brightness video washes out the text in
 * front of it, and the text is the part that tells a guest what to do.
 */
.standby {
  position: fixed; inset: 0; width: 100%; height: 100%;
  object-fit: cover; opacity: .30; z-index: -1;
  /* Faded to true black at the edges rather than cropped to a rectangle.
   * A clipped full-bleed image reads as a glowing panel through a one-way
   * mirror — the exact thing the black-first design exists to avoid. The
   * client's film should look like it is floating in the glass. */
  -webkit-mask-image: radial-gradient(ellipse 72% 62% at 50% 45%,
    #000 0%, rgba(0,0,0,.85) 45%, rgba(0,0,0,.35) 72%, transparent 100%);
  mask-image: radial-gradient(ellipse 72% 62% at 50% 45%,
    #000 0%, rgba(0,0,0,.85) 45%, rgba(0,0,0,.35) 72%, transparent 100%);
}
.logo {
  max-width: min(46vw, 360px); max-height: 15vh;
  object-fit: contain; margin: 0 auto 28px; display: block;
}
