:root {
  /* We are dark-only, so declare it: extensions that re-colour pages for dark
     mode (Noir and friends) back off when a site says it is already dark, and
     native widgets (inputs, scrollbars, caret) render dark too. */
  color-scheme: dark;
  --bg: #0a0a0b;
  --surface: #131315;
  --surface-raised: #1c1c1f;
  --line: #232326;
  --text: #e9e7e2;
  --text-dim: #a9a6a0;
  --text-faint: #7d7a75;
  --accent: #e2b874;
  --danger: #e0574a;
  --cold: #7aa7d9;
  --serif: "Instrument Serif", ui-serif, Georgia, serif;
  --sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}

* {
  box-sizing: border-box;
}

html {
  min-height: 100%;
  background: var(--bg);
  overscroll-behavior: none;
  scrollbar-width: thin;
  scrollbar-color: var(--line) transparent;
}

body {
  min-height: 100dvh;
  margin: 0;
  display: flex;
  flex-direction: column;
  background: var(--bg);
  color: var(--text);
  font-family: var(--sans);
  font-size: 15px;
  overscroll-behavior: none;
  -webkit-font-smoothing: antialiased;
}

/* ---------------------------------------------------------------- topbar */

.topbar {
  position: sticky;
  top: 0;
  z-index: 3;
  display: grid;
  grid-template-columns: 1fr auto;
  align-items: center;
  gap: 16px;
  padding: 20px 26px 10px;
}

/* The bar itself is invisible: only the wordmark and the call button live
   here. The log is faded out as it leaves the top of the viewport, rather
   than being cut off by an opaque strip.

   z-index: -1 puts the fade in the bar's own stacking context, behind the
   wordmark and the call button but still above the log (the whole context is
   z-index 3). Because the bar is sticky at top: 0, `top: 0` on the fade is
   the top of the viewport while stuck. */
.topbar::after {
  content: "";
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  z-index: -1;
  height: 40px;
  pointer-events: none;
  background: linear-gradient(rgba(10, 10, 11, 1) 28%, rgba(10, 10, 11, 0));
  opacity: 0;
  transition: opacity 180ms ease;
}

/* Only while there is log hidden above it; app.js sets the class. */
body.can-scroll-up .topbar::after {
  opacity: 1;
}

/* Once the log column is wide enough to clear both controls (~980px: the
   720px column centred leaves 130px a side, and the wordmark and version end
   just inside that), text can only pass behind empty gutters, so the short
   fade above is enough. Below it, text runs straight under the wordmark and
   the phone button, and the fade has to stand in for a solid bar. */
@media (max-width: 999px) {
  .topbar::after {
    height: 96px;
    background: linear-gradient(
      rgba(10, 10, 11, 1) 64%,
      rgba(10, 10, 11, 0)
    );
  }
}

.brand {
  display: flex;
  align-items: baseline;
  gap: 7px;
  justify-self: start;
  cursor: default;
  user-select: none;
  -webkit-user-select: none;
}

.wordmark {
  margin: 0;
  font-family: var(--serif);
  font-weight: 400;
  font-size: 30px;
  letter-spacing: -0.01em;
  line-height: 1;
}

.version {
  /* Named outright rather than inherited: beside the serif wordmark this would
     pick up the serif face, which is unreadable at 11px on a 1x display.
     Whole pixels for the same reason. */
  font-family: var(--sans);
  font-size: 11px;
  font-weight: 400;
  letter-spacing: 0.05em;
  line-height: 1;
  color: var(--text-faint);
  cursor: default;
  user-select: none;
  -webkit-user-select: none;
}

/* The wordmark is the account menu: a button with no chrome of its own, so the
   top bar keeps one element on the left and still has somewhere to put the
   account. */
.brand {
  position: relative;
}

.brand-button {
  display: inline-flex;
  align-items: baseline;
  gap: 7px;
  padding: 0;
  border: 0;
  border-radius: 6px;
  background: none;
  font: inherit;
  color: inherit;
  cursor: pointer;
}

.brand-button:focus-visible {
  outline: 1px solid var(--line);
  outline-offset: 7px;
}

