@import url("tokens.css");

/* Chirp.
 *
 * Visual language ported from the component library. Where a rule reproduces a
 * specific component from there, the comment names it, so the original can be
 * looked up when this needs to change.
 */

*,
*::before,
*::after {
  box-sizing: border-box;
}

html,
body {
  margin: 0;
  padding: 0;
}

/* The UA's `[hidden] { display: none }` lives in the user-agent origin, which
 * every author rule outranks -- specificity never enters into it. So any rule
 * here that sets `display` silently un-hides its element, and this app toggles
 * `.hidden` on six of them from js/app.js.
 *
 * It had already happened twice before this rule existed: the image preview
 * card (display: flex) sat on the Image tab as a broken-image placeholder, and
 * the code box (display: flex) showed with Encrypt switched off.
 */
[hidden] {
  display: none !important;
}

body {
  min-height: 100dvh;
  background: var(--background);
  color: var(--foreground);
  font: var(--text-body) / var(--leading-normal) var(--font-ui);
  -webkit-font-smoothing: antialiased;
  /* Tones are the point; stop a stray double-tap zoom mid-transfer. */
  touch-action: manipulation;
}

::selection {
  background: var(--indigo-5);
  color: var(--indigo-12);
}

/* --- crowd ------------------------------------------------------------ */
/* Effects › Crowd Canvas, drawn by js/crowd.js from vendor/all-peeps.png.
   Both the component and the sheet are skiper-ui's, used under the terms in
   the library's LICENSE-THIRD-PARTY.md: free to use in your own projects, not
   to be republished as components.

   Replaces a radial gradient wash that read as stock. */

#crowd {
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  width: 100%;
  height: 176px;
  z-index: -1;
  pointer-events: none;
  /* The sheet is black line art with a white fill, drawn for a light page --
     which is what this is, so it needs no inversion. Dropped well back: this
     is a background, and a crowd of people at full strength out-competes the
     interface in front of it. Switching back to a dark theme means putting
     `filter: invert(1)` back here. */
  opacity: 0.16;
}

.app {
  max-width: 520px;
  margin: 0 auto;
  padding: max(var(--space-6), env(safe-area-inset-top)) var(--space-4)
    max(var(--space-6), env(safe-area-inset-bottom));
}

/* --- header ----------------------------------------------------------- */

.head {
  text-align: center;
  margin: var(--space-2) 0 var(--space-6);
}

.head h1 {
  margin: 0;
  font-size: var(--text-h1);
  font-weight: 600;
  line-height: var(--leading-tight);
  letter-spacing: var(--tracking-tight);
  display: flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-3);
}

/* The mark is the site's avatar, in a circle that pulses on the same 2.6s beat
   the old speaker glyph did -- so it still reads as something emitting sound. */
.head-mark {
  display: grid;
  place-items: center;
  width: 44px;
  height: 44px;
  flex: none;
  overflow: hidden;
  border-radius: var(--radius-full);
  border: 1px solid var(--indigo-7);
  background: var(--indigo-3);
  animation: mark-pulse 2.6s var(--ease-standard) infinite;
}

.head-mark img {
  /* The avatar's ink fills 59% x 74% of a 1080 square, centred, and the face
     sits in the upper half of that -- so centring the artwork puts the neck in
     the middle of the circle. Scale past the frame and lift, measured rather
     than guessed: at 135% the head fills the circle with the chin still in. */
  width: 135%;
  height: 135%;
  object-fit: contain;
  transform: translateY(-8%);
}

@keyframes mark-pulse {
  0%,
  100% {
    box-shadow: 0 0 0 0 rgb(62 99 221 / 0.3);
  }
  55% {
    box-shadow: 0 0 0 8px rgb(62 99 221 / 0);
  }
}

.tagline {
  margin: var(--space-2) 0 0;
  color: var(--muted-foreground);
  font-size: var(--text-small);
}

/* --- mode tabs -------------------------------------------------------- */
/* The sliding indicator from App UI › Pagination — one highlight that travels
   between items rather than a background that pops on and off. js/ui.js moves
   it; this only describes it. */

.modes {
  position: relative;
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: var(--space-1);
  padding: var(--space-1);
  background: var(--card);
  border: 1px solid var(--border);
  border-radius: var(--radius-xl);
  margin-bottom: var(--space-4);
  isolation: isolate;
}

.glider {
  position: absolute;
  z-index: -1;
  top: var(--space-1);
  left: 0;
  height: calc(100% - var(--space-1) * 2);
  width: var(--glider-w, 0);
  border-radius: var(--radius-lg);
  background: var(--primary);
  box-shadow: var(--shadow-sm), 0 0 20px -4px var(--primary);
  transform: translate3d(var(--glider-x, 0), 0, 0);
  transition:
    transform var(--dur-slow) var(--spring-default),
    width var(--dur-slow) var(--spring-default);
}

/* Nothing to slide from on first paint; a glider flying in from the left edge
   on load reads as a bug. js/ui.js drops this class after positioning. */
.glider.is-init {
  transition: none;
}

.mode {
  appearance: none;
  border: 0;
  background: transparent;
  color: var(--muted-foreground);
  font: inherit;
  font-size: 14px;
  font-weight: 600;
  padding: 11px;
  border-radius: var(--radius-lg);
  cursor: pointer;
  transition: color var(--dur-fast) var(--ease-standard);
}

