Files
LLeMbas/src/lembas/web/static/css/admin.css
T
HomerandClaude Opus 5 92070d7879 Six things that looked like they worked
None of these fails loudly and two of them correct themselves if you reload,
which is why five were found by reading rather than by anybody reporting them.

The reply that lost its author is the one worth knowing about: the frame that
replaces a bubble when a reply lands was looking the models up as nobody, and
"no user" answers "no models" rather than "all models" -- so every finished
reply swapped the model's avatar for the plain mark and put the instance's name
where the model's should be, until the next page load put it back.

Beside it: a concurrency quota enforced on two of the six paths that start a
reply, including neither of the two most used; a custom theme whose success and
warning colours moved the text and left the background behind; a phone shell
sized to one viewport inside a document sized to another, which is the reported
scroll past the bottom of Settings; a whole conversation's Markdown rendered on
every page load and read by nothing; a skip guard inert since it was written;
and an endpoint nothing has ever called.

The scroll fix folded five near-identical scroller rules into one, which is
also where the containment they were all missing now lives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 12:49:26 +00:00

577 lines
19 KiB
CSS

/* Settings and administration screens. */
/* --- Page scaffolding ------------------------------------------------------
One scroll container per screen, and this is the rule that decides which.
`.admin-scroll` is it on every admin page. `.tabs__body` is it only where the
tabs are a *bounded* flex child -- `.main > .tabs` on the settings page --
which is why the selector below says so rather than naming the class alone.
It used to name the class alone, and the result was two scrollers stacked on
/admin/prompts, where `.tabs` sits inside `.admin-scroll > .admin-page`, a
plain block. `flex: 1` and `min-height: 0` mean nothing there, so
`.tabs__body` had `height: auto` and never scrolled while still declaring
`overflow-y: auto` -- and everything written to reset "the scroller" reset
that one, silently, while the reader was lost in the other. Under
`.admin-scroll` the body is now an ordinary block and the page scrolls as one.
*/
/* What makes one of these scroll is `.scroll-region` in app.css, which both of
these selectors are listed in. Named there so the four declarations exist
once; named *here* is the reasoning above, which is about which element is
the scroller on which screen rather than about how a scroller behaves. */
.page,
.admin-page {
max-width: 48rem;
margin: 0 auto;
padding: var(--sp-6) var(--sp-5) var(--sp-12);
}
/* A directory chosen rather than typed. The path is the only part allowed to
shrink, and it truncates -- an absolute path on somebody else's machine is
long enough to push the Clear button off the card otherwise. */
.dir-row { display: flex; align-items: center; gap: var(--sp-2); min-width: 0; }
.dir-row > .btn { min-width: 0; }
.dir-row__path {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-mono);
font-size: var(--text-xs);
}
.page-header { margin-bottom: var(--sp-6); }
.admin-lede,
.page-header__lede {
color: var(--ink-muted);
font-size: var(--text-sm);
line-height: var(--leading-relaxed);
margin: 0 0 var(--sp-6);
max-width: 44rem;
}
.admin-section-title,
.section-title {
display: flex;
align-items: center;
gap: var(--sp-2);
font-size: var(--text-lg);
margin: var(--sp-8) 0 var(--sp-4);
}
/* --- Tabs ------------------------------------------------------------------
Radio inputs plus sibling selectors: no JavaScript, and the browser keeps
the chosen tab across a re-render.
*/
.tabs { display: flex; flex-direction: column; flex: 1; min-height: 0; }
.tabs__bar {
display: flex;
align-items: center;
gap: var(--sp-1);
padding: 0 var(--sp-5);
border-bottom: 1px solid var(--border);
background: var(--bg);
flex: none;
overflow-x: auto;
scrollbar-width: none;
/* Sticky where the *page* is the scroller, which is the admin layout. The
Tools panel is longer than a screen, so without this the bar scrolls away
and changing tab means scrolling back up to find it. Harmless on the
settings page, where `.tabs__body` scrolls underneath a bar that never
moves anyway.
It needs the opaque `background` above it already has, or the panel would
show through. `z-index` because a panel's own cards establish stacking
contexts and would otherwise paint over it. */
position: sticky;
top: 0;
z-index: 1;
}
.tabs__tab {
display: inline-flex;
align-items: center;
gap: var(--sp-2);
height: var(--control-h-lg);
padding: 0 var(--sp-4);
border-bottom: 2px solid transparent;
color: var(--ink-muted);
font-size: var(--text-sm);
font-weight: 500;
cursor: pointer;
white-space: nowrap;
transition: color var(--transition-fast), border-color var(--transition-fast);
}
.tabs__tab:hover { color: var(--ink); }
/* The library's tabs are links between pages rather than radios, so the active
one is marked server-side. Same bar, same look. */
.tabs__tab.is-active { color: var(--ink); border-bottom-color: var(--accent); }
a.tabs__tab { text-decoration: none; }
.tabs__panel { display: none; }
/* The active tab's label. Each radio is immediately followed by its own label,
so this needs to know nothing about how many tabs there are or what they are
called. */
.tabs__bar input:checked + .tabs__tab {
color: var(--ink);
border-bottom-color: var(--accent);
}
/*
The active panel, matched by position.
CSS cannot compare a radio's id with a panel's data-tab, so this used to name
every tab twice -- and a tab added without also adding its two rules here
rendered a label that selected nothing. That is not a failure anyone spots in
review; it looks like a blank page.
Position is derivable, so it is used instead: the Nth radio shows the Nth
panel. Both lists are rendered in the same order, and a conditional tab drops
out of both at once, so they cannot drift apart. :nth-of-type counts by
element name, which is why the panels are <section> and the alerts above them
are <div> -- the alerts are not counted.
Enumerated to eight, comfortably more than exist. A ninth tab needs one line.
*/
.tabs__bar:has(input:nth-of-type(1):checked) ~ .tabs__body .tabs__panel:nth-of-type(1),
.tabs__bar:has(input:nth-of-type(2):checked) ~ .tabs__body .tabs__panel:nth-of-type(2),
.tabs__bar:has(input:nth-of-type(3):checked) ~ .tabs__body .tabs__panel:nth-of-type(3),
.tabs__bar:has(input:nth-of-type(4):checked) ~ .tabs__body .tabs__panel:nth-of-type(4),
.tabs__bar:has(input:nth-of-type(5):checked) ~ .tabs__body .tabs__panel:nth-of-type(5),
.tabs__bar:has(input:nth-of-type(6):checked) ~ .tabs__body .tabs__panel:nth-of-type(6),
.tabs__bar:has(input:nth-of-type(7):checked) ~ .tabs__body .tabs__panel:nth-of-type(7),
.tabs__bar:has(input:nth-of-type(8):checked) ~ .tabs__body .tabs__panel:nth-of-type(8) {
display: block;
}
.tabs__tab:has(:focus-visible) { outline: 2px solid var(--accent); outline-offset: -2px; }
/*
A form's action row, and the space after the form it closes.
Not a plain .btn-row: a settings form is a stack of cards, and Save has to
read as belonging to the cards above it rather than to whatever comes next.
The gap that matters is the one *after* the form -- a card following it (the
"Try it" panel on the search page, say) otherwise sits flush against Save and
looks like another field of the same form. That margin therefore belongs to
the form, not to the action row: the action row is always its form's last
child, so a bottom margin there would have nothing to push away from.
*/
.form-actions {
display: flex;
align-items: center;
gap: var(--sp-2);
flex-wrap: wrap;
margin-top: var(--sp-5);
}
.admin-page > form { margin-bottom: var(--sp-8); }
.admin-page > form:last-child { margin-bottom: 0; }
/* --- Cards ----------------------------------------------------------------- */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: var(--sp-5);
margin-bottom: var(--sp-4);
}
.card:last-child { margin-bottom: 0; }
.card__title {
display: flex;
align-items: center;
gap: var(--sp-2);
font-size: var(--text-md);
margin-bottom: var(--sp-2);
}
.card__lede {
color: var(--ink-muted);
font-size: var(--text-sm);
margin-bottom: var(--sp-4);
line-height: var(--leading-relaxed);
}
.card__footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-3);
margin-top: var(--sp-5);
padding-top: var(--sp-4);
border-top: 1px solid var(--border);
flex-wrap: wrap;
}
.card__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-3);
margin-bottom: var(--sp-4);
flex-wrap: wrap;
}
/*
Two or three short fields on one line.
Numbers with their own units — width and height, steps and cfg and denoise —
read as a set and take a fraction of the width each, so stacking them makes a
card that is mostly whitespace and a form that is mostly scrolling. Auto-fit
rather than a fixed count: the same markup gives three columns on a wide
screen and one on a narrow one with no breakpoint, which matters because
`chat.css` has a test refusing media queries and this file should not drift
from that habit without a reason.
*/
.field-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
gap: var(--sp-3);
}
.field-row > .field { margin-bottom: var(--sp-4); }
/* Legacy aliases so existing admin templates keep their spacing. */
.form-grid { display: block; }
.connection__head { display: flex; align-items: center; justify-content: space-between;
gap: var(--sp-3); margin-bottom: var(--sp-4); flex-wrap: wrap; }
.connection__footer { display: flex; align-items: center; justify-content: space-between;
gap: var(--sp-3); margin-top: var(--sp-5); padding-top: var(--sp-4);
border-top: 1px solid var(--border); flex-wrap: wrap; }
.field--actions { margin-top: var(--sp-5); margin-bottom: 0; }
/* --- Definition lists ------------------------------------------------------ */
.detail-list {
display: grid;
grid-template-columns: minmax(6rem, auto) 1fr;
gap: var(--sp-2) var(--sp-4);
margin: 0;
font-size: var(--text-sm);
}
.detail-list dt { color: var(--ink-muted); font-weight: 500; }
.detail-list dd { margin: 0; }
/* --- Status ---------------------------------------------------------------- */
.status-dot {
width: 0.55rem;
height: 0.55rem;
border-radius: var(--radius-full);
flex: none;
background: var(--ink-faint);
}
.status-dot.is-ok { background: var(--success); }
.status-dot.is-bad { background: var(--danger); }
.status-dot.is-off { background: var(--ink-faint); }
/* --- Filter bar ------------------------------------------------------------ */
.filter-bar {
display: flex;
flex-direction: column;
gap: var(--sp-3);
margin-bottom: var(--sp-4);
}
.filter-tabs {
display: flex;
gap: var(--sp-1);
flex-wrap: wrap;
border-bottom: 1px solid var(--border);
}
.filter-tab {
display: inline-flex;
align-items: center;
gap: var(--sp-2);
height: var(--control-h);
padding: 0 var(--sp-3);
border-bottom: 2px solid transparent;
color: var(--ink-muted);
font-size: var(--text-sm);
font-weight: 500;
text-decoration: none;
white-space: nowrap;
}
.filter-tab:hover { color: var(--ink); }
.filter-tab.is-active { color: var(--ink); border-bottom-color: var(--accent); }
.filter-tab__count {
font-size: var(--text-xs);
color: var(--ink-faint);
background: var(--surface-active);
border-radius: var(--radius-full);
padding: 0 0.4rem;
min-width: 1.4rem;
text-align: center;
}
.filter-tab.is-active .filter-tab__count { background: var(--accent-soft); color: var(--accent); }
.filter-form { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }
.filter-form .input { width: auto; flex: 1; min-width: 12rem; }
.filter-form .select { width: auto; min-width: 10rem; }
/* --- Model list ------------------------------------------------------------
Compact rows only. Editing is a page of its own -- a connection can advertise
a hundred models, and a list that renders a form for each is unusable.
*/
.bulk-bar {
display: flex;
align-items: center;
gap: var(--sp-2);
flex-wrap: wrap;
padding: var(--sp-2) var(--sp-3);
border: 1px solid var(--border);
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
border-bottom: 0;
background: var(--bg-sunken);
}
.bulk-bar__label { font-size: var(--text-sm); color: var(--ink-muted); }
.model-rows {
border: 1px solid var(--border);
border-radius: 0 0 var(--radius-lg) var(--radius-lg);
overflow: hidden;
background: var(--surface);
}
.model-row {
display: flex;
align-items: center;
gap: var(--sp-3);
padding: var(--sp-2) var(--sp-3);
border-bottom: 1px solid var(--border);
}
.model-row:last-child { border-bottom: 0; }
.model-row:hover { background: var(--surface-hover); }
.model-row.is-off { opacity: 0.55; }
.model-row__check { accent-color: var(--accent); width: 1rem; height: 1rem; flex: none; }
.model-row__pos {
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--ink-faint);
min-width: 1.75rem;
text-align: right;
flex: none;
}
.model-row__avatar { flex: none; width: 1.75rem; height: 1.75rem; }
.model-row__main { flex: 1; min-width: 0; }
.model-row__title {
display: flex;
align-items: center;
gap: var(--sp-2);
flex-wrap: wrap;
}
.model-row__name {
color: var(--ink);
font-weight: 500;
font-size: var(--text-sm);
text-decoration: none;
}
.model-row__name:hover { color: var(--accent); text-decoration: underline; }
.model-row__id {
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--ink-faint);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: block;
}
.model-row__actions { display: flex; align-items: center; gap: var(--sp-1); flex: none; }
.list-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-3);
margin-top: var(--sp-3);
flex-wrap: wrap;
}
/* --- Model detail ---------------------------------------------------------- */
.crumbs {
display: flex;
align-items: center;
gap: var(--sp-2);
margin-bottom: var(--sp-4);
font-size: var(--text-sm);
}
.crumbs > a:first-child {
display: inline-flex;
align-items: center;
gap: var(--sp-1);
color: var(--ink-muted);
text-decoration: none;
}
.crumbs > a:first-child:hover { color: var(--ink); }
.crumbs__back { transform: rotate(180deg); }
.model-detail__avatar { width: 3rem; height: 3rem; flex: none; }
.model-list { list-style: none; margin: 0; padding: 0; }
.model-list__item {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-3);
padding: var(--sp-3) 0;
border-bottom: 1px solid var(--border);
}
.model-list__item:first-child { padding-top: 0; }
.model-list__item:last-child { border-bottom: 0; padding-bottom: 0; }
/* --- Permission grids ------------------------------------------------------ */
.checkbox-row {
display: flex;
flex-wrap: wrap;
gap: var(--sp-2) var(--sp-4);
}
.checkbox-row .checkbox { flex: 0 0 auto; }
.checkbox.is-muted { opacity: 0.6; }
.perm-row {
align-items: flex-start;
padding: var(--sp-3) 0;
border-bottom: 1px solid var(--border);
}
.perm-row:last-child { border-bottom: 0; }
.perm-row input { margin-top: 0.15rem; }
.perm-row__desc {
display: block;
font-size: var(--text-xs);
color: var(--ink-faint);
font-weight: 400;
line-height: var(--leading-normal);
margin-top: 0.1rem;
}
.perm-list { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--sp-2); }
.perm-list__item {
display: flex;
align-items: center;
gap: var(--sp-2);
font-size: var(--text-sm);
}
.perm-list__state {
display: grid;
place-items: center;
width: 1.25rem;
height: 1.25rem;
border-radius: var(--radius-full);
background: var(--surface-active);
color: var(--ink-faint);
flex: none;
}
.perm-list__state.is-on { background: var(--success-soft); color: var(--success); }
/* --- Reference lists -------------------------------------------------------
The variable legend on the prompts page, and the read-only tool list beneath
it. A grid rather than a table because both have to collapse to a stack on a
narrow screen, which a table cannot do without abandoning its header.
*/
.ref-list { display: flex; flex-direction: column; }
.ref-row {
display: grid;
grid-template-columns: minmax(7rem, 11rem) 1fr minmax(0, 12rem);
gap: var(--sp-3);
align-items: baseline;
padding: var(--sp-2) 0;
border-top: 1px solid var(--border);
font-size: var(--text-sm);
line-height: var(--leading-normal);
}
.ref-row:first-child { border-top: 0; padding-top: 0; }
.ref-row > code { overflow-wrap: anywhere; }
.ref-row__value {
color: var(--ink-faint);
font-size: var(--text-xs);
overflow-wrap: anywhere;
}
@media (max-width: 44rem) {
.ref-row { grid-template-columns: 1fr; gap: var(--sp-1); }
}
/* The assembled prompt. Wraps rather than scrolls sideways -- it is prose, and
a horizontal scrollbar on prose is unreadable. */
.prompt-preview {
margin: 0 0 var(--sp-3);
padding: var(--sp-3);
max-height: 28rem;
overflow-y: auto;
white-space: pre-wrap;
overflow-wrap: anywhere;
background: var(--code-bg);
border: 1px solid var(--code-border);
border-radius: var(--radius);
font-family: var(--font-mono);
font-size: var(--text-xs);
line-height: var(--leading-normal);
color: var(--ink-muted);
}
.prompt-preview code { font-family: inherit; background: none; border: 0; padding: 0; }
/* --- Misc ------------------------------------------------------------------ */
.input--file {
height: auto;
padding: 0.35rem;
font-size: var(--text-xs);
background: var(--surface-raised);
}
.nav-item.is-disabled { opacity: 0.4; cursor: default; }
.nav-item.is-disabled:hover { background: none; color: var(--ink-muted); }
.field__hint code,
.card__lede code {
font-family: var(--font-mono);
font-size: 0.92em;
padding: 0.05em 0.3em;
border-radius: var(--radius-sm);
background: var(--code-bg);
border: 1px solid var(--code-border);
}
/* --- The permission modes, explained on the agents page ------------------- */
.mode-list { margin: 0; display: flex; flex-direction: column; gap: var(--sp-2); }
/* Whatever follows the list inside a card needs air -- on /admin/updates that is
the "Check the remote" button, and it sat flush against the last row as
though it were one. Keyed on *not being last* rather than on the sibling's
class, because three different things follow it there depending on the
host's state (the button, the version-mismatch alert, the not-a-checkout
hint) and enumerating them is how the fourth one gets missed. */
.mode-list:not(:last-child) { margin-bottom: var(--sp-5); }
.mode-list__row { display: flex; gap: var(--sp-3); align-items: baseline; }
.mode-list__row dt { flex: 0 0 5rem; color: var(--ink); }
.mode-list__row dd { margin: 0; color: var(--ink-muted); font-size: var(--text-sm); }
/* --- The schedule setup form ------------------------------------------------ */
/*
Which "when" fieldset is showing follows the radio, with no JavaScript. `:has()`
is what makes that possible, and it is the same move the "Something else" box
on an approval card makes: a control and the thing it reveals cannot fall out
of step if the stylesheet is the only thing relating them.
Hidden with `display: none` on the container rather than `[hidden]`, which
would need `!important` here -- see the note in app.css about `.btn` being
`inline-flex`. Fields inside a hidden fieldset are still submitted, which is
correct: `_rule_from_form` reads only the keys the chosen repeat mode uses.
*/
.schedule-repeat {
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: var(--sp-4);
margin-bottom: var(--sp-4);
display: flex;
flex-direction: column;
gap: var(--sp-3);
}
.schedule-repeat > legend { padding: 0 var(--sp-2); }
.schedule-repeat [data-repeat] { display: none; }
.schedule-repeat:has(input[value="every"]:checked) [data-repeat="every"] { display: block; }
.schedule-repeat:has(input[value="calendar"]:checked) [data-repeat="calendar"] { display: block; }
/*
A row of buttons above a list. `.btn-row` carries no bottom margin -- it is
used inside forms where `.field` provides the rhythm -- so "Add a workflow"
sat flush against the first row of the list it adds to, and the two read as
one control. Stated as an adjacency rather than a margin on the button row,
because the row is right to have none everywhere else it appears.
*/
.btn-row + .model-rows,
.btn-row + .model-list { margin-top: var(--sp-4); }