671e49cae8
One `ask_user` call can now carry several questions, and they come back in a single submit. Asking one at a time cost a round trip and an interruption each, and by the third you had forgotten the first. Each question becomes an item with its own key; several items share a call index, because they belong to one call and one tool turn has to answer them all. Each answer is quoted beside the question it belongs to -- with four on a card, a bare list would leave the model matching them up by position and sometimes getting it wrong. Options are radios rather than submit buttons, so picking one does not send the form while two other questions are still blank. What you type beats what you picked: someone who writes in the box after clicking an option meant the writing. `_questions_in` also reads the shapes a small model actually sends -- a bare `question` string, a list of plain strings, one object where a list belonged. Getting that wrong costs a whole round trip and shows a card saying nothing. Two test fixes, both mine. `test_posting_a_message_stores_both_turns` raced the background generation it started: against a connection that refuses instantly the reply sometimes won, writing the error and marking the row complete before the assertions could read it. And the generation registry is module-global, so a test that started a reply left an entry -- and a Task belonging to a closed event loop -- for the rest of the session. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
896 lines
26 KiB
CSS
896 lines
26 KiB
CSS
/*
|
|
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%;
|
|
}
|
|
|
|
.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); }
|
|
|
|
/* --- 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);
|
|
}
|
|
|
|
/* --- 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; }
|
|
.tool-activity__count { color: var(--ink-faint); }
|
|
.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;
|
|
}
|
|
|
|
/* --- 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; }
|
|
.interaction__options { display: flex; flex-wrap: wrap; gap: var(--sp-2); }
|
|
.interaction__note { color: var(--ink-faint); font-size: var(--text-xs); }
|
|
|
|
/* An option. A radio, so picking one unpicks the last, but shaped like the
|
|
button it reads as. */
|
|
.chip { position: relative; display: inline-flex; }
|
|
.chip input {
|
|
position: absolute;
|
|
inset: 0;
|
|
opacity: 0;
|
|
cursor: pointer;
|
|
margin: 0;
|
|
}
|
|
.chip span {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
min-height: var(--control-h);
|
|
padding: 0 var(--sp-3);
|
|
border: 1px solid var(--border-strong);
|
|
border-radius: var(--radius-sm);
|
|
background: var(--surface-raised);
|
|
color: var(--ink-muted);
|
|
font-size: var(--text-sm);
|
|
}
|
|
.chip input:hover + span { background: var(--surface-hover); color: var(--ink); }
|
|
.chip input:checked + span {
|
|
border-color: var(--accent);
|
|
background: var(--surface-active);
|
|
color: var(--ink);
|
|
}
|
|
.chip input:focus-visible + span { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
.interaction__detail {
|
|
margin: 0;
|
|
padding: var(--sp-3);
|
|
border-radius: var(--radius-sm);
|
|
background: var(--bg-sunken);
|
|
font-family: var(--font-mono);
|
|
font-size: var(--text-xs);
|
|
white-space: pre-wrap;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
.interaction__reason { margin: 0; color: var(--ink-muted); font-size: var(--text-xs); }
|
|
.interaction__actions {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: var(--sp-2);
|
|
align-items: center;
|
|
}
|
|
.interaction__write { display: flex; gap: var(--sp-2); flex: 1 1 16rem; min-width: 0; }
|
|
.interaction__write .input { flex: 1; min-width: 0; }
|
|
|
|
/* --- Stop, notes and editing ---------------------------------------------- */
|
|
.msg__status { font-size: var(--text-xs); color: var(--ink-faint); font-style: italic; }
|
|
.msg__status:empty { display: none; }
|
|
|
|
.msg__waiting {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--sp-3);
|
|
padding: var(--sp-2) 0;
|
|
}
|
|
/* Once the answer has content the caret carries the "still going" signal, so
|
|
the dots go, but Stop must stay reachable until the stream ends. */
|
|
.msg__body--live:not(:empty) + .msg__waiting .dots { display: none; }
|
|
|
|
/* Send and Stop are one button. Which icon shows is decided here rather than
|
|
in JavaScript, so the state is visible in the markup and the swap is free. */
|
|
.composer__icon { display: flex; }
|
|
[data-composer-action="send"] .composer__icon--stop,
|
|
[data-composer-action="stop"] .composer__icon--send { display: none; }
|
|
|
|
.composer__btn[data-composer-action="stop"] {
|
|
background: var(--danger);
|
|
border-color: var(--danger);
|
|
color: var(--ink-inverse);
|
|
}
|
|
.composer__btn[data-composer-action="stop"]:hover:not(:disabled) {
|
|
background: var(--danger-hover);
|
|
border-color: var(--danger-hover);
|
|
}
|
|
/* The microphone is the same shape: one button, state in a data attribute. */
|
|
[data-mic-state="idle"] .composer__icon--recording,
|
|
[data-mic-state="working"] .composer__icon--recording,
|
|
[data-mic-state="recording"] .composer__icon--mic { display: none; }
|
|
|
|
.composer__mic[data-mic-state="recording"] {
|
|
background: var(--danger);
|
|
border-color: var(--danger);
|
|
color: var(--ink-inverse);
|
|
animation: mic-pulse 1.6s ease-in-out infinite;
|
|
}
|
|
|
|
@keyframes mic-pulse {
|
|
0%, 100% { box-shadow: 0 0 0 0 var(--accent-soft); }
|
|
50% { box-shadow: 0 0 0 5px transparent; }
|
|
}
|
|
|
|
/* Play and stop on the read-aloud button, chosen by the class audio.js sets. */
|
|
.speak__icon { display: flex; }
|
|
[data-speak] .speak__icon--stop,
|
|
[data-speak].is-speaking .speak__icon--play { display: none; }
|
|
[data-speak].is-speaking .speak__icon--stop { display: flex; }
|
|
[data-speak].is-speaking { color: var(--accent); }
|
|
|
|
.composer__stop-square {
|
|
width: 0.7rem;
|
|
height: 0.7rem;
|
|
border-radius: 2px;
|
|
background: currentColor;
|
|
}
|
|
|
|
.msg__note {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--sp-1);
|
|
margin: var(--sp-2) 0 0;
|
|
font-size: var(--text-xs);
|
|
color: var(--ink-faint);
|
|
font-style: italic;
|
|
}
|
|
|
|
.msg--editing .msg__main { width: 100%; }
|
|
.edit-form { display: flex; flex-direction: column; gap: var(--sp-3); }
|
|
.edit-form .textarea { min-height: 4rem; }
|
|
|
|
/* --- Message actions ------------------------------------------------------ */
|
|
.msg__actions {
|
|
display: flex;
|
|
gap: var(--sp-1);
|
|
margin-top: var(--sp-2);
|
|
opacity: 0;
|
|
transition: opacity var(--transition-fast);
|
|
}
|
|
.msg:hover .msg__actions,
|
|
.msg:focus-within .msg__actions { opacity: 1; }
|
|
.msg__actions .is-copied { color: var(--success); }
|
|
|
|
/* --- Rendered Markdown ---------------------------------------------------- */
|
|
.msg__body > :first-child { margin-top: 0; }
|
|
.msg__body > :last-child { margin-bottom: 0; }
|
|
|
|
.msg__body h1, .msg__body h2, .msg__body h3,
|
|
.msg__body h4, .msg__body h5, .msg__body h6 {
|
|
margin: var(--sp-5) 0 var(--sp-2);
|
|
}
|
|
.msg__body h1 { font-size: var(--text-xl); }
|
|
.msg__body h2 { font-size: var(--text-lg); }
|
|
.msg__body h3 { font-size: var(--text-md); }
|
|
|
|
.msg__body ul, .msg__body ol { margin: 0 0 var(--sp-4); padding-left: var(--sp-6); }
|
|
.msg__body li { margin-bottom: var(--sp-1); }
|
|
|
|
.msg__body blockquote {
|
|
margin: 0 0 var(--sp-4);
|
|
padding: var(--sp-1) var(--sp-4);
|
|
border-left: 3px solid var(--border-strong);
|
|
color: var(--ink-muted);
|
|
font-style: italic;
|
|
}
|
|
|
|
.msg__body hr { border: 0; border-top: 1px solid var(--border); margin: var(--sp-5) 0; }
|
|
|
|
.msg__body :not(pre) > code {
|
|
font-family: var(--font-mono);
|
|
font-size: 0.875em;
|
|
padding: 0.13em 0.36em;
|
|
border-radius: var(--radius-sm);
|
|
background: var(--code-bg);
|
|
border: 1px solid var(--code-border);
|
|
}
|
|
|
|
.msg__body table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
margin: 0 0 var(--sp-4);
|
|
font-size: var(--text-sm);
|
|
display: block;
|
|
overflow-x: auto;
|
|
}
|
|
.msg__body th, .msg__body td {
|
|
border: 1px solid var(--border);
|
|
padding: var(--sp-2) var(--sp-3);
|
|
text-align: left;
|
|
}
|
|
.msg__body th { background: var(--surface); font-weight: 600; }
|
|
|
|
.msg__body img { max-width: 100%; height: auto; border-radius: var(--radius); }
|
|
|
|
/* --- Code blocks ---------------------------------------------------------- */
|
|
.code-block {
|
|
margin: 0 0 var(--sp-4);
|
|
border: 1px solid var(--code-border);
|
|
border-radius: var(--radius);
|
|
background: var(--code-bg);
|
|
overflow: hidden;
|
|
}
|
|
.code-block__label {
|
|
padding: var(--sp-1) var(--sp-3);
|
|
font-family: var(--font-mono);
|
|
font-size: var(--text-xs);
|
|
color: var(--ink-faint);
|
|
border-bottom: 1px solid var(--code-border);
|
|
background: color-mix(in srgb, var(--code-bg) 60%, var(--surface));
|
|
}
|
|
.code-block__pre {
|
|
margin: 0;
|
|
padding: var(--sp-3) var(--sp-4);
|
|
overflow-x: auto;
|
|
font-family: var(--font-mono);
|
|
font-size: var(--text-sm);
|
|
line-height: 1.55;
|
|
}
|
|
.code-block__pre code { font-family: inherit; background: none; border: 0; padding: 0; }
|
|
|
|
/*
|
|
Pygments token colours, mapped onto theme tokens rather than a fixed scheme,
|
|
so code follows the active theme. classprefix "pg-" is set in markdown.py.
|
|
*/
|
|
.pg-c, .pg-c1, .pg-cm, .pg-cs, .pg-cp { color: var(--ink-faint); font-style: italic; }
|
|
/* Keywords take --warning, not the brand accent: strings are already green, and
|
|
two greens in one code block is not a colour scheme, it is a bug report. */
|
|
.pg-k, .pg-kn, .pg-kd, .pg-kc, .pg-kr, .pg-kt { color: var(--warning); }
|
|
.pg-s, .pg-s1, .pg-s2, .pg-sb, .pg-sd, .pg-se, .pg-sh, .pg-si, .pg-sx { color: var(--success); }
|
|
.pg-m, .pg-mi, .pg-mf, .pg-mh, .pg-mo { color: var(--danger); }
|
|
.pg-nf, .pg-nd { color: var(--accent); }
|
|
.pg-nc, .pg-nn { color: var(--accent-hover); font-weight: 600; }
|
|
.pg-nb, .pg-bp { color: var(--accent); }
|
|
.pg-nv, .pg-vi, .pg-vg, .pg-vc { color: var(--ink); }
|
|
.pg-o, .pg-ow, .pg-p { color: var(--ink-muted); }
|
|
.pg-err { color: var(--danger); }
|
|
.pg-gd { color: var(--danger); }
|
|
.pg-gi { color: var(--success); }
|
|
|
|
/* --- Composer ------------------------------------------------------------- */
|
|
.composer {
|
|
flex: none;
|
|
padding: var(--sp-3) var(--sp-5) var(--sp-4);
|
|
border-top: 1px solid var(--border);
|
|
background: var(--bg);
|
|
}
|
|
.composer__inner { max-width: var(--thread-max-width); margin: 0 auto; }
|
|
|
|
/* A column: chips on top, then the control row. The chips are inside the form
|
|
so their hidden file_ids inputs are submitted with the message. */
|
|
.composer__form {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--sp-2);
|
|
padding: var(--sp-2);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius-xl);
|
|
background: var(--surface);
|
|
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
|
}
|
|
.composer__row {
|
|
display: flex;
|
|
gap: var(--sp-1);
|
|
align-items: flex-end;
|
|
}
|
|
/* Attach and send are the same size and sit on the same baseline as the last
|
|
line of the textarea, so the control row reads as one object. */
|
|
.composer__btn { flex: none; align-self: flex-end; border-radius: var(--radius-full); }
|
|
.composer__form:focus-within {
|
|
border-color: var(--accent);
|
|
box-shadow: 0 0 0 3px var(--accent-soft);
|
|
}
|
|
.composer__input {
|
|
flex: 1;
|
|
min-width: 0;
|
|
border: 0;
|
|
background: none;
|
|
resize: none;
|
|
padding: 0.5rem var(--sp-2);
|
|
font-size: var(--text-base);
|
|
line-height: var(--leading-normal);
|
|
max-height: 20rem;
|
|
color: var(--ink);
|
|
}
|
|
.composer__input:focus { outline: none; }
|
|
.composer__hint {
|
|
margin: var(--sp-2) 0 0;
|
|
font-size: var(--text-xs);
|
|
color: var(--ink-faint);
|
|
text-align: center;
|
|
}
|
|
|
|
/* --- Folders -------------------------------------------------------------- */
|
|
.folder__row { padding-right: var(--sp-1); }
|
|
.folder__toggle {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--sp-2);
|
|
flex: 1;
|
|
min-width: 0;
|
|
background: none;
|
|
border: 0;
|
|
padding: 0;
|
|
color: inherit;
|
|
font: inherit;
|
|
cursor: pointer;
|
|
text-align: left;
|
|
}
|
|
.folder__chevron {
|
|
display: inline-flex;
|
|
transition: transform var(--transition-fast);
|
|
}
|
|
.folder__chevron.is-open { transform: rotate(90deg); }
|
|
.folder__contents { padding-left: var(--sp-4); }
|
|
|
|
.nav-item__link {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--sp-2);
|
|
flex: 1;
|
|
min-width: 0;
|
|
color: inherit;
|
|
text-decoration: none;
|
|
}
|
|
|
|
/* --- Attachment chips (composer) ------------------------------------------ */
|
|
.composer { position: relative; }
|
|
|
|
.composer__attachments {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: var(--sp-2);
|
|
padding: var(--sp-1) var(--sp-1) 0;
|
|
}
|
|
.composer__attachments:empty { display: none; }
|
|
|
|
.chip {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--sp-2);
|
|
padding: var(--sp-2);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius);
|
|
background: var(--surface);
|
|
max-width: 20rem;
|
|
font-size: var(--text-sm);
|
|
}
|
|
.chip--error { border-color: var(--danger); background: var(--danger-soft); }
|
|
.chip--error .chip__icon { color: var(--danger); }
|
|
|
|
.chip__thumb {
|
|
display: block;
|
|
width: 2.25rem;
|
|
height: 2.25rem;
|
|
border-radius: var(--radius-sm);
|
|
object-fit: cover;
|
|
flex: none;
|
|
}
|
|
.chip__icon { color: var(--ink-muted); flex: none; display: flex; }
|
|
.chip__body { min-width: 0; flex: 1; display: flex; flex-direction: column; }
|
|
.chip__name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
.chip__meta { font-size: var(--text-xs); color: var(--ink-faint); }
|
|
.chip__warning { font-size: var(--text-xs); color: var(--danger); }
|
|
|
|
/* --- Drag and drop -------------------------------------------------------- */
|
|
.dropzone-overlay {
|
|
position: absolute;
|
|
inset: 0;
|
|
z-index: 5;
|
|
display: none;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: var(--sp-2);
|
|
border: 2px dashed var(--accent);
|
|
border-radius: var(--radius-lg);
|
|
background: color-mix(in srgb, var(--bg) 88%, var(--accent));
|
|
color: var(--accent);
|
|
font-weight: 500;
|
|
/* The overlay must not eat the drop event it is advertising. */
|
|
pointer-events: none;
|
|
}
|
|
.composer.is-dropping .dropzone-overlay { display: flex; }
|
|
|
|
/* --- Attachments in the thread -------------------------------------------- */
|
|
.attachments {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
align-items: flex-start;
|
|
gap: var(--sp-2);
|
|
margin-bottom: var(--sp-2);
|
|
}
|
|
/* No frame: an attachment is a picture, and a border around it only ever drew
|
|
at the wrong width. The anchor shrink-wraps its image rather than filling the
|
|
column, and the width/height attributes on the <img> are overridden so a
|
|
small image is shown at its own size instead of being stretched. */
|
|
.attachments__image {
|
|
display: inline-flex;
|
|
max-width: 100%;
|
|
border-radius: var(--radius);
|
|
overflow: hidden;
|
|
line-height: 0;
|
|
}
|
|
.attachments__image img {
|
|
display: block;
|
|
width: auto;
|
|
height: auto;
|
|
max-width: min(22rem, 100%);
|
|
max-height: 20rem;
|
|
object-fit: contain;
|
|
}
|
|
.attachments__doc {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: var(--sp-2);
|
|
padding: var(--sp-2) var(--sp-3);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius);
|
|
background: var(--surface);
|
|
font-size: var(--text-sm);
|
|
max-width: 100%;
|
|
}
|
|
.attachments__doc-body { display: flex; flex-direction: column; min-width: 0; }
|
|
.attachments__doc-body > a { overflow-wrap: anywhere; }
|
|
|
|
/* --- Model avatars -------------------------------------------------------- */
|
|
.model-avatar {
|
|
width: 2rem;
|
|
height: 2rem;
|
|
flex: none;
|
|
border-radius: var(--radius);
|
|
object-fit: cover;
|
|
background: var(--surface-active);
|
|
}
|
|
.model-avatar--initial {
|
|
display: grid;
|
|
place-items: center;
|
|
font-family: var(--font-display);
|
|
font-weight: 600;
|
|
font-size: var(--text-sm);
|
|
/* Hue comes from the model id (see the stable_hue filter); saturation and
|
|
lightness are fixed so every generated badge stays legible in both themes. */
|
|
background: hsl(var(--avatar-hue, 40) 42% 34%);
|
|
color: hsl(var(--avatar-hue, 40) 60% 92%);
|
|
}
|
|
:root[data-theme="shire"] .model-avatar--initial {
|
|
background: hsl(var(--avatar-hue, 40) 46% 82%);
|
|
color: hsl(var(--avatar-hue, 40) 60% 22%);
|
|
}
|
|
|
|
/* --- Theme toggle --------------------------------------------------------- */
|
|
/* Only the icon for the theme you would switch TO is shown. Both live in the
|
|
same button, so the wrapper must not add a line box of its own. */
|
|
.theme-icon { display: flex; }
|
|
:root[data-theme="moria"] .theme-icon--dark { display: none; }
|
|
:root[data-theme="shire"] .theme-icon--light { display: none; }
|
|
|
|
/* Alpine sets x-cloak until it has initialised; without this, collapsed
|
|
folders flash open on every page load. */
|
|
[x-cloak] { display: none !important; }
|
|
|
|
/* --- Unread indicator ------------------------------------------------------ */
|
|
.unread-dot {
|
|
width: 0.5rem;
|
|
height: 0.5rem;
|
|
border-radius: var(--radius-full);
|
|
background: var(--success);
|
|
flex: none;
|
|
/* A ring so it stays visible against the active row's lighter background. */
|
|
box-shadow: 0 0 0 2px color-mix(in srgb, var(--success) 25%, transparent);
|
|
}
|
|
.unread-dot[hidden] { display: none; }
|