.mode:hover {
  color: var(--foreground);
}

.mode.is-active {
  color: var(--primary-foreground);
}

.mode:focus-visible,
.ptab:focus-visible {
  outline: 2px solid var(--ring);
  outline-offset: 2px;
}

/* --- payload type tabs ------------------------------------------------ */

.ptabs {
  position: relative;
  display: flex;
  gap: var(--space-6);
  margin-bottom: var(--space-4);
  border-bottom: 1px solid var(--border);
}

.ptabs .glider {
  z-index: 0;
  top: auto;
  bottom: -1px;
  height: 2px;
  border-radius: var(--radius-full);
  box-shadow: 0 0 12px -2px var(--primary);
}

.ptab {
  appearance: none;
  border: 0;
  background: none;
  color: var(--muted-foreground);
  font: inherit;
  font-size: 14px;
  font-weight: 600;
  padding: 0 0 10px;
  cursor: pointer;
  transition: color var(--dur-fast) var(--ease-standard);
}

.ptab:hover,
.ptab.is-active {
  color: var(--foreground);
}

/* --- fields ----------------------------------------------------------- */

.field {
  display: block;
}

.field-label,
.opt-title {
  display: block;
  font-size: var(--text-micro);
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: var(--tracking-micro);
  color: var(--muted-foreground);
  margin-bottom: var(--space-2);
}

textarea {
  width: 100%;
  background: var(--input);
  border: 1px solid var(--border);
  border-radius: var(--radius-xl);
  color: var(--foreground);
  font: inherit;
  padding: var(--space-3);
  resize: vertical;
  transition:
    border-color var(--dur-fast) var(--ease-standard),
    box-shadow var(--dur-fast) var(--ease-standard);
}

textarea::placeholder {
  color: var(--slate-9);
}

textarea:hover {
  border-color: var(--border-strong);
}

textarea:focus {
  outline: none;
  border-color: var(--ring);
  box-shadow: 0 0 0 3px rgb(67 93 177 / 0.25);
}

/* --- byte budget ------------------------------------------------------ */

.meter-row {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  margin: var(--space-3) var(--space-0-5) 0;
}

.budget {
  flex: 1;
  height: 4px;
  background: var(--slate-4);
  border-radius: var(--radius-full);
  overflow: hidden;
}

.budget-fill {
  height: 100%;
  width: 0;
  background: var(--primary);
  border-radius: var(--radius-full);
  transition:
    width var(--dur-base) var(--ease-expressive),
    background-color var(--dur-fast) var(--ease-standard);
}

.budget-fill.warn {
  background: var(--warning);
}

.budget-fill.over {
  background: var(--danger);
}

.budget-text {
  font-family: var(--font-mono);
  font-size: var(--text-micro);
  color: var(--muted-foreground);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
  transition: color var(--dur-fast) var(--ease-standard);
}

.budget-text.over {
  color: var(--danger-text);
}

/* --- dropzone --------------------------------------------------------- */
/* App UI › File Upload. The original wraps react-dropzone; the drag handling
   is in js/ui.js and this is its resting, hover and drag-over skin. */

.picker {
  position: relative;
  display: grid;
  justify-items: center;
  gap: var(--space-2);
  margin-top: var(--space-3);
  padding: var(--space-6) var(--space-4);
  text-align: center;
  border: 1px dashed var(--border-strong);
  border-radius: var(--radius-xl);
  background: var(--card);
  color: var(--muted-foreground);
  font-size: var(--text-small);
  font-weight: 600;
  cursor: pointer;
  transition:
    border-color var(--dur-fast) var(--ease-standard),
    background-color var(--dur-fast) var(--ease-standard),
    color var(--dur-fast) var(--ease-standard);
}

/* The tile, and the icon that sits inside it. Two pseudo-elements rather than
   real nodes so app.js keeps writing to a single #picker-label. */
.picker::before {
  content: "";
  width: 40px;
  height: 40px;
  border-radius: var(--radius-xl);
  border: 1px solid var(--border);
  background: var(--muted);
  transition:
    transform var(--dur-base) var(--spring-snappy),
    border-color var(--dur-fast) var(--ease-standard),
    background-color var(--dur-fast) var(--ease-standard);
}

/* Painted over the tile rather than into it: a background-image bakes in its
   own stroke colour and cannot follow the theme, a mask takes currentColor. */
.picker::after {
  content: "";
  position: absolute;
  top: var(--space-6);
  margin-top: 10px;
  width: 20px;
  height: 20px;
  background: currentColor;
  mask: var(--icon-image) center / contain no-repeat;
  -webkit-mask: var(--icon-image) center / contain no-repeat;
  pointer-events: none;
  transition: transform var(--dur-base) var(--spring-snappy);
}

.picker:hover::after,
.picker.is-dragging::after {
  transform: translateY(-2px);
}

.picker.is-dragging::after {
  transform: scale(1.08) translateY(-3px);
}

.picker:hover {
  border-color: var(--ring);
  color: var(--primary-soft);
  background: var(--slate-3);
}

.picker:hover::before {
  transform: translateY(-2px);
}