.menu {
  position: absolute;
  top: calc(100% + 12px);
  left: 0;
  z-index: 4;
  min-width: 216px;
  padding: 8px;
  border: 1px solid var(--line);
  border-radius: 12px;
  background: var(--surface);
  box-shadow: 0 20px 44px rgba(0, 0, 0, 0.55);
}

.menu[hidden] {
  display: none;
}

.menu-email {
  margin: 0;
  padding: 8px 10px 9px;
  max-width: 258px;
  font-size: 12.5px;
  color: var(--text-faint);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.menu-rule {
  height: 1px;
  margin: 4px 0 6px;
  background: var(--line);
}

.menu-item {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 14px;
  width: 100%;
  padding: 9px 10px;
  border: 0;
  border-radius: 8px;
  background: none;
  color: inherit;
  font: inherit;
  font-size: 13.5px;
  text-align: left;
  cursor: pointer;
}

.menu-item:hover:not(:disabled) {
  background: rgba(255, 255, 255, 0.06);
}

.menu-item:focus-visible {
  background: rgba(255, 255, 255, 0.06);
  outline: 1px solid var(--line);
  outline-offset: 1px;
}

.menu-item:disabled {
  color: var(--text-faint);
  cursor: default;
}

/* ---------------------------------------------------------------- dialog */

/* A native dialog, so the browser does the hard parts: focus trapped inside,
   Escape closes, the rest of the page inert behind it. */
.dialog {
  width: min(380px, calc(100vw - 40px));
  padding: 22px;
  border: 1px solid var(--line);
  border-radius: 18px;
  background: var(--surface);
  color: var(--text);
  box-shadow: 0 30px 70px rgba(0, 0, 0, 0.6);
}

/* The dialog takes focus itself, so no field is focused on open and nothing
   gets a ring before the reader has chosen where to type. */
.dialog:focus {
  outline: none;
}

.dialog::backdrop {
  background: rgba(6, 6, 7, 0.62);
  backdrop-filter: blur(3px);
}

.dialog[open] {
  animation: dialog-in 150ms ease-out;
}

/* Closing drops [open], and the element with it, so the exit is a class that
   holds it on screen and closes it once the animation has run. */
.dialog.closing {
  animation: dialog-out 120ms ease-in both;
}

.dialog.closing::backdrop {
  animation: backdrop-out 120ms ease-in both;
}

@keyframes dialog-in {
  from {
    opacity: 0;
    transform: translateY(6px) scale(0.985);
  }
}

@keyframes dialog-out {
  to {
    opacity: 0;
    transform: translateY(4px) scale(0.99);
  }
}

@keyframes backdrop-out {
  to {
    opacity: 0;
  }
}

.dialog-title {
  margin: 0;
  font-family: var(--serif);
  font-weight: 400;
  font-size: 24px;
  letter-spacing: -0.01em;
}

.dialog-note {
  margin: 4px 0 18px;
  font-size: 12.5px;
  color: var(--text-faint);
}

.field {
  display: block;
  margin-bottom: 14px;
}

.field-label {
  display: block;
  margin-bottom: 6px;
  font-size: 11px;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-faint);
}

.field-input {
  width: 100%;
  padding: 10px 12px;
  border: 1px solid var(--line);
  border-radius: 10px;
  background: var(--bg);
  color: var(--text);
  font: inherit;
  font-size: 14px;
}

.field-input:focus {
  outline: none;
  border-color: var(--text-faint);
}

.field-input:focus-visible {
  border-color: var(--accent);
}

/* WebKit repaints autofilled fields with its own pale yellow and an inset
   shadow, which on a dark surface looks like a bug. The fill is replaced with
   the field's own surface rather than removed, because the yellow is drawn as
   a background image and cannot be cleared any other way. */
.field-input:-webkit-autofill,
.field-input:-webkit-autofill:hover,
.field-input:-webkit-autofill:focus,
.text:-webkit-autofill,
.text:-webkit-autofill:hover,
.text:-webkit-autofill:focus {
  -webkit-text-fill-color: var(--text);
  -webkit-box-shadow: 0 0 0 1000px var(--bg) inset;
  box-shadow: 0 0 0 1000px var(--bg) inset;
  caret-color: var(--text);
}

.dialog-error {
  margin: 0 0 12px;
  font-size: 12.5px;
  color: var(--danger);
}

.dialog-actions {
  display: flex;
  justify-content: flex-end;
  gap: 8px;
  margin-top: 18px;
}

