:root {
  color-scheme: light;
  /* One accent, used sparingly: brand mark, links, citations, focus. Everything
     else is ink on paper. The greys share the paper's warm hue so the page reads
     as one material; --ink-3 is the lightest that still clears 4.5:1 on paper. */
  --paper: #f7f6f3;
  --surface: #fff;
  --wash: #edebe6;
  --ink: #1b1a18;
  --ink-2: #5f5b55;
  --ink-3: #75706a;
  --line: #e0dcd5;
  --shadow-rgb: 27, 26, 24;
  --accent: #6a49c9;
  --accent-strong: #4825a3;
  --accent-soft: #f1edfa;
  --accent-line: #c9baf3;
  /* Five sizes and no others; the two display headlines set their own. */
  --text-xs: 0.75rem;
  --text-sm: 0.875rem;
  --text-base: 1rem;
  --text-lg: clamp(1.125rem, 1.5vw, 1.375rem);
  /* One curve, two speeds: fast for a response to the pointer, slow for
     something arriving. The composer's split keeps its own overshoot. */
  --ease: cubic-bezier(0.16, 1, 0.3, 1);
  --fast: 200ms;
  --slow: 600ms;
  font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
  background: var(--paper);
  color: var(--ink);
}
* {
  box-sizing: border-box;
}
html {
  min-height: 100%;
  scroll-behavior: smooth;
}
body {
  min-width: 320px;
  min-height: 100vh;
  margin: 0;
  overflow-x: hidden;
}
button,
input,
textarea {
  font: inherit;
}
a {
  color: inherit;
}
button {
  cursor: pointer;
}
:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 4px;
}
.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}
.site-header {
  position: relative;
  z-index: 3;
  display: flex;
  align-items: center;
  justify-content: space-between;
  width: min(100% - 5rem, 1480px);
  height: 86px;
  margin: 0 auto;
}
.wordmark {
  display: inline-flex;
  align-items: center;
  gap: 0.6rem;
  font-size: var(--text-lg);
  font-weight: 700;
  letter-spacing: -0.06em;
  text-decoration: none;
}
.wordmark-icon {
  display: block;
  width: 28px;
  height: 24px;
  object-fit: contain;
}
.wordmark-dot {
  color: var(--accent);
}
.site-header nav {
  display: flex;
  align-items: center;
  gap: clamp(1.2rem, 3vw, 2.5rem);
}
.site-header nav a,
.reset-button {
  border: 0;
  padding: 0.4rem 0;
  background: none;
  font-size: var(--text-sm);
  font-weight: 500;
  text-decoration: none;
  transition: color var(--fast);
}
.site-header nav a:hover,
.reset-button:hover {
  color: var(--accent);
}
.stage {
  width: min(100% - 5rem, 1180px);
  min-height: calc(100vh - 86px);
  margin: 0 auto;
  padding: 0 0 240px;
}
/* Before the first question the page is one fixed screen: pinned to the
   viewport on all sides, nothing to scroll. The conversation brings scrolling back. */
body:has(.stage:not(.has-conversation)) {
  position: fixed;
  inset: 0;
  overflow: hidden;
}
.stage:not(.has-conversation) {
  height: 100%;
  min-height: 0;
  padding-bottom: 0;
}
.opening {
  position: relative;
  display: grid;
  grid-template-rows: 1fr;
  min-height: calc(100vh - 86px);
  min-height: calc(100dvh - 86px);
  overflow: hidden;
  opacity: 1;
  visibility: visible;
  transition:
    grid-template-rows var(--slow) var(--ease),
    opacity var(--fast) ease,
    visibility 0s;
}
.opening-media {
  position: fixed;
  z-index: 0;
  top: auto;
  right: 0;
  bottom: 0;
  left: auto;
  /* 90% of the viewport height, on the right. At 530:794 that height needs a
     width of 90 × 530 / 794 = 60.08dvh; half the viewport width caps it on
     narrow, tall windows so it never runs into the headline. */
  width: min(60.08dvh, 50vw);
  aspect-ratio: 530 / 794;
  overflow: hidden;
  pointer-events: none;
}
/* the halftone canvas replaces the picture once it has drawn a frame; the
   video keeps playing underneath as its source */
.opening-halftone {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
}
.opening-media.is-halftone .opening-video {
  opacity: 0;
}
.opening-media.is-hidden {
  display: none;
}
.opening-video {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: contain;
  object-position: center bottom;
}
.opening-inner {
  position: relative;
  z-index: 1;
  width: min(72%, 760px);
  min-height: 0;
  overflow: hidden;
  padding: clamp(4.5rem, 13vh, 9rem) 0 clamp(3.5rem, 7vh, 6rem);
  animation: rise var(--slow) var(--ease) backwards;
}
.opening-note {
  margin: 0 0 clamp(2rem, 5vh, 4rem);
  color: var(--ink-3);
  font-size: var(--text-xs);
  font-weight: 600;
  letter-spacing: 0.14em;
  text-transform: uppercase;
}
/* Two lines, always: "Hi, I'm" / "Songhai Fan." — the name never breaks, so
   the size is capped where "Songhai Fan." (about 5.2em wide) still fits the
   text column. The small negative margin pulls the H's side bearing back so
   the headline's left edge lines up optically with the text under it. */