/* Set by js/ui.js on dragenter, cleared on dragleave and drop. */
.picker.is-dragging {
  border-color: var(--primary);
  border-style: solid;
  background: var(--indigo-2);
  color: var(--primary-soft);
}

.picker.is-dragging::before {
  border-color: var(--indigo-7);
  background-color: var(--indigo-3);
  transform: scale(1.08) translateY(-3px);
}

.picker input {
  position: absolute;
  width: 1px;
  height: 1px;
  opacity: 0;
  pointer-events: none;
}

.picker-hint {
  font-weight: 400;
  font-size: var(--text-micro);
  color: var(--slate-9);
}

/* --- image preview ---------------------------------------------------- */

.preview {
  display: flex;
  align-items: center;
  gap: var(--space-4);
  margin-top: var(--space-3);
  padding: var(--space-3);
  background: var(--card);
  border: 1px solid var(--border);
  border-radius: var(--radius-xl);
  animation: rise var(--dur-slow) var(--ease-expressive);
}

.preview img {
  width: 74px;
  height: 74px;
  flex: none;
  /* contain, not cover: the thumbnail keeps the photo's proportions, so the
     preview has to letterbox rather than crop back to a square. */
  object-fit: contain;
  border-radius: var(--radius-lg);
  background: var(--sunken);
  border: 1px solid var(--border);
  /* These are deliberately tiny images; keep the pixels crisp rather than
     letting the browser smooth them into mush at display size. */
  image-rendering: pixelated;
}

.preview-facts {
  min-width: 0;
}

.preview-facts strong {
  display: block;
  font-size: 14px;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
}

.preview-facts small {
  display: block;
  color: var(--muted-foreground);
  font-size: 12px;
  margin-top: var(--space-1);
  line-height: var(--leading-snug);
}

.note {
  margin: var(--space-3) var(--space-0-5) 0;
  color: var(--muted-foreground);
  font-size: 12px;
  line-height: var(--leading-normal);
}

/* --- incoming transfers ----------------------------------------------- */

.xfer {
  background: var(--card);
  border: 1px solid var(--border);
  border-radius: var(--radius-xl);
  padding: var(--space-4);
  margin-top: var(--space-4);
  animation: rise var(--dur-slow) var(--ease-expressive);
}

.xfer-head {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: var(--space-3);
  font-size: var(--text-small);
}

.xfer-head strong {
  font-weight: 600;
}

/* A transfer runs for minutes with nothing between chirps; the dot is the
   only proof it has not stalled. */
.xfer-head strong::before {
  content: "";
  display: inline-block;
  width: 6px;
  height: 6px;
  margin-right: var(--space-2);
  border-radius: var(--radius-full);
  background: var(--primary);
  animation: blip 1.4s var(--ease-standard) infinite;
}

@keyframes blip {
  0%,
  100% {
    opacity: 1;
    box-shadow: 0 0 0 0 rgb(62 99 221 / 0.5);
  }
  50% {
    opacity: 0.4;
    box-shadow: 0 0 0 5px rgb(62 99 221 / 0);
  }
}

.xfer-count {
  color: var(--muted-foreground);
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
  font-size: 12px;
}

.xfer-bar {
  position: relative;
  height: 6px;
  margin-top: var(--space-3);
  background: var(--slate-4);
  border-radius: var(--radius-full);
  overflow: hidden;
}

.xfer-fill {
  position: relative;
  height: 100%;
  width: 0;
  background: linear-gradient(90deg, var(--indigo-8), var(--indigo-10));
  border-radius: var(--radius-full);
  overflow: hidden;
  transition: width var(--dur-slow) var(--ease-expressive);
}

/* Progress that only moves when a packet lands looks frozen in between. The
   sheen keeps the bar alive without faking progress it has not made. */
.xfer-fill::after {
  content: "";
  position: absolute;
  inset: 0;
  background: linear-gradient(
    90deg,
    transparent,
    rgb(255 255 255 / 0.28),
    transparent
  );
  animation: sheen 1.8s linear infinite;
}

@keyframes sheen {
  from {
    transform: translateX(-100%);
  }
  to {
    transform: translateX(100%);
  }
}

.msg-image {
  display: block;
  width: 100%;
  max-width: 220px;
  height: auto;
  border-radius: var(--radius-lg);
  margin-bottom: var(--space-3);
  background: var(--sunken);
  border: 1px solid var(--border);
  image-rendering: pixelated;
  animation: develop var(--dur-slow) var(--ease-decelerate);
}

/* An image that took four minutes to arrive deserves more than appearing. */
@keyframes develop {
  from {
    opacity: 0;
    filter: blur(10px) saturate(0.4);
    transform: scale(0.97);
  }
  to {
    opacity: 1;
    filter: none;
    transform: none;
  }
}

/* --- options ---------------------------------------------------------- */

.opts {
  margin: var(--space-4) 0;
  background: var(--card);
  border: 1px solid var(--border);
  border-radius: var(--radius-xl);
  overflow: hidden;
  transition: border-color var(--dur-fast) var(--ease-standard);
}

.opts[open] {
  border-color: var(--border-strong);
}