.dialog-button {
  padding: 9px 16px;
  border: 1px solid transparent;
  border-radius: 10px;
  font: inherit;
  font-size: 13.5px;
  cursor: pointer;
}

.dialog-button.ghost {
  border-color: var(--line);
  background: none;
  color: var(--text-dim);
}

.dialog-button.ghost:hover {
  color: var(--text);
  border-color: var(--text-faint);
}

.dialog-button.primary {
  background: var(--text);
  color: var(--bg);
}

.dialog-button.primary:hover {
  background: #ffffff;
}

.dialog-button:disabled {
  opacity: 0.6;
  cursor: default;
}

/* Cold is the ordinary state of a container nobody has called yet, so it is
   said quietly: a dot that only exists while there is nothing to talk to. */
/* Sits just after the version, raised off the baseline by its own margin so
   it reads as a mark on the label rather than a full stop after the name. */
.dot {
  width: 5px;
  height: 5px;
  align-self: baseline;
  margin-left: 1px;
  margin-bottom: 4px;
  border-radius: 50%;
  background: var(--cold);
  opacity: 0;
  transition: opacity 220ms ease;
}

body.is-cold .dot {
  opacity: 0.85;
}

/* The one line that speaks for the server: startup while it loads, a word
   while the socket is being re-opened, gone as soon as it is up. It sits
   between the log and the composer, where a status belongs, rather than in the
   conversation itself. */
.status {
  width: calc(100% - 40px);
  max-width: 680px;
  margin: 0 auto 8px;
  font-size: 12.5px;
  color: var(--text-faint);
  text-align: center;
}

.call {
  justify-self: end;
  display: grid;
  place-items: center;
  width: 40px;
  height: 40px;
  padding: 0;
  border: 1px solid var(--line);
  border-radius: 50%;
  background: var(--surface);
  color: #ffffff;
  cursor: pointer;
  transition: color 140ms ease, border-color 140ms ease,
    background 140ms ease;
}

.call:hover {
  background: var(--surface-raised);
}

.call:disabled,
.call.starting {
  opacity: 0.55;
  cursor: default;
}

