7b67568f2c
Seven reported problems.
**Bulk model actions 404'd.** /admin/models/{model_id} was registered
before /admin/models/bulk, and FastAPI matches in registration order, so
"bulk" was parsed as a model id. Moved above the parameterised route,
with a comment saying why, and a regression test.
**Empty chats piled up.** There is now no endpoint that creates one.
"New chat" is a link to /chat, which renders a composer with no row
behind it, and POST /api/chats/start writes the chat together with its
first message. Opening one and walking away leaves nothing.
**Pinning meant two different things.** The picker is now always in the
administrator's position order; pinned models get shortcuts in the chat
sidebar and nothing else. A picker whose order silently differs from the
admin screen is just confusing.
**Model images were missing in chat.** Assistant bubbles now show the
avatar of the model that actually wrote the turn -- which is not always
the model the chat is set to now -- falling back to the LLeMbas mark.
The picker shows it too.
**No global or per-model system prompt.** Three layers now: instance
(Admin -> General), model (Admin -> Models), chat. Precedence, not
concatenation: most specific wins outright. Stacking them reads well in
a settings screen and badly in practice, because two layers that
disagree give the model contradictory instructions and nobody can tell
which is losing. The chat panel shows the inherited prompt as
placeholder text so "leave empty to inherit" is not a guess.
**Alignment and button sizing.** Added --control-h and friends to
tokens.css; every button, input and select takes its height from them,
so a mixed row is flush by construction rather than by per-instance
nudging. Icon buttons are square at that height. Added .btn-row,
.card__header/.card__footer and .grid so pages stop carrying inline
styles, and moved every admin page onto them.
**Settings needed structure.** The user settings page is now tabbed
(Account / Models / Appearance / Security) using radio inputs and
sibling selectors -- no JavaScript, and the browser keeps the chosen tab
across a re-render.
Caught while checking: the chat.css surgery had deleted the attachment,
chip and dropzone rules. Restored, and there is now a check that every
literal class used in a template has a CSS rule.
197 tests, ruff clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
513 lines
14 KiB
CSS
513 lines
14 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,
|
|
.msg__body--streaming { 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; }
|
|
.msg__body--streaming:not(:empty) + .msg__waiting { display: none; }
|
|
|
|
.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. */
|
|
.msg__body--streaming::after {
|
|
content: "";
|
|
display: inline-block;
|
|
width: 0.45rem;
|
|
height: 1.05em;
|
|
margin-left: 1px;
|
|
vertical-align: text-bottom;
|
|
background: var(--gold);
|
|
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); }
|
|
|
|
.reasoning__icon { color: var(--gold); 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; }
|
|
}
|
|
|
|
/* --- 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; }
|
|
.pg-k, .pg-kn, .pg-kd, .pg-kc, .pg-kr, .pg-kt { color: var(--gold); }
|
|
.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; }
|
|
.composer__form {
|
|
display: flex;
|
|
gap: var(--sp-1);
|
|
align-items: flex-end;
|
|
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);
|
|
}
|
|
/* 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 {
|
|
margin: 0 0 var(--sp-2);
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: var(--sp-2);
|
|
}
|
|
.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 {
|
|
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;
|
|
gap: var(--sp-2);
|
|
margin-bottom: var(--sp-2);
|
|
}
|
|
.attachments__image {
|
|
display: block;
|
|
border-radius: var(--radius);
|
|
overflow: hidden;
|
|
border: 1px solid var(--border);
|
|
line-height: 0;
|
|
}
|
|
.attachments__image img {
|
|
max-width: min(22rem, 100%);
|
|
max-height: 20rem;
|
|
width: auto;
|
|
height: auto;
|
|
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; }
|