.opts > summary {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  padding: var(--space-3) var(--space-4);
  font-size: 14px;
  font-weight: 600;
  cursor: pointer;
  color: var(--muted-foreground);
  list-style: none;
  transition:
    color var(--dur-fast) var(--ease-standard),
    background-color var(--dur-fast) var(--ease-standard);
}

.opts > summary:hover {
  color: var(--foreground);
  background: var(--slate-3);
}

.opts > summary::-webkit-details-marker {
  display: none;
}

.opts > summary::after {
  content: "";
  width: 16px;
  height: 16px;
  margin-left: auto;
  background: currentColor;
  mask: var(--icon-chevron) center / contain no-repeat;
  transition: transform var(--dur-base) var(--ease-expressive);
}

.opts[open] > summary::after {
  transform: rotate(180deg);
}

/* Stagger the rows as the panel opens, rather than dumping four at once. */
.opts[open] > .opt {
  animation: rise var(--dur-base) var(--ease-expressive) backwards;
}

.opts[open] > .opt:nth-of-type(1) { animation-delay: 20ms; }
.opts[open] > .opt:nth-of-type(2) { animation-delay: 50ms; }
.opts[open] > .opt:nth-of-type(3) { animation-delay: 80ms; }
.opts[open] > .opt:nth-of-type(4) { animation-delay: 110ms; }

.opt {
  padding: var(--space-4);
  border-top: 1px solid var(--border);
}

/* The receive panel reuses .opt standalone, where the stacking divider that
   separates rows inside the options card just reads as a stray line. */
section > .opt {
  border-top: 0;
  padding: 0;
  animation: none;
}

.opt-head {
  display: flex;
  align-items: flex-start;
  gap: var(--space-3);
}

.opt-head strong {
  display: block;
  font-size: 14px;
  font-weight: 600;
}

.opt small,
.row-switch small {
  display: block;
  color: var(--muted-foreground);
  font-size: 12px;
  line-height: var(--leading-snug);
  margin-top: var(--space-0-5);
}

.opt-hint {
  margin-top: var(--space-2);
  min-height: 1.4em;
}

.opt-switches {
  display: grid;
  gap: var(--space-4);
}

.row-switch {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-4);
  cursor: pointer;
}

.row-switch strong {
  font-size: 14px;
  font-weight: 600;
}

/* --- switch ----------------------------------------------------------- */

.switch {
  position: relative;
  flex: none;
  width: 44px;
  height: 26px;
  display: inline-block;
}

.switch input {
  position: absolute;
  opacity: 0;
  width: 100%;
  height: 100%;
  margin: 0;
  cursor: pointer;
  z-index: 1;
}

.slider {
  position: absolute;
  inset: 0;
  background: var(--slate-8);
  border-radius: var(--radius-full);
  transition: background-color var(--dur-fast) var(--ease-standard);
  pointer-events: none;
}

.slider::before {
  content: "";
  position: absolute;
  width: 20px;
  height: 20px;
  left: 3px;
  top: 3px;
  background: #fff;
  border-radius: var(--radius-full);
  box-shadow: var(--shadow-sm);
  /* Spring curve: the knob should beat the finger, then settle rather than
     stop dead. */
  transition:
    transform var(--dur-base) var(--spring-snappy),
    width var(--dur-instant) var(--ease-standard);
}

/* Squash on press. Costs nothing, and makes the control feel physical. */
.switch input:active + .slider::before {
  width: 24px;
}

.switch input:checked:active + .slider::before {
  transform: translateX(14px);
}

.switch input:checked + .slider {
  background: var(--primary);
}

.switch input:checked + .slider::before {
  transform: translateX(18px);
}

.switch input:focus-visible + .slider {
  box-shadow: 0 0 0 3px rgb(67 93 177 / 0.45);
}

/* --- chips ------------------------------------------------------------ */

.chips {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2);
}

.chip {
  appearance: none;
  border: 1px solid var(--border);
  background: var(--slate-3);
  color: var(--muted-foreground);
  font: inherit;
  font-size: var(--text-small);
  font-weight: 500;
  padding: 7px 13px;
  border-radius: var(--radius-full);
  cursor: pointer;
  transition:
    background-color var(--dur-fast) var(--ease-standard),
    border-color var(--dur-fast) var(--ease-standard),
    color var(--dur-fast) var(--ease-standard),
    transform var(--dur-instant) var(--ease-standard);
}

.chip:hover {
  border-color: var(--border-strong);
  color: var(--foreground);
}

.chip:active {
  transform: scale(0.96);
}

.chip.is-active {
  background: var(--primary);
  border-color: var(--indigo-10);
  color: var(--primary-foreground);
  box-shadow: 0 0 16px -6px var(--primary);
}

.chip:focus-visible {
  outline: 2px solid var(--ring);
  outline-offset: 2px;
}

/* --- the six-digit code ----------------------------------------------- */
/* App UI › OTP Input, in both directions: the sender shows six cells, the
   receiver types into six cells. The receiver's real <input> is still a single
   field underneath -- js/ui.js mirrors its value into the cells -- because that
   input is what holds the value everything else reads. */

