/* Chat thread, composer, message bodies and code blocks. Loaded only on chat pages. Like app.css, every value resolves through tokens.css. */ /* --- Thread --------------------------------------------------------------- */ /* A `.scroll-region` (app.css); the smooth behaviour is this one's own, because this is the scroller something is repeatedly scrolled *to* -- the newest message, a jump back to the bottom -- and the others are not. */ .thread-scroll { scroll-behavior: smooth; } .thread { max-width: var(--thread-max-width); margin: 0 auto; padding: var(--sp-6) var(--sp-5) var(--sp-8); display: flex; flex-direction: column; gap: var(--sp-6); } /* The new-chat screen. Deliberately the only thing in the transcript that animates on arrival. A message bubble must not: the steps container is replaced with `innerHTML` up to twelve times a second while a reply streams, and the `done` frame replaces the whole article -- so an entry animation on a bubble re-triggers on every swap and what it produces is not an arrival, it is a flicker at twelve hertz. This element renders once and is never swapped. */ .thread__intro { display: grid; place-items: center; gap: var(--sp-3); text-align: center; animation: intro-rise var(--dur-3) var(--ease-out) both; padding: var(--sp-12) 0 var(--sp-6); } /* --- Messages ------------------------------------------------------------- */ .msg { display: grid; grid-template-columns: 2rem 1fr; gap: var(--sp-3); align-items: start; } .msg__gutter { display: grid; place-items: center; width: 2rem; height: 2rem; border-radius: var(--radius-full); overflow: hidden; } .msg__mark { width: 2rem; height: 2rem; } .msg__avatar { width: 2rem; height: 2rem; border-radius: var(--radius); } .msg__initial { width: 2rem; height: 2rem; display: grid; place-items: center; border-radius: var(--radius-full); background: var(--surface-active); color: var(--ink-muted); font-size: var(--text-sm); font-weight: 600; } .msg__main { min-width: 0; } .msg__meta { display: flex; align-items: baseline; gap: var(--sp-2); margin-bottom: var(--sp-1); } .msg__author { font-family: var(--font-display); font-size: var(--text-base); font-weight: 600; } .msg__model { font-size: var(--text-xs); color: var(--ink-faint); font-family: var(--font-mono); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 16rem; } .msg__body { line-height: var(--leading-relaxed); overflow-wrap: break-word; } /* User turns and mid-stream assistant text are plain text, so newlines and runs of spaces have to survive. */ .msg__body--plain { white-space: pre-wrap; } .msg--user .msg__body--plain { background: var(--bubble-user); padding: var(--sp-3) var(--sp-4); border-radius: var(--radius-lg); display: inline-block; max-width: 100%; } /* A reply is a sequence of steps: thinking, prose, a tool call, more prose. The gap is what separates one from the next -- without it a paragraph and the command it led to run together and the ordering the whole thing exists for is not legible. Each child brings its own margins, so this only has to space them consistently. */ .msg__steps { display: flex; flex-direction: column; gap: var(--sp-2); } .msg__steps:empty { display: none; } /* The blocks inside carry the rhythm; their own bottom margins would double it. */ .msg__steps > .reasoning { margin-bottom: 0; } .msg__error { margin: var(--sp-2) 0; align-items: flex-start; } /* --- Streaming indicator -------------------------------------------------- */ /* Shown until the first token arrives, then hidden by the sibling selector below -- no JavaScript involved in either direction. */ .msg__waiting { padding: var(--sp-2) 0; } .dots { display: inline-flex; gap: 0.25rem; align-items: center; } .dots i { width: 0.4rem; height: 0.4rem; border-radius: var(--radius-full); background: var(--ink-faint); animation: dot-pulse 1.3s ease-in-out infinite; } .dots i:nth-child(2) { animation-delay: 0.18s; } .dots i:nth-child(3) { animation-delay: 0.36s; } @keyframes dot-pulse { 0%, 60%, 100% { opacity: 0.28; transform: translateY(0); } 30% { opacity: 1; transform: translateY(-2px); } } /* A caret trailing the text while it streams. Attached to the last block so it sits at the end of the prose rather than on a line of its own. */ .msg__body--live:not(:empty) > :last-child::after { content: ""; display: inline-block; width: 0.45rem; height: 1.05em; margin-left: 1px; vertical-align: text-bottom; background: var(--leaf); opacity: 0.75; animation: caret 1.05s steps(1) infinite; } @keyframes caret { 0%, 49% { opacity: 0.75; } 50%, 100% { opacity: 0; } } /* --- Reasoning ------------------------------------------------------------ */ .reasoning { margin: 0 0 var(--sp-3); border: var(--border-w) solid var(--border); border-radius: var(--radius); background: color-mix(in srgb, var(--surface) 70%, transparent); font-size: var(--text-sm); } /* A live block is emitted before the first token arrives, and plenty of models emit no reasoning at all. Hiding it until it has content means those models never show an empty "Thinking" box, and no JavaScript is involved either way. */ .reasoning--live:not(:has(.reasoning__body:not(:empty))) { display: none; } .reasoning__summary { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) var(--sp-3); cursor: pointer; color: var(--ink-muted); list-style: none; user-select: none; border-radius: var(--radius); } .reasoning__summary::-webkit-details-marker { display: none; } .reasoning__summary:hover { color: var(--ink); background: var(--surface-hover); } /* "Thinking" with an ellipsis that types itself: `.` `..` `...`, on a step timer so it lands on whole dots rather than sliding. Animating `content` is the only way to do this without a JavaScript timer to start, stop and clean up when the block is swapped away -- and this one simply stops existing when the element does. `min-width` on the pseudo-element so the label does not jog left and right as the dots come and go; `ch` because it is exactly three dot-widths. */ .reasoning__working::after { content: "..."; display: inline-block; min-width: 1.6ch; text-align: left; animation: thinking-dots 1.5s steps(1) infinite; } @keyframes thinking-dots { 0% { content: "."; } 33% { content: ".."; } 66% { content: "..."; } } /* The seconds and the token count. Tabular figures so a climbing number does not shift the text beside it on every tick. Empty until the first frame arrives, and `:empty` keeps its separator from showing before that. */ .reasoning__stats { color: var(--ink-faint); font-variant-numeric: tabular-nums; } .reasoning__stats:not(:empty)::before { content: " · "; } /* --- Compacted turns ------------------------------------------------------- Summarised messages, kept and readable but out of the way. Collapsed by default: the point of compacting was that they stopped mattering. */ .compacted { border: 1px dashed var(--border-strong); border-radius: var(--radius); background: color-mix(in srgb, var(--surface) 50%, transparent); } .compacted__summary { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) var(--sp-3); cursor: pointer; color: var(--ink-muted); font-size: var(--text-sm); list-style: none; user-select: none; } .compacted__summary::-webkit-details-marker { display: none; } .compacted__summary:hover { color: var(--ink); } .compacted[open] .reasoning__chevron { transform: rotate(180deg); } .compacted__note { margin: 0; padding: 0 var(--sp-3); font-size: var(--text-xs); color: var(--ink-faint); line-height: var(--leading-normal); } .compacted__body { display: flex; flex-direction: column; gap: var(--sp-6); padding: var(--sp-4) var(--sp-3); opacity: 0.75; } /* --- Suggestions ----------------------------------------------------------- Starting points on the empty screen. Cards rather than a list, because they are things to press. */ .suggestions { display: grid; /* 🚨 `min-width: 0` is what keeps this grid on the screen, and `width: 100%` alone did not: it is a grid item of `.thread__intro`, so it carries `min-width: auto`, which for a grid item means *a min-content floor* -- and min-width beats width. Its min-content size is two cards side by side, so it rendered 428px wide inside a 390px phone with `width: 100%` set and ignored. That floor is also why writing the track as `minmax(min(100%, 13rem), 1fr)` -- the tree's standing rule, and right -- made it *worse* on its own, 428px to 455px: a percentage is indefinite while the floor is being measured, so the track fell back to a card's max-content and raised the very number that was overflowing. The two go together. With the floor removed, `width: 100%` finally resolves against the 366px column, `min(100%, …)` hands the track 366px to clamp against, and `auto-fit` places one column. It scrolled `.thread-scroll` rather than the page, which is why a pass looking for a document that scrolls sideways never saw it: `overflow-y: auto` makes the other axis scrollable too. Reported on a phone, found by asking which *element* could scroll and then reading its computed `width` against its parent's. */ min-width: 0; grid-template-columns: repeat(auto-fit, minmax(min(100%, 13rem), 1fr)); gap: var(--sp-3); width: 100%; max-width: 40rem; margin-top: var(--sp-6); text-align: left; } .suggestion { display: flex; flex-direction: column; gap: var(--sp-1); padding: var(--sp-3) var(--sp-4); border: var(--border-w) solid var(--border); border-radius: var(--radius-lg); background: var(--surface); color: var(--ink); cursor: pointer; text-align: left; transition: background var(--transition-fast), border-color var(--transition-fast); } .suggestion:hover { background: var(--surface-hover); border-color: var(--border-strong); } .suggestion:focus-visible { outline: var(--outline-w) solid var(--accent); outline-offset: 2px; } .suggestion__name { font-weight: 600; font-size: var(--text-sm); } .suggestion__note { font-size: var(--text-xs); color: var(--ink-muted); line-height: var(--leading-normal); } /* Something is happening to the whole thread rather than to one message -- compaction, which makes a model call and can take a while. Shaped like a turn so it sits in the column rather than floating over it. */ .thread__working { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-3) 0; color: var(--ink-muted); font-size: var(--text-sm); } /* --- Metrics --------------------------------------------------------------- What a reply cost, under the bubble. Quiet by default: it is reference, not something to read every time. */ .msg__metrics { display: flex; align-items: center; flex-wrap: wrap; gap: var(--sp-3); margin-top: var(--sp-2); font-size: var(--text-xs); color: var(--ink-faint); font-variant-numeric: tabular-nums; } .msg__metrics:empty { display: none; } .metric { display: inline-flex; align-items: center; gap: var(--sp-1); cursor: default; } .metric__bar { display: inline-block; width: 3rem; height: 0.3rem; border-radius: var(--radius-full); background: var(--surface-active); overflow: hidden; } .metric__fill { display: block; height: 100%; background: var(--ink-faint); transition: width var(--transition); } .metric--context.is-warning { color: var(--warning); } .metric--context.is-warning .metric__fill { background: var(--warning); } .metric--context.is-danger { color: var(--danger); } .metric--context.is-danger .metric__fill { background: var(--danger); } .reasoning__icon { color: var(--leaf); flex: none; } .reasoning__label { flex: 1; font-style: italic; } .reasoning__chevron { flex: none; transition: transform var(--transition-fast); } .reasoning[open] .reasoning__chevron { transform: rotate(180deg); } .reasoning__body { padding: 0 var(--sp-3) var(--sp-3); margin-left: var(--sp-2); border-left: var(--border-w-thick) solid var(--border-strong); padding-left: var(--sp-3); white-space: pre-wrap; color: var(--ink-muted); font-size: var(--text-sm); line-height: var(--leading-relaxed); max-height: 26rem; overflow-y: auto; scrollbar-width: thin; } /* While a model is thinking. This was an opacity fade on the icon, which at a glance is indistinguishable from an icon that is simply a bit faint -- and "is it working or has it stopped?" is the one question this element exists to answer. So it now turns as well as breathes, and carries a ring that sweeps: rotation is the thing the eye reads as *ongoing* rather than as decoration, and it is the difference between a reply that is being written and one that has quietly died. Two animations on two elements rather than one compound transform, because the icon is a `` of a shared sprite and the ring is a pseudo-element -- and because `prefers-reduced-motion` should be able to stop the spin while leaving the colour, which two separate declarations allow and one does not. No timer, no class to add or remove, nothing to clean up: it stops existing when the element does, which is the same reason the animated ellipsis is a `content` keyframe. */ .reasoning--live .reasoning__icon { animation: think-pulse var(--dur-slow) var(--ease-in-out) infinite, think-turn calc(var(--dur-slow) * 2.5) linear infinite; transform-origin: 50% 50%; } .reasoning--live .reasoning__label { position: relative; } .reasoning--live .reasoning__label::after { content: ""; position: absolute; left: 0; right: 0; bottom: -2px; height: var(--border-w); background: linear-gradient(90deg, transparent, var(--leaf), transparent); background-size: 50% 100%; background-repeat: no-repeat; animation: think-sweep calc(var(--dur-slow) * 1.5) var(--ease-in-out) infinite; } @keyframes think-turn { to { transform: rotate(360deg); } } @keyframes think-sweep { 0% { background-position: -60% 0; } 100% { background-position: 160% 0; } } @keyframes think-pulse { 0%, 100% { opacity: 0.45; } 50% { opacity: 1; } } /* --- Tool activity -------------------------------------------------------- Deliberately the same object as the reasoning block: both answer "what did it do before it replied", and giving them two visual languages would suggest a difference that is not there. */ .tool-activity-list:empty { display: none; } .tool-activity { margin: 0 0 var(--sp-3); border: var(--border-w) solid var(--border); border-radius: var(--radius); background: color-mix(in srgb, var(--surface) 70%, transparent); font-size: var(--text-sm); } .tool-activity--error { border-color: var(--danger); } .tool-activity__summary { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) var(--sp-3); cursor: pointer; color: var(--ink-muted); list-style: none; user-select: none; border-radius: var(--radius); } .tool-activity__summary::-webkit-details-marker { display: none; } .tool-activity__summary:hover { color: var(--ink); background: var(--surface-hover); } .tool-activity__icon { color: var(--leaf); flex: none; } .tool-activity__label { flex: 1; min-width: 0; } .tool-activity__count { color: var(--ink-faint); } /* The model's own account of what a call is for, under the command it belongs to. `display: block` inside the flex row's label, so the icon and the chevron stay centred against both lines. */ .tool-activity__why { display: block; color: var(--ink-faint); font-size: var(--text-xs); } .tool-activity[open] .reasoning__chevron { transform: rotate(180deg); } .tool-activity__body { padding: 0 var(--sp-3) var(--sp-3); display: flex; flex-direction: column; gap: var(--sp-3); } .tool-activity__error { margin: 0; color: var(--ink-muted); } .tool-result { display: flex; flex-direction: column; gap: 2px; padding-left: var(--sp-3); border-left: var(--border-w-thick) solid var(--border-strong); min-width: 0; } .tool-result__title { color: var(--accent); font-weight: 500; overflow-wrap: anywhere; } .tool-result__host { color: var(--ink-faint); font-size: var(--text-xs); } .tool-result__snippet { margin: 0; color: var(--ink-muted); line-height: var(--leading-relaxed); } /* What a tool returned, verbatim. Preformatted rather than rendered: this is third-party text and markdown is the one path allowed to emit HTML. */ .tool-result__text { margin: 0; padding: var(--sp-3); border-radius: var(--radius-sm); background: var(--bg-sunken); color: var(--ink-muted); font-family: var(--font-mono); font-size: var(--text-xs); line-height: var(--leading-relaxed); white-space: pre-wrap; overflow-wrap: anywhere; max-height: 22em; overflow-y: auto; } /* What a generation drew, inside its own tool block. Bounded by height rather than by width: a portrait at 1024x1536 sized to the column would push everything under it off the screen, and this is the block's supporting evidence rather than the thing itself -- the picture proper is an attachment on the bubble above. `width: auto` with a max height is what keeps a landscape and a portrait both looking deliberate. */ .tool-image { display: block; margin: 0; } .tool-image img { display: block; max-width: 100%; max-height: 20rem; width: auto; height: auto; border-radius: var(--radius-sm); background: var(--bg-sunken); } /* A diff, from a write or an update. The same visual language as .tool-result__text above -- both answer "what did it do", and two languages would suggest a difference that is not there. The colours are --success and --danger rather than anything new: --success is deliberately a different hue from --leaf so an added line does not read as the brand accent, and --danger is already what an error border uses, so a removed line reads as removed rather than as broken. The padding is on the line, not on the block, so a highlighted row runs the full width instead of stopping short of the rounded corner. */ .diff { margin: 0; padding: var(--sp-2) 0; border-radius: var(--radius-sm); background: var(--bg-sunken); font-family: var(--font-mono); font-size: var(--text-xs); line-height: var(--leading-relaxed); max-height: 26em; overflow: auto; } .diff__line { display: block; padding: 0 var(--sp-3); white-space: pre-wrap; overflow-wrap: anywhere; } .diff__line--add { background: var(--success-soft); color: var(--success); } .diff__line--del { background: var(--danger-soft); color: var(--danger); } .diff__line--meta { color: var(--ink-faint); } .diff__line--ctx { color: var(--ink-muted); } /* --- The model asking you something --------------------------------------- */ /* Attributed to the model on purpose. A card styled like the application is a card people answer with things they would not tell a chatbot. */ .interaction { display: flex; flex-direction: column; gap: var(--sp-3); margin: var(--sp-3) 0; padding: var(--sp-4); border: var(--border-w) solid var(--accent); border-radius: var(--radius-md); background: var(--surface); } .interaction__from { display: flex; align-items: center; gap: var(--sp-2); margin: 0; color: var(--ink-faint); font-size: var(--text-xs); text-transform: uppercase; letter-spacing: 0.06em; } .interaction__form { display: flex; flex-direction: column; gap: var(--sp-4); } /* One block per question. Several go on one card and submit together. */ .interaction__question { display: flex; flex-direction: column; gap: var(--sp-2); margin: 0; padding: 0; border: 0; min-width: 0; } .interaction__question + .interaction__question { padding-top: var(--sp-4); border-top: var(--border-w) solid var(--border); } .interaction__title { margin: 0; padding: 0; color: var(--ink); font-weight: 500; } /* Stacked, one per line. A row of chips was fine while an option was two words and nothing else; an option now carries a description as well, and a row has nowhere to put the second and no room to read the first. */ .interaction__options { display: flex; flex-direction: column; gap: var(--sp-2); } .interaction__note { color: var(--ink-faint); font-size: var(--text-xs); } /* One option: the control, then a label and an optional line under it. The whole row is the target -- it is a `