.opening h1 {
  margin: 0 0 0 -0.055em;
  font-size: clamp(3.5rem, 9vw, 8.75rem);
  font-weight: 400;
  letter-spacing: -0.05em;
  line-height: 0.92;
}
.opening h1 em {
  font-style: normal;
  white-space: nowrap;
}
.opening-intro {
  max-width: 36rem;
  margin: clamp(1.5rem, 4vh, 2.5rem) 0 0;
  font-size: var(--text-lg);
  line-height: 1.45;
  letter-spacing: -0.015em;
  text-wrap: pretty;
}
/* the role is the one thing in the intro worth weight; the rest stays light */
.opening-intro strong {
  font-weight: 600;
}
/* ── the invitation ──
   A question, then the two ways to answer it, set as equals: the same pill,
   the same size, the same "who · what" structure, with "or" between them.
   Neither is written into the sentence as an aside to the other. */
.opening-invitation {
  display: grid;
  gap: 0.75rem;
  max-width: 37rem;
  margin: clamp(1.5rem, 3.5vh, 2rem) 0 0;
}
.invite-lead {
  margin: 0;
  color: var(--ink-2);
  font-size: var(--text-base);
}
.invite-options {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.6rem;
}
.invite-or {
  color: var(--ink-3);
  font-size: var(--text-sm);
}
.opening-invitation .invite-pill {
  min-width: 0;
  padding: 0.3rem 1rem 0.3rem 0.3rem;
}
.opening-invitation .invite-pill span {
  font-size: var(--text-sm);
}
.opening-invitation .invite-pill small {
  margin-top: 0.1rem;
}
/* On a phone the two choices stack as equal full-width rows; stacked, the
   "or" is implied and would only dangle at the end of the first line. */
@media (max-width: 760px) {
  .invite-options {
    display: grid;
    grid-template-columns: minmax(0, 22rem);
    gap: 0.5rem;
  }
  .invite-or {
    display: none;
  }
}
/* In a conversation the switch shows only the way you are not using: Email me
   while talking to f, f while writing to Songhai. It sits to the left of the
   input as the alternative to it, not as a header above it. */
body:has(.stage.has-conversation) .composer-inner {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  column-gap: 0.6rem;
}
body:has(.stage.has-conversation) .composer-inner > * {
  grid-column: 1 / -1;
}
body:has(.stage.has-conversation) .speaker-switch {
  grid-column: 1;
  grid-row: 2;
  align-self: end;
  margin: 0 0 0.25rem;
}
body:has(.stage.has-conversation) #composer {
  grid-column: 2;
  grid-row: 2;
}
body:has(.stage.has-conversation) .suggestions {
  grid-row: 1;
}
body:has(.stage.has-conversation) .composer-status {
  grid-row: 3;
}
body:has(.stage.has-conversation) .composer-meta {
  grid-row: 4;
}
/* "[Email me] or [ask f…]" — the same either/or as the opening sentence */
.speaker-or {
  align-self: center;
  margin: 0 0.15rem 0 0.25rem;
  color: var(--ink-3);
  font-size: var(--text-sm);
}
@media (max-width: 760px) {
  /* just the face on a phone, so the input keeps its width */
  body:has(.stage.has-conversation) .speaker-option {
    min-width: 0;
    padding: 0.26rem;
  }
  body:has(.stage.has-conversation) .speaker-option span {
    display: none;
  }
}
/* On the opening screen the sentence above is the switch; the dock's copy
   appears once the conversation has started. */