.code-box {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  row-gap: var(--space-3);
  /* Six cells plus the button is 290px of content inside an .opt that is only
     230px wide on a 320px phone. Wrapping puts the button on its own row there
     and leaves it inline everywhere else. */
  flex-wrap: wrap;
  margin-top: var(--space-3);
  padding: var(--space-3);
  background: var(--sunken);
  border: 1px dashed var(--indigo-7);
  border-radius: var(--radius-xl);
}

.code-digits {
  display: flex;
  gap: var(--space-1);
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
}

.code-box .mini {
  margin-left: auto;
}

.otp-cell {
  display: grid;
  place-items: center;
  width: 28px;
  height: 38px;
  font-size: 18px;
  font-weight: 600;
  color: var(--indigo-12);
  background: var(--indigo-3);
  border: 1px solid var(--indigo-7);
  border-radius: var(--radius-lg);
  animation: cell-in var(--dur-base) var(--ease-expressive) backwards;
}

/* Regenerating should read as six new digits, not one new string. */
.otp-cell:nth-child(1) { animation-delay: 0ms; }
.otp-cell:nth-child(2) { animation-delay: 35ms; }
.otp-cell:nth-child(3) { animation-delay: 70ms; }
.otp-cell:nth-child(4) { animation-delay: 105ms; }
.otp-cell:nth-child(5) { animation-delay: 140ms; }
.otp-cell:nth-child(6) { animation-delay: 175ms; }

@keyframes cell-in {
  from {
    opacity: 0;
    transform: translateY(-6px) scale(0.8);
  }
  to {
    opacity: 1;
    transform: none;
  }
}

.otp {
  position: relative;
  margin-top: var(--space-3);
  /* The boxes are built by js/ui.js. If that file is missing the grid collapses
     to nothing and takes the absolutely positioned input with it, leaving no
     way to enter a code at all. */
  min-height: 52px;
}

/* app.js toggles [hidden] on the input itself; the cells are decoration and
   have to follow it. js/ui.js sets .is-hidden for the same reason, so this
   still works on a browser without :has(). */
.otp:has(> .code-input[hidden]),
.otp.is-hidden {
  display: none;
}

.otp-boxes {
  display: grid;
  grid-template-columns: repeat(6, 1fr);
  gap: var(--space-2);
  pointer-events: none;
}

.otp-box {
  display: grid;
  place-items: center;
  height: 52px;
  font-family: var(--font-mono);
  font-size: 22px;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  color: var(--foreground);
  background: var(--input);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  transition:
    border-color var(--dur-fast) var(--ease-standard),
    background-color var(--dur-fast) var(--ease-standard),
    box-shadow var(--dur-fast) var(--ease-standard);
}

.otp-box span {
  display: block;
  animation: digit-in var(--dur-fast) var(--ease-expressive);
}

@keyframes digit-in {
  from {
    opacity: 0;
    transform: translateY(7px) scale(0.8);
  }
  to {
    opacity: 1;
    transform: none;
  }
}

.otp-box.is-filled {
  border-color: var(--border-strong);
  background: var(--slate-3);
}

/* The caret has to live in the cells, because the real input is transparent. */
.otp-box.is-active {
  border-color: var(--ring);
  background: var(--slate-3);
  box-shadow: 0 0 0 3px rgb(67 93 177 / 0.25);
}

/* The caret glides between cells rather than jumping -- the behaviour from
   Controls › Smooth Input, written here rather than taken: that component is
   skiper-ui and must not be redistributed from a public repository.
   One element that travels, not a pseudo-element per cell, because there is
   nothing to animate between two different elements appearing and vanishing. */
.otp-caret {
  position: absolute;
  top: 50%;
  left: 0;
  width: 2px;
  height: 24px;
  margin-top: -12px;
  border-radius: 1px;
  background: var(--primary-soft);
  opacity: 0;
  pointer-events: none;
  transform: translate3d(var(--caret-x, 0), 0, 0);
  transition:
    transform var(--dur-base) var(--spring-snappy),
    opacity var(--dur-fast) var(--ease-standard);
}

.otp-caret.is-on {
  opacity: 1;
  animation: blink 1.1s steps(2, start) infinite;
}

/* Blinking while travelling makes the glide stutter; it resumes on arrival. */
.otp-caret.is-moving {
  animation: none;
}

/* The input keeps every native behaviour -- numeric keypad, paste, selection,
   autofill -- and simply is not painted. */
.code-input {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  margin: 0;
  padding: 0;
  border: 0;
  background: transparent;
  color: transparent;
  caret-color: transparent;
  font: inherit;
  cursor: pointer;
}

.code-input:focus {
  outline: none;
}

.mini {
  appearance: none;
  border: 1px solid var(--border);
  background: var(--slate-3);
  color: var(--muted-foreground);
  font: inherit;
  font-size: 12px;
  font-weight: 500;
  padding: 7px 11px;
  border-radius: var(--radius-md);
  cursor: pointer;
  flex: none;
  transition:
    background-color var(--dur-fast) var(--ease-standard),
    color var(--dur-fast) var(--ease-standard),
    transform var(--dur-instant) var(--ease-standard);
}

.mini:hover {
  background: var(--slate-4);
  color: var(--foreground);
}

.mini:active {
  transform: scale(0.95);
}

/* --- primary button --------------------------------------------------- */
/* Controls › Animated Button: a shine sweeps the label while a matching
   gradient runs the border. One second of motion, one second of rest. */

