@layer components {
  /* ——— Chat ———
     Bubbles, chalet's way (user, 2026-08-23, overruling HANSHI 10's flat
     stream — see Deviations): a line is a face and a bubble, the bubble no
     wider than its words, others on the start side, yours on the end side in
     the mizu wash. The face sits in a column narrower than itself, so it laps
     into the bubble — chalet's 2ch column for a 3.5ch face. Chalet's
     BEHAVIOUR is under this — the threading, the day separators, the pending
     line, the failed line, the jump-to-newest — so every class the formatter
     toggles is drawn here.

     Scoped to .messages on purpose — `.message` on its own is already the
     long-form message article on a project page, and that has to stay a
     block. Same class name, narrower reach. */
  /* Parked out of sight until the opening scroll has landed: the stream
     paints top-first while it loads, then the controller jumps it to the
     newest line (or the permalinked one), and that jump was one hard flick
     on every open — worst on a phone, visible on a desktop (user,
     2026-08-30). messages_controller adds the class after its first
     positioning.

     Opacity, not visibility, and the difference is the whole fix: WebKit
     leaves a visibility-hidden subtree out of the paint tree, so the faces'
     bitmaps only rasterized ON the reveal frame — an intermittent one-frame
     blink whenever the decoded data hadn't been kept (user, 2026-08-30, a
     room with reactions blinking every third open even after an explicit
     decode()). An opacity-0 subtree paints for real while parked, so the
     reveal flips a compositor-only property over pixels that already
     exist. Either way the jump can measure; display could not. */
  .messages:not(.messages--positioned) {
    opacity: 0;
  }

  .messages {
    --message-gap: var(--block-space);
    --message-avatar-size: 2.25rem;
    /* The column the face is given; the rest of it — a third, Basecamp's
       overhang — hangs over the bubble. */
    --message-avatar-column: 1rem;
    /* Chalet's 0.66em: a bubble is a shape of its own, neither the flat
       sheet's 4px nor a floating thing's 6px (Deviations). */
    --bubble-radius: 0.66em;
    --bubble-padding-block: var(--block-space-half);
    --bubble-padding-inline: var(--inline-space-double);
    --bubble-max-inline-size: min(72ch, 85%);

    display: grid;
    grid-auto-rows: min-content;
    row-gap: var(--message-gap);
    /* A few lines sit at the foot; `safe`, because a plain `end` pushes a
       full stream's first lines over the box's top edge, where no scrollbar
       reaches them (measured: scrollHeight equal to the box, the line above
       it). Safe falls back to start the moment the stream overflows. */
    align-content: safe end;
    flex: 1;
    min-block-size: 0;
    overflow-y: auto;
    overscroll-behavior: contain;
    position: relative;
    /* The last line never sits on the composer's rule; the faces never sit
       under the scrollbar, which on an overlay system paints over the
       box's last pixels — chalet insets its lines the same way. The
       composer takes the same inset, so the send and the faces share an
       edge (chat-composer.css); a narrower room retunes `--room-inset`
       once for both (panel-chat.css). */
    padding-block-end: var(--block-space);
    padding-inline: var(--room-inset, var(--inline-space-double));
    scroll-padding-block-end: var(--block-space);
  }

  .messages .message {
    display: grid;
    grid-template-columns: var(--message-avatar-column) 1fr;
    grid-template-areas:
      "sep sep"
      "avatar body";
    column-gap: var(--inline-space);
    row-gap: 0;
    /* Hidden until the formatter has decided what it is — otherwise a line
       flashes un-threaded and un-dated for a frame. */
    visibility: hidden;

    &.message--formatted {
      visibility: visible;
    }

    /* A run by one person inside five minutes: no face, no name, no gap. */
    &.message--threaded {
      margin-block-start: calc(var(--message-gap) * -1 + var(--block-space-half));

      .message__avatar,
      .message__author {
        display: none;
      }
    }

    &.message--failed {
      opacity: 0.5;

      .message__body {
        outline: 1px dashed var(--color-negative);
      }
    }

    /* A permalink's line: the one mark you came here for. */
    &.message--highlighted .message__body {
      outline: var(--focus-ring);
      outline-offset: 2px;
      animation: message-highlight-fade 1s 1s forwards;
    }

    /* Nothing but emoji is drawn big and without a bubble — chalet's rule. */
    &.message--emoji .message__body {
      background-color: transparent;
      padding-inline: 0;

      .message__content {
        font-size: 2.4em;
        line-height: 1.4;
      }
    }

    /* Someone said your name: the bubble says so where the line sits, the
       panel's row already said so in words. */
    &.message--mentioned .message__body {
      background-color: var(--color-accent-tint);
      color: var(--color-accent-ink);
    }
  }

  /* Yours: the bubble on the end side, the face hanging off the end edge and
     lapping into it, the meta read backwards so the name sits by the face —
     chalet's .message--me, Basecamp's shape too. */
  .messages .message--mine {
    grid-template-columns: 1fr var(--message-avatar-column);
    grid-template-areas:
      "sep sep"
      "body avatar";

    .message__avatar {
      justify-self: end;
    }

    .message__body {
      justify-self: end;
      color: var(--color-selected-ink);
      background-color: var(--color-selected);
    }

    .message__meta {
      flex-direction: row-reverse;
    }

    /* Everything on the wash takes mizu's paired ink; the time still recedes,
       but from inside the pair — a faint grey would put default ink on a tint. */
    .message__meta .message__author,
    .message__timestamp {
      color: inherit;
    }

    .message__timestamp {
      opacity: 0.65;
    }
  }

  /* The face is wider than its column, so it overhangs the bubble; the ring
     of canvas cuts it out of the tint, as a pile's faces cut out of each
     other (avatars.css). */
  .messages .message__avatar {
    --avatar-size: var(--message-avatar-size);

    grid-area: avatar;
    align-self: start;
    z-index: 1;
    box-shadow: 0 0 0 2px var(--color-canvas);
    /* The initials face is a translucent tint; over the bubble it let the
       wash through. The canvas under the tint makes it opaque. */
    background: linear-gradient(var(--color-surface) 0 0) var(--color-canvas);
  }

  /* A bubble is chalet's, not fizzy's (user, 2026-08-29): `.rich-content p
     { margin: 0 }` — every paragraph, not only the ones with a sibling after
     them, so the last line does not leave half a line of air inside the
     bubble's own padding. Fizzy's rule (lexxy.css) already flushes the rest;
     this is the step past it that chat wants. */
  .message__body .lexxy-content p {
    margin: 0;
  }

  .messages .message__body {
    grid-area: body;
    /* Relative for the applause: the boost trigger and form hang off the
       bubble's foot corner (below). */
    position: relative;
    min-inline-size: 0;
    inline-size: fit-content;
    max-inline-size: var(--bubble-max-inline-size);
    padding: var(--bubble-padding-block) var(--bubble-padding-inline);
    background-color: var(--color-surface);
    border-radius: var(--bubble-radius);
  }

  .messages .message__meta {
    display: flex;
    align-items: baseline;
    gap: var(--inline-space);
    font-size: var(--text-small);
    line-height: 1.5;

    .message__author {
      font-weight: 600;
      color: var(--color-ink);
    }

    .message__permalink,
    .message__timestamp {
      font-size: var(--text-x-small);
      color: var(--color-ink-faint);
      text-decoration: none;
    }
  }

  /* The ⋯ waits for the pointer — in the meta line right after the time,
     chalet's narrow seat. Not pointer-events: none like the dock's verbs —
     it has to be reachable by keyboard, and focus reveals it. */
  .messages .message__actions {
    align-self: center;

    .message__options {
      /* The line's height, not the circle's: at 28px the button stretched
         the meta row and hung the dots 3px under the text line beside the
         time — measured, twice: 1lh was the first try, and the button's own
         fluid font made that 27.6px, a circle wearing a smaller name. A rem
         says what is meant. Chalet flattens its ⋯ to the line on a phone for
         the same reason (their messages.css); the touch halo (buttons.css)
         still hands the finger its 44px. */
      --btn-size: 1.25rem;

      opacity: 0;
      /* The delay is chalet's: a pointer crossing the feed does not strobe
         a control on every line it passes. */
      transition: opacity var(--speed-quick) var(--ease-out) var(--speed-quick);
    }
  }

  .messages .message:is(:hover, :focus-within) .message__options,
  .messages .message__options:focus-visible {
    opacity: 1;
  }

  /* On a sheet with room, chalet's wide seat instead: the ⋯ floats just past
     the bubble — off its end for another's line, off its start for yours —
     centred on the bubble's own height (their messages.css, at min-width
     100ch; ours asks the container, and the panel, which no `main` query
     reaches, keeps the meta seat above). Absolute against .message__body,
     still the row's descendant, so the row's :hover reveal reaches it — and
     the whole row is the hover zone, approach from either side. */
  @container main (width > 70ch) {
    .messages .message__actions {
      position: absolute;
      inset-block-start: 50%;
      inset-inline-start: calc(100% + var(--inline-space-half));
      translate: 0 -50%;
    }

    .messages .message--mine .message__actions {
      inset-inline-start: auto;
      inset-inline-end: calc(100% + var(--inline-space-half));
    }
  }

  /* No pointer to wait for: chalet shows the line's ⋯ at half ink on touch,
     full while its menu stands open (their messages.css — ours asks the
     popover where theirs asked [open], and hover alone decides: desktop
     responsive mode emulates no coarse pointer, yet has no hover either). */
  /* The showing itself is hover-reveals.css's, with every other control that
     waits for a pointer; only how far it shows is the feed's own. A custom
     property is what carries that across — it is set here, in `components`,
     and read there, in `utilities`, because properties inherit and do not
     take sides between layers.

     Which also retires the specificity note this block used to carry: the
     later layer wins outright now, so nothing here has to be three selectors
     deep to be seen (it was two deep once, and the phone never saw its dots
     — caught 2026-08-24). */
  @media (hover: none) {
    .messages .message__actions {
      --hover-reveal-touch: 0.5;

      &:has(:popover-open) {
        --hover-reveal-touch: 1;
      }
    }
  }

  /* Edit and delete are yours on your own lines, and an admin's on any —
     decided in the browser (message_formatter.js sets message--mine, the
     body wears admin), because the line renders the same for everybody. */
  .messages .message__own-action {
    display: none;
  }

  .messages .message--mine .message__own-action,
  body.admin .messages .message__own-action {
    display: list-item;
  }

  /* The words. Rendered rich text leaves a trailing margin under its last
     block, which would double the rhythm before the next line. */
  .messages .message__content {
    word-break: break-word;

    > :last-child {
      margin-block-end: 0;
    }
  }

  /* The line's menu is narrower than the dialog's floor: a grid of eight
     and five short rows, nothing that wants 16rem. */
  .messages .message__menu {
    min-inline-size: 11rem;
  }

  /* The applause under a line — campfire's economy (user, 2026-08-24): a
     line with no boosts spends NOTHING on reactions; the way in is the ⋯,
     whose menu carries the quick eight, the fifty and the written boost.
     Once the line has applause — or the form lands in its frame from the
     menu — the strip stands in the bubble's flow, the add-reaction chip
     (campfire's glyph) at the pills' end. */
  .messages .boosts:has(.boosts__list .boost, .boost__form) {
    margin-block-start: var(--block-space-half);
  }

  .messages .boosts:not(:has(.boosts__list .boost, .boost__form)) {
    display: none;
  }

  /* The day divider is the feed's (.day-divider--centred, dividers.css); this
     only places it in the line's grid and shows it on the first line of each
     day, which the formatter decides. */
  .messages .message__day-separator {
    grid-area: sep;
    display: none;
    gap: var(--inline-space-double);
    margin-block: var(--block-space) var(--block-space-double);
    font-weight: 400;
  }

  .messages .message--first-of-day .message__day-separator {
    display: flex;
  }

  /* A picture fills its bubble to the edges — chalet's attachment bubble:
     the padding goes, the meta and the applause keep their own, and the
     corners square where the pieces meet (their messages.css, on our
     names — user, 2026-08-24). */
  .messages .message__body:has(.message__media) {
    padding: 0;

    .message__meta {
      padding: var(--bubble-padding-block) var(--bubble-padding-inline);
    }

    &:has(.boost) {
      padding-block-end: var(--bubble-padding-block);

      .boosts {
        padding-inline: var(--inline-space);
      }
    }
  }

  /* A picture or a video sits in the text column at its own size, never
     wider than the words would run. It wears the bubble's own radius and a
     hairline (chalet's), squared at the top against the meta above it, and
     at the foot when applause sits under it. */
  .messages .message__media {
    /* Chalet's composition, whole: the picture keeps its own (half) size and
       CENTRES in the bubble when the applause row runs wider — their
       wrapper's `center`, the piece whose absence left the frame stranded
       at the start edge (user, 2026-08-24). */
    margin-inline: auto;
    max-inline-size: 100%;
    overflow: clip;
    border: var(--border);
    border-radius: var(--bubble-radius);
    border-start-start-radius: 0;
    border-start-end-radius: 0;

    .message__body:has(.boost) & {
      border-end-start-radius: 0;
      border-end-end-radius: 0;
    }

    img,
    video {
      display: block;
      max-inline-size: 100%;
      block-size: auto;
    }

    img {
      cursor: zoom-in;
    }
  }

  /* A file on its way up: the name, and the bar filling behind it. */
  .message__pending-upload {
    position: relative;
    overflow: clip;
    padding: var(--block-space-half) var(--inline-space);
    border-radius: var(--border-radius);

    &::before {
      content: "";
      position: absolute;
      inset: 0;
      inline-size: var(--percentage);
      background-color: var(--color-surface);
    }

    > * {
      position: relative;
    }
  }

  /* A quote with its author under it — a reply (reply_controller.js), in
     chalet's shape: a thick start bar and no italics, then the author as a
     small semibold line led by a dash. The dash is drawn here rather than
     typed, since the presentation filter strips the one the reply typed. */
  .messages .message__content blockquote {
    margin: 0 var(--inline-space);
    padding-inline-start: 1.5ch;
    border-inline-start: var(--inline-space-half) solid var(--color-line);
    font-style: normal;
  }

  .messages cite {
    display: block;
    margin-block-end: 0.5em;
    margin-inline-start: 1.5ch;
    font-size: var(--text-small);
    font-style: normal;
    font-weight: 600;
    color: inherit;

    &::before {
      content: "— ";
      color: var(--color-ink-faint);
    }
  }

  /* A link preview is a hairline card and nothing more — no image at the
     side, no favicon, no fill until you reach for it. */
  .link-preview {
    display: block;
    max-inline-size: 32rem;
    margin-block-start: var(--block-space-half);
    padding: var(--block-space) var(--inline-space-double);
    color: var(--color-ink);
    border: var(--border);
    border-radius: var(--border-radius);
    text-decoration: none;

    .og-embed__image {
      display: none;
    }

    &:hover {
      background-color: var(--color-surface);
    }
  }

  /* The way back to now, floating over the stream's foot once you have
     scrolled up past a new line. */
  .message-area__return-to-latest {
    position: absolute;
    inset-block-end: calc(var(--footer-height) + 5rem);
    inset-inline-start: 50%;
    translate: -50% 0;
    z-index: 1;
  }

  /* The room's head: a face and a name for a ping, the tool heading for a
     project's chat, the bell at the end of either; the panel's, tighter,
     retunes the one space (panel-chat.css). */
  .chat-head {
    /* A fallback, not a declaration on the element: set here it would beat
       the value the panel inherits down. */
    padding-block-end: var(--chat-head-space, var(--block-space));
    /* Earned the way toolbar.css says one is earned: it separates the room's
       chrome from the room itself. A ping, the panel and the search page all
       draw this head with no toolbar over it, so this IS their chrome strip. */
    border-block-end: var(--border);
    margin-block-end: var(--chat-head-space, var(--block-space));

    /* Except on a project's chat, where the toolbar drew that very line 60px
       up and the head below it is only a title — and no other tool page
       underlines its title (user, 2026-08-30). The padding goes with the
       border it was air around; the margin left standing is the same
       --block-space every other tool header wears as a utility. */
    .toolbar + & {
      padding-block-end: 0;
      border-block-end: 0;
    }
  }

  @keyframes message-highlight-fade {
    to { outline-color: transparent; }
  }

  /* A phone-wide sheet takes the panel's density (panel-chat.css): the lines
     and the composer nearer the edges, a size-down face, a wider bubble, the
     head a step down — and half the air between runs, which is what lets a
     phone show more than a few lines (user, 2026-08-24). The stream and the
     composer are siblings, so each takes the inset itself. */
  @container main (max-width: 70ch) {
    .messages,
    .chat-composer {
      --room-inset: var(--inline-space);
    }

    .messages {
      --message-gap: var(--block-space-half);
      --message-avatar-size: 1.75rem;
      --message-avatar-column: 0.75rem;
      --bubble-max-inline-size: 95%;
    }

    .chat-head {
      --chat-head-space: var(--block-space-half);

      /* A step above body, no more: the room below is the page, and BC's
         mobile chat wears a modest title too (user, 2026-08-24).

         Asked of a head that actually has a room under it. The reason is the
         justification above: "the room below is the page" is the whole of why
         the title steps down, and it is false on chats/searches/show, which
         borrows .chat-head for its way back and puts a results list below
         instead. That page took the room's type with the layout and lost the
         step base.css just gave every h1 (2026-08-29). */
      &:has(~ .message-area) h1 {
        font-size: var(--text-medium);
      }
    }
  }
}
