/* Letters & Dispatches — composer + inbox.

   Visual language: paper. The composer is a stationery sheet with a
   small toolbar; the inbox below is a stack of folded notes that open
   on click. Date stamps reuse the postmark motif from the museum cards
   so the page feels like a continuation of the wall of correspondence,
   not a separate UI. Variables defined in statue.css (--bg, --bg-card,
   --fg, --accent, --line, --muted, etc.) are inherited. */

/* ── Page shell ─────────────────────────────────────────────────── */

.ld-shell {
  max-width: 44rem;
  margin: 4rem auto 6rem;
  padding: 0 1.25rem;
}

/* Three-column grid so the back pill and the "correspondence" eyebrow
   share a top row, with the eyebrow centered against the full masthead
   width (the trailing 1fr column mirrors the leading one). The title
   sits below on its own row, spanning the full width. */
.ld-masthead {
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  align-items: center;
  text-align: center;
  margin-bottom: 2.5rem;
  position: relative;
}

/* Back-to-museum pill — visually matches .le-back and .sa-back-home so
   all three sub-exhibits use the same "doorway back to the museum"
   motif. Lives in the first grid column, left-aligned. */
.ld-back {
  grid-column: 1;
  grid-row: 1;
  justify-self: start;
  display: inline-flex;
  align-items: center;
  gap: 8px;
  padding: 6px 14px;
  background: var(--bg);
  color: var(--accent);
  font-family: ui-serif, Georgia, "Times New Roman", serif;
  font-style: italic;
  font-size: 14px;
  border: 1px solid var(--line);
  border-radius: 999px;
  text-decoration: none;
  white-space: nowrap;
  transition: background 120ms ease, color 120ms ease, border-color 120ms ease;
}

.ld-back i { font-size: 14px; }

.ld-back:hover {
  background: var(--bg-card);
  color: var(--accent-hover);
  border-color: var(--accent);
}

.ld-eyebrow {
  grid-column: 2;
  grid-row: 1;
  font-size: 0.72rem;
  letter-spacing: 0.22em;
  text-transform: uppercase;
  color: var(--accent);
}

.ld-title {
  grid-column: 1 / -1;
  grid-row: 2;
  font-family: ui-serif, Georgia, "Times New Roman", serif;
  font-style: italic;
  font-weight: 400;
  font-size: clamp(2rem, 5vw, 3rem);
  /* Extra top margin separates the title from the back-pill / eyebrow
     row above it. */
  margin: 1.6rem 0 0.6rem;
  letter-spacing: -0.01em;
}

.ld-sub {
  color: var(--fg-soft);
  font-style: italic;
  margin: 0;
  font-size: 1.05rem;
}

@media (max-width: 32rem) {
  /* Phone: collapse to a single stacked column so the back pill and
     eyebrow each get their own row above the title. */
  .ld-masthead {
    grid-template-columns: 1fr;
    row-gap: 0.7rem;
  }
  .ld-back { grid-column: 1; grid-row: 1; justify-self: center; }
  .ld-eyebrow { grid-column: 1; grid-row: 2; }
  .ld-title { grid-column: 1; grid-row: 3; margin-top: 0.5rem; }
  .ld-back .ld-back-full { display: none; }
}

/* ── Composer ───────────────────────────────────────────────────── */

.ld-composer { margin-bottom: 3.5rem; }

/* Mode toggle — segmented "Written / Voice" control above the paper.
   The two real radio inputs are visually hidden but still drive the
   :checked state of their labels (and the position of the sliding
   thumb behind them) and remain the keyboard / screen-reader anchor.
   The `.is-voice` class on the wrapper, set by JS, is a redundant
   handle for animating the thumb when CSS-only :has() isn't available
   (it usually is, but the class makes the rule unambiguous). */
.ld-mode {
  position: relative;
  display: flex;
  width: max-content;
  margin: 0 auto 1.4rem;
  padding: 3px;
  background: rgba(247, 240, 232, 0.55);
  border: 1px solid rgba(58, 48, 39, 0.10);
  border-radius: 999px;
  box-shadow: 0 1px 1px rgba(58, 48, 39, 0.03);
}

.ld-mode-input {
  /* Visually hidden — keep it focusable so keyboard nav still works. */
  position: absolute;
  opacity: 0;
  pointer-events: none;
  width: 1px;
  height: 1px;
}

.ld-mode-option {
  position: relative;
  z-index: 1;
  display: inline-flex;
  align-items: center;
  gap: 0.45rem;
  padding: 0.5rem 1.1rem;
  font-size: 0.82rem;
  letter-spacing: 0.08em;
  text-transform: lowercase;
  color: var(--muted);
  border-radius: 999px;
  cursor: pointer;
  user-select: none;
  transition: color 200ms ease;
}

.ld-mode-option i {
  font-size: 0.95rem;
  line-height: 1;
}

/* Active label rides above the thumb and takes the accent color. */
.ld-mode-input:checked + .ld-mode-option {
  color: var(--accent);
}

/* The sliding "ink blot" behind the active label. Width is 50% of the
   inner container minus its padding; transform-x flips on .is-voice. */
.ld-mode-thumb {
  position: absolute;
  top: 3px;
  bottom: 3px;
  left: 3px;
  width: calc(50% - 3px);
  background: var(--bg-card);
  border: 1px solid rgba(58, 48, 39, 0.12);
  border-radius: 999px;
  box-shadow: 0 1px 2px rgba(58, 48, 39, 0.06);
  transition: transform 240ms cubic-bezier(.4,.05,.2,1);
  z-index: 0;
}

.ld-mode.is-voice .ld-mode-thumb {
  transform: translateX(100%);
}

/* Voice + written panels are stacked in the same grid cell so the
   paper sizes to the taller of the two and stays put when the user
   flips modes — the inactive panel still occupies the cell, it's just
   crossfaded out. Without the stack, switching mid-compose would jolt
   the paper's height (and the footer beneath it) by ~100px.

   Implementation:
     • `.ld-mode-panels` is a single-cell grid; both children land in
       row 1 / col 1 via `grid-area`.
     • Active panel is fully opaque; `.is-inactive` is opacity:0 +
       visibility:hidden so it drops out of the a11y tree and can't be
       tabbed into, but still contributes to the grid track's size.
     • The transition is keyed off opacity for a clean crossfade. */
.ld-mode-panels {
  display: grid;
}

.ld-mode-panels > .ld-mode-panel {
  grid-area: 1 / 1;
  transition: opacity 260ms ease, visibility 260ms ease;
}

.ld-mode-panel.is-inactive {
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
}

.ld-mode-panel-voice {
  display: flex;
  flex-direction: column;
  gap: 1rem;
  padding: 1.6rem 1.4rem 1.4rem;
  /* Match the written panel's natural height (toolbar ~38px + editor
     min-height 9rem + paddings) so the grid track is the same regardless
     of which mode is showing. Slightly larger than the text panel's
     natural min so review-state controls have room to expand below the
     mic without nudging the footer. */
  min-height: 13rem;
  justify-content: center;
}

/* ── Voice composer ───────────────────────────────────────────────
   Three states on .ld-mode-panel-voice[data-voice-state]:
     • idle      — mic icon visible, pulse hidden, hint text reads "tap…"
     • recording — pulse visible (ring + breathing animation), stop icon
                   visible instead of mic, timer counts up
     • review    — review panel below the button is shown (audio preview
                   + "re-record" link). Tapping the big button starts a
                   fresh take rather than re-stopping. */
.ld-voice-stage {
  display: flex;
  align-items: center;
  gap: 1.1rem;
}

.ld-record {
  /* Override the global heavy button — this one is a circular tap
     target, sized so it works as a thumb button on phones. */
  position: relative;
  width: 4.2rem;
  height: 4.2rem;
  padding: 0;
  border-radius: 50%;
  border: 1px solid rgba(176, 106, 91, 0.35);
  background: rgba(176, 106, 91, 0.10);
  color: var(--accent);
  cursor: pointer;
  font-size: 1.7rem;
  line-height: 1;
  letter-spacing: 0;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
  transition: background 160ms ease, border-color 160ms ease, transform 160ms ease;
}

.ld-record:hover {
  background: rgba(176, 106, 91, 0.18);
  border-color: rgba(176, 106, 91, 0.55);
}

.ld-record:active {
  transform: scale(0.96);
}

.ld-record:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
}

/* The two icons stack — only one is visible per state. Keeps the button
   from resizing as state changes. */
.ld-record-icon-idle,
.ld-record-icon-rec {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  transition: opacity 160ms ease;
}
.ld-record-icon-rec { opacity: 0; }

.ld-mode-panel-voice[data-voice-state="recording"] .ld-record {
  background: rgba(176, 106, 91, 0.18);
  border-color: var(--accent);
  color: var(--accent);
}
.ld-mode-panel-voice[data-voice-state="recording"] .ld-record-icon-idle { opacity: 0; }
.ld-mode-panel-voice[data-voice-state="recording"] .ld-record-icon-rec { opacity: 1; }

/* Pulse ring — only painted while recording. Two concentric ripples
   on a long delay so the button breathes rather than blinks. The ring
   sits behind the button by virtue of being a ::before-like sibling
   with negative z-index in the local stacking context. */