body:has(.stage:not(.has-conversation)) .speaker-switch {
  display: none;
}
.has-conversation .opening {
  min-height: 0;
  grid-template-rows: 0fr;
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
  transition:
    grid-template-rows var(--slow) var(--ease),
    opacity var(--fast) ease,
    visibility 0s var(--slow);
}
.has-conversation .opening-inner {
  padding-top: 0;
  padding-bottom: 0;
  animation: none;
  transition: padding var(--slow) var(--ease);
}
.conversation {
  position: relative;
  z-index: 1;
  display: grid;
  gap: clamp(2.5rem, 5vw, 4rem);
  max-width: 100%;
}
.turn {
  scroll-margin-top: 2rem;
  display: grid;
  gap: 1.25rem;
  padding-bottom: clamp(1rem, 3vw, 2.5rem);
}
.user-line {
  display: flex;
  justify-content: flex-end;
}
.user-line span {
  max-width: min(72%, 34rem);
  border-radius: 19px 19px 5px 19px;
  padding: 0.7rem 1.05rem;
  background: var(--ink);
  color: var(--paper);
  font-size: var(--text-base);
  line-height: 1.45;
  overflow-wrap: anywhere;
  text-wrap: pretty;
  animation: rise var(--slow) var(--ease) both;
}
.assistant-line {
  min-width: 0;
  padding-left: 3.3rem;
}
.email-receipt {
  display: flex;
  align-items: center;
  gap: 0.7rem;
  padding-left: 3.3rem;
  color: var(--ink-3);
  font-size: var(--text-sm);
}
.email-receipt img {
  width: 42px;
  height: 42px;
  object-fit: cover;
  border-radius: 50%;
}
.email-receipt p {
  margin: 0;
  border-radius: 19px 19px 19px 5px;
  padding: 0.72rem 1.05rem;
  background: var(--wash);
  color: var(--ink);
}
.assistant-identity {
  display: flex;
  align-items: center;
  gap: 0.7rem;
  margin: 0 0 0.4rem -3.3rem;
  animation: rise var(--slow) 80ms var(--ease) both;
}
.assistant-avatar {
  display: block;
  width: 42px;
  height: 42px;
  flex: none;
  object-fit: contain;
}
.assistant-label {
  margin: 0;
  color: var(--ink-3);
  font-size: var(--text-xs);
  font-weight: 600;
  letter-spacing: 0.06em;
}
.assistant-copy,
.assistant-small {
  width: fit-content;
  max-width: min(42rem, 100%);
  border-radius: 19px 19px 19px 5px;
  padding: 0.72rem 1.05rem;
  background: var(--wash);
  color: var(--ink);
  font-size: var(--text-base);
  letter-spacing: 0;
  line-height: 1.55;
  text-wrap: pretty;
}
.assistant-copy {
  margin: 0 0 0.45rem;
  animation: rise var(--slow) 160ms var(--ease) both;
}
.assistant-small {
  margin: 0 0 1.3rem;
  animation: rise var(--slow) 240ms var(--ease) both;
}
.assistant-copy + .project-grid,
.assistant-copy + .cv-card,
.assistant-copy + .about-card,
.assistant-copy + .cv-actions,
.assistant-copy + .reply-choices {
  margin-top: 0.8rem;
}
.assistant-link {
  display: inline-flex;
  border-top: 0;
  border-right: 0;
  border-left: 0;
  background: none;
  color: inherit;
  cursor: pointer;
  align-items: center;
  gap: 0.5rem;
  border-bottom: 1px solid currentColor;
  padding: 0.3rem 0;
  font-size: var(--text-sm);
  font-weight: 600;
  text-decoration: none;
  transition: color var(--fast);
}
.assistant-link:hover {
  color: var(--accent);
}
.ai-answer {
  white-space: pre-wrap;
  overflow-wrap: anywhere;
}
.ai-loading {
  color: var(--ink-3);
}
/* waiting for f: three dots that breathe, and words that move on as the wait grows */
.ai-thinking {
  display: flex;
  align-items: center;
  gap: 0.7rem;
  color: var(--ink-2);
}
.thinking-dots {
  display: inline-flex;
  gap: 4px;
}
.thinking-dots i {
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--ink-3);
  animation: breathe 1.2s ease-in-out infinite;
}
.thinking-dots i:nth-child(2) {
  animation-delay: 0.15s;
}
.thinking-dots i:nth-child(3) {
  animation-delay: 0.3s;
}
.thinking-text {
  animation: rise var(--slow) var(--ease) both;
}
.ai-retry {
  margin-top: 0.4rem;
}
.citation-link {
  margin-left: 0.1em;
  color: var(--accent);
  font-size: 0.78em;
  font-weight: 700;
  text-decoration: none;
}
.citation-link:hover {
  text-decoration: underline;
}
.source-list {
  display: grid;
  gap: 0.35rem;
  max-width: 42rem;
  margin: 0.65rem 0 1.2rem;
}
.source-label {
  width: 100%;
  color: var(--ink-3);
  font-size: var(--text-xs);
  font-weight: 700;
  letter-spacing: 0.1em;
}
.source-card {
  display: grid;
  gap: 0.28rem;
  border-bottom: 1px solid var(--line);
  padding: 0.7rem 0;
  color: var(--ink);
  text-decoration: none;
  animation: rise var(--slow) var(--ease) both;
  animation-delay: calc(120ms + var(--order) * 40ms);
}
.source-card:hover .source-card-title {
  color: var(--accent);
}
.source-card-title {
  font-size: var(--text-sm);
  transition: color var(--fast);
  font-weight: 600;
  line-height: 1.4;
}
.source-card-snippet {
  color: var(--ink-3);
  font-size: var(--text-xs);
  line-height: 1.5;
}
/* ── recent writing ──
   Five rows on hairlines: the date as a quiet margin note, the title, and
   the post's first line. The whole row is the link. */
