/* Chat thread, composer, message bodies and code blocks. Loaded only on chat pages. Like app.css, every value resolves through tokens.css. */ /* --- Thread --------------------------------------------------------------- */ .thread-scroll { flex: 1; overflow-y: auto; scroll-behavior: smooth; scrollbar-width: thin; scrollbar-color: var(--border-strong) transparent; } .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); } .thread__intro { display: grid; place-items: center; gap: var(--sp-3); text-align: center; 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: 1px 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; grid-template-columns: repeat(auto-fit, minmax(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: 1px 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: 2px 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: 2px 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; } /* Gentle pulse on the icon while thinking is still streaming. */ .reasoning--live .reasoning__icon { animation: think-pulse 1.6s ease-in-out infinite; } @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: 1px 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: 2px 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: 1px 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: 1px 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 `