@property --mask-x {
  syntax: "<percentage>";
  inherits: false;
  initial-value: 100%;
}

.primary {
  position: relative;
  width: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-2);
  appearance: none;
  border: 0;
  border-radius: var(--radius-xl);
  background: var(--primary);
  color: var(--primary-foreground);
  font: inherit;
  font-size: 16px;
  font-weight: 600;
  padding: 15px;
  cursor: pointer;
  overflow: hidden;
  box-shadow: 0 8px 24px -12px var(--primary);
  transition:
    transform var(--dur-instant) var(--ease-standard),
    background-color var(--dur-fast) var(--ease-standard),
    opacity var(--dur-fast) var(--ease-standard),
    box-shadow var(--dur-fast) var(--ease-standard);
}

.primary:hover {
  background: var(--primary-hover);
  box-shadow: 0 10px 28px -10px var(--primary);
}

.primary:active {
  transform: scale(0.985);
}

.primary:focus-visible {
  outline: 2px solid var(--primary-soft);
  outline-offset: 2px;
}

/* Icon. A mask rather than an inline <svg> because app.js rewrites the
   button's text content, which would take a real child node with it. */
.primary::before {
  content: "";
  width: 18px;
  height: 18px;
  flex: none;
  background: currentColor;
  mask: var(--btn-icon, none) center / contain no-repeat;
  transition: transform var(--dur-base) var(--spring-snappy);
}

/* Border run. */
.primary::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  padding: 1px;
  background: linear-gradient(
    -75deg,
    transparent 30%,
    rgb(255 255 255 / 0.66) 50%,
    transparent 70%
  );
  background-size: 200% 100%;
  -webkit-mask:
    linear-gradient(#fff 0 0) content-box,
    linear-gradient(#fff 0 0);
  -webkit-mask-composite: xor;
  mask:
    linear-gradient(#fff 0 0) content-box,
    linear-gradient(#fff 0 0);
  mask-composite: exclude;
  pointer-events: none;
  opacity: 0;
  animation: border-run 2s linear infinite;
}

@keyframes border-run {
  0% {
    background-position: 100% 0;
    opacity: 0;
  }
  25% {
    opacity: 1;
  }
  50% {
    background-position: 0 0;
    opacity: 0;
  }
  100% {
    background-position: 0 0;
    opacity: 0;
  }
}

#send-label {
  -webkit-mask-image: linear-gradient(
    -75deg,
    #fff calc(var(--mask-x) + 20%),
    transparent calc(var(--mask-x) + 30%),
    #fff calc(var(--mask-x) + 100%)
  );
  mask-image: linear-gradient(
    -75deg,
    #fff calc(var(--mask-x) + 20%),
    transparent calc(var(--mask-x) + 30%),
    #fff calc(var(--mask-x) + 100%)
  );
  animation: label-shine 2s linear infinite;
}

@keyframes label-shine {
  0% {
    --mask-x: 100%;
  }
  50% {
    --mask-x: -100%;
  }
  100% {
    --mask-x: -100%;
  }
}

/* The shine exists to pull attention to an idle call-to-action. Once the
   button is doing the thing, it is noise. */
.primary:disabled::after,
.primary.is-busy::after,
.primary.is-listening::after {
  animation: none;
}

.primary:disabled #send-label,
.primary.is-busy #send-label {
  animation: none;
  -webkit-mask-image: none;
  mask-image: none;
}

.primary:disabled {
  opacity: 0.45;
  cursor: default;
  box-shadow: none;
}

.primary:disabled:hover {
  background: var(--primary);
}

.primary.is-busy {
  background: var(--indigo-4);
  color: var(--indigo-12);
  box-shadow: none;
}

.primary.is-listening {
  background: var(--success);
  color: #06231a;
  box-shadow: 0 8px 24px -12px var(--success);
}

.primary.is-listening:hover {
  background: var(--grass-10);
}

/* --- button icons ----------------------------------------------------- */

#send-btn {
  --btn-icon: var(--icon-volume);
}

#send-btn.is-busy {
  --btn-icon: var(--icon-stop);
}

#listen-btn {
  --btn-icon: var(--icon-mic);
}

/* A live microphone should look live. */
#listen-btn.is-listening::before {
  animation: mic-throb 1.5s var(--ease-standard) infinite;
}

@keyframes mic-throb {
  0%,
  100% {
    transform: scale(1);
    opacity: 0.8;
  }
  50% {
    transform: scale(1.14);
    opacity: 1;
  }
}

/* --- scope ------------------------------------------------------------ */

.scope {
  width: 100%;
  height: 72px;
  display: none;
  margin-bottom: var(--space-4);
  background: linear-gradient(180deg, var(--slate-2), var(--sunken));
  border: 1px solid var(--border);
  border-radius: var(--radius-xl);
}

.scope.is-on {
  display: block;
  animation: rise var(--dur-base) var(--ease-expressive);
}

/* --- status ----------------------------------------------------------- */

.status {
  margin: var(--space-3) var(--space-0-5) 0;
  font-size: var(--text-small);
  color: var(--muted-foreground);
  text-align: center;
  min-height: 1.5em;
  transition: color var(--dur-fast) var(--ease-standard);
}