.post-list {
  display: grid;
  max-width: 42rem;
  margin: 0.6rem 0 0;
  padding: 0;
  list-style: none;
  border-top: 1px solid var(--line);
}
.post-loading {
  padding: 0.9rem 0;
  color: var(--ink-3);
  font-size: var(--text-sm);
}
.post-item {
  border-bottom: 1px solid var(--line);
  animation: rise var(--slow) var(--ease) both;
  animation-delay: calc(80ms + var(--order) * 50ms);
}
.post-link {
  display: grid;
  grid-template-columns: 6.5rem minmax(0, 1fr);
  column-gap: 1rem;
  row-gap: 0.2rem;
  padding: 0.85rem 0;
  color: var(--ink);
  text-decoration: none;
}
.post-date {
  grid-row: span 2;
  padding-top: 0.15rem;
  color: var(--ink-3);
  font-size: var(--text-xs);
  font-variant-numeric: tabular-nums;
}
.post-title {
  font-size: var(--text-sm);
  font-weight: 600;
  line-height: 1.4;
  transition: color var(--fast);
}
.post-link:hover .post-title {
  color: var(--accent);
}
.post-lang {
  margin-left: 0.5rem;
  border: 1px solid var(--line);
  border-radius: 999px;
  padding: 0.05rem 0.4rem;
  color: var(--ink-3);
  font-size: 0.625rem;
  font-weight: 500;
  vertical-align: 0.1em;
}
.post-excerpt {
  overflow: hidden;
  color: var(--ink-3);
  font-size: var(--text-xs);
  line-height: 1.5;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.post-more {
  margin: 0.9rem 0 0;
}
@media (max-width: 760px) {
  .post-link {
    grid-template-columns: minmax(0, 1fr);
  }
  .post-date {
    grid-row: auto;
    padding-top: 0;
  }
}
.project-grid {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: clamp(0.7rem, 1.8vw, 1.4rem);
  max-width: 1020px;
  padding-top: 0.5rem;
}
.project-tile {
  position: relative;
  display: block;
  aspect-ratio: 1;
  overflow: hidden;
  border-radius: 10px;
  background: var(--wash);
  color: #fff;
  text-decoration: none;
  /* handed in from the top-left like the CV, one after another */
  animation: hand-in 800ms var(--ease) both;
  animation-delay: calc(160ms + var(--order) * 70ms);
}
.project-tile img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  filter: saturate(0.65) brightness(0.74);
  transition:
    transform var(--slow) var(--ease),
    filter var(--slow) var(--ease);
}
.project-tile:hover img,
.project-tile:focus-visible img {
  transform: scale(1.03);
  filter: saturate(1) brightness(0.65);
}
.project-tile::after {
  position: absolute;
  inset: 35% 0 0;
  content: "";
  background: linear-gradient(transparent, rgba(0, 0, 0, 0.68));
  pointer-events: none;
}
.tile-title {
  position: absolute;
  z-index: 1;
  right: 1rem;
  bottom: 1rem;
  left: 1rem;
  font-size: var(--text-base);
  font-weight: 600;
  line-height: 1.25;
  letter-spacing: -0.02em;
  text-wrap: balance;
}
.cv-card {
  width: min(100%, 440px);
  margin: 0.5rem 0 0;
  animation: hand-in 800ms 160ms var(--ease) both;
}
/* Pages and cards are handed over from the top-left, turning square as they
   land: the CV and the project tiles arrive the same way. */
@keyframes hand-in {
  from { opacity: 0; transform: translate3d(-10vw, -14vh, 0) rotate(-4deg) scale(0.94); }
}
/* the page itself is the object: a hairline edge and a soft lift, nothing around it */
.cv-preview-link {
  display: block;
  overflow: hidden;
  border-radius: 4px;
  box-shadow:
    0 0 0 1px var(--line),
    0 12px 36px rgba(var(--shadow-rgb), 0.08);
  transition: box-shadow var(--fast);
}
.cv-preview-link:hover {
  box-shadow:
    0 0 0 1px var(--ink-3),
    0 12px 36px rgba(var(--shadow-rgb), 0.1);
}
.cv-preview {
  display: block;
  width: 100%;
  height: auto;
}
.cv-caption {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 1rem;
  margin-top: 0.9rem;
}
.cv-title {
  font-size: var(--text-sm);
  font-weight: 600;
}
.cv-title small {
  margin-left: 0.5rem;
  color: var(--ink-3);
  font-size: var(--text-xs);
  font-weight: 400;
}
.cv-links {
  display: flex;
  gap: 1rem;
}
.cv-link {
  color: var(--ink-2);
  font-size: var(--text-sm);
  text-decoration: none;
  transition: color var(--fast);
}
.cv-link:hover {
  color: var(--ink);
}
.cv-actions {
  display: flex;
  flex-wrap: wrap;
  gap: 1.5rem;
  margin-top: 2rem;
}
.about-card {
  display: grid;
  grid-template-columns: 132px minmax(0, 1fr);
  gap: 1.5rem;
  align-items: start;
  max-width: 600px;
  margin: 0.5rem 0 0;
  animation: rise var(--slow) 160ms var(--ease) both;
}
/* height:auto undoes the height attribute, which otherwise stretches the
   photo; the 1:0.94 crop trims the grey band along the top of the original */