.ld-record-pulse {
  position: absolute;
  inset: -8px;
  border-radius: 50%;
  border: 2px solid rgba(176, 106, 91, 0.45);
  opacity: 0;
  pointer-events: none;
}
.ld-mode-panel-voice[data-voice-state="recording"] .ld-record-pulse {
  animation: ld-record-pulse 1.6s ease-out infinite;
}
@keyframes ld-record-pulse {
  0%   { transform: scale(0.95); opacity: 0.7; }
  80%  { transform: scale(1.45); opacity: 0; }
  100% { transform: scale(1.45); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
  .ld-record-pulse { animation: none !important; }
}

.ld-voice-meta {
  display: flex;
  flex-direction: column;
  gap: 0.1rem;
  min-width: 0;
  flex: 1 1 auto;
}

.ld-voice-timer {
  font-family: ui-monospace, "SF Mono", "Menlo", monospace;
  font-size: 1.55rem;
  font-weight: 500;
  color: var(--fg);
  letter-spacing: 0.02em;
  font-variant-numeric: tabular-nums;
}

.ld-voice-hint {
  color: var(--muted);
  font-style: italic;
  font-size: 0.88rem;
}

.ld-voice-review {
  display: flex;
  flex-direction: column;
  gap: 0.55rem;
  align-items: stretch;
}

.ld-voice-preview {
  width: 100%;
  max-width: 100%;
  /* Native <audio controls> picks its own height; this keeps it tidy. */
  min-height: 38px;
}

.ld-paper {
  background: var(--bg-card);
  border: 1px solid rgba(58, 48, 39, 0.10);
  border-radius: 4px;
  box-shadow:
    0 1px 1px rgba(58, 48, 39, 0.04),
    0 8px 22px rgba(58, 48, 39, 0.10);
  /* Subtle paper-fiber texture via an inline SVG turbulence filter.
     The earlier ruled-line gradient could never look right at every
     font-size + line-height combo; this is text-metric-independent
     and just reads as "paper" instead. The filter outputs a constant
     brown color whose alpha is modulated by fractal noise — most
     pixels end up fully transparent, the rest land at ~5–12% opacity,
     so the warm bg-card shows through with faint grain on top.
     stitchTiles='stitch' makes the 220×220 tile seam-free. */
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='220' height='220'%3E%3Cfilter id='p'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0.16 0 0 0 0 0.12 0 0 0 0 0.08 0.11 0 0 0 -0.04'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23p)'/%3E%3C/svg%3E");
  position: relative;
  overflow: hidden;
  /* origin near the top of the sheet so the seal-and-send animation
     pivots from where the wax seal would sit. */
  transform-origin: 50% 1.5rem;
  transition: transform 480ms cubic-bezier(.4,.05,.2,1),
              opacity 360ms ease,
              box-shadow 360ms ease;
}

/* ── Seal & send animation ─────────────────────────────────────────
   Multi-stage send sequence, ~1.8s total. The aim is for the act of
   sending to feel like sending — folding the sheet, pressing a wax
   seal onto the fold, and watching the sealed letter leave — rather
   than a single brief fade.

   The animation is split across three elements so each stage can have
   its own timing without choreographing one big keyframe block:

     1. .ld-paper             — anticipation lift, then fold (scaleY
                                compresses from the top origin) and
                                hold. The fold crease line is a ::before
                                on this element so it scales with the
                                paper. No fly-away on .ld-paper itself.
     2. .ld-seal-stamp        — sits above the paper's visual middle.
                                Drops from above-and-tilted, lands with
                                a small bounce, and stays put.
     3. .ld-paper-stage       — the wrapper around both. Holds still
                                through stages 1–2, then carries paper
                                and stamp together in a fly-away.

   Stage timeline (percentages over 1800ms):
     0%–11%   (0–200ms)   paper rises, shadow deepens (anticipation)
     11%–28%  (200–500ms) paper folds + crease line draws across
     28%–50%  (500–900ms) wax stamp drops from above, lands with bounce
     50%–61%  (900–1100ms) brief hold — sealed letter visible
     61%–100% (1100–1800ms) stage flies up + tilts + scales + fades

   Reduced-motion users get a plain fade so we never autoplay 1.8s of
   motion for someone who opted out. */

.ld-paper-stage {
  position: relative;
  /* Center origin so the fly-away rotation pivots around the middle of
     the sealed letter — reads as "thrown into the mail" rather than
     swinging from one edge. */
  transform-origin: 50% 50%;
}

/* Fold crease — ::before pseudo on the paper, drawn across its middle.
   Hidden by default; the seal-paper keyframes ramp its opacity + scale
   during the fold stage so it appears in sync with the scaleY squash. */
.ld-paper::before {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  top: 50%;
  height: 1px;
  background: linear-gradient(
    to right,
    rgba(58, 48, 39, 0)   0%,
    rgba(58, 48, 39, 0.18) 10%,
    rgba(58, 48, 39, 0.28) 50%,
    rgba(58, 48, 39, 0.18) 90%,
    rgba(58, 48, 39, 0)   100%
  );
  box-shadow:
    0 1px 3px rgba(58, 48, 39, 0.14),
    0 -1px 2px rgba(255, 255, 255, 0.35);
  transform: scaleX(0.2);
  opacity: 0;
  pointer-events: none;
  z-index: 4;
}

/* Wax seal — sibling of .ld-paper inside the stage, absolutely
   positioned over the paper's fold line. The radial-gradient builds
   the wax disc: a hot highlight at upper-left, accent-colored body,
   and a darker rim. Inset shadows fake the molten-then-cooled relief.
   The disc is plain (no embossed glyph) so it reads as a quiet seal
   rather than a stamped crest, and uses the same terracotta tones as
   the "seal & send" button so the whole sealing motion stays in one
   color family.

   top: 40% (not 50%) because the paper scales from a top-anchored
   transform-origin during the fold stage, so the visual middle of the
   folded paper sits above the geometric middle of the stage. 40%
   lands the stamp on the crease across reasonable paper heights. */
.ld-seal-stamp {
  position: absolute;
  left: 50%;
  top: 40%;
  width: 64px;
  height: 64px;
  margin-left: -32px;
  margin-top: -32px;
  border-radius: 50%;
  background:
    radial-gradient(circle at 38% 32%,
      #d29384 0%,
      var(--accent) 35%,
      var(--accent-hover) 75%,
      #6f3d34 100%);
  box-shadow:
    inset -4px -5px 8px rgba(0, 0, 0, 0.32),
    inset 4px 4px 6px rgba(255, 255, 255, 0.18),
    0 6px 14px rgba(111, 61, 52, 0.40);
  opacity: 0;
  transform: translate(0, -120px) scale(0.35) rotate(-22deg);
  pointer-events: none;
  z-index: 5;
}

/* Paper keyframes — anticipation lift, fold, hold. Transform-origin
   for the paper is `50% 1.5rem` (set on .ld-paper), so scaleY
   compresses the bottom of the sheet upward, suggesting a fold from
   the top crease while keeping the masthead in place visually. */
@keyframes ld-seal-paper {
  0%   { transform: translateY(0) scale(1, 1);
         box-shadow:
           0 1px 1px rgba(58, 48, 39, 0.04),
           0 8px 22px rgba(58, 48, 39, 0.10); }
  /* Anticipation — lifts slightly and the shadow grows. */
  11%  { transform: translateY(-5px) scale(1, 1);
         box-shadow:
           0 2px 3px rgba(58, 48, 39, 0.06),
           0 18px 30px rgba(58, 48, 39, 0.18); }
  /* Fold begins. */
  20%  { transform: translateY(-5px) scale(1, 0.93); }
  /* Fold completes. */
  28%  { transform: translateY(-5px) scale(1, 0.78);
         box-shadow:
           0 2px 3px rgba(58, 48, 39, 0.08),
           0 22px 36px rgba(58, 48, 39, 0.22); }
  /* Hold during stamp drop — slight wobble when the seal lands. */
  44%  { transform: translateY(-5px) scale(1, 0.78) rotate(0deg); }
  48%  { transform: translateY(-3px) scale(1, 0.78) rotate(0.5deg); }
  52%  { transform: translateY(-5px) scale(1, 0.78) rotate(-0.3deg); }
  56%  { transform: translateY(-5px) scale(1, 0.78) rotate(0deg); }
  100% { transform: translateY(-5px) scale(1, 0.78) rotate(0deg); }
}

/* Crease keyframes — fades in and extends across the page during the
   fold stage. Stays at full opacity afterwards so the fold reads as a
   permanent crease for the rest of the animation. */
@keyframes ld-seal-crease {
  0%, 14%  { opacity: 0; transform: scaleX(0.2); }
  28%      { opacity: 1; transform: scaleX(1); }
  100%     { opacity: 1; transform: scaleX(1); }
}

/* Wax stamp keyframes — invisible through the fold stage, then drops
   from above and slightly tilted. A small over-shoot on landing
   (scale 1.1) settles into the final pose, suggesting wax compressing
   under pressure. The seal stays put through the rest of the animation;
   the stage carries it during the fly-away. */
@keyframes ld-seal-stamp {
  0%, 28%  { opacity: 0; transform: translate(0, -120px) scale(0.35) rotate(-22deg); }
  /* Falling — fades in as it accelerates downward. */
  40%      { opacity: 1; transform: translate(0, -10px) scale(1.1) rotate(-2deg); }
  /* Compression overshoot at landing. */
  48%      { opacity: 1; transform: translate(0, 0) scale(1.18) rotate(2deg); }
  /* Settle. */
  54%      { opacity: 1; transform: translate(0, 0) scale(0.96) rotate(2deg); }
  58%      { opacity: 1; transform: translate(0, 0) scale(1) rotate(2deg); }
  100%     { opacity: 1; transform: translate(0, 0) scale(1) rotate(2deg); }
}

/* Stage keyframes — holds still through the seal stages, then carries
   paper + stamp together in a fly-away. The fly-away combines an
   upward translate, a downscale, a rotate, and a fade so the sealed
   letter reads as "leaving the composer" rather than just dimming. */
@keyframes ld-seal-stage {
  0%, 61%  { transform: translate(0, 0) scale(1) rotate(0deg); opacity: 1; }
  78%      { transform: translate(20px, -70px) scale(0.86) rotate(-6deg); opacity: 0.85; }
  100%     { transform: translate(60px, -160px) scale(0.6) rotate(-10deg); opacity: 0; }
}

@keyframes ld-arrive {
  0%   { transform: translateY(14px) rotate(0.8deg); opacity: 0; }
  60%  { opacity: 1; }
  100% { transform: translateY(0) rotate(0); opacity: 1; }
}

.ld-paper-stage.is-sealing {
  animation: ld-seal-stage 1800ms cubic-bezier(.4, .05, .2, 1) forwards;
  pointer-events: none;
}
.ld-paper-stage.is-sealing .ld-paper {
  animation: ld-seal-paper 1800ms cubic-bezier(.4, .05, .2, 1) forwards;
}
.ld-paper-stage.is-sealing .ld-paper::before {
  animation: ld-seal-crease 1800ms cubic-bezier(.4, .05, .2, 1) forwards;
}
.ld-paper-stage.is-sealing .ld-seal-stamp {
  /* Slight overshoot easing on the stamp so the landing reads as
     impact + settle rather than a gentle descent. */
  animation: ld-seal-stamp 1800ms cubic-bezier(.5, 0, .35, 1.25) forwards;
}

.ld-paper.is-arriving {
  animation: ld-arrive 560ms cubic-bezier(.2, .7, .3, 1.05);
}

@media (prefers-reduced-motion: reduce) {
  .ld-paper-stage.is-sealing { animation: none; opacity: 0; transition: opacity 320ms ease; }
  .ld-paper-stage.is-sealing .ld-paper,
  .ld-paper-stage.is-sealing .ld-paper::before,
  .ld-paper-stage.is-sealing .ld-seal-stamp { animation: none; }
  .ld-paper.is-arriving { animation: none; }
}

.ld-toolbar {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0.55rem 0.9rem;
  border-bottom: 1px solid rgba(58, 48, 39, 0.08);
  background: rgba(247, 240, 232, 0.55);
}

.ld-tool {
  /* override the heavy global button */
  background: transparent;
  color: var(--fg-soft);
  border: 1px solid transparent;
  border-radius: 4px;
  padding: 0.25rem 0.55rem;
  font-size: 0.95rem;
  letter-spacing: 0;
  cursor: pointer;
  min-width: 1.85rem;
  line-height: 1.1;
}
.ld-tool:hover {
  background: rgba(176, 106, 91, 0.10);
  color: var(--accent);
}
.ld-tool.is-active {
  background: rgba(176, 106, 91, 0.16);
  color: var(--accent);
  border-color: rgba(176, 106, 91, 0.25);
}

.ld-tool-sep {
  width: 1px;
  height: 18px;
  background: rgba(58, 48, 39, 0.15);
  margin: 0 0.25rem;
}

.ld-tool-hint {
  color: var(--muted);
  font-size: 0.78rem;
  font-style: italic;
}

.ld-editor {
  min-height: 9rem;
  padding: 1.05rem 1.4rem 1.4rem;
  /* Monospace, matching the .statue-card-telegram card on /home so a
     written note feels like a dispatch / telegram being typed. */
  font-family: ui-monospace, "SF Mono", "Menlo", monospace;
  font-size: 1rem;
  line-height: 1.5;
  color: var(--fg);
  outline: none;
  white-space: pre-wrap;
  /* `overflow-wrap: anywhere` is more aggressive than the legacy
     `word-wrap: break-word` — it breaks even mid-word at any
     character when needed to keep a long unbroken string from
     expanding the editor horizontally. The legacy property only
     breaks at word boundaries, which leaves a 200-character URL
     blowing out the paper's right edge. */
  word-wrap: break-word;
  overflow-wrap: anywhere;
}

.ld-editor[contenteditable="true"]:empty::before {
  content: attr(data-placeholder);
  color: var(--muted);
  font-style: italic;
  pointer-events: none;
}

.ld-editor strong { font-weight: 600; }
.ld-editor em { font-style: italic; }

.ld-composer-footer {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 1rem 1.5rem;
  padding: 0.85rem 1.25rem 1rem;
  border-top: 1px dashed rgba(58, 48, 39, 0.12);
  background: rgba(247, 240, 232, 0.35);
}

/* Delivery hint — sits where the date picker used to. Delivery time
   is randomized server-side (mostly 2-7 days, occasionally weeks,
   rarely much longer), so the sender doesn't pick a date. The italic
   muted line is just a single short reminder that the wait is part
   of the experience, not a missing scheduling control. */
.ld-delivery-note {
  color: var(--muted);
  font-style: italic;
  font-size: 0.88rem;
  letter-spacing: 0.01em;
  /* Allow the note to shrink (and wrap its own text internally if
     space gets tight) before the footer's flex-wrap throws the
     actions onto a new row. Combined with the actions' flex-shrink:0
     below, this keeps the buttons on the same row as the note
     whenever the row is wide enough to hold the buttons' natural
     width — which is the case on most desktop widths. */
  flex: 1 1 auto;
  min-width: 0;
}

.ld-link-btn {
  background: transparent;
  border: none;
  color: var(--accent);
  font-size: 0.82rem;
  font-style: italic;
  cursor: pointer;
  padding: 0;
  letter-spacing: 0;
}
.ld-link-btn:hover { color: var(--accent-hover); text-decoration: underline; }

.ld-composer-actions {
  display: inline-flex;
  align-items: center;
  gap: 1rem;
  /* Don't shrink the actions block — buttons should keep their full
     padded widths even when the footer is tight. The delivery note
     beside them shrinks first (it's set to flex: 1 1 auto). When
     the row is genuinely too narrow even after the note has fully
     collapsed (phones), the footer's `flex-wrap: wrap` kicks the
     actions to a new line as a last resort. */
  flex: 0 0 auto;
}

.ld-status {
  color: var(--muted);
  font-style: italic;
  font-size: 0.85rem;
  min-height: 1.2em;
}

.ld-send {
  padding: 0.55rem 1.1rem;
  font-size: 0.92rem;
  letter-spacing: 0.06em;
  text-transform: lowercase;
  /* Stack the two labels (idle + confirm) in the same cell so the
     button width is the max of the two and the swap doesn't shift the
     surrounding layout. The unused label is faded out + made
     pointer-inert via .ld-send-label rules below. */
  display: inline-grid;
  position: relative;
  transition: background-color 220ms ease, color 220ms ease,
              border-color 220ms ease, box-shadow 220ms ease;
}
.ld-send-label {
  grid-area: 1 / 1;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 0.45rem;
  transition: opacity 180ms ease;
}
.ld-send-label-confirm { opacity: 0; pointer-events: none; }

/* Wax mini-disc that appears inside the confirm-state button. A small
   radial-gradient disc in the same terracotta tones as the button
   itself and the full wax seal that drops onto the paper — visually
   rhymes with the eventual seal so the act of "press to seal" reads
   as committing to what's about to happen. */
.ld-send-wax {
  width: 0.9rem;
  height: 0.9rem;
  border-radius: 50%;
  background:
    radial-gradient(circle at 38% 32%,
      #d29384 0%,
      var(--accent) 45%,
      #6f3d34 100%);
  box-shadow:
    inset -1px -1px 2px rgba(0, 0, 0, 0.40),
    inset 1px 1px 1px rgba(255, 255, 255, 0.25),
    0 1px 2px rgba(111, 61, 52, 0.40);
  flex-shrink: 0;
}

/* Confirm-state styling. The send button keeps its native accent
   color in both states — only the label morphs and the mini wax disc
   appears, so the confirm step feels like the same button arming
   rather than a different button taking its place. */
.ld-composer-actions[data-confirm-state="confirming"] .ld-send-label-idle {
  opacity: 0;
  pointer-events: none;
}
.ld-composer-actions[data-confirm-state="confirming"] .ld-send-label-confirm {
  opacity: 1;
  pointer-events: auto;
}

/* ── Save-as-draft / delete-draft button ─────────────────────────
   Secondary action paired with seal-and-send. Ghost/outline style
   (no fill, accent border) in the default "save draft" state; fills
   with accent on hover. While a draft from the book is loaded into
   the composer the button swaps to "delete draft" with a muted
   warning tint so the destructive action reads as distinct from the
   constructive one. Labels are stacked in an inline-grid so the
   button's width is the max of both and the swap doesn't jolt
   the surrounding layout. */
.ld-save-draft {
  position: relative;
  display: inline-grid;
  padding: 0.5rem 0.95rem;
  font-size: 0.86rem;
  letter-spacing: 0.06em;
  text-transform: lowercase;
  background: transparent;
  color: var(--accent);
  border: 1px solid var(--accent);
  border-radius: 999px;
  font-family: inherit;
  cursor: pointer;
  transition: background 160ms ease, color 160ms ease,
              border-color 160ms ease, opacity 160ms ease;
}
.ld-save-draft-label {
  grid-area: 1 / 1;
  transition: opacity 160ms ease;
}
.ld-save-draft-label-delete { opacity: 0; pointer-events: none; }

.ld-save-draft:hover,
.ld-save-draft:focus-visible {
  background: var(--accent);
  color: var(--bg-card);
  outline: none;
}
.ld-save-draft:disabled {
  opacity: 0.45;
  cursor: default;
}

/* Delete-draft state — applied via data-state="delete" when a draft
   is loaded in the composer. Muted brown border + label colour
   reads as "destructive but not alarming"; hover fills with a
   warning brown rather than the constructive accent so the action's
   meaning is clear. */
.ld-save-draft[data-state="delete"] {
  color: var(--muted);
  border-color: rgba(58, 48, 39, 0.4);
}
.ld-save-draft[data-state="delete"] .ld-save-draft-label-save {
  opacity: 0; pointer-events: none;
}
.ld-save-draft[data-state="delete"] .ld-save-draft-label-delete {
  opacity: 1; pointer-events: auto;
}
.ld-save-draft[data-state="delete"]:hover,
.ld-save-draft[data-state="delete"]:focus-visible {
  background: #8a4a3d;
  color: var(--bg-card);
  border-color: #8a4a3d;
}

/* Voice mode hides the save-draft button — but with `visibility:
   hidden` rather than `display: none` so the button's slot stays
   reserved in the footer's flex layout. If we collapsed the slot
   with `display: none`, the delivery-note + actions row would
   reflow on every mode switch (text → voice shrinks the actions
   row, often un-wrapping a previously-wrapped row; voice → text
   re-introduces it and may wrap again). Keeping the slot keeps
   the footer geometry identical across modes — no jolt, no
   resize. The button stays interactive-inert via the visibility
   property; the slot is just empty space. */
.ld-composer[data-mode="voice"] .ld-save-draft { visibility: hidden; }

/* ── Save-to-drafts animation ────────────────────────────────────
   Parallel to the seal-and-send animation but with a different
   motion vocabulary, so the two actions feel related but distinct:

     • Seal+send (.is-sealing): paper folds, wax stamp drops, the
       letter flies UP and AWAY off-screen (the mailbox swallows it).
     • Save+file (.is-filing): paper folds shallower, no wax stamp,
       slides DOWN and slightly LEFT toward the drafts book sitting
       below — the gesture of tucking a sheet into the cover of a
       book on the desk.

   Both end with the paper-stage hidden and a fresh sheet "arriving"
   on the composer via the existing .ld-paper.is-arriving class, so
   the user sees a clean composer ready for the next note either way. */
@keyframes ld-file-paper {
  0%   { transform: translateY(0) scale(1, 1);
         box-shadow:
           0 1px 1px rgba(58, 48, 39, 0.04),
           0 8px 22px rgba(58, 48, 39, 0.10); }
  /* Small anticipation lift — same opening beat as the seal so the
     two animations feel like siblings. */
  10%  { transform: translateY(-4px) scale(1, 1);
         box-shadow:
           0 2px 3px rgba(58, 48, 39, 0.06),
           0 14px 26px rgba(58, 48, 39, 0.16); }
  /* Fold in half (deeper than the seal, since the paper is going to
     be tucked away rather than mailed). */
  32%  { transform: translateY(-4px) scale(1, 0.6);
         box-shadow:
           0 2px 3px rgba(58, 48, 39, 0.06),
           0 16px 28px rgba(58, 48, 39, 0.18); }
  /* Hold briefly so the fold reads, then ride the stage animation
     down to the drafts book. */
  100% { transform: translateY(-4px) scale(1, 0.6); }
}

@keyframes ld-file-stage {
  0%, 32%  { transform: translate(0, 0) scale(1) rotate(0deg); opacity: 1; }
  /* Slide down + slightly left, scaling down and tilting forward, as
     if the sheet were being tucked under the cover of the book below
     the composer. The translate values approximate the visual offset
     to the drafts ledger; JS doesn't need to measure layouts at this
     resolution. */
  72%      { transform: translate(-10px, 70px) scale(0.7) rotate(-2deg); opacity: 0.88; }
  100%     { transform: translate(-22px, 150px) scale(0.42) rotate(-5deg); opacity: 0; }
}

.ld-paper-stage.is-filing {
  animation: ld-file-stage 1200ms cubic-bezier(.4, .05, .2, 1) forwards;
  pointer-events: none;
}
.ld-paper-stage.is-filing .ld-paper {
  animation: ld-file-paper 1200ms cubic-bezier(.4, .05, .2, 1) forwards;
}
.ld-paper-stage.is-filing .ld-paper::before {
  /* Reuse the seal-crease keyframes — the fold-line motif is the
     same regardless of whether the paper is being sealed or filed. */
  animation: ld-seal-crease 1200ms cubic-bezier(.4, .05, .2, 1) forwards;
}

@media (prefers-reduced-motion: reduce) {
  .ld-paper-stage.is-filing { animation: none; opacity: 0; transition: opacity 280ms ease; }
  .ld-paper-stage.is-filing .ld-paper,
  .ld-paper-stage.is-filing .ld-paper::before { animation: none; }
}

/* ── Inbox ──────────────────────────────────────────────────────── */

/* ── Ledger covers (Drafts + Addressed-to-you) ────────────────────
   Both books share one cover treatment: a bound-cover with a
   terracotta stitched spine, sitting flat on the desk with an
   asymmetric drop shadow. The cover is the *only* part with the
   bound-book framing — the body region beneath it is plain, so
   the bundles/drafts read as loose papers tucked underneath the
   cover rather than as pages inside a giant binder. Clicking the
   cover slides the body out from under it. */

.ld-section {
  position: relative;
  margin: 0 0 1.4rem;
  /* No background, border, shadow, or tilt of its own — it's just a
     grouping wrapper. The cover carries the bound-book look and the
     body carries the loose-papers look; the section is the seam. */
}

.ld-ledger {
  position: relative;
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 0.6rem 1.4rem;
  flex-wrap: wrap;
  /* Own stacking context — the inbox cover hosts the paperclip's
     back/tag/front layers (z-index 0..3), which need to stack
     against the cover itself rather than bubble up into the page. */
  isolation: isolate;
  /* Left padding clears the 13px spine; right/top/bottom give the
     cover content breathing room from the cover edges. */
  padding: 1rem 1.3rem 1.05rem 1.85rem;
  background: var(--bg-card);
  border: 1px solid rgba(58, 48, 39, 0.18);
  border-left: none;
  border-radius: 0 5px 5px 0;
  /* Asymmetric drop shadow — heavier on the right and below than on
     the spine side. Reads as a closed book casting a shadow away
     from its bound edge, much weightier than the floppy letter
     cards below. */
  box-shadow:
    1px 1px 0 rgba(58, 48, 39, 0.06),
    4px 6px 14px rgba(58, 48, 39, 0.12);
  /* Barely any tilt — the ledger cover sits flatter on the desk than
     the letters do. */
  transform: rotate(-0.25deg);
  cursor: pointer;
  user-select: none;
  /* z-index keeps the cover painting above the body so the body's
     top edge tucks visually under the cover's bottom edge — sells the
     "papers emerging from under the cover" rather than "rows inside
     the same binder." */
  z-index: 2;
}

.ld-ledger:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
  border-radius: 0 3px 3px 0;
}

/* The bound spine — terracotta vertical strip flush with the cover's
   left edge, picking up the page's --accent (#b06a5b). Lives on the
   cover only; the body has no spine because the body is loose papers,
   not bound pages. */
.ld-ledger::before {
  content: "";
  position: absolute;
  top: 0;
  bottom: 0;
  left: 0;
  width: 13px;
  background: linear-gradient(
    to right,
    #9a5849 0%,
    #b06a5b 60%,
    #c48270 92%,
    rgba(176, 106, 91, 0.25) 100%
  );
  border-radius: 3px 0 0 3px;
  box-shadow: inset -1px 0 0 rgba(86, 38, 28, 0.18);
}

/* Stitching marks down the spine — cream dashes evoking the thread
   that binds a sewn cover. Inset slightly from the cover's top and
   bottom so the stitching doesn't run flush to the corners. */
.ld-ledger::after {
  content: "";
  position: absolute;
  top: 0.85rem;
  bottom: 0.85rem;
  left: 4.5px;
  width: 4px;
  background: repeating-linear-gradient(
    to bottom,
    rgba(247, 240, 232, 0.55) 0,
    rgba(247, 240, 232, 0.55) 4px,
    transparent 4px,
    transparent 13px
  );
  border-radius: 1px;
  pointer-events: none;
}

.ld-ledger-title {
  font-family: ui-serif, Georgia, "Times New Roman", serif;
  font-weight: 400;
  font-style: italic;
  font-size: 1.4rem;
  margin: 0;
  /* Lighter than --fg — softened so the heading sits at "page
     furniture" intensity rather than competing with the body copy
     of the letters below. */
  color: rgba(58, 48, 39, 0.72);
  letter-spacing: 0.01em;
}

/* Right-side meta column on the cover — baseline-aligned inline
   with the title rather than stacked above its value, so the cover
   reads as a single sentence: "Addressed to you / last delivery:
   May 22 · 7:42 am". The label sits on the same writing-line as
   both the title (printed) and the scrawl (handwritten). */
.ld-ledger-meta {
  display: inline-flex;
  align-items: baseline;
  gap: 0.45rem;
}

.ld-ledger-meta-label {
  font-family: ui-serif, Georgia, "Times New Roman", serif;
  font-weight: 400;
  font-style: italic;
  font-size: 0.85rem;
  color: var(--muted);
  letter-spacing: 0.02em;
}

/* The meta entry value uses .ld-scrawl on both ledgers (Caveat
   handwriting on a dashed rule). Class declared further down. */

/* Fold caret — same affordance as the per-month-bundle fold, so
   "click to expand" reads as one consistent gesture at every
   nesting level. Rotates between closed/open via the .is-open
   class on the section. */
.ld-ledger-fold {
  width: 0.85rem;
  height: 0.85rem;
  border-right: 1.5px solid var(--accent);
  border-bottom: 1.5px solid var(--accent);
  transform: rotate(45deg);
  opacity: 0.55;
  margin-left: 0.4rem;
  flex-shrink: 0;
  /* Drop to baseline of the row so the caret sits visually centered
     against the italic title baseline rather than floating above. */
  align-self: center;
  transition: transform 280ms cubic-bezier(.4, .05, .2, 1), opacity 200ms ease;
}

.ld-section.is-open .ld-ledger-fold {
  transform: rotate(-135deg) translate(-2px, -2px);
  opacity: 0.85;
}

/* Accordion shell — same grid-template-rows trick the per-note and
   per-month-bundle accordions use, so all three nesting levels
   open/close with the same smooth height curve. No background and no
   border on the shell or body: the bundles and drafts paint directly
   on the page background, so they look like loose papers that were
   tucked under the cover, not pages inside a binder. */
.ld-ledger-shell {
  display: grid;
  grid-template-rows: minmax(0, 0fr);
  /* Explicit minmax(0, 1fr) column so a long unbroken string inside
     the body can't expand the grid track past the parent's width.
     Default `grid-template-columns: none` becomes a single auto
     track sized to its content, which a long unbroken word would
     blow wide; the minmax constraint pins the upper bound to the
     parent's available width. */
  grid-template-columns: minmax(0, 1fr);
  overflow: hidden;
  transition: grid-template-rows 420ms cubic-bezier(.4, .05, .2, 1);
  position: relative;
  z-index: 1;
  /* Negative top margin pulls the body's top edge up under the
     cover's bottom edge — papers emerge from underneath the cover
     instead of starting on a separate row below it. */
  margin-top: -4px;
}

.ld-section.is-open .ld-ledger-shell {
  grid-template-rows: minmax(0, 1fr);
}

.ld-ledger-body {
  min-height: 0;
  /* Top padding clears the cover's drop shadow AND gives the first
     bundle a clear visual break from the binder above — without the
     extra space the bundle's front sheet sits right up against the
     cover's bottom edge and reads as part of the same object.
     Bottom padding gives the last bundle a tail of empty space
     before the next ledger section. */
  padding: 1.7rem 0.2rem 0.9rem 0.2rem;
}

.ld-inbox-list {
  display: flex;
  flex-direction: column;
  gap: 0.8rem;
}

/* ── Drafts list ──────────────────────────────────────────────────
   Each draft is a small paper card that visually echoes the closed
   letter envelopes in the inbox below: same cream paper stock, same
   subtle hand-placed tilt, same postmark-style stamp on the right
   (just "created" rather than "written"/"delivered"). One-line
   preview only — the card height stays constant as content changes
   so auto-save edits to one draft don't reflow the rest of the
   stack. Selecting a draft (click anywhere on the card) loads it
   into the composer; the selected card flips into a warm-accent
   tint so the link between composer and source draft is unambiguous. */

.ld-drafts-list {
  display: flex;
  flex-direction: column;
  gap: 0.7rem;
}

.ld-drafts-list .ld-empty {
  padding: 1.4rem 0;
}

.ld-draft {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  /* Flex items default to min-width:auto, which means a child's
     intrinsic minimum content size (a single long unbroken word in
     the preview) can push the card wider than its parent. Pin
     min-width to 0 so the preview's own ellipsis clipping can
     actually take effect against the available width. */
  min-width: 0;
  background: var(--bg-card);
  border: 1px solid rgba(58, 48, 39, 0.12);
  border-radius: 3px;
  padding: 0.7rem 0.95rem;
  box-shadow:
    0 1px 1px rgba(58, 48, 39, 0.04),
    0 3px 8px rgba(58, 48, 39, 0.06);
  /* Per-draft rotation set by JS via --draft-rotate (noise-seeded on
     id) so adjacent drafts don't sit at identical angles. ±0.3°. */
  transform: rotate(var(--draft-rotate, 0deg));
  cursor: pointer;
  /* Background + border transition lets the selected-tint swap glide
     in rather than snap. */
  transition: background 200ms ease, border-color 200ms ease,
              box-shadow 200ms ease;
}

.ld-draft:hover {
  border-color: rgba(58, 48, 39, 0.22);
  box-shadow:
    0 1px 1px rgba(58, 48, 39, 0.05),
    0 4px 10px rgba(58, 48, 39, 0.08);
}

/* Selected — the draft currently loaded in the composer. Warm
   terracotta tint over the card's cream paper (NOT a translucent
   wash, which would let the page bg show through and read as
   "transparent"), plus a solid accent border so the link between
   the editor and the source row is unambiguous. Replaces the prior
   "editing a draft" status message: the cue lives on the draft
   itself rather than as a text label that would resize the
   composer area.

   Implementation: stack a low-alpha accent layer over the opaque
   card colour with linear-gradient + multi-background. Keeps the
   tint adjustable via the rgba alpha while the card stays opaque.
   The visual is identical to a hand-picked solid colour, but
   easier to tune. */
.ld-draft.is-selected {
  background:
    linear-gradient(rgba(176, 106, 91, 0.10), rgba(176, 106, 91, 0.10)),
    var(--bg-card);
  border-color: var(--accent);
  box-shadow:
    0 1px 1px rgba(58, 48, 39, 0.05),
    0 4px 10px rgba(176, 106, 91, 0.18);
}

.ld-draft.is-selected:hover {
  background:
    linear-gradient(rgba(176, 106, 91, 0.14), rgba(176, 106, 91, 0.14)),
    var(--bg-card);
  border-color: var(--accent);
}

.ld-draft-preview {
  flex: 1 1 auto;
  min-width: 0;
  font-family: ui-serif, Georgia, "Times New Roman", serif;
  font-size: 1rem;
  line-height: 1.4;
  color: rgba(58, 48, 39, 0.82);
  /* Strict single-line clamp — drafts in the book never expand, so
     the card's height stays fixed regardless of body length. That
     keeps auto-save updates invisible: changes to the body don't
     change the card height, so nothing reflows. */
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.ld-draft-preview-empty {
  font-style: italic;
  color: var(--muted);
}

/* Stamps container — mirrors the inbox notes' `.ld-note-stamps`
   layout (right side of the card, single column when one stamp).
   We reuse the existing `.ld-note-stamp` / `-label` / `-date`
   styles for the actual stamp visual so the typography and ink-fade
   noise pattern match between drafts and letters exactly. */
.ld-draft-stamps {
  flex: 0 0 auto;
  display: inline-flex;
}

/* The handwritten entry. Caveat is the primary face — a casual
   pen-style handwriting font; the fallback chain reaches for the
   OS-installed handwriting fonts so the entry still reads as
   "written in by hand" before the webfont arrives (or for users
   who block Google Fonts).

   The ruled line under the entry lives on the span itself
   (border-bottom), so the line is always exactly as long as the
   handwritten text — the carrier wrote across the blank and
   stopped. The slight rotation gives the scrawl a hand-placed
   feel without leaning so far it stops looking like part of the
   ledger's ruled row. */
.ld-scrawl {
  font-family:
    "Caveat",
    "Bradley Hand", "Segoe Script", "Lucida Handwriting",
    "Marker Felt", cursive;
  font-weight: 600;
  font-size: 1.4rem;
  line-height: 1.1;
  /* Sepia ink — softer than the dark form text. Reads as ballpoint
     pen catching the cream page, and pairs better with the lightened
     "Addressed to you" color above (both sit at roughly the same
     visual weight, so neither dominates the banner). */
  color: rgba(58, 48, 39, 0.72);
  padding: 0 0.25rem 0.12rem;
  border-bottom: 1px dashed rgba(58, 48, 39, 0.32);
  /* Tiny lift so descenders on letters like "y" don't crash into
     the rule. Caveat sits high on its em-box so this is a subtle
     nudge rather than a big offset. */
  transform: rotate(-0.6deg) translateY(-1px);
  transform-origin: left baseline;
  white-space: nowrap;
}

/* ── Month bundles ──────────────────────────────────────────────────
   Notes are grouped by the month they were delivered. Each bundle is
   a section with a big circular postmark stamp (month abbreviation +
   year), a letter count, and a chevron that drives an open/closed
   accordion using the same grid-template-rows trick the individual
   notes use. Only the most recent bundle starts expanded; older months
   sit as collapsed strips you click to unbundle. Keeps the inbox a
   constant ~handful of section headers regardless of how many letters
   have accumulated. */

.ld-month-bundle {
  position: relative;
  /* The bottom gap leaves room for the back-sheet pseudos to peek
     downward without crashing into the next bundle in the list. */
  margin-bottom: 2rem;
  /* Performance: skip the layout, paint, and SVG-filter work for any
     bundle that's scrolled off-screen. With a year of correspondence
     in the inbox, most bundles are out of view at any given moment;
     this lets the browser do roughly zero work for those instead of
     filtering every stamp inside them on every paint.
     `contain-intrinsic-size: auto N` gives a height estimate before
     the bundle is rendered for the first time so the scrollbar
     geometry doesn't bounce; `auto` then reuses the measured size on
     subsequent renders. */
  content-visibility: auto;
  contain-intrinsic-size: auto 130px;
}

/* The header is the "front sheet" of the bundle: a paper-colored
   card with two more paper-colored cards stacked behind it (via
   ::before and ::after) at slight offsets and rotations. The whole
   thing reads as a small stack of letters lying on the inbox,
   labeled with a postmark and a count. Neither the bundle nor the
   header creates a stacking context, so the pseudos' z-index: -1
   places them behind the notes-shell when the bundle is open — the
   notes paint in front of the stack, as if pulled out of it. */
.ld-month-header {
  position: relative;
  display: flex;
  align-items: center;
  gap: 1.1rem;
  padding: 1rem 1.3rem;
  cursor: pointer;
  user-select: none;
  background: var(--bg-card);
  border: 1px solid rgba(58, 48, 39, 0.12);
  border-radius: 4px;
  box-shadow:
    0 1px 1px rgba(58, 48, 39, 0.04),
    0 4px 12px rgba(58, 48, 39, 0.07);
  /* Rotation comes from the noise-seeded --bundle-rotate (set by JS on
     the bundle wrapper, inherited here). `translate` is a separate CSS
     property so hover/open lifts can stack on top without overwriting
     the rotation. */
  transform: rotate(var(--bundle-rotate, -0.4deg));
  translate: 0 0;
  transition: translate 240ms ease, box-shadow 240ms ease;
}

.ld-month-header::before,
.ld-month-header::after {
  content: "";
  position: absolute;
  inset: 0;
  background: var(--bg-card);
  border: 1px solid rgba(58, 48, 39, 0.10);
  border-radius: 4px;
  z-index: -1;
  transition: transform 280ms cubic-bezier(.4,.05,.2,1);
}

.ld-month-header::before {
  transform: rotate(1.4deg) translate(3px, 5px);
  box-shadow: 0 1px 1px rgba(58, 48, 39, 0.04);
}

.ld-month-header::after {
  transform: rotate(-1.1deg) translate(-3px, 9px);
  z-index: -2;
  box-shadow: 0 1px 1px rgba(58, 48, 39, 0.03);
}

/* Opened bundle — the back sheets fan a touch further apart, as if
   the stack has been loosened, and the front sheet lifts a hair. The
   front sheet's rotation stays put (driven by the noise var) so the
   per-bundle character is preserved across hover/open states. */
.ld-month-bundle.is-open .ld-month-header {
  translate: 0 -1px;
}
/* When the bundle opens, the two back-sheet pseudos fan outward
   slightly — one slips down-and-right, the other down-and-left.
   Reads as a stack of letters being loosened on a desk. This is
   the *bundle* opening, which is a different motion from a
   *letter* opening (the previous concern about horizontal note
   drift was about the note article itself rotating around a moving
   pivot during body-shell expansion; the bundle's back sheets are
   fixed-size pseudos, not animating articles, so they can shuffle
   without dragging the rest of the inbox sideways). */
.ld-month-bundle.is-open .ld-month-header::before {
  transform: rotate(2.2deg) translate(5px, 8px);
}
.ld-month-bundle.is-open .ld-month-header::after {
  transform: rotate(-1.8deg) translate(-5px, 13px);
}

.ld-month-header:hover {
  translate: 0 -1px;
  box-shadow:
    0 2px 2px rgba(58, 48, 39, 0.06),
    0 8px 20px rgba(58, 48, 39, 0.10);
}
.ld-month-bundle.is-open .ld-month-header:hover {
  translate: 0 -2px;
}

.ld-month-header:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 4px;
}

/* The postmark — circular, tilted, accent-colored. Bigger and bolder
   than the per-note date stamps so it reads as a section heading and
   not just another little date sitting next to a letter. */
.ld-month-postmark {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  width: 3.6rem;
  height: 3.6rem;
  border: 2px solid var(--accent);
  border-radius: 50%;
  color: var(--accent);
  font-family: ui-monospace, "SF Mono", "Menlo", monospace;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  /* Per-bundle `--ink-fade` (set by JS in buildMonthBundle, seeded
     by year-month) rides on the postmark's overall opacity so
     adjacent monthly postmarks land at visibly different darkness
     levels. The base 0.86 + ±0.22 variance = ~0.64–1.0 effective
     opacity range. */
  opacity: calc(0.86 + var(--ink-fade, 0));
  /* Slight tilt + tiny scale up on hover for the whole header. The
     postmark is the focal point of the strip so a bit of motion on
     hover sells the "this is interactive" affordance. */
  transform: rotate(-3.5deg);
  flex-shrink: 0;
  transition: transform 220ms ease;
  /* Transparent interior, same reason as `.ld-note-stamp` above:
     keeps the saturation-blend filter from finding any pixels to
     fade inside the empty circle. The card behind shows through
     cleanly, no marks. */
  background: transparent;
}

.ld-month-header:hover .ld-month-postmark {
  transform: rotate(-2deg) scale(1.04);
}

.ld-month-postmark-name {
  font-size: 0.78rem;
  line-height: 1;
}

.ld-month-postmark-year {
  font-size: 0.56rem;
  line-height: 1;
  margin-top: 0.22rem;
  opacity: 0.7;
}

.ld-month-info {
  flex: 1 1 auto;
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: 0.15rem;
}

.ld-month-count {
  color: var(--muted);
  font-style: italic;
  font-size: 0.92rem;
}

/* Chevron — same motif as the per-note fold caret so the inbox uses
   one consistent "click to expand" affordance at both nesting levels. */
.ld-month-fold {
  width: 0.85rem;
  height: 0.85rem;
  border-right: 1.5px solid var(--accent);
  border-bottom: 1.5px solid var(--accent);
  transform: rotate(45deg);
  margin-right: 0.4rem;
  opacity: 0.55;
  transition: transform 280ms cubic-bezier(.4,.05,.2,1), opacity 200ms ease;
  flex-shrink: 0;
}

.ld-month-bundle.is-open .ld-month-fold {
  transform: rotate(-135deg) translate(-2px, -2px);
  opacity: 0.85;
}

/* Notes inside the bundle — same grid-rows accordion as a single
   note's body. Need `minmax(0, ...)` rather than bare `Nfr` for the
   row to actually collapse to 0; see the .ld-note-body-shell rule
   below for the long-form explanation. */
.ld-month-notes-shell {
  display: grid;
  grid-template-rows: minmax(0, 0fr);
  overflow: hidden;
  transition: grid-template-rows 380ms cubic-bezier(.4,.05,.2,1);
}

.ld-month-bundle.is-open .ld-month-notes-shell {
  grid-template-rows: minmax(0, 1fr);
}

.ld-month-notes {
  display: flex;
  flex-direction: column;
  gap: 1.6rem;
  /* Padding-top has to clear the back-sheet pseudos that fan ~13px
     below the front sheet when the bundle is open. 2.5rem gives the
     first note real breathing room below the stack rather than
     bumping right up against it. */
  padding: 2.5rem 0 1.6rem;
  min-height: 0;
}

.ld-empty {
  text-align: center;
  color: var(--muted);
  font-style: italic;
  padding: 2rem 0;
}

/* Each envelope. A folded note sits at rest as a strip; clicking it
   expands the wrapper to reveal the body underneath. The body is
   double-wrapped (.ld-note-body-shell > .ld-note-body) so we can
   animate ONLY grid-template-rows on the shell — that keeps the close
   animation a single smooth height curve, instead of the old max-height
   + padding combo that closed in two visible steps. */
.ld-note {
  position: relative;
  background: var(--bg-card);
  border: 1px solid rgba(58, 48, 39, 0.10);
  border-radius: 4px;
  box-shadow:
    0 1px 1px rgba(58, 48, 39, 0.04),
    0 6px 18px rgba(58, 48, 39, 0.08);
  transition: box-shadow 200ms ease, transform 200ms ease;
}

.ld-note:hover {
  box-shadow:
    0 2px 2px rgba(58, 48, 39, 0.05),
    0 10px 24px rgba(58, 48, 39, 0.12);
}

/* Noise-driven rest rotation. `--note-rotate` is set per-id by JS
   (range ±0.3°, falls back to 0°). The key detail is the
   `transform-origin`: pinning the pivot to the article's top edge
   instead of the default center means the rotation pivot doesn't
   move when the body shell expands. The visible top of the note
   stays anchored to its layout position throughout open/close, so
   the article doesn't appear to slide left or right as it grows —
   only the bottom of the article drifts horizontally as it gets
   taller, and that drift is small (≈ sin(0.3°) × body height ≈ 1–2
   px for a typical letter) and reads as part of the unfolding
   rather than as the whole note shifting sideways. The previous
   default `transform-origin: center` moved the pivot down as the
   article grew, which made the entire visible article appear to
   translate horizontally and get clipped by the bundle's notes-
   shell — exactly the issue we hit before. */
.ld-note {
  transform: rotate(var(--note-rotate, 0deg));
  transform-origin: top center;
  /* The note's rest rotation animates back to 0° when the letter is
     opened — see `.ld-note.is-open` below. Matching the body-shell's
     380ms cubic-bezier so the straightening and the unfolding run in
     lockstep: the user clicks, the letter both rights itself and
     unfolds at the same time, then tilts back to its hand-placed
     angle when closed. This solves the previous "open letters drift
     horizontally and get clipped" problem since open letters are at
     0° rotation (no horizontal drift to worry about), while
     preserving the noise-driven tilt variation when you're skimming
     a page of closed letters. */
  transition: transform 380ms cubic-bezier(.4,.05,.2,1);
  /* Same content-visibility optimization as the bundle wrapper: skip
     painting + filtering for notes that are scrolled off-screen.
     This is the single biggest performance win on this page —
     individual stamps each carry an SVG filter, and at 200+ notes
     that's a lot of filter work per paint when there's no
     virtualization. content-visibility turns it into roughly zero
     work for off-screen notes. */
  content-visibility: auto;
  contain-intrinsic-size: auto 60px;
}

.ld-note.is-open {
  transform: rotate(0deg);
}

.ld-note-header {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 0.55rem 1rem;
  /* Bottom padding gives the rotated stamps a little breathing room
     before the header's bottom border — a stamp tilted ~6° has its
     corners noticeably lower than its center, and a tight bottom edge
     made the corners read as crowding the divider. */
  padding: 1rem 1.3rem 1.1rem;
  border-bottom: 1px solid rgba(58, 48, 39, 0.06);
  cursor: pointer;
  user-select: none;
}

.ld-note-from {
  font-size: 0.72rem;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--accent);
  /* Pick up the same blotchy-fade ink filter as the date stamps —
     `from Stephanie` / `from Jonas` reads as another ink impression
     on the envelope and should age with the letter. The per-tier
     overrides (further down with the other is-aged-N rules) swap in
     stronger filters for older letters. */
  filter: url(#ld-ink-pristine);
}

.ld-note.is-aged-1 .ld-note-from { filter: url(#ld-ink-aged1); }
.ld-note.is-aged-2 .ld-note-from { filter: url(#ld-ink-aged2); }
.ld-note.is-aged-3 .ld-note-from { filter: url(#ld-ink-aged3); }

/* Stamps row — sits in the middle of the header. The two stamps
   ("written" and "delivered") use the same postmark motif as the
   museum cards but tilt in opposite directions so they read as two
   separate impressions rather than a single composite. */
.ld-note-stamps {
  display: inline-flex;
  align-items: center;
  /* Gap widened from 0.7rem to 1rem to absorb the bigger horizontal
     stamp jitter (±7px each → up to 14px relative shift between
     neighbors). A narrower gap would let two opposite-direction
     shifts visually collide. */
  gap: 1rem;
  flex-wrap: wrap;
}

.ld-note-stamp {
  display: inline-flex;
  flex-direction: column;
  align-items: center;
  gap: 0.18rem;
  font-family: ui-monospace, "SF Mono", "Menlo", monospace;
  font-size: 0.68rem;
  letter-spacing: 0.16em;
  font-weight: 600;
  color: var(--accent);
  border: 1.3px solid var(--accent);
  border-radius: 3px;
  padding: 5px 9px 4px;
  text-transform: uppercase;
  line-height: 1.05;
  opacity: calc(0.78 + var(--ink-fade, 0));
  /* Transparent interior: any non-zero bg fill (even at 40% alpha)
     gives the saturation-blend filter pixels to act on, which then
     reads as visible "fading patches" inside the empty rectangle of
     the stamp. With no fill, those pixels are genuinely zero alpha;
     the filter's two branches (source and desat) both sum to zero
     there, so the interior shows the card behind exactly as-is —
     no fading, no marks. The border and text are the only opaque
     pixels left, so they're the only places the noise lives. */
  background: transparent;
}

.ld-note-stamp-label {
  font-size: 0.6rem;
  letter-spacing: 0.22em;
  opacity: 0.7;
}

.ld-note-stamp-date {
  font-size: 0.75rem;
}

/* The base rotations (-2°/-5°) are jittered per-note in JS via
   --stamp-rotate, plus a tiny per-stamp translation via --stamp-dx /
   --stamp-dy, so each note's stamps look individually placed rather
   than uniformly tilted. Translate is written before rotate so the
   shift stays in screen axes (down=down) regardless of the angle.
   Fallback values keep the stamps tilted if the script doesn't run. */
.ld-note-stamp-written {
  transform: translate(var(--stamp-dx, 0), var(--stamp-dy, 0)) rotate(var(--stamp-rotate, -2deg));
  color: var(--fg-soft);
  /* Border matches the text color exactly (both --fg-soft, both
     fully opaque). The previous `rgba(94, 82, 74, 0.55)` border
     was the same hue but at 55% alpha, which made the text read
     as much darker than the border — especially noticeable once
     the saturation-blend filter was added on top. Aligning the
     two colors keeps the stamp reading as a single ink tone
     before the noise modulates it. */
  border-color: var(--fg-soft);
}

.ld-note-stamp-delivered {
  transform: translate(var(--stamp-dx, 0), var(--stamp-dy, 0)) rotate(var(--stamp-rotate, -5deg));
}

/* ── Stamp ink texture — pressed-impression via SVG displacement ───
   Stamps and postmarks pick up a "pressed by hand" look from an SVG
   `feDisplacementMap` filter defined inline in the template
   (`#ld-ink-*`). The filter operates on the rasterized rendering of
   the element: every pixel gets nudged by a tiny amount based on a
   turbulence-noise map. Three things this approach gets right that
   the previous ::after-overlay approach kept getting wrong:

     1. The filter moves *existing* pixels rather than adding new
        ones. Transparent pixels — the empty interior of a stamp —
        have nothing to move, so the interior stays clean. No spots
        or speckles where there shouldn't be any.
     2. Pixel colors don't change. The previous attempts kept hitting
        either light splotches (when the noise revealed lighter
        paper underneath) or dark splotches (when the noise added a
        darkening overlay). Displacement only shifts positions, so
        ink can never accidentally turn into paper-color or
        accent-darker — the worst it can do is wobble its own edges.
     3. The filter operates uniformly on border + text + glyphs,
        because all of them are part of the same rasterized layer.
        No need to split where the noise applies.

   Per-stamp variation comes from `--ink-fade` riding on the stamp's
   own opacity (further down) — so adjacent stamps still land at
   visibly different overall darkness levels, which addresses the
   "everything is uniformly faded" concern. Per-weathering-tier
   variation comes from swapping in stronger filters (#ld-ink-aged1
   → -aged3) which use larger displacement scales — an aged-3
   letter's stamps will look visibly more wobbled / less crisp
   than a pristine letter's. */

.ld-note-stamp,
.ld-month-postmark {
  filter: url(#ld-ink-pristine);
}

.ld-note.is-aged-1 .ld-note-stamp { filter: url(#ld-ink-aged1); }
.ld-note.is-aged-2 .ld-note-stamp { filter: url(#ld-ink-aged2); }
.ld-note.is-aged-3 .ld-note-stamp { filter: url(#ld-ink-aged3); }

/* Body shell — grid trick lets us transition height from 0 → content's
   intrinsic size with a single property, so closing is one smooth move
   instead of the old max-height + padding two-step. The inner body
   keeps its padding always; overflow on the shell hides the spillage
   while closed.

   Important: the track must be `minmax(0, Xfr)`, NOT bare `Xfr`. A
   bare `Nfr` track defaults to `minmax(auto, Nfr)`, which floors the
   track at the grid item's min-content height — and a div full of
   text has a non-zero min-content height (its padding plus a line of
   text), so the row never actually collapses to 0 and the accordion
   stays stuck partly open. `minmax(0, ...)` forces the floor to 0. */
.ld-note-body-shell {
  display: grid;
  grid-template-rows: minmax(0, 0fr);
  overflow: hidden;
  transition: grid-template-rows 380ms cubic-bezier(.4,.05,.2,1);
}

.ld-note.is-open .ld-note-body-shell {
  grid-template-rows: minmax(0, 1fr);
}

/* Two details that keep the text rendering rock-steady through the
   shell's grid-rows accordion:
     1. `align-self: start` — without it, the body stretches to fill
        the (animating) grid row, which means the body's box height
        changes continuously and the browser re-rasterizes text at
        every intermediate clipping edge. Pinning to start keeps the
        body at its natural height; only the shell's clipping window
        grows, so glyph positions never shift.
     2. No opacity transition. The grid-row reveal already produces
        the "letter unfolding" effect by progressively uncovering the
        body from top to bottom. Adding an opacity 0 → 1 fade on top
        of that meant the body sat on a composite layer with
        grayscale anti-aliased text for the duration of the
        animation, then dropped back to subpixel AA the instant
        opacity hit 1 — that AA-mode switch read as "text shifts /
        becomes slightly taller" right when the animation finished.
        Letting opacity stay at 1 throughout keeps the text in one
        rendering mode the whole time; the shell's overflow:hidden
        does all the reveal work on its own. */
.ld-note-body {
  align-self: start;
  position: relative;
  /* Isolate so the body ink-fade overlay (.ld-note-body::after below,
     applied to aged letters) only blends with the body's own text and
     paper-grain background — not the card or weathering stains
     behind it. */
  isolation: isolate;
  padding: 1.15rem 1.3rem 1.4rem;
  /* Same telegram-style monospace as the composer editor (.ld-editor)
     so received notes read in the same dispatch register they were
     written in. */
  font-family: ui-monospace, "SF Mono", "Menlo", monospace;
  font-size: 0.85rem;
  line-height: 1.65;
  color: var(--fg);
  /* Same subtle paper-fiber texture as .ld-paper — see the comment
     there for what the filter is doing. Reads as paper without trying
     (and failing) to align ruled lines to the text-line metrics. */
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='220' height='220'%3E%3Cfilter id='p'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0.16 0 0 0 0 0.12 0 0 0 0 0.08 0.11 0 0 0 -0.04'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23p)'/%3E%3C/svg%3E");
}

.ld-note-body strong { font-weight: 600; }
.ld-note-body em { font-style: italic; }

/* ── Letter open animation — crease + rustle ──────────────────────
   The grid-template-rows accordion above handles the layout. These
   add the analog-letter feel on top:

     • Crease shadow: a blurred horizontal line at the top of the body
       that fades in early in the toggle and out by the end, reading
       as a fold line being un-creased (or re-creased on close).
     • Rustle: a tiny up-then-down nudge on the whole card, like the
       sheet of paper shifting slightly as it folds/unfolds. Uses the
       separate `translate` CSS property so it doesn't fight the
       per-note rest-rotation set by :nth-child rules above.

   Both run on `.is-toggling`, which JS adds for ~640ms whenever
   toggleNote() fires (opening or closing), then removes so the
   animation re-plays cleanly on the next toggle. */

.ld-note-body::before {
  content: "";
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  height: 18px;
  background: linear-gradient(
    to bottom,
    rgba(58, 48, 39, 0.18) 0%,
    rgba(58, 48, 39, 0.06) 50%,
    transparent 100%
  );
  opacity: 0;
  pointer-events: none;
  z-index: 1;
}

.ld-note.is-toggling .ld-note-body::before {
  animation: ld-crease 620ms ease-out;
}

@keyframes ld-crease {
  0%   { opacity: 0; }
  25%  { opacity: 1; }
  100% { opacity: 0; }
}

.ld-note.is-toggling {
  animation: ld-rustle 380ms cubic-bezier(.3,.7,.4,1);
}

@keyframes ld-rustle {
  0%, 100% { translate: 0 0; }
  40%      { translate: 0 -2px; }
  70%      { translate: 0 1px; }
}

@media (prefers-reduced-motion: reduce) {
  .ld-note.is-toggling { animation: none; }
  .ld-note-body::before { display: none; }
  .ld-note-body { transition: opacity 200ms ease; }
}

/* Voice-note body — drops the monospace + paper texture (a built-in
   audio player on textured paper reads as visual noise) and replaces
   them with a tidier card that frames the native <audio> element. */
.ld-note-body-voice {
  font-family: ui-serif, Georgia, "Times New Roman", serif;
  background-image: none;
  padding: 1.1rem 1.3rem 1.3rem;
}

.ld-voice-player {
  display: flex;
  flex-direction: column;
  gap: 0.55rem;
}

.ld-voice-player-meta {
  display: inline-flex;
  align-items: center;
  gap: 0.45rem;
  font-size: 0.8rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--muted);
  font-family: ui-monospace, "SF Mono", "Menlo", monospace;
}

.ld-voice-player-meta i { font-size: 1rem; }

.ld-voice-player-audio {
  width: 100%;
  min-height: 38px;
}

/* Small mic icon next to "from Stephanie" / "from Jonas" so a glance at
   a folded note tells you whether it's a recording or a written note,
   without unfolding it. */
.ld-note-voice-tag {
  font-size: 0.78rem;
  color: var(--accent);
  margin-left: 0.15rem;
  vertical-align: -0.05rem;
  opacity: 0.85;
}

/* Fold indicator — small caret on the right of the header that
   rotates when the note is open. */
.ld-note-fold {
  width: 0.9rem;
  height: 0.9rem;
  border-right: 1.5px solid var(--accent);
  border-bottom: 1.5px solid var(--accent);
  transform: rotate(45deg);
  margin-right: 0.2rem;
  opacity: 0.55;
  transition: transform 280ms cubic-bezier(.4,.05,.2,1), opacity 200ms ease;
  flex-shrink: 0;
}
.ld-note.is-open .ld-note-fold {
  transform: rotate(-135deg) translate(-2px, -2px);
  opacity: 0.85;
}

/* Unread state — a small dot on the from-line, plus a slightly warmer
   background to suggest "still in the envelope". Both affordances are
   rendered as always-present layers (a ::before tint on the card and a
   ::before dot on the from-line) and toggled via opacity/scale, so when
   the note is first opened they fade out gracefully instead of snapping. */
.ld-note::before {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  background: rgba(176, 106, 91, 0.045);
  opacity: 0;
  pointer-events: none;
  transition: opacity 520ms ease;
  z-index: 0;
}
.ld-note.is-unread::before { opacity: 1; }

/* ── Weathering ────────────────────────────────────────────────────
   Letters that spent a long time in the mail pick up character. The
   client computes (delivery_at - written_at) in days and slaps one
   of three tier classes on the card; each tier nudges the base paper
   color toward yellow and adds one or more "stain" radial gradients
   via the card's ::after pseudo-element.

     • is-aged-1 (14–30d): faint tea-color, one small stain top-right.
     • is-aged-2 (30–180d): yellower, second stain bottom-left.
     • is-aged-3 (180–730d): heavily aged, three stains, deeper hue.

   The tint is applied as a layered background on .ld-note rather
   than a filter so text color/contrast in the header stays correct
   (filter:sepia desaturates *everything*, including the accent-red
   "from <name>" and the postmark borders). The stains layer above
   the tint via ::after, beneath the header content. */

.ld-note.is-aged-1 {
  background:
    linear-gradient(rgba(214, 184, 130, 0.10), rgba(214, 184, 130, 0.10)),
    var(--bg-card);
}

.ld-note.is-aged-2 {
  background:
    linear-gradient(rgba(214, 184, 130, 0.20), rgba(214, 184, 130, 0.20)),
    var(--bg-card);
}

.ld-note.is-aged-3 {
  background:
    linear-gradient(rgba(214, 184, 130, 0.32), rgba(214, 184, 130, 0.32)),
    var(--bg-card);
}

.ld-note.is-aged-1::after,
.ld-note.is-aged-2::after,
.ld-note.is-aged-3::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  pointer-events: none;
  z-index: 0;
  mix-blend-mode: multiply;
}

/* Stain positions and ellipse sizes are now in pixels rather than
   percentages. With % ellipses the gradient stretched vertically as
   the card grew (open vs closed), which read as the stain "moving"
   when the letter unfolded. Pixel-sized ellipses stay the same shape
   regardless of card height. The X position is still a percentage
   (card width doesn't change with open/closed, so a % there is
   actually stable), but Y is in absolute pixels from the top — a
   stain at Y=130px shows up only once the body is open, because the
   shell's overflow:hidden clips it while the body is collapsed. The
   --stain-N-x / --stain-N-y vars are seeded by JS using the shared
   noise helper so each note wears the same marks every time but
   different notes don't all get marked in the same spot. */

.ld-note.is-aged-1::after {
  background:
    radial-gradient(ellipse 60px 38px
      at var(--stain-1-x, 80%) var(--stain-1-y, 22px),
      rgba(143, 96, 56, 0.22) 0%, transparent 70%);
}

.ld-note.is-aged-2::after {
  background:
    radial-gradient(ellipse 65px 40px
      at var(--stain-1-x, 80%) var(--stain-1-y, 22px),
      rgba(143, 96, 56, 0.30) 0%, transparent 70%),
    radial-gradient(ellipse 55px 36px
      at var(--stain-2-x, 16%) var(--stain-2-y, 75px),
      rgba(143, 96, 56, 0.24) 0%, transparent 70%);
}

.ld-note.is-aged-3::after {
  background:
    radial-gradient(ellipse 72px 46px
      at var(--stain-1-x, 82%) var(--stain-1-y, 22px),
      rgba(143, 96, 56, 0.36) 0%, transparent 70%),
    radial-gradient(ellipse 58px 38px
      at var(--stain-2-x, 16%) var(--stain-2-y, 80px),
      rgba(143, 96, 56, 0.28) 0%, transparent 70%),
    radial-gradient(ellipse 90px 60px
      at var(--stain-3-x, 50%) var(--stain-3-y, 130px),
      rgba(143, 96, 56, 0.10) 0%, transparent 80%);
}

/* ── Body text ink fade ───────────────────────────────────────────
   The same ink-didn't-transfer-evenly effect applied to the body
   text of weathered letters. Pristine letters keep crisp lettering;
   aged letters get progressively more fading as the transit time
   grows. The noise tile is bigger (64×64) and a bit coarser
   (baseFrequency 1.2 with two octaves) than the stamp version so
   the fade reads as ink rather than stamp stippling — feels like
   handwriting that smudged in places rather than a uniform texture.

   `mix-blend-mode: lighten` against the body's dark text pulls the
   ink toward paper-color in scattered patches. The body has
   `isolation: isolate` set above so this blend only affects the
   body's own text and paper-grain background, not the surrounding
   card or weathering stains. */

/* Aged-letter body text uses the same SVG displacement filter the
   stamps use — picks up the same pressed-and-imperfect look. Pristine
   body text gets no filter, so it stays crisp; older letters get
   progressively more pixel-wobble. The filter operates on the body's
   rasterized rendering (text + paper-grain bg), but the paper-grain
   is so faint (~11% alpha) that its displacement is essentially
   invisible — the wobble registers almost entirely on the text. */
.ld-note.is-aged-1 .ld-note-body { filter: url(#ld-ink-aged1); }
.ld-note.is-aged-2 .ld-note-body { filter: url(#ld-ink-aged2); }
.ld-note.is-aged-3 .ld-note-body { filter: url(#ld-ink-aged3); }

/* Header sits above the tint + stain layers so its content isn't dimmed. */
.ld-note-header,
.ld-note-body-shell,
.ld-sealed-strip { position: relative; z-index: 1; }

/* Unread accent dot — small dot on the from-line. Width and margin
   animate alongside the scale so the from-text slides over to fill
   the gap rather than leaving a hole where the dot used to be. */
.ld-note-from::before {
  content: "";
  display: inline-block;
  width: 0.45rem;
  height: 0.45rem;
  background: var(--accent);
  border-radius: 50%;
  vertical-align: 0.1rem;
  max-width: 0;
  margin-right: 0;
  transform: scale(0);
  opacity: 0;
  transition:
    transform 360ms cubic-bezier(.4,.05,.2,1),
    opacity 320ms ease,
    max-width 360ms cubic-bezier(.4,.05,.2,1),
    margin-right 360ms cubic-bezier(.4,.05,.2,1);
}
.ld-note.is-unread .ld-note-from::before {
  max-width: 0.45rem;
  margin-right: 0.55rem;
  transform: scale(1);
  opacity: 1;
}

/* Sealed state — the envelope is closed and the body is unreadable
   until delivery_at. Subtle wax-seal motif via a red dot, and the
   header is non-interactive. */
.ld-note.is-sealed {
  background:
    linear-gradient(rgba(58, 48, 39, 0.02), rgba(58, 48, 39, 0.02)),
    var(--bg-card);
}
.ld-note.is-sealed .ld-note-header {
  cursor: default;
}
.ld-note.is-sealed .ld-note-fold {
  display: none;
}

.ld-sealed-strip {
  display: flex;
  align-items: center;
  gap: 0.7rem;
  padding: 0.9rem 1.3rem 1.05rem;
  color: var(--muted);
  font-style: italic;
  font-size: 0.92rem;
}

.ld-wax {
  display: inline-block;
  width: 0.95rem;
  height: 0.95rem;
  border-radius: 50%;
  background: radial-gradient(circle at 35% 30%, #c97264, #8a3a2f 70%);
  box-shadow:
    inset 0 0 2px rgba(0,0,0,0.25),
    0 1px 1px rgba(58, 48, 39, 0.15);
  flex-shrink: 0;
}

/* ── Home paperclip + "New" notification ───────────────────────────
   When the recipient has at least one unread, delivered note, JS
   injects three siblings into `.ld-pin-wrap` around the card:

     1. .ld-clip-back  — rendered BEFORE the card in DOM order, so
        it paints BENEATH the card. This SVG contains the part of
        the paperclip wire whose lower portion is meant to be hidden
        behind the page: the inner U-bend's prongs extend past the
        card's top edge, but the card sits on top and covers them.
        From the front, only the bit that pokes above the card edge
        is visible — which is exactly what you'd see looking at a
        real paperclip-on-paper.

     2. .ld-new-tag    — a small piece of paper that says "New",
        positioned just below the page-edge "fold" zone.

     3. .ld-clip-front — rendered AFTER the card and the New tag,
        so it paints ABOVE both. This is the outer wire of the
        paperclip: top arc, then a long front prong descending onto
        the card front and OVER the "New" tag, holding it in place.

   The wrapper takes the grid cell that the card used to occupy;
   the card inside is the wrapper's :nth-child(1) and would
   otherwise pick up the wrong tilt rule. The override below
   restores the original :nth-child(5) tilt for the Letters & Dispatches
   card so the card grid still looks the same. */

.ld-pin-wrap {
  position: relative;
  display: block;
  /* Allow the back-clip SVG to draw outside the wrapper bounds
     (it sticks above the card's top edge). */
  overflow: visible;
  /* Establish a local stacking context so the back-clip SVG's
     z-index is interpreted against the card here, not bubbled up
     into the museum grid where it might fight other siblings. */
  isolation: isolate;
}

/* The wrapper takes on the polaroid drop instead of the card, so the
   paperclip and "New" slip — both absolutely positioned against the
   wrapper — ride down on scroll as part of the same "piece of paper" as
   the card. The card inside has its own transform zeroed (further down)
   so we don't apply the drop twice. JS in _base.html selects the
   wrapper alongside the real .statue-card elements when setting
   --card-progress.

   The vars below mirror the :nth-child(2) tilt + :nth-child(even)
   y-offset that the wrapper's grid slot would otherwise pick up from
   statue.css — except those rules scope to .statue-card and the
   wrapper isn't one, so we restate them here. Adjust to match the
   wrapper's current grid position whenever the museum card list
   changes. */
.panel-cards .ld-pin-wrap {
  --tilt-drop: 2deg;
  --tilt-rest: 1.2deg;
  --offset-x: 10px;
  --offset-y: 55px;
  --card-progress: 0;
  transform:
    translate(
      var(--offset-x),
      calc(var(--offset-y) + (-65px * (1 - var(--card-progress))))
    )
    rotate(calc(
      var(--tilt-drop) * (1 - var(--card-progress)) +
      var(--tilt-rest) * var(--card-progress)
    ));
  transition: transform 160ms ease;
  will-change: transform;
}

/* Hover lift moves up to the wrapper too — the same 2px nudge as a
   regular card, but applied to the whole "clipped page" so the
   paperclip lifts with it. */
.panel-cards .ld-pin-wrap:hover {
  transform:
    translate(
      var(--offset-x),
      calc(var(--offset-y) - 2px + (-65px * (1 - var(--card-progress))))
    )
    rotate(calc(
      var(--tilt-drop) * (1 - var(--card-progress)) +
      var(--tilt-rest) * var(--card-progress)
    ));
}

/* On single-column phone layouts, statue.css zeroes the y-offset for
   odd/even cards. The wrapper isn't a .statue-card so that rule doesn't
   apply — match it here. */
@media (max-width: 48rem) {
  .panel-cards .ld-pin-wrap { --offset-y: 0px; }
}

/* Card inside follows the wrapper; zero its own transform (and its
   hover transform from statue.css) so the drop isn't applied twice. */
.panel-cards .ld-pin-wrap > .statue-card-letters-dispatches,
.panel-cards .ld-pin-wrap > .statue-card-letters-dispatches:hover {
  transform: none;
}

/* "correspondence" is a long eyebrow word — at the default 0.75rem it
   crowds the postmark stamp on narrow screens. Trim it a touch on just
   this card so the other eyebrows keep their size. */
.statue-card-letters-dispatches .statue-card-eyebrow {
  font-size: 0.65rem;
}

/* Common positioning + sizing for both halves of the paperclip,
   so the back and front SVGs stack perfectly over each other. */
.ld-clip-back,
.ld-clip-front {
  position: absolute;
  top: -36px;
  left: 22px;
  width: 36px;
  height: 76px;
  /* Whole clip rotates as one piece on hover. The transform is
     applied to both halves identically. */
  transform: rotate(-8deg);
  transform-origin: 22px 44px;
  transition: transform 260ms ease;
  pointer-events: none;
  filter: drop-shadow(0 1px 1px rgba(58, 48, 39, 0.25));
}

.ld-pin-wrap:hover .ld-clip-back,
.ld-pin-wrap:hover .ld-clip-front {
  transform: rotate(-5.5deg) translateY(-1px);
}

.ld-clip-back  { z-index: 0; /* behind the card */ }
.ld-clip-front { z-index: 3; /* above the New tag */ }

.ld-clip-back svg,
.ld-clip-front svg {
  display: block;
  width: 100%;
  height: 100%;
  overflow: visible;
}

/* The "New" notification paper — a small folded slip, sat on the
   card surface with its top edge tucked under the paperclip's
   front prong. Visually distinct paper stock (lighter, with its
   own shadow) so it reads as a separate piece of paper clipped
   on top, not a label baked into the card. */
.ld-new-tag {
  position: absolute;
  top: -10px;
  left: 22px;
  /* Width matches the paperclip footprint so the clip looks like
     it's holding the slip across its width. */
  min-width: 3.8rem;
  padding: 0.55rem 0.65rem 0.5rem;
  background: #fffaf0;
  border: 1px solid rgba(58, 48, 39, 0.20);
  border-radius: 2px;
  box-shadow:
    0 1px 1px rgba(58, 48, 39, 0.08),
    0 4px 10px rgba(58, 48, 39, 0.14);
  color: var(--accent);
  font-family: ui-serif, Georgia, "Times New Roman", serif;
  font-style: italic;
  font-size: 0.85rem;
  text-align: center;
  letter-spacing: 0.01em;
  line-height: 1.15;
  /* Subtle tilt, opposite the paperclip, so it looks tucked in by
     hand rather than placed neatly. */
  transform: rotate(3deg);
  z-index: 2;
  pointer-events: none;
}

.ld-new-tag .ld-new-count {
  display: block;
  font-style: normal;
  font-size: 0.6rem;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--muted);
  margin-top: 0.1rem;
}

/* Phone — pin sits closer to the card's left edge. */
@media (max-width: 32rem) {
  .ld-clip-back,
  .ld-clip-front {
    left: 18px; width: 32px; height: 70px; top: -32px;
    transform-origin: 22px 40px;
  }
  .ld-new-tag { left: 18px; }
}

/* ── Paperclip on the inbox ledger cover ─────────────────────────
   When the inbox has unread mail, the same paperclip + "New" slip
   used on the /home museum card is injected onto the inbox-ledger
   cover by JS (the same drawLettersDispatchesIndicator function
   handles both — the ledger header carries the same
   data-letters-dispatches-wrap / data-letters-dispatches-card
   hooks the museum card does). The clip sits at the top of the
   cover, the "New" slip dangles into the title area. Opening the
   ledger fades the assembly out and detaches it from the DOM,
   sold as the gesture of pulling the clip off the cover to read.

   Position overrides reposition the clip + slip for the ledger's
   geometry (different size + layout than the museum card) and use a
   short fade-out transition that fires when the assembly is given
   the .is-detaching class right before removal. */
.ld-ledger .ld-clip-back,
.ld-ledger .ld-clip-front {
  /* Anchored to the cover's left edge, just past the spine, so the
     clip hangs over the title area at a hand-tucked angle. Smaller
     than the museum version (32×68 vs 36×76) so it scales correctly
     to the cover's lower profile. */
  top: -30px;
  left: 1.65rem;
  width: 32px;
  height: 68px;
  transform: rotate(-7deg);
  transform-origin: 16px 40px;
  /* Fade out before detach. The clip + tag are removed wholesale
     when the ledger opens; the fade gives the removal a beat of
     visible motion rather than a jarring pop. */
  transition: transform 260ms ease, opacity 320ms ease;
}

.ld-ledger:hover .ld-clip-back,
.ld-ledger:hover .ld-clip-front {
  transform: rotate(-5deg) translateY(-1px);
}

.ld-ledger .ld-new-tag {
  top: -10px;
  left: 1.65rem;
  min-width: 3.4rem;
  padding: 0.45rem 0.6rem 0.4rem;
  font-size: 0.8rem;
  transform: rotate(2.5deg);
  transition: transform 260ms ease, opacity 320ms ease;
}

/* Detach state — set by JS just before yanking the assembly out of
   the DOM, gives the elements a brief outward motion + fade so the
   removal reads as "pulled off" rather than "popped." */
.ld-ledger .ld-clip-back.is-detaching,
.ld-ledger .ld-clip-front.is-detaching {
  opacity: 0;
  transform: rotate(-12deg) translate(-6px, -10px);
}
.ld-ledger .ld-new-tag.is-detaching {
  opacity: 0;
  transform: rotate(8deg) translate(-4px, -14px);
}

/* ── Phone layout ──────────────────────────────────────────────────
   Phones get a narrower outer gutter (so cards take most of the
   width), a denser inbox header that keeps the original "from /
   stamps / fold" row layout but tightens stamp padding so it fits a
   single line, and a fully redesigned composer footer: delivery becomes
   a tidy two-row grid (label + input on top, hint/clear right-aligned
   under the input) and the actions row stacks status above a full-
   width "seal & send" button for a proper thumb target. */
@media (max-width: 32rem) {
  /* Shell — pull the 1.25rem desktop gutter in so cards stretch close
     to the viewport edges, and trim the 4/6rem vertical frame. */
  .ld-shell {
    margin: 2rem auto 4rem;
    padding: 0 0.3rem;
  }
  .ld-composer { margin-bottom: 2.5rem; }

  /* Mode toggle — full-width segmented control on phones so each option
     is a comfortable tap target rather than a thumb-pinch chip. */
  .ld-mode {
    width: 100%;
    margin-bottom: 1rem;
  }
  .ld-mode-option {
    flex: 1 1 0;
    justify-content: center;
    padding: 0.6rem 0.5rem;
    font-size: 0.86rem;
  }

  /* Voice composer — tighten panel padding, scale the mic up a touch so
     it's a confident thumb target, and let the timer + hint sit beside it
     on the same row as the desktop layout. */
  .ld-mode-panel-voice {
    padding: 1.4rem 1rem 1.3rem;
    gap: 1rem;
  }
  .ld-record {
    width: 4.6rem;
    height: 4.6rem;
    font-size: 1.85rem;
  }
  .ld-voice-timer { font-size: 1.4rem; }
  .ld-voice-hint { font-size: 0.82rem; }

  /* Composer paper — toolbar drops its hint text (no room beside the
     B/I buttons), editor padding tightens horizontally but keeps the
     desktop padding-top so the noise-textured background reads the
     same way on every viewport. */
  .ld-tool-hint { display: none; }
  .ld-editor {
    padding: 1.05rem 1rem 1.2rem;
    min-height: 8rem;
    /* Pinned to 16px (= 1rem) — the safe floor for iOS Safari, which
       auto-zooms the viewport when the user focuses an editable field
       with a smaller font. If the desktop editor shrinks below this
       in the future, this override keeps the phone from zooming. */
    font-size: 1rem;
  }

  /* Footer — two clearly grouped sections, separated by a generous
     row gap, with a thin dashed divider between them inherited from
     the desktop rule. */
  .ld-composer-footer {
    flex-direction: column;
    align-items: stretch;
    gap: 1rem;
    padding: 0.95rem 1rem 1rem;
  }

  /* Delivery hint — centered on mobile so it reads as a caption rather
     than a left-aligned scrap of text next to the send button. */
  .ld-delivery-note {
    text-align: center;
    font-size: 0.85rem;
  }

  /* Actions — vertical stack: status sits as a small centered line
     above a full-width primary button. The button is the dominant
     visual + tap target on the sheet, which fits the "compose →
     send" rhythm of a phone screen. */
  .ld-composer-actions {
    flex-direction: column;
    align-items: stretch;
    gap: 0.45rem;
  }
  .ld-status {
    text-align: center;
    font-size: 0.85rem;
  }
  .ld-send {
    width: 100%;
    padding: 0.8rem 1rem;
    font-size: 0.98rem;
  }

  /* Ledger covers on phones — the title and meta column stack
     vertically so a long "last delivery" timestamp doesn't crowd
     the title on a narrow viewport. The meta stays baseline-inline
     internally (label + scrawl on the same line), it just sits on
     its own row beneath the title. */
  .ld-ledger {
    flex-direction: column;
    align-items: flex-start;
    text-align: left;
    gap: 0.4rem;
    padding: 0.95rem 1rem 1rem 1.7rem;
  }
  .ld-ledger-title { font-size: 1.25rem; }
  /* Fold caret moves to the top-right corner of the cover so it
     stays a visible affordance on phones without sitting awkwardly
     at the bottom of the stacked column. */
  .ld-ledger-fold {
    position: absolute;
    top: 1.05rem;
    right: 1rem;
  }
  .ld-ledger-body { padding: 0.85rem 0.2rem 0.3rem 0.2rem; }
  .ld-scrawl { font-size: 1.3rem; }

  /* Month bundles — keep the stacked-card visual, just tighten the
     padding so the bundle fits inside the narrower mobile gutter and
     the postmark shrinks a touch to match. */
  .ld-month-header {
    gap: 0.85rem;
    padding: 0.85rem 1rem;
  }
  .ld-month-postmark {
    width: 3.1rem;
    height: 3.1rem;
  }
  .ld-month-postmark-name { font-size: 0.7rem; }
  .ld-month-postmark-year { font-size: 0.52rem; }
  .ld-month-count { font-size: 0.85rem; }
  .ld-month-notes { gap: 1.2rem; padding: 1.6rem 0 1.3rem; }
  .ld-month-bundle { margin-bottom: 1.6rem; }

  /* Inbox card padding — pull horizontal in to match the tighter
     shell. On phones the header stacks: from-line on top, stamps row
     centered below it, with the fold caret hidden so the header reads
     as two clean lines instead of three competing affordances. The
     per-stamp --stamp-dx jitter still applies on top of the centering,
     so the stamps stay hand-placed rather than locked to a grid. */
  .ld-inbox-list { gap: 0.6rem; }
  .ld-note-header {
    /* Bottom padding mirrors the desktop bump — rotated stamps need a
       little extra room before the divider so the lower corners don't
       crowd it. */
    padding: 0.85rem 1rem 0.95rem;
    flex-direction: column;
    align-items: stretch;
    /* Row gap separates the from-line from the stamps row. Bumped a
       bit beyond a normal line break so the stamps clearly sit on
       their own band. */
    gap: 0.9rem 0;
  }
  .ld-note-body { padding: 1.05rem 1rem 1.3rem; }
  .ld-sealed-strip { padding: 0.8rem 1rem 0.95rem; }
  /* Stamps row spans full width and centers its two children, so the
     pair sits in the middle of the card regardless of the from-line
     length. Wider gap than desktop's 1rem would crowd the phone
     header, but anything under ~0.75rem lets the ±7px stamp jitter
     cause visible overlap. 0.85rem strikes a balance. */
  .ld-note-stamps {
    display: flex;
    justify-content: center;
    gap: 0.85rem;
  }
  .ld-note-stamp { padding: 4px 7px 3px; }
  .ld-note-stamp-date {
    font-size: 0.68rem;
    letter-spacing: 0.13em;
  }
  /* Fold caret is desktop-only — on phones the whole card is the tap
     target and the caret would just clutter a stacked header. */
  .ld-note-fold { display: none; }
}