.status.good {
  color: var(--success-text);
}

.status.bad {
  color: var(--danger-text);
}

.status.warn {
  color: var(--warning-text);
}

/* --- listening -------------------------------------------------------- */

.listen-wrap {
  margin: var(--space-6) 0;
}

/* Media › Music Player's equalizer, applied to a real signal: the comb mask
   turns one bar into segments, so the meter reads as a level rather than as
   progress towards something. */
.level {
  height: 12px;
  background: var(--slate-5);
  margin-top: var(--space-3);
  -webkit-mask: repeating-linear-gradient(
    90deg,
    #000 0 4px,
    transparent 4px 7px
  );
  mask: repeating-linear-gradient(90deg, #000 0 4px, transparent 4px 7px);
  /* A dead meter under an un-pressed button reads as debris. It only earns
     full contrast once there is a signal to show. */
  opacity: 0.4;
  transition: opacity var(--dur-base) var(--ease-standard);
}

#listen-btn.is-listening + .level {
  opacity: 1;
}

.level-fill {
  height: 100%;
  width: 0;
  background: linear-gradient(
    90deg,
    var(--success) 0%,
    var(--success) 55%,
    var(--warning) 78%,
    var(--danger) 100%
  );
  /* Sized to the track, not to the fill, so a segment keeps its colour as the
     level rises instead of the whole gradient rescaling under it. */
  background-size: 100vw 100%;
  transition: width 80ms linear;
}

/* --- inbox ------------------------------------------------------------ */

.inbox {
  display: grid;
  gap: var(--space-3);
  margin-top: var(--space-6);
  /* Only bites once several messages have arrived; below that the page scrolls
     as it always did. */
  max-height: min(60vh, 520px);
  overflow-y: auto;
  scrollbar-width: thin;
  scrollbar-color: var(--slate-7) transparent;
}

/* The fade only means anything where content is actually cut off, so js/ui.js
   drives each edge from the scroll position. At 0 the stop collapses onto the
   edge and that side stays hard. */
.inbox.is-clipped {
  --fade: 28px;
  -webkit-mask-image: linear-gradient(
    to bottom,
    transparent 0,
    #000 calc(var(--fade-t, 0) * var(--fade)),
    #000 calc(100% - var(--fade-b, 0) * var(--fade)),
    transparent 100%
  );
  mask-image: linear-gradient(
    to bottom,
    transparent 0,
    #000 calc(var(--fade-t, 0) * var(--fade)),
    #000 calc(100% - var(--fade-b, 0) * var(--fade)),
    transparent 100%
  );
}

/* App UI › Empty & Error States -- icon tile, dashed frame, one line of body. */
.empty {
  position: relative;
  display: grid;
  justify-items: center;
  text-align: center;
  color: var(--muted-foreground);
  font-size: var(--text-small);
  padding: var(--space-10) var(--space-6);
  margin: 0;
  border: 1px dashed var(--border);
  border-radius: var(--radius-xl);
}

.empty::before {
  content: "";
  width: 44px;
  height: 44px;
  margin-bottom: var(--space-4);
  border-radius: var(--radius-xl);
  border: 1px solid var(--border);
  background: var(--muted);
}

.empty::after {
  content: "";
  position: absolute;
  top: calc(var(--space-10) + 12px);
  width: 20px;
  height: 20px;
  background: currentColor;
  mask: var(--icon-inbox) center / contain no-repeat;
  -webkit-mask: var(--icon-inbox) center / contain no-repeat;
}

.msg {
  position: relative;
  background: var(--card);
  border: 1px solid var(--border);
  border-radius: var(--radius-xl);
  padding: var(--space-4);
  overflow: hidden;
  animation: rise var(--dur-slow) var(--ease-expressive);
  transition: border-color var(--dur-base) var(--ease-standard);
}

/* Controls › Spotlight Card. js/ui.js writes --mx/--my from the pointer; on a
   phone there is no pointer, so this simply never lights up. */
.msg::before {
  content: "";
  position: absolute;
  inset: 0;
  pointer-events: none;
  opacity: 0;
  background: radial-gradient(
    320px circle at var(--mx, 50%) var(--my, 50%),
    rgb(62 99 221 / 0.10),
    transparent 70%
  );
  transition: opacity var(--dur-base) var(--ease-standard);
}

.msg:hover {
  border-color: var(--border-strong);
}

.msg:hover::before {
  opacity: 1;
}

.msg > * {
  position: relative;
}

@keyframes rise {
  from {
    opacity: 0;
    transform: translateY(10px);
  }
  to {
    opacity: 1;
    transform: none;
  }
}

.msg-body {
  margin: 0;
  font-size: var(--text-body);
  word-break: break-word;
  white-space: pre-wrap;
  min-height: 1.5em;
}

.msg-body a {
  color: var(--primary-soft);
  text-decoration-color: var(--indigo-8);
  text-underline-offset: 3px;
  transition: text-decoration-color var(--dur-fast) var(--ease-standard);
}

.msg-body a:hover {
  text-decoration-color: var(--primary-soft);
}

.caret::after {
  content: "▌";
  color: var(--primary-soft);
  animation: blink 0.9s steps(2) infinite;
}