.about-photo {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 1 / 0.94;
  border-radius: 4px;
  object-fit: cover;
  object-position: center bottom;
  box-shadow: 0 0 0 1px var(--line);
}
.about-body {
  display: grid;
  gap: 0.35rem;
}
.about-body p {
  margin: 0;
}
.about-name {
  font-size: var(--text-base);
  font-weight: 600;
}
.about-name span {
  color: var(--ink-3);
  font-weight: 400;
}
.about-role {
  color: var(--ink-3);
  font-size: var(--text-xs);
  letter-spacing: 0.06em;
  text-transform: uppercase;
}
.about-body .about-bio {
  margin-top: 0.4rem;
  color: var(--ink-2);
  font-size: var(--text-sm);
  line-height: 1.6;
  text-wrap: pretty;
}
.about-facts {
  display: grid;
  grid-template-columns: 3.5rem minmax(0, 1fr);
  gap: 0.35rem 0.75rem;
  margin: 0.6rem 0 0;
  border-top: 1px solid var(--line);
  padding-top: 0.7rem;
  font-size: var(--text-sm);
}
.about-facts dt {
  color: var(--ink-3);
  font-size: var(--text-xs);
  letter-spacing: 0.1em;
  line-height: 1.75;
  text-transform: uppercase;
}
.about-facts dd {
  margin: 0;
  color: var(--ink);
}
.about-body .about-links {
  margin-top: 0.6rem;
}
button.cv-link {
  border: 0;
  padding: 0;
  background: none;
}
.reply-choices {
  display: flex;
  flex-wrap: wrap;
  gap: 0.7rem;
  margin-top: 1rem;
}
.reply-choices button {
  border: 1px solid var(--line);
  border-radius: 999px;
  padding: 0.65rem 1rem;
  background: transparent;
  color: var(--ink);
  font-size: var(--text-sm);
  transition:
    border-color var(--fast),
    color var(--fast);
}
.reply-choices button:hover {
  border-color: var(--accent);
  color: var(--accent);
}
.composer-dock {
  position: fixed;
  z-index: 5;
  right: 0;
  bottom: 0;
  left: 0;
  padding: 2rem 2.5rem max(2.25rem, calc(env(safe-area-inset-bottom) + 1rem));
  background: transparent;
}
.composer-inner {
  width: min(100%, 1180px);
  margin: 0 auto;
}
/* ── progressive blur under the dock ──
   Conversation scrolls up behind the composer, so the dock gets a backdrop
   that thickens towards the bottom instead of a hard edge: a light blur that
   starts high, a strong one that starts lower, each faded in by a mask, and a
   wash of paper on the strong one so text under the bar stays quiet. Only in
   a conversation — on the opening screen there is nothing behind it but the
   portrait, which should stay sharp. */
