:root {
  --green-light: #eefcc7;
  --green-med: #c6da84;
  --green-splat: #80bd3e;
  --ink: #000000;
  --ink-dim: rgba(0, 0, 0, 0.55);
  --white: #ffffff;
  --danger: #e5533d;
  --tag-error: #fd8122;
  --swipe-reveal: 88px;

  --font-display: "SLIME PERSONAL USE", "Chalkboard SE", "Marker Felt", ui-rounded, system-ui, sans-serif;
  --font-body: "Sharp Grotesk", -apple-system, "Helvetica Neue", Arial, sans-serif;

  /* Shared motion curves so every transition in the app feels like it belongs to
     the same hand, instead of each one picking its own generic "ease". */
  --ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
  --ease-out: cubic-bezier(0, 0, 0.2, 1);
  --ease-bounce: cubic-bezier(0.34, 1.56, 0.64, 1);
  --ease-sheet: cubic-bezier(0.32, 0.72, 0, 1);
}

@font-face {
  font-family: "SLIME PERSONAL USE";
  src: url("fonts/SLIME-Personal-Use.ttf") format("truetype");
  font-weight: 700;
  font-style: normal;
  font-display: swap;
}

@font-face {
  font-family: "Sharp Grotesk";
  src: url("fonts/SharpGrotesk-Medium15.otf") format("opentype");
  font-weight: 500;
  font-style: normal;
  font-display: swap;
}

* { box-sizing: border-box; }

/* iOS Safari shows a default gray tap-highlight rectangle over any button that
   doesn't disable it — most noticeable on the circular record button, where its
   real (rectangular) hit-box flashes as a square over the visually round button. */
button {
  -webkit-tap-highlight-color: transparent;
  /* Consistent across every button in the app rather than only the ones that
     happened to need it fixed first (previously just .record-btn) — without it, a
     mistimed rapid double-tap on any other button (menu items, login, delete, ...)
     can trigger the browser's double-tap-to-zoom instead of two normal taps. */
  touch-action: manipulation;
}

.hidden { display: none; }

html, body {
  height: 100%;
}

body {
  margin: 0;
  color: var(--ink);
  font-family: var(--font-body);
  display: flex;
  justify-content: center;
  background: var(--green-splat);
  overflow: hidden;
}

/* Registering these as typed <color> custom properties (rather than animating the
   `background` shorthand / a gradient function directly) is what makes the color
   cycling below actually interpolate smoothly frame-by-frame — browsers have no
   defined way to blend between two different gradient functions, so animating
   `background: radial-gradient(...)` directly either snaps between keyframe values
   or interpolates in a visibly jagged, discontinuous way. A registered <color>
   property blends channel-by-channel like any normal color transition; the gradient
   shape/stops around it never change, only this color does. */
@property --blob-color-a {
  syntax: "<color>";
  inherits: false;
  initial-value: #80bd3e;
}

@property --blob-color-b {
  syntax: "<color>";
  inherits: false;
  initial-value: #7bb0bd;
}

/* Watercolor background: two large, softly blurred blobs drift and shift hue
   independently, so at most two soft color spots bleed into the base at once.
   A bigger blur plus a longer, gentler fade-to-transparent (vs. the previous
   sharper 68% cutoff) is what makes the spots read as soaking into the base color
   instead of sitting on top of it as a distinct colored patch. */
.bg-blob {
  position: fixed;
  filter: blur(60px);
  opacity: 0.85;
  pointer-events: none;
  z-index: 0;
  will-change: transform;
}

.bg-blob-a {
  width: 55vmax;
  height: 55vmax;
  top: -12vmax;
  left: -12vmax;
  background: radial-gradient(circle, var(--blob-color-a) 0%, var(--blob-color-a) 35%, transparent 85%);
  animation: blobA-move 42s ease-in-out infinite, blobA-color 30s ease-in-out infinite;
}

.bg-blob-b {
  width: 48vmax;
  height: 48vmax;
  bottom: -12vmax;
  right: -12vmax;
  background: radial-gradient(circle, var(--blob-color-b) 0%, var(--blob-color-b) 35%, transparent 85%);
  animation: blobB-move 36s ease-in-out infinite, blobB-color 34s ease-in-out infinite;
}

@keyframes blobA-move {
  0%   { transform: translate(0, 0); }
  25%  { transform: translate(14vw, 8vh); }
  50%  { transform: translate(3vw, 18vh); }
  75%  { transform: translate(-10vw, 5vh); }
  100% { transform: translate(0, 0); }
}