@keyframes blink {
  50% {
    opacity: 0;
  }
}

.msg-meta {
  display: flex;
  gap: var(--space-2);
  align-items: center;
  margin-top: var(--space-3);
  font-size: var(--text-micro);
  color: var(--muted-foreground);
  font-variant-numeric: tabular-nums;
}

/* Relative time carries no absolute reference, so the exact instant lives in
   the title attribute rather than being lost. */
.msg-meta time {
  cursor: help;
  text-decoration: underline dotted transparent;
  text-underline-offset: 3px;
  transition: text-decoration-color var(--dur-fast) var(--ease-standard);
}

.msg-meta time:hover {
  text-decoration-color: var(--slate-8);
}

.tag {
  border: 1px solid var(--border);
  border-radius: var(--radius-full);
  padding: 2px 8px;
  font-weight: 500;
}

.tag.locked {
  color: var(--success-text);
  border-color: rgb(70 167 88 / 0.4);
  background: rgb(70 167 88 / 0.1);
}

.tag.failed {
  color: var(--danger-text);
  border-color: rgb(229 72 77 / 0.4);
  background: rgb(229 72 77 / 0.1);
}

.copy {
  margin-left: auto;
  appearance: none;
  border: 1px solid var(--border);
  background: var(--slate-3);
  color: var(--muted-foreground);
  font: inherit;
  font-size: var(--text-micro);
  font-weight: 500;
  padding: 4px 10px;
  border-radius: var(--radius-md);
  cursor: pointer;
  text-decoration: none;
  transition:
    background-color var(--dur-fast) var(--ease-standard),
    color var(--dur-fast) var(--ease-standard);
}

.copy:hover {
  background: var(--slate-4);
  color: var(--foreground);
}

/* --- footer ----------------------------------------------------------- */

.foot {
  margin-top: var(--space-10);
  text-align: center;
}

.foot p {
  color: var(--slate-9);
  font-size: 12px;
  line-height: var(--leading-normal);
  margin: 0;
}

/* --- confetti --------------------------------------------------------- */

#confetti {
  position: fixed;
  inset: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
  z-index: 50;
  display: none;
}

#confetti.is-on {
  display: block;
}

/* --- odometer --------------------------------------------------------- */
/* Text › Animated Number. One digit per cell, and only a cell whose digit
   changed animates -- see the note in js/ui.js for why this is not the
   library's strip-of-ten implementation. */

.odo {
  display: inline-block;
}

.odo.is-rolling {
  animation: odo-roll var(--dur-base) var(--ease-expressive);
}

/* No clipping mask: at 11px the travel is under 6px, and a mask would push the
   inline-block's baseline to its bottom edge and drop the row out of alignment
   with the text beside it. */
@keyframes odo-roll {
  from {
    opacity: 0;
    transform: translateY(calc(var(--roll, 1) * 0.55em));
  }
  to {
    opacity: 1;
    transform: none;
  }
}

/* --- icons ------------------------------------------------------------ */
/* Lucide paths, the set the library's components draw from. Inlined as masks
   so a button's icon follows its text colour and can be swapped by a class
   without touching the DOM. The two `-dim` variants are backgrounds rather
   than masks, so they carry their own stroke colour. */

:root {
  --icon-volume: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M11 4.7a.7.7 0 0 0-1.2-.5L6.4 7.6a1.4 1.4 0 0 1-1 .4H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.4a1.4 1.4 0 0 1 1 .4l3.4 3.4a.7.7 0 0 0 1.2-.5z'/%3E%3Cpath d='M16 9a5 5 0 0 1 0 6'/%3E%3Cpath d='M19.4 5.6a9 9 0 0 1 0 12.7'/%3E%3C/svg%3E");
  --icon-stop: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23000'%3E%3Crect x='6' y='6' width='12' height='12' rx='2'/%3E%3C/svg%3E");
  --icon-mic: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z'/%3E%3Cpath d='M19 10v2a7 7 0 0 1-14 0v-2'/%3E%3Cpath d='M12 19v3'/%3E%3C/svg%3E");
  --icon-chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
  --icon-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='3' width='18' height='18' rx='2'/%3E%3Ccircle cx='9' cy='9' r='2'/%3E%3Cpath d='m21 15-3.1-3.1a2 2 0 0 0-2.8 0L6 21'/%3E%3C/svg%3E");
  --icon-inbox: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M22 12h-6l-2 3h-4l-2-3H2'/%3E%3Cpath d='M5.4 5.1 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.4-6.9A2 2 0 0 0 16.8 4H7.2a2 2 0 0 0-1.8 1.1z'/%3E%3C/svg%3E");
}

/* --- reduced motion --------------------------------------------------- */

@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.001ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.001ms !important;
  }

  /* Ambient loops have no useful end frame to snap to, so they are removed
     outright rather than collapsed to 0.001ms. */
  .primary::after,
  #send-label,
  .xfer-fill::after,
  .xfer-head strong::before,
  .head-mark,
  .otp-caret.is-on,
  .odo.is-rolling,
  #listen-btn.is-listening::before {
    animation: none !important;
  }

  #send-label {
    -webkit-mask-image: none;
    mask-image: none;
  }
}