body:has(.stage.has-conversation) .composer-dock::before,
body:has(.stage.has-conversation) .composer-dock::after {
  position: absolute;
  right: 0;
  bottom: 0;
  left: 0;
  z-index: -1;
  content: "";
  pointer-events: none;
}
body:has(.stage.has-conversation) .composer-dock::before {
  top: -4rem;
  -webkit-backdrop-filter: blur(3px);
  backdrop-filter: blur(3px);
  -webkit-mask: linear-gradient(to bottom, transparent, #000 45%);
  mask: linear-gradient(to bottom, transparent, #000 45%);
}
body:has(.stage.has-conversation) .composer-dock::after {
  top: -1.5rem;
  background: linear-gradient(
    to bottom,
    color-mix(in srgb, var(--paper) 0%, transparent),
    color-mix(in srgb, var(--paper) 88%, transparent) 55%
  );
  -webkit-backdrop-filter: blur(12px);
  backdrop-filter: blur(12px);
  -webkit-mask: linear-gradient(to bottom, transparent, #000 40%);
  mask: linear-gradient(to bottom, transparent, #000 40%);
}
.speaker-switch {
  display: flex;
  gap: 0.55rem;
  margin-bottom: 0.8rem;
}
.speaker-option {
  display: inline-flex;
  align-items: center;
  gap: 0.55rem;
  min-width: 115px;
  border: 1px solid var(--line);
  border-radius: 999px;
  padding: 0.26rem 0.8rem 0.26rem 0.3rem;
  background: var(--surface);
  color: var(--ink);
  text-align: left;
  transition:
    border-color var(--fast),
    background var(--fast);
}
.speaker-option:hover {
  border-color: var(--ink-3);
}
.speaker-option.is-active {
  border-color: var(--accent-line);
  background: var(--accent-soft);
}
.speaker-option img {
  width: 38px;
  height: 38px;
  border-radius: 50%;
  object-fit: cover;
}
.speaker-option span {
  display: grid;
  gap: 0.1rem;
  font-size: var(--text-sm);
  font-weight: 700;
  line-height: 1.1;
}
.speaker-option small {
  color: var(--ink-3);
  font-size: var(--text-xs);
  font-weight: 400;
}
.suggestions[hidden],
.composer-status[hidden] {
  display: none;
}
.suggestions {
  display: flex;
  flex-wrap: wrap;
  gap: 0.6rem;
  margin: 0 0 1rem;
}
.suggestions button {
  border: 1px solid var(--line);
  border-radius: 999px;
  padding: 0.52rem 0.9rem;
  background: var(--surface);
  color: var(--ink-2);
  font-size: var(--text-sm);
  transition:
    border-color var(--fast),
    color var(--fast),
    transform var(--fast) var(--ease);
}
.suggestions button:hover {
  border-color: var(--ink-3);
  color: var(--ink);
}
/* ── the split ──
   At rest the bar runs the full width and the send button sits hidden inside
   its right end. Focusing the field is the moment someone has decided to send
   something, so that is when the bar gives the button room: the bar pulls its
   right edge back by exactly the button plus the gap, and the goo filter necks
   the two apart. The button itself never moves and the outer box never changes
   size, so the dock and the text inside the bar hold still. Content keeps it
   open on its own, so a draft left in the field still has its way to send.
   The curve runs a little past its target and back, so the neck snaps rather
   than easing apart. */
#composer {
  --bar: 56px;
  --send: 40px;
  --gap: 8px;
  --tuck: calc(var(--send) + var(--gap));
  --split: 520ms cubic-bezier(0.22, 1.3, 0.71, 1);
  position: relative;
  display: flex;
  align-items: flex-end;
  gap: var(--gap);
}
.composer-goo {
  position: absolute;
  inset: 0;
  filter: url(#composer-goo)
    drop-shadow(0 6px 24px rgba(var(--shadow-rgb), 0.06));
  pointer-events: none;
}
.goo-bar,
.goo-send {
  position: absolute;
  background: var(--surface);
}
.goo-bar {
  inset: 0;
  border-radius: 28px;
  transition: right var(--split);
}
.goo-send {
  right: 0;
  bottom: calc((var(--bar) - var(--send)) / 2);
  width: var(--send);
  height: var(--send);
  border-radius: 50%;
}
.composer-bar {
  position: relative;
  display: flex;
  flex: 1;
  flex-wrap: wrap;
  align-items: center;
  min-width: 0;
  min-height: var(--bar);
  margin-right: calc(-1 * var(--tuck));
  padding: 0.38rem 1rem 0.38rem 1.25rem;
  transition: margin-right var(--split);
}
#composer:focus-within .goo-bar,
#composer[data-armed] .goo-bar,
#composer[data-busy] .goo-bar {
  right: var(--tuck);
}
#composer:focus-within .composer-bar,
#composer[data-armed] .composer-bar,
#composer[data-busy] .composer-bar {
  margin-right: 0;
}
/* ── the letter ──
   Writing to Songhai is a letter, not a chat message, so it has its own
   surface: a modal holding an envelope, with corners cut rather than rounded,
   the airmail edge, the address block and a stamp. Everything is drawn from
   the page's own ink and accent; only the airmail red is new, because without
   it the stripe stops reading as airmail. */
.letter-modal {
  width: min(100% - 2rem, 36rem);
  max-width: none;
  max-height: none;
  margin: auto;
  border: 0;
  padding: 0;
  overflow: visible;
  background: transparent;
  color: var(--ink);
}
.letter-modal::backdrop {
  background: color-mix(in srgb, var(--paper) 70%, transparent);
  -webkit-backdrop-filter: blur(6px);
  backdrop-filter: blur(6px);
}
.letter-modal[open]::backdrop {
  animation: fade var(--fast) ease both;
}
.letter {
  position: relative;
  display: grid;
  gap: 1rem;
  border-radius: 10px;
  padding: 1.6rem 1.75rem 1.25rem;
  background: var(--surface);
  box-shadow:
    0 1px 2px rgba(var(--shadow-rgb), 0.05),
    0 24px 64px rgba(var(--shadow-rgb), 0.12);
}
.letter-modal[open] .letter {
  animation: rise var(--slow) var(--ease) both;
}
.letter-message {
  width: 100%;
  min-height: 9rem;
  border: 0;
  outline: 0;
  padding: 0;
  resize: none;
  background: transparent;
  color: var(--ink);
  font-size: var(--text-base);
  line-height: 1.6;
}
.letter-message::placeholder {
  color: var(--ink-3);
}
.letter-foot {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  padding-top: 0.25rem;
}
.letter-status {
  flex: 1;
  margin: 0;
  color: var(--ink-3);
  font-size: var(--text-xs);
}
.letter-status.is-error {
  color: var(--accent);
}
.letter-cancel,
.letter-send {
  border-radius: 999px;
  padding: 0.6rem 1.1rem;
  font-size: var(--text-sm);
  font-weight: 600;
  transition: background var(--fast), border-color var(--fast), color var(--fast);
}
.letter-cancel {
  border: 1px solid var(--line);
  background: transparent;
  color: var(--ink-2);
}
.letter-cancel:hover {
  border-color: var(--ink-3);
  color: var(--ink);
}
.letter-send {
  border: 1px solid var(--ink);
  background: var(--ink);
  color: var(--paper);
}
.letter-send:hover {
  background: #000;
}
.letter[data-busy] .letter-send {
  opacity: 0.6;
  cursor: progress;
}

.envelope {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  gap: 1.5rem;
  padding-bottom: 0.9rem;
  border-bottom: 1px dashed var(--line);
}
.envelope-address {
  display: grid;
  align-content: start;
  gap: 0.35rem;
  max-width: 26rem;
}
.envelope-line {
  display: grid;
  grid-template-columns: 4.5rem minmax(0, 1fr);
  align-items: baseline;
  margin: 0;
  color: var(--ink);
  font-size: var(--text-base);
}
.envelope-line > span {
  color: var(--ink-3);
  font-size: var(--text-xs);
  letter-spacing: 0.12em;
  text-transform: uppercase;
}
.envelope-line strong {
  padding: 0.25rem 0 0.45rem;
  font-weight: 500;
}
/* ── the stamp ──
   After the plain-design definitive stamps: one sheet of cream paper right
   out to the perforations, one bold mark in ink in the middle, the year
   small in a corner and the caption in capitals along the foot. Flat — no
   frame, shadow, gradient or tilt. The cream is warmer than the white
   envelope, which is what sets the stamp off it. */
.envelope-post {
  align-self: start;
}
.envelope-stamp {
  display: block;
  padding: 4px;
  background: #f3ecdb;
  -webkit-mask:
    linear-gradient(#000 0 0) content-box,
    radial-gradient(circle, transparent 2px, #000 2.4px) -3.5px -3.5px / 7px 7px;
  mask:
    linear-gradient(#000 0 0) content-box,
    radial-gradient(circle, transparent 2px, #000 2.4px) -3.5px -3.5px / 7px 7px;
}
.stamp-face {
  position: relative;
  display: grid;
  place-items: center;
  width: 58px;
  height: 72px;
  background: #f3ecdb;
}
.stamp-face img {
  display: block;
  width: 36px;
  height: 31px;
  margin-bottom: 8px;
}
.stamp-year {
  position: absolute;
  top: 4px;
  right: 4px;
  color: var(--ink);
  font-size: 0.375rem;
  font-weight: 600;
  letter-spacing: 0.04em;
  line-height: 1;
}
.stamp-caption {
  position: absolute;
  right: 0;
  bottom: 5px;
  left: 0;
  color: var(--ink);
  font-size: 0.375rem;
  font-weight: 500;
  letter-spacing: 0.03em;
  white-space: nowrap;
  line-height: 1;
  text-align: center;
  text-transform: uppercase;
}
.stamp-caption b {
  font-weight: 800;
}
.goo-defs {
  position: absolute;
  width: 0;
  height: 0;
  overflow: hidden;
}
.envelope-line input {
  width: 100%;
  min-width: 0;
  border: 0;
  border-bottom: 1px solid var(--line);
  outline: 0;
  padding: 0.25rem 0 0.45rem;
  background: transparent;
  color: var(--ink);
  font-size: var(--text-base);
  transition: border-color var(--fast);
}
.envelope-line input:focus {
  border-bottom-color: var(--accent);
}
#message {
  flex: 1;
  min-width: 0;
  min-height: 38px;
  max-height: 10rem;
  border: 0;
  outline: 0;
  padding: 0.45rem 0;
  resize: none;
  background: transparent;
  color: var(--ink);
  font-size: var(--text-base);
  line-height: 1.45;
}
#message::placeholder {
  color: var(--ink-3);
}
/* ── two states, one material ──
   Split off but empty, the button is the same white as the bar it came out of:
   offered, asking for nothing. The first character makes it the thing you are
   about to press, and it says so by filling — the only solid object in the
   composer, which is what a primary action is. No hover lift: it would pull the
   button off the blob that draws its edge. */
.send-button {
  position: relative;
  display: grid;
  place-items: center;
  width: var(--send);
  height: var(--send);
  flex: none;
  margin-bottom: calc((var(--bar) - var(--send)) / 2);
  border: 0;
  border-radius: 50%;
  padding: 0;
  background: transparent;
  color: var(--ink-3);
  opacity: 0;
  transition:
    opacity var(--fast) ease,
    background var(--fast) ease,
    color var(--fast) ease,
    transform var(--fast) var(--ease);
}
#composer:focus-within .send-button,
#composer[data-armed] .send-button,
#composer[data-busy] .send-button {
  opacity: 1;
}
#composer[data-armed] .send-button {
  background: var(--ink);
  color: var(--paper);
}
#composer[data-armed] .send-button:hover {
  background: #000;
}
.send-button:active {
  transform: scale(0.92);
}
/* Busy: filled like a primary action, with a ring running inside it. Answering
   shows a stop square, because a reply can be abandoned. */
.send-button .icon-stop {
  display: none;
}
#composer[data-busy] .send-button {
  background: var(--ink);
  color: var(--paper);
}
#composer[data-busy] .send-button::after {
  position: absolute;
  inset: 3px;
  content: "";
  border: 1.5px solid rgba(247, 246, 243, 0.18);
  border-top-color: var(--paper);
  border-radius: 50%;
  animation: spin 0.9s linear infinite;
}
#composer[data-busy="reply"] .icon-arrow {
  display: none;
}
#composer[data-busy="reply"] .icon-stop {
  display: block;
}
.suggestions button:disabled {
  opacity: 0.45;
  cursor: default;
}
.suggestions button:disabled:hover {
  border-color: var(--line);
  color: var(--ink-2);
}
.suggestions button:active {
  transform: scale(0.96);
}
.composer-meta {
  display: flex;
  justify-content: space-between;
  gap: 1rem;
  margin: 0.85rem 0.2rem 0;
  color: var(--ink-3);
  font-size: var(--text-xs);
}
.composer-status {
  margin: 0.55rem 0.2rem 0;
  color: var(--accent);
  font-size: var(--text-xs);
}
.noscript {
  position: fixed;
  bottom: 12rem;
  left: 2rem;
  z-index: 6;
  max-width: 30rem;
  background: var(--surface);
  padding: 1rem;
}
@media (max-height: 800px) {
  .opening-inner {
    padding-top: 2.4rem;
  }
  .opening-note {
    margin-bottom: 1.25rem;
  }
  .opening h1 {
    font-size: clamp(3.5rem, 7.5vw, 7rem);
  }
  .opening-intro {
    margin-top: 1.25rem;
  }
  .opening-invitation {
    margin-top: 1rem;
  }
  .composer-dock {
    padding-top: 0.75rem;
  }
}
/* ── the envelope arriving ──
   One small move per object, in the order you would make a real one: the
   address is written, the stamp is placed, and the airmail edge is simply
   there. Nothing loops and nothing bounces twice;
   each part moves once and then holds still. */