@keyframes blobA-color {
  0%   { --blob-color-a: #80bd3e; }
  33%  { --blob-color-a: #6cbda3; }
  66%  { --blob-color-a: #9fb87f; }
  100% { --blob-color-a: #80bd3e; }
}

@keyframes blobB-move {
  0%   { transform: translate(0, 0); }
  30%  { transform: translate(-12vw, -10vh); }
  60%  { transform: translate(-3vw, -20vh); }
  100% { transform: translate(0, 0); }
}

@keyframes blobB-color {
  0%   { --blob-color-b: #7bb0bd; }
  40%  { --blob-color-b: #c6da84; }
  70%  { --blob-color-b: #74bd6c; }
  100% { --blob-color-b: #7bb0bd; }
}

.app {
  width: 100%;
  max-width: 480px;
  height: 100vh;
  height: 100dvh;
  /* Kept in sync with the real visual viewport by app.js — see syncAppHeight().
     Falls back to the dvh value above until JS runs, and again if visualViewport
     isn't supported at all. */
  height: var(--app-height, 100dvh);
  display: flex;
  flex-direction: column;
  position: relative;
  z-index: 1;
  /* .record-splat (oversized so it reads as a big soft blob) is absolutely
     positioned inside here — without clipping, its overflow inflates the
     document's scrollable area and the whole page scrolls the header out of
     view the instant it appears. */
  overflow: hidden;
  transition: background-color 0.45s var(--ease-standard);
}

/* Matches Figma "3 - Record Note" (and, identically, "2 - Start Voice Note" and the
   splash screen): flat light green, not the darker animated watercolor gradient
   the rest of the app sits on. On a wide (desktop) viewport, the rest of the app is
   deliberately boxed into a phone-width card — but that card was clipping most of
   .record-splat and leaving the darker gradient visible in the margins on either
   side. Dropping the max-width here lets these screens go full-bleed instead, so
   the light green fill reaches every edge and the splat isn't cut off. Applied
   whenever #recordSplat is showing in any of its states (splash, "vomit here", or
   recording) — see app.js. */
.app.recording,
.app.backdrop-active {
  background-color: var(--green-light);
  max-width: none;
  width: 100%;
}

.topbar {
  flex-shrink: 0;
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: calc(20px + env(safe-area-inset-top)) 20px 8px;
  z-index: 5;
  opacity: 1;
  transition: opacity 0.3s var(--ease-standard);
}

/* Matches Figma "3 - Record Note" / "2 - Start Voice Note" / "0 - Splash Screen":
   none of the three have a header at all — all fade it out rather than leaving it
   floating over the splat backdrop. .at-splash is baked into the HTML by default
   (matching splash being visible from the very first paint); app.js removes it
   once splash dismisses, handing off to whichever of .recording / .at-vomit (or
   neither, revealing the header for a normal populated list) comes next. */
.topbar.recording,
.topbar.at-vomit,
.topbar.at-splash {
  opacity: 0;
  pointer-events: none;
}

.logo {
  display: flex;
  align-items: baseline;
  gap: 10px;
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 32px;
  letter-spacing: -1.28px;
  line-height: 1;
  color: var(--ink);
}

.logo-word,
.logo-vomit {
  /* These are tap targets (see app.js tapTiltWord), not <button>s, so they miss the
     global `button { -webkit-tap-highlight-color: transparent; }` rule above and
     iOS Safari shows its default gray highlight rectangle over them on tap. */
  -webkit-tap-highlight-color: transparent;
}

.logo-word {
  display: inline-block;
  transform: rotate(-5.58deg);
  transition: transform 0.25s var(--ease-bounce);
}

.logo-vomit {
  display: inline-block;
  transform: rotate(2.83deg);
  transition: transform 0.25s var(--ease-bounce);
}

/* Mouse/trackpad only — touch devices have no real hover state to un-tilt from.
   Each word tilts only when hovered individually, not both at once when hovering
   the shared logo container. */
@media (hover: hover) and (pointer: fine) {
  .logo-word:hover {
    transform: rotate(-14deg);
  }

  .logo-vomit:hover {
    transform: rotate(10deg);
  }
}

/* Same tilt, triggered spontaneously on a timer (see app.js) instead of by hover —
   applies on every device, touch included. */
.logo-word.auto-tilt {
  transform: rotate(-14deg);
}

.logo-vomit.auto-tilt {
  transform: rotate(10deg);
}

.icon-btn-plain {
  background: none;
  border: none;
  /* 24px icon + 10px padding = 44px hit area, the iOS HIG minimum tap target —
     was 6px (36px total). */
  padding: 10px;
  margin: 0;
  cursor: pointer;
  line-height: 0;
}

.icon-btn-plain img {
  width: 24px;
  height: 24px;
  display: block;
}

.notes-feed-wrap {
  position: relative;
  flex: 1;
  min-height: 0;
  overflow: hidden;
}

.notes-feed {
  height: 100%;
  overflow-y: auto;
  /* Bottom padding clears the floating composer overlaid on top of it, so the last
     bubble can still be scrolled fully into view above the button/label instead of
     stopping at a hard boundary. */
  padding: 16px 20px calc(200px + env(safe-area-inset-bottom));
  /* Base transition for when app.js clears its own inline "transition: none" (used
     during live drag tracking) — without this the pull-to-refresh release snapped
     back with no animation at all. */
  transition: transform 0.3s var(--ease-out);
  will-change: transform;
  /* Rather than a flat panel behind the composer to keep it legible over scrolled
     content, fade the list itself out as it nears the bottom — the last bubble
     recedes into the shared background instead of butting up against the button. */
  -webkit-mask-image: linear-gradient(to bottom, black calc(100% - 56px), transparent 100%);
  mask-image: linear-gradient(to bottom, black calc(100% - 56px), transparent 100%);
}

/* With typing off, the composer has no gradient of its own left to fade into
   (see .composer.typing-disabled::before) — so the list shouldn't fade either;
   it can just read flat all the way to the last bubble. */
.app.typing-disabled .notes-feed {
  -webkit-mask-image: none;
  mask-image: none;
}

/* Pull-to-refresh (touch devices only — driven entirely by touch events in app.js).
   Sits behind the scrollable list and is revealed as the list is dragged down.
   Matches Figma "7 - List View - Pull to Refresh": slime drips down from the very
   top edge instead of a plain circular spinner, with the refresh-cw icon sitting
   within the drip. */
.pull-indicator {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 90px;
  overflow: hidden;
  z-index: 0;
  opacity: 0;
  pointer-events: none;
  transform: translateY(-90px);
  transition: transform 0.3s var(--ease-out), opacity 0.3s var(--ease-out);
}

.pull-drip {
  position: absolute;
  /* Narrower than the container (rather than the old edge-to-edge 140%) so the
     blob's own rounded, dripping silhouette — with the page's background showing
     around it — is what actually reads here, instead of a full-bleed solid fill
     that was nearly indistinguishable from the page's own green background. */
  top: -10px;
  left: 50%;
  width: 70%;
  max-width: none;
  transform: translateX(-50%);
  pointer-events: none;
  filter: drop-shadow(0 3px 8px rgba(0, 0, 0, 0.3));
}

.pull-spinner {
  position: absolute;
  top: 56px;
  left: 50%;
  width: 24px;
  height: 24px;
  transform: translateX(-50%);
  display: block;
}

.pull-indicator.refreshing .pull-spinner {
  animation: pull-spin 0.7s linear infinite;
}

@keyframes pull-spin {
  from { transform: translateX(-50%) rotate(0deg); }
  to { transform: translateX(-50%) rotate(360deg); }
}

.bubble-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 14px;
}

/* Matches Figma "list item, vibe check": no card/bubble background (unlike a real
   note) — sits directly on the page, generated once every 10 real notes and
   persisted from then on (see app.js). Zero horizontal padding is deliberate and
   confirmed against Figma: the vibe-check container shares the exact same x/width
   as a regular note card's own OUTER edge (both list-item variants sit flush in
   the same 361px-wide slot) — it's flush with where the card itself starts, not
   with the card's inset text (which sits 16px further right because of the card's
   own padding). Each word of the big title gets its own small random tilt at
   render time rather than fixed per-word degrees, since the title itself varies in
   word count from one vibe check to the next. */
.vibe-check-item {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 16px;
  padding: 24px 0;
  width: 100%;
  touch-action: pan-y;
  animation: vibe-check-in 0.5s var(--ease-bounce) both;
  /* Matches .note-bubble's own transition — set so the same attachSwipeToDelete()
     swipe-and-snap-back mechanic used for real notes works identically here. */
  transition: transform 0.25s var(--ease-out);
}

@keyframes vibe-check-in {
  0% { opacity: 0; transform: translateY(-16px) scale(0.92); }
  100% { opacity: 1; transform: translateY(0) scale(1); }
}

.vibe-check-label {
  width: 100%;
  margin: 0;
  text-align: left;
  font-family: var(--font-body);
  font-size: 14px;
  color: var(--ink);
}

/* header sm (Figma Style Guide), not header lg — the vibe-check title reads as a
   line of regular header text, not its own oversized display headline. */
.vibe-check-title {
  display: flex;
  flex-wrap: wrap;
  column-gap: 10px;
  row-gap: 4px;
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 32px;
  letter-spacing: -1.28px;
  line-height: 1;
  color: var(--ink);
}

/* Same hover/tap tilt mechanic as .logo-word/.logo-vomit (see the header logo
   rules above and tapTiltWord() in app.js): each word rests at its own small
   random tilt (--tilt, set inline per word since the word count varies per vibe
   check) and swings further into --tilt-hover on hover or tap, rather than a
   fixed inline transform that CSS couldn't ever override. */
.vibe-check-title span {
  display: inline-block;
  transform: rotate(var(--tilt, 0deg));
  transition: transform 0.25s var(--ease-bounce);
  -webkit-tap-highlight-color: transparent;
}

@media (hover: hover) and (pointer: fine) {
  .vibe-check-title span:hover {
    transform: rotate(var(--tilt-hover, 0deg));
  }
}

.vibe-check-title span.auto-tilt {
  transform: rotate(var(--tilt-hover, 0deg));
}

.vibe-check-subtitle {
  margin: 0;
  font-family: var(--font-body);
  font-size: 14px;
  color: var(--ink);
}

/* Matches Figma "3 - Record Note": recording navigates away from the list entirely
   (see .record-splat below and .composer.recording) rather than just dimming it, so
   the list fully hides instead of staying at partial opacity.
   The opacity transition lives here on the wrap, not on .bubble-list/.empty-state —
   those are children of .notes-feed, which has a -webkit-mask-image (the fade-to-
   transparent bottom edge). Animating opacity on masked content triggered a Safari
   compositing bug on real iOS devices: a jagged/scalloped outline flashed around
   each note card for the first couple of frames of the fade. Animating opacity one
   level up, outside the masked element, avoids it entirely. */
.notes-feed-wrap {
  transition: opacity 0.3s var(--ease-standard);
}

/* .backdrop-active covers the splash and "vomit here" screens the same way
   .recording covers the actual recording flow — all three replace the list
   entirely rather than just dimming it (see the comment above), and all three
   need the list actually hidden, not just visually covered: without this it was
   still sitting there at full opacity underneath the splat, showing straight
   through every transparent gap in that image (it's a hand-drawn blob shape, not
   a solid rectangle) and around whatever the splat/content doesn't happen to
   cover. Baked onto .notes-feed-wrap by default in the HTML (matching splash
   being visible from the very first paint), then handed off by app.js from there. */
.notes-feed-wrap.recording,
.notes-feed-wrap.backdrop-active {
  opacity: 0;
  pointer-events: none;
}

/* Matches Figma "2 - Start Voice Note": replaces the ordinary list/header/composer
   chrome entirely whenever there are zero notes yet, rather than just showing a
   line of empty-state text in the list area. No background or splat of its own —
   the shared #recordSplat (see .record-splat.at-vomit) and .app's own light-green
   fill provide the backdrop. This section only owns the "vomit here" text + arrow;
   the button is the real #recordBtn, repositioned here by .composer.at-vomit
   instead of a second, separate button. Nothing in here is interactive. */
.vomit-here-screen {
  position: absolute;
  inset: 0;
  z-index: 6;
  pointer-events: none;
  opacity: 1;
  transition: opacity 0.3s var(--ease-standard);
}

.vomit-here-screen.hidden {
  opacity: 0;
}

/* Positioned to sit just above wherever .composer.at-vomit puts the button (42%
   up from the bottom, ~106px tall) — matches the old flex-column gap between the
   two when they were one group, now that they're separate elements. */
.vomit-here-content {
  position: absolute;
  left: 50%;
  bottom: calc(42% + 130px);
  transform: translateX(-50%);
  display: flex;
  flex-direction: column;
  align-items: center;
}

.vomit-here-heading {
  margin: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 64px;
  letter-spacing: -2.56px;
  color: var(--ink);
  line-height: 1.1;
}

.vomit-here-vomit {
  transform: rotate(-16deg);
}

.vomit-here-here {
  transform: rotate(-5deg);
}

.vomit-here-arrow {
  position: absolute;
  top: 130px;
  left: calc(50% + 55px);
  width: 36px;
  height: auto;
  transform: scaleY(-1) rotate(10deg);
  pointer-events: none;
}

.note-swipe-wrap {
  position: relative;
  border-radius: 24px;
  overflow: hidden;
}

/* Hidden until an actual swipe drag reveals it (see the `.revealed` class, toggled
   by attachSwipeToDelete() in app.js). A real note's own opaque .note-bubble
   background used to be the only thing keeping this red layer out of view — fine
   for notes, but .vibe-check-item deliberately has no card background of its own
   (see its comment in style.css), so this bg showed straight through it on every
   render instead of only while swiping. Explicit opacity gating fixes both. */
.note-swipe-bg {
  position: absolute;
  inset: 0;
  background: var(--danger);
  display: flex;
  align-items: center;
  justify-content: flex-end;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.15s var(--ease-standard);
}

.note-swipe-bg.revealed {
  opacity: 1;
  pointer-events: auto;
}

.swipe-trash-btn {
  width: var(--swipe-reveal);
  height: 100%;
  border: none;
  background: none;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
}

.swipe-trash-btn img {
  width: 22px;
  height: 22px;
  filter: invert(1);
}

.note-bubble {
  position: relative;
  background: var(--green-light);
  border-radius: 24px;
  padding: 24px 16px 20px 16px;
  touch-action: pan-y;
  /* Base transition for when app.js clears its own inline "transition: none" (used
     during live drag tracking) — without this the swipe snap had no animation. */
  transition: transform 0.25s var(--ease-out);
  /* No permanent will-change here — app.js only sets it (inline) for the duration of
     an actual horizontal swipe drag. Promoting every rendered row to its own GPU
     layer at all times caused a real bug on iOS Safari: the red .note-swipe-bg
     underneath would flash through for a frame during fast vertical scrolling,
     because the browser was racing to (re)composite that many permanently-promoted
     layers against the scroll. Layer promotion is cheap to add/remove on demand, so
     scope it to just the interaction that needs it. */
}

.note-text {
  margin: 0;
  font-size: 18px;
  line-height: 1.3;
  color: var(--ink);
  white-space: pre-wrap;
}

.note-text.empty {
  color: var(--ink-dim);
  font-style: italic;
}

/* Matches the Figma "list item" component: both variants share this same
   secondary-text style — "error" notes additionally prepend an orange bracketed
   tag (see .note-tag-label) before the " • timestamp". */
.note-time {
  display: block;
  margin-top: 10px;
  font-size: 14px;
  color: var(--green-splat);
}

/* Tap (touch) or hover (mouse/trackpad) reveals a custom tooltip explaining the
   error and how to avoid it next time — see .note-tag-tooltip and the click
   handler in app.js. No native `title` attribute and no `cursor: help` question
   mark: a plain pointer/no-cursor-change reads better on a device that can't
   hover anyway, and the custom tooltip is the only affordance either way. */
.note-tag-label {
  display: inline-block;
  position: relative;
  color: var(--tag-error);
  cursor: default;
  /* Padding + matching negative margin grows the actual hit area (this is a tap
     target — see the click handler on it in app.js) without shifting where it
     visually sits next to the timestamp beside it. Doesn't reach the full 44px
     guideline (there's no room to without overlapping neighboring text), but a
     real improvement over the bare, unpadded text it was. */
  padding: 4px 6px;
  margin: -4px -6px;
  /* Not a <button>, so it doesn't inherit the global button rule — same iOS
     tap-highlight-rectangle issue that rule exists to prevent everywhere else. */
  -webkit-tap-highlight-color: transparent;
}

.note-tag-tooltip {
  position: absolute;
  bottom: calc(100% + 8px);
  left: 0;
  z-index: 5;
  width: max-content;
  max-width: 220px;
  padding: 8px 10px;
  border-radius: 10px;
  background: var(--ink);
  color: var(--white);
  font-size: 12px;
  line-height: 1.4;
  font-weight: 400;
  text-align: left;
  white-space: normal;
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
  transition: opacity 0.15s var(--ease-standard);
}

.note-tag-label.tooltip-open .note-tag-tooltip {
  opacity: 1;
  visibility: visible;
}

/* Mouse/trackpad only — touch devices have no real hover state (that's what the
   tap-to-toggle .tooltip-open class above is for instead). */
@media (hover: hover) and (pointer: fine) {
  .note-tag-label:hover .note-tag-tooltip,
  .note-tag-label:focus-visible .note-tag-tooltip {
    opacity: 1;
    visibility: visible;
  }
}

.note-delete {
  display: none;
  position: absolute;
  top: 6px;
  right: 6px;
  align-items: center;
  justify-content: center;
  /* Was 30x30 — under the 44px guideline. Hover-gated to mouse/trackpad (see the
     media query below), where pointer precision matters less than on touch, but
     still worth sizing up rather than leaving a needlessly small target. */
  width: 36px;
  height: 36px;
  background: none;
  border: none;
  border-radius: 10px;
  cursor: pointer;
  padding: 9px;
  transition: background-color 0.15s var(--ease-standard);
}

.note-delete img {
  width: 18px;
  height: 18px;
}

.note-delete:hover { background: rgba(0, 0, 0, 0.08); }

/* Non-touch devices (mouse/trackpad): a trash icon instead of the swipe gesture,
   shown only while hovering (or keyboard-focusing) that specific row — not
   persistently on every row at once. */
@media (hover: hover) and (pointer: fine) {
  .note-swipe-wrap:hover .note-delete,
  .note-swipe-wrap:focus-within .note-delete {
    display: flex;
  }
  .note-bubble,
  .vibe-check-item {
    padding-right: 44px;
  }
  .note-swipe-bg { display: none; }
}

.composer {
  /* Floats over the list instead of reserving its own flex row, so the list can
     fill the full height and scroll underneath it. */
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  /* Matches Figma's latest "7 - List View" mocks: button and prompt text sit
     closer together and closer to the very bottom edge than before. */
  gap: 6px;
  padding: 10px 20px calc(16px + env(safe-area-inset-bottom));
  z-index: 5;
  pointer-events: none;
  opacity: 1;
  transition: bottom 0.45s var(--ease-sheet), opacity 0.3s var(--ease-standard);
}

/* Matches Figma "0 - Splash Screen": no button or prompt at all while splash is up
   — baked in by default (matching splash being visible from the very first paint),
   removed by app.js once splash dismisses, revealing the button already sitting
   in whatever position (bottom rest, or risen for "vomit here") comes next. */
.composer.at-splash {
  opacity: 0;
  pointer-events: none;
}

/* Matches Figma "7 - List View": a flat gradient fade (transparent to solid splat
   green) grounds the footer against whatever colors the animated background blobs
   happen to be showing through above it — replaces the old hand-drawn pill/blob
   shape that used to sit only behind the idle text. Rest-state only: hidden while
   recording, when the backdrop/splat takes over the whole screen instead. */
.composer::before {
  content: "";
  position: absolute;
  inset: 0;
  background: linear-gradient(to bottom, transparent, var(--green-splat) 60%);
  z-index: -1;
  pointer-events: none;
  transition: opacity 0.3s var(--ease-standard);
}

.composer.recording::before {
  opacity: 0;
}

/* Matches Figma "7 - List View - Long": with the type-a-thought prompt gone (see
   the "Enable Typing" menu toggle), there's no text sitting on the footer that
   this gradient exists to make readable, so it goes too — just the plain mic
   button floating on the list. */
.composer.typing-disabled::before {
  opacity: 0;
}

/* The composer's own box spans the full width as a hit-testing dead zone (needed so
   its content is positioned correctly) — re-enable pointer events only on the parts
   that are actually interactive, so taps/scrolls pass through everywhere else to
   whatever's scrolled underneath. */
.composer > * {
  pointer-events: auto;
}

/* Matches Figma "3 - Record Note": while recording, the composer (button + its
   waveform) rises off the very bottom edge but stays well down the screen rather
   than jumping to the vertical center — expressed as a bottom percentage so it
   scales with viewport height instead of a fixed pixel offset tuned to one screen
   size. .app.recording drops its max-width so the background/splat can go
   full-bleed on desktop, but the button and waveform still need to stay a sane,
   centered phone-card width rather than stretching edge to edge — so constrain
   and re-center just the composer here. */
.composer.recording {
  bottom: 20%;
  left: 50%;
  right: auto;
  width: 100%;
  max-width: 480px;
  transform: translateX(-50%);
}

/* Matches Figma "2 - Start Voice Note": same button (see index.html — there's no
   second, separate button for this screen), risen further up than the recording
   position since there's "vomit here" text + an arrow to make room for above it. */
.composer.at-vomit {
  bottom: 42%;
  left: 50%;
  right: auto;
  width: 100%;
  max-width: 480px;
  transform: translateX(-50%);
}

.composer.at-vomit::before {
  opacity: 0;
}

.composer.at-vomit .composer-label-slot {
  opacity: 0;
  pointer-events: none;
}

/* Matches Figma "7 - List View - Long": typing is off by default (see the "Enable
   Typing" menu toggle in app.js) — nothing but the mic button sits in the footer
   at all, not even the resting "Or type it, if you're boring" prompt. */
.composer.typing-disabled .composer-label-slot {
  display: none;
}

/* One shared splat, reused (not re-created) across the splash, "vomit here", and
   recording screens — see the .at-splash / .at-vomit / .active state classes below,
   all toggled by app.js. Because they're all just different rotate/mirror values
   on the same centered, same-sized element sharing one transition, moving between
   them reads as one continuous piece of slime settling into a new pose, rather
   than separate images cross-fading. Hidden and non-interactive at rest (i.e. the
   ordinary populated list, no state class present). */
.record-splat {
  position: absolute;
  top: 50%;
  left: 50%;
  /* Sized off viewport height, not the container's width — .app.recording/
     .backdrop-active are now full-bleed, and a width tied to that (e.g. a plain
     230%) would balloon to several thousand px on a wide desktop window, zooming
     in on the blob until it was just a flat color fill with no visible shape. A
     vh-based size keeps the same proportions this was tuned at on a phone-sized
     viewport regardless of how wide the browser window is. This is now a fixed
     reference size — .spinning/.expanding below scale *from* it with
     transform:scale() rather than resizing it with width, so the shrink/grow
     doesn't have to repaint against a changing layout box. translate(-50%,-50%)
     still centers correctly regardless, since percentage translate is resolved
     against this element's own (unscaled) layout box, not its painted size. */
  width: 110vh;
  max-width: none;
  transform: translate(-50%, -50%) rotate(18deg) scaleX(-1);
  pointer-events: none;
  z-index: 1;
  opacity: 0;
  transition: opacity 0.45s var(--ease-standard), transform 0.6s var(--ease-standard);
}

.record-splat.active {
  opacity: 1;
}

/* Matches Figma "0 - Splash Screen": centered, no rotation or mirroring — the pose
   the app boots into by default (baked into the HTML itself, not applied by JS),
   before settling into whichever of "vomit here" or the recording backdrop is
   next. */
.record-splat.at-splash {
  opacity: 1;
  transform: translate(-50%, -50%) rotate(0deg);
}

/* Matches Figma "2 - Start Voice Note". */
.record-splat.at-vomit {
  opacity: 1;
  transform: translate(-50%, -50%) rotate(-39deg) scaleY(-1);
}

/* Matches Figma "5 - Spinner Middle" ("rotate like a spinner, the slime should
   morph as if it were a real liquid"): once recording stops, the splat shrinks to
   a small centered blob and spins continuously as the loading indicator for the
   transcribing phase, instead of a plain circular spinner. */
/* 40vh / 110vh (the base width above) — shrinks via scale() instead of width. */
.record-splat.spinning {
  animation: splat-spin 2.4s linear infinite;
}

@keyframes splat-spin {
  from { transform: translate(-50%, -50%) rotate(0deg) scaleX(-1) scale(0.3636); }
  to { transform: translate(-50%, -50%) rotate(360deg) scaleX(-1) scale(0.3636); }
}

/* Matches Figma "6 - Spinner End" ("slime should expand to fill the whole screen
   and act as the new background before loading in the additional content"): right
   as transcription finishes, the splat balloons out to fully cover the screen —
   see exitTranscribingPhase() in app.js, which snaps the list/composer/header back
   to resting underneath while this is fully covering, then fades it away after.
   200vh / 110vh (the base width) — grows via scale() instead of width. Restates
   the base rotate/mirror explicitly (rather than inheriting it) since this state
   has no animation of its own to carry them the way .spinning's keyframes do. */
.record-splat.expanding {
  transform: translate(-50%, -50%) rotate(18deg) scaleX(-1) scale(1.8182);
}

.composer-label-slot {
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: 40px;
  transition: opacity 0.15s var(--ease-standard);
}

/* Matches Figma "3 - Record Note" / the spinner screens: no status text at all
   while recording OR while the splat-spinner is transcribing it afterward — just
   the button/waveform, then just the spinning splat. Text returns once the
   composer drops back to its resting idle prompt. */
.record-btn.recording ~ .composer-label-slot,
.record-btn.transcribing ~ .composer-label-slot {
  opacity: 0;
  pointer-events: none;
}

/* Matches Figma's latest "7 - List View" mocks: plain text sitting directly on the
   footer's gradient (see .composer::before) — no pill/blob shape behind it anymore. */
.composer-label {
  background: none;
  border: none;
  padding: 0;
  margin: 0;
  font-family: var(--font-body);
  font-size: 18px;
  color: var(--ink);
  text-align: center;
  cursor: pointer;
  transition: opacity 0.15s var(--ease-standard);
}

.composer-label:disabled {
  cursor: default;
}

.composer-label:not(:disabled):hover {
  opacity: 0.65;
}

.type-form {
  display: none;
  align-items: center;
  gap: 8px;
  width: 100%;
  max-width: 320px;
  background: var(--green-light);
  border-radius: 999px;
  padding: 6px 8px 6px 18px;
}

.composer-label-slot.typing .composer-label {
  display: none;
}

.composer-label-slot.typing .type-form {
  display: flex;
  animation: type-form-in 0.22s var(--ease-out) both;
}

.composer-label-slot:not(.typing) .composer-label {
  animation: fade-in 0.18s var(--ease-out) both;
}

@keyframes type-form-in {
  0% { opacity: 0; transform: scale(0.92); }
  100% { opacity: 1; transform: scale(1); }
}

@keyframes fade-in {
  0% { opacity: 0; }
  100% { opacity: 1; }
}

.type-input {
  flex: 1;
  min-width: 0;
  border: none;
  background: none;
  outline: none;
  font-family: var(--font-body);
  font-size: 16px;
  color: var(--ink);
}

.type-input::placeholder {
  color: var(--ink-dim);
}

.type-submit {
  flex-shrink: 0;
  width: 32px;
  height: 32px;
  border-radius: 50%;
  background: var(--green-med);
  border: none;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 16px;
  line-height: 1;
  color: var(--ink);
  cursor: pointer;
  transition: background-color 0.15s var(--ease-standard), color 0.15s var(--ease-standard);
}

.type-submit:hover {
  background: var(--green-splat);
  color: var(--white);
}

/* A live waveform that scrolls left and reacts to actual voice volume while
   recording (see app.js). Only ever visible together with .composer.recording
   (matches Figma "3 - Record Note") — sits just above the risen button with its
   bottom edge roughly at the button's top edge, rather than directly behind/around
   it, so the two don't visually collide. */
.waveform-canvas {
  position: absolute;
  left: -20px;
  /* <canvas> is a replaced element — unlike a <div>, it won't stretch to fill a
     left+right span without an explicit width, and falls back to its narrow
     intrinsic size instead. */
  width: calc(100% + 40px);
  top: -150px;
  height: 160px;
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.2s var(--ease-standard);
}

.waveform-canvas.active {
  opacity: 1;
}

.record-btn {
  position: relative;
  width: 100px;
  height: 106px;
  border: none;
  background: transparent;
  cursor: pointer;
  padding: 0;
  flex-shrink: 0;
  touch-action: manipulation;
  -webkit-user-select: none;
  user-select: none;
  transition: opacity 0.2s var(--ease-standard);
}

.record-btn:disabled {
  cursor: not-allowed;
  opacity: 0.5;
}

/* Matches Figma's "stop button disappears" annotation on "4 - Spinner Start": the
   splat itself is now the loading indicator (see .record-splat.spinning), so the
   button just fades away entirely for the transcribing phase rather than showing
   its own separate spinner. */
.record-btn:disabled.transcribing {
  opacity: 0;
}

/* Shadow/base circle: fixed in place, always visible underneath the face circle. */
.record-btn .blob-back {
  position: absolute;
  top: 6px;
  left: 0;
  width: 100px;
  height: 100px;
  border-radius: 50%;
  background: var(--green-med);
  pointer-events: none;
  z-index: 1;
}

/* Face circle: sits on top at rest, physically slides down to merge with the shadow
   circle on press (and takes on its color), like a real button being depressed. */
.record-btn .blob-front {
  position: absolute;
  top: 0;
  left: 0;
  width: 100px;
  height: 100px;
  border-radius: 50%;
  background: var(--white);
  display: flex;
  align-items: center;
  justify-content: center;
  pointer-events: none;
  z-index: 2;
  transform: translateY(0);
  /* Slight spring overshoot on release for a physical, springy pop back up. */
  transition: transform 0.22s var(--ease-bounce), background-color 0.14s var(--ease-standard);
  will-change: transform, background-color;
}

.record-btn.recording .blob-front {
  transform: translateY(6px);
  background: var(--green-med);
  /* ease-out (not ease-in): the color has to start moving the instant you press,
     not sit still through a slow start before snapping late in the transition. */
  transition: transform 0.09s ease-out, background-color 0.09s ease-out;
}

/* Mouse/trackpad only — touch devices don't have a real hover state, and this would
   otherwise stick "lifted" after a tap. Only the front (light green) circle rises;
   the back (med green) shadow circle stays put, so the button reads as taller. */
@media (hover: hover) and (pointer: fine) {
  .record-btn:not(:disabled):not(.recording):hover .blob-front {
    transform: translateY(-4px);
  }
}

.record-btn .record-icon {
  position: absolute;
  z-index: 3;
  width: 50%;
  height: 50%;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%) scale(1);
  opacity: 1;
  transition: opacity 0.14s var(--ease-standard), transform 0.14s var(--ease-standard);
}

.record-btn .icon-stop {
  opacity: 0;
  transform: translate(-50%, -50%) scale(0.85);
}

.record-btn.recording .icon-mic {
  opacity: 0;
  transform: translate(-50%, -50%) scale(0.85);
}

.record-btn.recording .icon-stop {
  opacity: 1;
  transform: translate(-50%, -50%) scale(1);
}

.menu-scrim {
  position: fixed;
  inset: 0;
  background: rgba(49, 76, 19, 0.6);
  z-index: 10;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.32s var(--ease-sheet);
}

.menu-scrim.open {
  opacity: 1;
  pointer-events: auto;
}

.menu-drawer {
  position: fixed;
  top: 0;
  right: 0;
  height: 100%;
  width: 88%;
  max-width: 380px;
  z-index: 11;
  /* Figma's "8 - Menu" mock insets list-item content well clear of the wavy left
     edge (~88px from the drawer's own left edge, not just a token-sized gutter) —
     our previous 24px both sides left items reading as flush against that edge.
     Widening the drawer alongside the bigger left inset keeps the actual text
     column from getting too cramped for this app's longer menu-item copy. */
  padding: calc(70px + env(safe-area-inset-top)) 32px calc(24px + env(safe-area-inset-bottom)) 64px;
  display: flex;
  flex-direction: column;
  gap: 4px;
  transform: translateX(100%);
  transition: transform 0.32s var(--ease-sheet);
  will-change: transform;
  /* .menu-wave-edge below is deliberately much bigger than the drawer and hangs
     off all four sides of it — same as the source Figma frame, which clips it to
     its own bounds. Without this the wave would visibly spill past the drawer's
     own edges instead of just shaping its left side. */
  overflow: hidden;
}

.menu-drawer.open {
  transform: translateX(0);
}

/* No background on .menu-drawer itself — deliberately. Its ink (see .menu-wave-edge)
   only ever reaches a little past the drawer's own left edge, so a flat rectangular
   fill covering the whole drawer box would sit on top of that ink everywhere and
   make it invisible: same color, indistinguishable, wave contributes nothing. This
   fill instead starts 40px in — past where the wave's own undulation is — so the
   wave is the ONLY thing providing color in the edge strip where it actually needs
   to read as a wavy boundary against the dimmed scrim behind it. */
.menu-drawer::after {
  content: "";
  position: absolute;
  top: 0;
  right: 0;
  bottom: 0;
  left: 40px;
  background: var(--green-light);
  z-index: -1;
  pointer-events: none;
}

/* Every real child of the drawer needs to paint above .menu-wave-edge below:
   absolutely/relatively positioned elements paint above plain in-flow content by
   default (that's the CSS stacking order, regardless of DOM order), so without
   this the wave would overlap and clip the first few letters of "Menu stuff",
   "Mic Settings", etc. wherever it crosses into the drawer's own content area. */
.menu-drawer > * {
  position: relative;
  z-index: 1;
}

/* Wavy left edge (matches the "8 - Menu" mock): the actual Figma drip/wave shape
   (a single irregular blob, not a repeating tile), reproduced with the exact same
   position + rotation Figma itself uses, letting the browser do the rotation math
   directly via `transform: rotate()` rather than a pre-baked/pre-rotated asset — an
   SVG mask-image and a manually pre-rotated path both turned out unreliable (mask
   sizing behaved inconsistently across how it interpreted the source's viewBox, and
   hand-transforming the path's own coordinates introduced a rotation-direction sign
   error). Letting a real browser `transform` do the rotation is what actually
   reproduces Figma's own output byte-for-byte in intent. */
.menu-wave-edge {
  position: absolute;
  left: -110.18px;
  top: -68.96px;
  width: 631.979px;
  height: 1022.123px;
  z-index: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  pointer-events: none;
}

.menu-wave-shape {
  flex: none;
  transform: rotate(-81.33deg);
}

.menu-wave-shape svg {
  display: block;
}

.menu-heading {
  margin: 0 0 18px;
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 32px;
  letter-spacing: -1.28px;
  color: var(--ink);
}

/* Matches the latest "8 - Menu" mock: Force Refresh, Mic Settings, and the
   account action are grouped together right under the heading (not split with
   the account action pinned to the bottom) — the privacy note is what's pushed
   to the bottom now (see .menu-note below). */
.menu-items {
  display: flex;
  flex-direction: column;
  gap: 24px;
}

.menu-item-group {
  display: flex;
  flex-direction: column;
  gap: 4px;
}

.menu-item-subtitle {
  margin: 0;
  font-size: 12px;
  color: var(--ink);
}

.menu-item {
  display: flex;
  align-items: center;
  justify-content: space-between;
  width: 100%;
  background: none;
  border: none;
  font-family: var(--font-body);
  font-size: 18px;
  color: var(--ink);
  padding: 0;
  cursor: pointer;
  text-align: left;
  transition: opacity 0.15s var(--ease-standard);
}

.menu-item img {
  width: 24px;
  height: 24px;
}

.menu-item:hover { opacity: 0.7; }

/* Pushed to the bottom of the drawer via margin-top: auto, same technique the
   account block used to use when it lived down here. */
.menu-note {
  margin-top: auto;
  font-size: 12px;
  line-height: 1.4;
  color: var(--green-splat);
}

/* Splash screen (matches Figma "0 - Splash Screen"). No background or splat of its
   own anymore — deliberately transparent so the shared #recordSplat and .app's own
   light-green fill (see .app.backdrop-active), sitting just underneath in the
   stacking order, show through as its backdrop. Only owns the logo + hand
   illustration on top of that shared backdrop. */
.splash-screen {
  position: fixed;
  inset: 0;
  z-index: 30;
  overflow: hidden;
  display: flex;
  align-items: center;
  justify-content: center;
  opacity: 1;
  visibility: visible;
  transition: opacity 0.4s ease, visibility 0.4s ease;
}

.splash-screen.dismissed {
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
}

.splash-content {
  position: relative;
  z-index: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  animation: splash-content-in 0.5s ease 0.25s both;
}

@keyframes splash-content-in {
  0% { opacity: 0; transform: translateY(12px); }
  100% { opacity: 1; transform: translateY(0); }
}

/* Matches Figma "0 - Splash Screen": "word" and "vomit" stack diagonally and
   overlap, not the side-by-side layout .login-logo uses on the actual login
   screen — a dedicated set of classes rather than reusing .login-logo, since the
   two screens genuinely want different arrangements of the same wordmark. */
.splash-logo {
  position: relative;
  width: 340px;
  height: 130px;
  margin: 0;
}

.splash-logo-word,
.splash-logo-vomit {
  position: absolute;
  display: flex;
  align-items: center;
  justify-content: center;
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 64px;
  letter-spacing: -2.56px;
  line-height: 1;
  color: var(--ink);
  white-space: nowrap;
}

.splash-logo-word {
  left: calc(50% - 170px);
  top: 0;
  width: 186px;
  height: 111px;
  transform: rotate(-16.02deg);
}

.splash-logo-vomit {
  left: calc(50% - 10px);
  top: 55px;
  width: 179px;
  height: 91px;
  transform: rotate(8.86deg);
}

.splash-mic-wrap {
  position: relative;
  width: 328px;
  max-width: 70vw;
  aspect-ratio: 328 / 304;
  margin-top: 24px;
}

.splash-mic-bg {
  position: absolute;
  left: 22%;
  top: 11%;
  width: 56%;
  height: 70%;
}

.splash-mic-union,
.splash-mic-ellipse {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
}

.splash-mic-ellipse {
  width: 95%;
  height: 81%;
  left: 3%;
  top: 19%;
}

.splash-mic-hand {
  position: absolute;
  left: 0;
  top: -8%;
  width: 100%;
  height: auto;
  pointer-events: none;
}

/* Login / Create Account screen (matches Figma "9 - Login") */
.login-screen {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  /* Matches .app's own fix for the same problem (see syncAppHeight() in app.js):
     inset:0 sizes a fixed element off the layout viewport, which on iOS Safari
     does NOT shrink for the on-screen keyboard the way the visual viewport does —
     content near the bottom of this screen (the Apple button, resend link, status
     messages) could end up trapped behind the keyboard with nothing new to scroll
     into, since the box itself never got smaller. */
  height: var(--app-height, 100dvh);
  z-index: 20;
  background: var(--green-light);
  overflow: hidden;
  display: flex;
  flex-direction: column;
  opacity: 1;
  visibility: visible;
  transition: opacity 0.3s var(--ease-standard), visibility 0.3s var(--ease-standard);
}

.login-screen.closed {
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
}

.login-splat {
  position: absolute;
  top: -8%;
  left: 50%;
  transform: translateX(-50%);
  width: 190%;
  max-width: none;
  pointer-events: none;
  z-index: 0;
}

.login-topbar {
  position: relative;
  z-index: 2;
  padding: calc(20px + env(safe-area-inset-top)) 20px 20px;
  flex-shrink: 0;
}

.login-content {
  position: relative;
  z-index: 2;
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  justify-content: center;
  padding: 0 24px 40px;
  max-width: 480px;
  margin: 0 auto;
  width: 100%;
  box-sizing: border-box;
}

.login-logo {
  margin: 0 0 36px;
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 64px;
  letter-spacing: -2.56px;
  line-height: 1;
  text-align: center;
}

.login-logo-word {
  display: inline-block;
  transform: rotate(-16deg);
}

.login-logo-vomit {
  display: inline-block;
  transform: rotate(9deg);
  margin-left: 14px;
}

.login-form {
  display: flex;
  flex-direction: column;
  gap: 16px;
}

/* Passwordless: the email form and the "check your email" panel are two separate
   blocks in the same slot, toggled one at a time — .hidden alone loses this fight
   since .login-form's/.login-check-email's own `display` is declared later in the
   cascade at equal specificity, so the combined selectors here are what actually
   win regardless of source order. */
.login-form.hidden,
.login-check-email.hidden {
  display: none;
}

.login-check-email {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 16px;
}

/* Hand-drawn wobbly outlines from Figma, not CSS pills — each field/button uses its
   own traced blob shape as a background image rather than a straight border-radius. */
.login-input {
  width: 100%;
  box-sizing: border-box;
  border: none;
  background: transparent url("assets/login-email-bg.svg") center / 100% 100% no-repeat;
  padding: 20px 26px;
  height: 68px;
  font-family: var(--font-body);
  font-size: 18px;
  color: var(--ink);
  outline: none;
}

.login-input::placeholder {
  color: var(--ink);
  opacity: 0.6;
}

.login-check-email-text {
  font-size: 16px;
  line-height: 1.5;
  color: var(--ink);
  text-align: center;
  margin: 0;
}

.login-submit {
  border: none;
  background: transparent url("assets/login-button-bg.svg") center / 100% 100% no-repeat;
  padding: 20px 24px;
  height: 68px;
  font-family: var(--font-body);
  font-size: 18px;
  font-weight: 500;
  color: var(--ink);
  cursor: pointer;
  margin-top: 8px;
  transition: filter 0.15s var(--ease-standard);
}

.login-submit:hover {
  filter: brightness(0.94);
}

.login-status {
  text-align: center;
  font-size: 14px;
  line-height: 1.4;
  color: var(--ink-dim);
  margin: 4px 0 0;
}

.login-toggle {
  background: none;
  border: none;
  font-family: var(--font-body);
  font-size: 15px;
  color: var(--ink);
  text-align: center;
  cursor: pointer;
  text-decoration: none;
  margin-top: 8px;
  padding: 8px;
}

.login-divider {
  display: flex;
  align-items: center;
  gap: 12px;
  margin: 20px 0;
  color: var(--ink-dim);
  font-size: 14px;
}

.login-divider::before,
.login-divider::after {
  content: "";
  flex: 1;
  height: 1px;
  background: rgba(0, 0, 0, 0.15);
}

/* Follows Apple's own Sign in with Apple button guidelines (black fill, white
   logo + label) rather than this app's usual hand-drawn shapes — the one button
   in the app that's deliberately not in the house style, since Apple's HIG
   requires their mark to stay recognizable. */
.login-apple-btn {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 10px;
  width: 100%;
  border: none;
  border-radius: 999px;
  background: #000;
  color: #fff;
  height: 56px;
  font-family: var(--font-body);
  font-size: 17px;
  font-weight: 500;
  cursor: pointer;
  transition: opacity 0.15s var(--ease-standard);
}

.login-apple-btn:hover {
  opacity: 0.85;
}

.login-apple-icon {
  width: 18px;
  height: 18px;
  fill: #fff;
  flex-shrink: 0;
}

/* Tone down the large, spatial ambient motion for anyone who's asked for it at the
   OS level — small UI feedback (button presses, fades) stays, since removing that
   entirely tends to feel broken rather than accessible. */
@media (prefers-reduced-motion: reduce) {
  .bg-blob {
    animation: none;
  }

  .splash-content {
    animation: none;
    opacity: 1;
    transform: none;
  }

  /* The shared splat still fades and repositions (removing the transition here
     would leave it permanently mis-rotated between states), but quickly and
     without the spinner's continuous rotation. */
  .record-splat {
    transition-duration: 0.2s;
  }

  .record-splat.spinning {
    animation: none;
  }

  /* Same spatial-motion policy as .splash-content above — this was added later
     (the vibe-check feature) and missed this block; its translateY+scale entrance
     is the same kind of motion the splash-content rule already turns off. */
  .vibe-check-item {
    animation: none;
    opacity: 1;
    transform: none;
  }
}