.call svg {
  width: 17px;
  height: 17px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.7;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.call .icon-hangup {
  display: none;
  /* Filled rather than outlined: the same path as a silhouette. With no
     stroke to overhang the viewBox, the rotated handset can also be scaled
     up to nearly fill it. */
  fill: currentColor;
  stroke: none;
}

.call.in-call {
  color: var(--danger);
}

.call.in-call .icon-call {
  display: none;
}

.call.in-call .icon-hangup {
  display: block;
  /* A touch larger than the handset on the other face: the rotated glyph
     reads smaller than an upright one at the same box size. */
  width: 22px;
  height: 22px;
}

/* -------------------------------------------------------------- chat log */

/* The page is the scroller: the scrollbar sits at the window edge, and the
   empty space either side of the chat column scrolls the log too. */
.log {
  flex: 1 0 auto;
  display: flex;
  flex-direction: column;
  gap: 18px;
  width: 100%;
  max-width: 720px;
  margin: 0 auto;
  padding: 12px 26px 26px;
}

.hint {
  align-self: center;
  margin: auto 0;
  max-width: 44ch;
  color: var(--text-faint);
  font-size: 13.5px;
  line-height: 1.6;
  text-align: center;
}

/* The hint and the cold line are one thought, so they share one block. Left as
   siblings, each paragraph's auto margins split the free space and the second
   one lands at the bottom of the log. */
.hint-block {
  display: flex;
  flex-direction: column;
  gap: 0.85em;
  align-self: center;
  margin: auto 0;
}

.hint-block .hint {
  margin: 0;
}

.msg {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.msg .line {
  display: flex;
  gap: 9px;
  align-items: baseline;
  max-width: 100%;
}

.msg .body {
  min-width: 0;
  white-space: pre-wrap;
  overflow-wrap: anywhere;
}

.msg.user {
  align-self: flex-end;
  align-items: flex-end;
  max-width: 80%;
}

.msg.user .line {
  justify-content: flex-end;
}

.msg.user .body {
  background: var(--surface-raised);
  color: #d8d5ce;
  padding: 9px 14px;
  border-radius: 16px;
  /* border-bottom-right-radius: 6px; */
}

.msg.user.pending .body {
  color: var(--text-faint);
}

/* User text is rendered by the same parser, so it arrives wrapped in a
   paragraph whose default margin would fight the bubble's own padding. */
.msg.user .body > * {
  margin: 0;
}

.msg.user .body strong {
  font-weight: 500;
  color: #f1eee7;
}

.msg.user .body em {
  color: #ece9e2;
}

/* Only reachable if the prompt itself was a list. */
.msg.user .body ul,
.msg.user .body ol {
  padding-left: 1.15em;
}

.msg.giles {
  align-self: flex-start;
  max-width: 88%;
}

.msg.giles .body {
  font-size: 16px;
  line-height: 1.6;
  color: #dedbd4;
  /* app.js renders the reply as paragraphs and lists, so there are no
     newlines left in the text to preserve: `pre-wrap` would double them. */
  white-space: normal;
}

/* A long answer needs somewhere to rest: paragraphs breathe, and a list gets
   a hanging indent so wrapped items line up under their text, not their dot. */
.msg.giles .body > * {
  margin: 0;
}

/* Same specificity as the reset above and declared after it, so the gap is
   what wins: a plain `p { margin: 0 }` would have out-specified this. */
.msg.giles .body > * + * {
  margin-top: 0.72em;
}

.msg.giles .body ul,
.msg.giles .body ol {
  padding-left: 1.15em;
}

.msg.giles .body li + li {
  margin-top: 0.3em;
}

.msg.giles .body li > ul,
.msg.giles .body li > ol {
  margin-top: 0.3em;
}

.msg.giles .body li::marker {
  color: var(--text-faint);
}

/* A tool note inside a turn sits under the text, lined up with it rather than
   with the glyph: the glyph belongs to what was said, not to the call. */
.msg > .note {
  padding-left: 22px;
}

/* Emphasis is deliberately quiet: a shade brighter and one weight up, rather
   than the browser's shouting bold. */
.msg.giles .body strong {
  font-weight: 500;
  color: #f4f1ea;
}

.msg.giles .body em {
  color: #ece9e2;
}

/* ---------------------------------------------------- voice transcripts */

.glyph {
  flex: 0 0 auto;
  width: 13px;
  height: 13px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.7;
  stroke-linecap: round;
  color: var(--text-faint);
  transform: translateY(1px);
}

.msg.voice .body {
  color: var(--text-dim);
  font-size: 14px;
  line-height: 1.55;
}

.msg.user.voice .body {
  background: transparent;
  padding: 0;
  color: var(--text-dim);
  text-align: right;
}

body.hide-voice .msg.voice {
  display: none;
}

.note {
  font-size: 12px;
  letter-spacing: 0.02em;
  color: var(--text-faint);
  padding-left: 2px;
}

.note.error {
  color: #d98b7a;
}

/* -------------------------------------------------- "Giles is speaking" */

.bars {
  display: inline-flex;
  align-items: flex-end;
  gap: 3px;
  height: 12px;
  opacity: 0;
  transition: opacity 160ms ease;
}

.msg.giles.is-speaking .bars {
  opacity: 1;
}

.bars span {
  width: 3px;
  height: 4px;
  border-radius: 2px;
  background: var(--accent);
  animation: bar 1s ease-in-out infinite;
}

.bars span:nth-child(2) {
  animation-delay: 150ms;
}

.bars span:nth-child(3) {
  animation-delay: 300ms;
}

@keyframes bar {
  0%,
  100% {
    height: 4px;
  }
  50% {
    height: 12px;
  }
}

@media (prefers-reduced-motion: reduce) {
  .bars span {
    animation: none;
    height: 8px;
  }
}

/* ------------------------------------------------------------- composer */

/* Docks the composer to the bottom of the viewport while the page scrolls. */
.dock {
  position: sticky;
  bottom: 0;
  z-index: 2;
  padding: 10px 0 18px;
  background: var(--bg);
}

/* ...and again before it reaches the composer. Same trick: behind the
   composer, above the log.

   This one is deliberately tall, so text dissolves across a long run instead
   of hitting a short band and vanishing. It ends flush with the top of the
   dock: the composer is opaque, so anything lower would be hidden anyway. The
   stops hold the first stretch nearly clear (text stays readable), then ease
   into solid behind the dock's padding. */
.dock::before {
  content: "";
  position: absolute;
  bottom: 100%;
  left: 0;
  right: 0;
  z-index: -1;
  height: 120px;
  pointer-events: none;
  background: linear-gradient(
    rgba(10, 10, 11, 0) 16%,
    rgba(10, 10, 11, 0.35) 48%,
    rgba(10, 10, 11, 0.8) 78%,
    rgba(10, 10, 11, 1)
  );
  opacity: 0;
  transition: opacity 180ms ease;
}

body.can-scroll-down .dock::before {
  opacity: 1;
}

.composer {
  display: flex;
  align-items: center;
  gap: 6px;
  width: calc(100% - 40px);
  max-width: 680px;
  margin: 0 auto;
  padding: 6px 6px 6px 18px;
  background: var(--surface);
  border: 1px solid #1d1d20;
  border-radius: 24px;
}

.composer:focus-within {
  border-color: #34343a;
}

.text {
  flex: 1;
  min-width: 0;
  padding: 8px 0;
  background: transparent;
  border: 0;
  outline: none;
  color: var(--text);
  font: inherit;
}

.text::placeholder {
  color: var(--text-faint);
}

.send,
.mic,
.toggle {
  display: grid;
  place-items: center;
  width: 38px;
  height: 38px;
  flex: 0 0 auto;
  padding: 0;
  border: 1px solid transparent;
  border-radius: 50%;
  background: transparent;
  color: var(--text-dim);
  cursor: pointer;
  transition: color 140ms ease, background 140ms ease, border-color 140ms ease,
    opacity 140ms ease;
}

.send:hover,
.mic:hover,
.toggle:hover {
  color: var(--text);
  background: var(--surface-raised);
}

.send svg,
.mic svg,
.toggle svg {
  width: 18px;
  height: 18px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.6;
  stroke-linecap: round;
  stroke-linejoin: round;
}

/* The send arrow and the transcript toggle have no shape until they are
   hovered, so the composer's 6px gap reads as a gap between two objects. Pull
   the toggle back over it and they sit as one cluster. */
.toggle {
  margin-left: -6px;
}

.toggle[aria-pressed="false"] {
  opacity: 0.45;
}

.mic {
  position: relative;
  /* Clips the level fill to the circle. */
  overflow: hidden;
  border-color: var(--line);
}

.mic .icon-muted {
  display: none;
}

.mic.is-off .icon-mic {
  display: none;
}

.mic.is-off .icon-muted {
  display: block;
}

/* "Mic is live": the glyph and the ring turn accent. No glow: the waterline
   already shows the level, so the ring only has to say which state this is. */
.mic.is-on {
  color: var(--accent);
  border-color: rgba(226, 184, 116, 0.45);
}

.mic:disabled {
  opacity: 0.4;
  cursor: default;
}

.mic:disabled:hover {
  color: var(--text-dim);
  background: transparent;
}

/* Mic level, drawn on the button itself: the circle fills from the bottom, so
   the fill height *is* the level and nothing has to be inserted into the
   composer row when a call starts. The 10 fps chunk rate is smoothed by the
   short transition and by the attack/release in app.js. */

.mic::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: calc(var(--level, 0) * 100%);
  background: rgba(226, 184, 116, 0.16);
  pointer-events: none;
  transition: height 90ms linear;
}

/* The glyph stays on top of the fill. Slightly larger than the composer's
   other icons: the mic is a tall, narrow shape, so at the shared 18px it
   reads smaller than the arrow and the transcript lines beside it.

   22px, not 21px: the button's content box is 36px (38 minus a 1px border),
   so an even icon leaves a whole 7px either side. At 21px the icon box lands
   on a half pixel and the glyph looks 1px off-centre. */
.mic svg {
  position: relative;
  z-index: 1;
  width: 22px;
  height: 22px;
}

/* ------------------------------------------------------------ password */

body.gate {
  display: grid;
  place-items: center;
}

.gate-card {
  width: min(360px, calc(100% - 48px));
  display: flex;
  flex-direction: column;
  gap: 18px;
}

.warn {
  margin: 0;
  font-size: 13px;
  color: #d98b7a;
}