.letter-modal[open] .envelope-address {
  animation: rise var(--slow) var(--ease) 80ms both;
}
.letter-modal[open] .envelope-stamp {
  animation: stamp-place var(--slow) var(--ease) 120ms both;
}
@keyframes fade {
  from { opacity: 0; }
}
@keyframes stamp-place {
  from { opacity: 0; transform: translateY(-8px); }
}
/* Everything that arrives arrives the same way: a short rise into place.
   Cards used to fly in rotated from the corner; content should land, not perform. */
@keyframes breathe {
  0%,
  80%,
  100% {
    opacity: 0.3;
    transform: none;
  }
  40% {
    opacity: 1;
    transform: translateY(-2px);
  }
}
@keyframes spin {
  to {
    transform: rotate(1turn);
  }
}
@keyframes rise {
  from {
    opacity: 0;
    transform: translateY(12px);
  }
  to {
    opacity: 1;
    transform: none;
  }
}
@media (max-width: 760px) {
  .site-header,
  .stage {
    width: min(100% - 2.25rem, 1180px);
  }
  .site-header {
    height: 68px;
  }
  .stage {
    min-height: calc(100vh - 68px);
    padding-bottom: 250px;
  }
  .opening-inner {
    padding-top: clamp(4rem, 12vh, 7rem);
  }
  .opening {
    min-height: calc(100vh - 68px);
    min-height: calc(100dvh - 68px);
  }
  .opening-media {
    right: 0;
    left: auto;
    width: min(90vw, 40rem, 60vh);
  }
  .opening-inner {
    width: 100%;
  }
  .opening h1 {
    font-size: clamp(3.6rem, 14vw, 6.5rem);
  }
  .opening-intro {
    margin-top: 1.6rem;
  }
  .project-grid {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
  .composer-dock {
    padding: 2rem 1.125rem max(1.5rem, calc(env(safe-area-inset-bottom) + 0.75rem));
  }
  .speaker-option {
    min-width: 102px;
  }
  .speaker-option img {
    width: 34px;
    height: 34px;
  }
  /* On a phone the stamp moves into the corner over the address. */
  .letter {
    padding: 1.3rem 1.2rem 1rem;
  }
  .letter-foot {
    flex-wrap: wrap;
  }
  .letter-status {
    flex-basis: 100%;
  }
  .letter-cancel {
    margin-left: auto;
  }
  .envelope {
    position: relative;
    grid-template-columns: minmax(0, 1fr);
  }
  .envelope-address {
    padding-right: 3.2rem;
  }
  /* the same stamp, printed smaller, so the perforation keeps its pitch */
  .envelope-post {
    position: absolute;
    top: -0.3rem;
    right: 0;
    transform: scale(0.62);
    transform-origin: top right;
  }
  .envelope-line {
    grid-template-columns: 3.6rem minmax(0, 1fr);
  }
  .envelope-line > span {
    font-size: 0.625rem;
    letter-spacing: 0.08em;
  }
  .suggestions {
    flex-wrap: nowrap;
    overflow-x: auto;
    scrollbar-width: none;
  }
  .suggestions::-webkit-scrollbar {
    display: none;
  }
  .suggestions button {
    flex: none;
  }
  #composer {
    --bar: 54px;
    --send: 38px;
  }
  .composer-bar {
    padding-left: 1rem;
  }
  .composer-meta span:first-child {
    display: none;
  }
  .about-card {
    grid-template-columns: minmax(0, 1fr);
    gap: 0.9rem;
  }
  .about-photo {
    width: 96px;
  }
}
@media (max-width: 760px) and (max-height: 800px) {
  .opening-inner {
    padding-top: 1.8rem;
  }
  .opening-note {
    margin-bottom: 1rem;
  }
  .opening h1 {
    font-size: clamp(3rem, 11vw, 4.8rem);
  }
  .opening-intro {
    margin-top: 1rem;
  }
  .opening-invitation {
    margin-top: 0.85rem;
    font-size: var(--text-sm);
  }
}
@media (max-width: 420px) {
  .opening h1 {
    font-size: clamp(3.25rem, 13.5vw, 4.5rem);
  }
  .tile-title {
    right: 0.6rem;
    bottom: 0.6rem;
    left: 0.6rem;
  }
  }
@media (prefers-reduced-motion: reduce) {
  html {
    scroll-behavior: auto;
  }
  .opening,
  .has-conversation .opening-inner {
    transition: none;
  }
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-delay: 0ms !important;
    transition-duration: 0.01ms !important;
  }
}
