Files
LLeMbas/src/lembas/web/static/css/app.css
T
HomerandClaude Opus 5.5 db22962164 A reload offer only for a page that is older, and a tab switch that moves one scroller
The update toast fired whenever a service worker was waiting, so after every
release it appeared on pages that were already the release -- including one
fetched with Ctrl+Shift+R. It now compares the waiting worker's release with
the page's own, and so does the reload that follows another tab accepting it.

On Admin -> Prompts, visually hidden labels were positioned against the page
and made the document 6771px tall behind an overflow-hidden root; the tab
handler's scrollIntoView then scrolled that root and lifted the shell 56px.
Every scroll region is now a containing block, and the handler moves only the
container that scrolls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 06:17:18 +00:00

1910 lines
62 KiB
CSS

/*
Application styles.
Rules here resolve colour, spacing and radius through the variables in
tokens.css and never hard-code a value. Layout is flexbox and grid only --
no framework, no preprocessor, no build step.
*/
/* --- Reset ---------------------------------------------------------------- */
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body {
height: 100%;
}
/*
A page built around the shell never scrolls its own document.
`.shell` is `100dvh` -- the *dynamic* viewport, which is what you can actually
see -- while `html` and `body` above are `100%`, which resolves against the
initial containing block and is the *large* viewport, the one you get with the
browser's toolbar retracted. On a desktop those are the same number and this
rule does nothing. On a phone they differ by the height of the toolbar, and
the difference is a document taller than its own window: you scroll past the
bottom of the sidebar and the main column into bare background, and because
every gesture retracts or extends the toolbar the shell resizes underneath you
and it never settles.
Reported on /settings, true of every page with a shell. `:has()` rather than a
class because the shell is what decides this, not the route -- the auth, error
and offline pages have no shell and genuinely do scroll their document, and
they must keep doing so.
*/
html:has(body > .shell),
body:has(> .shell) {
height: 100dvh;
overflow: hidden;
/* A flick that reaches the end of an inner scroller stops there rather than
pulling the page around behind it. */
overscroll-behavior: none;
}
/*
The `hidden` attribute has to win.
The browser's own rule is `[hidden] { display: none }`, which any component
rule setting `display` outranks -- `.btn` is `display: inline-flex`, so a
button hidden from JavaScript stayed visible. That is not a styling nit: it
is how the Stop button came to sit permanently beside Send. Anything toggled
with `hidden` anywhere in the application depends on this line.
*/
[hidden] {
display: none !important;
}
body {
margin: 0;
/* The browser's own grey flash on tap is a rectangle around whatever box the
control happens to be, drawn in a colour no theme here chose. Removed in
favour of the `:active` states below, which are the application's own --
removed *with* a replacement, never on its own. */
-webkit-tap-highlight-color: transparent;
font-family: var(--font-body);
font-size: var(--text-base);
line-height: var(--leading-normal);
color: var(--ink);
background: var(--bg);
-webkit-font-smoothing: antialiased;
}
h1, h2, h3, h4, h5, h6 {
margin: 0;
font-family: var(--font-display);
font-weight: 600;
line-height: var(--leading-tight);
letter-spacing: 0.01em;
}
p { margin: 0 0 var(--sp-4); }
p:last-child { margin-bottom: 0; }
a {
color: var(--accent);
text-decoration-color: color-mix(in srgb, var(--accent) 40%, transparent);
text-underline-offset: 2px;
}
a:hover { color: var(--accent-hover); }
button, input, textarea, select {
font: inherit;
color: inherit;
}
/* A single, consistent focus ring. Never remove it without a replacement. */
:focus-visible {
outline: var(--outline-w) solid var(--accent);
outline-offset: 2px;
border-radius: var(--radius-sm);
}
:focus:not(:focus-visible) { outline: none; }
.visually-hidden {
position: absolute;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
/* --- Icons ---------------------------------------------------------------- */
.icon {
width: 1.25em;
height: 1.25em;
flex: none;
fill: none;
stroke: currentColor;
stroke-width: 1.7;
stroke-linecap: round;
stroke-linejoin: round;
}
.icon--sm { width: 1em; height: 1em; }
.icon--lg { width: 1.5em; height: 1.5em; }
/* The leaf is a filled silhouette, not a stroked pictogram. */
.icon--leaf { fill: currentColor; stroke: none; }
/* --- Buttons ---------------------------------------------------------------
Every variant is the same height and vertically centres its contents, so a
row of mixed buttons lines up without per-instance nudging. Icon buttons are
square at that height rather than a different shape.
*/
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--sp-2);
height: var(--control-h);
padding: 0 var(--control-px);
border: var(--border-w) solid var(--border);
border-radius: var(--radius);
background: var(--surface-raised);
color: var(--ink);
font-size: var(--text-sm);
font-weight: 500;
line-height: 1;
cursor: pointer;
white-space: nowrap;
text-decoration: none;
transition: background var(--transition-fast), border-color var(--transition-fast),
color var(--transition-fast);
}
.btn:hover:not(:disabled) {
background: var(--surface-hover);
border-color: var(--border-strong);
color: var(--ink);
}
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
.btn--primary {
background: var(--accent);
border-color: var(--accent);
color: var(--accent-ink);
}
.btn--primary:hover:not(:disabled) {
background: var(--accent-hover);
border-color: var(--accent-hover);
color: var(--accent-ink);
}
.btn--danger { color: var(--danger); }
.btn--danger:hover:not(:disabled) {
background: var(--danger-soft);
border-color: var(--danger);
color: var(--danger);
}
.btn--ghost { background: transparent; border-color: transparent; }
.btn--ghost:hover:not(:disabled) { background: var(--surface-hover); border-color: transparent; }
/* Square, and the same height as everything beside it. */
.btn--icon {
width: var(--control-h);
/* Square, and it stays square. Without this a flex row that runs out of room
shrinks it instead of its neighbours -- the sidebar toggle measured 18px
across on a 390px chat, less than half the target it is supposed to be,
while the row beside it kept every pixel it had asked for. A control's
size is not the give in a layout; text is. */
flex: none;
padding: 0;
background: transparent;
border-color: transparent;
color: var(--ink-muted);
}
.btn--icon:hover:not(:disabled) { background: var(--surface-hover); color: var(--ink); }
.btn--icon.btn--primary {
color: var(--accent-ink);
background: var(--accent);
border-color: var(--accent);
}
/*
A toggle whose panel is open.
`syncToggles` in app.js has been setting this class on every toggle pointing
at a panel since the terminal landed, and nothing has ever styled it -- so an
open inspector and a closed one gave their topbar buttons an identical
appearance, and the only signal was the panel itself, which is off-screen at
narrow widths. Written against `.btn` rather than `.btn--icon` so anything
that becomes a toggle later is covered.
*/
.btn.is-active {
background: var(--accent-soft);
border-color: transparent;
color: var(--accent);
}
.btn.is-active:hover:not(:disabled) {
background: var(--accent-soft);
color: var(--accent-hover);
}
.btn--sm { height: var(--control-h-sm); padding: 0 var(--control-px-sm); font-size: var(--text-xs); }
.btn--sm.btn--icon { width: var(--control-h-sm); padding: 0; }
.btn--lg { height: var(--control-h-lg); padding: 0 var(--sp-5); font-size: var(--text-base); }
.btn--block { width: 100%; }
.btn--grow { flex: 1; }
/* Rows of buttons: gap and alignment in one place, not per instance. */
.btn-row { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }
.btn-row--end { justify-content: flex-end; }
.spacer { flex: 1; }
/* --- Forms -----------------------------------------------------------------
Inputs share the button height, so a control row is flush by construction.
*/
.field { margin-bottom: var(--sp-4); }
.field:last-child { margin-bottom: 0; }
.field__label {
display: block;
margin-bottom: var(--sp-2);
font-size: var(--text-sm);
font-weight: 500;
color: var(--ink);
}
.field__hint {
margin-top: var(--sp-2);
font-size: var(--text-xs);
color: var(--ink-faint);
line-height: var(--leading-normal);
}
.input,
.select {
width: 100%;
height: var(--control-h);
padding: 0 var(--control-px);
border: var(--border-w) solid var(--border);
border-radius: var(--radius);
background: var(--bg-sunken);
color: var(--ink);
font-size: var(--text-sm);
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.textarea {
width: 100%;
padding: var(--sp-2) var(--control-px);
border: var(--border-w) solid var(--border);
border-radius: var(--radius);
background: var(--bg-sunken);
color: var(--ink);
font-size: var(--text-sm);
line-height: var(--leading-normal);
resize: vertical;
min-height: 5rem;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.input:focus,
.textarea:focus,
.select:focus {
outline: none;
border-color: var(--accent);
box-shadow: var(--ring);
}
.input::placeholder, .textarea::placeholder { color: var(--ink-faint); }
/*
File inputs.
A file input is two things in one box -- a button the browser draws and the
chosen filename beside it -- and neither inherits anything useful. Left alone
with `.input`, the padding applies to the whole control so the button sits
hard against the left edge while the text floats off its centre line.
So: no horizontal padding on the control, the button styled to the same
height as everything else and given the right border that separates it, and
the filename centred with line-height rather than flexbox, which file inputs
do not lay out reliably.
*/
.input[type="file"] {
padding: 0 var(--control-px) 0 0;
line-height: calc(var(--control-h) - 2px);
cursor: pointer;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--ink-muted);
}
.input[type="file"]::file-selector-button {
height: calc(var(--control-h) - 2px);
margin: 0 var(--sp-3) 0 0;
padding: 0 var(--control-px);
border: 0;
border-right: var(--border-w) solid var(--border);
background: var(--surface-hover);
color: var(--ink);
font: inherit;
font-weight: 500;
cursor: pointer;
transition: background var(--transition-fast);
}
.input[type="file"]:hover::file-selector-button { background: var(--surface-active); }
.input--mono, .textarea--mono { font-family: var(--font-mono); font-size: var(--text-xs); }
.select {
appearance: none;
padding-right: var(--sp-8);
/* Chevron drawn in CSS, so no icon font and no extra element. */
background-image:
linear-gradient(45deg, transparent 50%, currentColor 50%),
linear-gradient(135deg, currentColor 50%, transparent 50%);
background-position: right 1.1rem center, right 0.85rem center;
background-size: 0.3rem 0.3rem, 0.3rem 0.3rem;
background-repeat: no-repeat;
cursor: pointer;
}
/* Sits inside a composed control that draws its own frame (the model picker). */
.select--bare {
border-color: transparent;
background-color: transparent;
width: auto;
max-width: 14rem;
padding-left: var(--sp-1);
}
.select--bare:focus { box-shadow: none; }
.checkbox {
display: flex;
align-items: center;
gap: var(--sp-2);
font-size: var(--text-sm);
cursor: pointer;
line-height: var(--leading-normal);
}
.checkbox input {
accent-color: var(--accent);
width: var(--check-size);
height: var(--check-size);
flex: none;
cursor: pointer;
}
/* Every tick box, not only the ones inside a `.checkbox` label -- the admin
lists put bare ones in a row and those were 16px square on a phone. */
input[type="checkbox"],
input[type="radio"] {
accent-color: var(--accent);
width: var(--check-size);
height: var(--check-size);
}
/* Except the ones that are deliberately 1px: a visually-hidden radio is the
state behind a label, and the label is the target. */
input.visually-hidden[type="radio"],
input.visually-hidden[type="checkbox"] {
width: 1px;
height: 1px;
}
/* Multi-column form layout, one definition. */
.grid { display: grid; gap: var(--sp-4); }
/* `min(100%, …)` on every auto-fit track and `min-width: 0` with it, for the
reason `.suggestions` in chat.css sets out at length. The pair is not
optional: `min(100%, …)` stops the track demanding more than the box, and
`min-width: 0` stops the *box* demanding more than its parent -- a grid or
flex item carries `min-width: auto`, which is a min-content floor, and a
floor beats `width`. A stylesheet cannot tell whether one of these grids has
been dropped into a flex parent today, so both go on every one of them.
`tests/test_narrow_grids.py` refuses a track that has only half the pair. */
.grid--2 { min-width: 0; grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr)); }
.grid--3 { min-width: 0; grid-template-columns: repeat(auto-fit, minmax(min(100%, 9rem), 1fr)); }
/* --- Alerts --------------------------------------------------------------- */
.alert {
display: flex;
gap: var(--sp-3);
padding: var(--sp-3) var(--sp-4);
border: var(--border-w) solid var(--border);
border-left-width: var(--border-w-accent);
border-radius: var(--radius);
font-size: var(--text-sm);
margin-bottom: var(--sp-4);
background: var(--surface);
}
.alert--error { border-left-color: var(--danger); background: var(--danger-soft); color: var(--ink); }
.alert--success { border-left-color: var(--success); background: var(--success-soft); }
.alert--warning { border-left-color: var(--warning); background: var(--warning-soft); }
.alert__icon { color: var(--danger); flex: none; margin-top: 0.15rem; }
/* --- Badges --------------------------------------------------------------- */
.badge {
display: inline-flex;
align-items: center;
gap: var(--sp-1);
padding: 0.1rem 0.45rem;
border-radius: var(--radius-full);
font-size: var(--text-xs);
font-weight: 500;
background: var(--surface-active);
color: var(--ink-muted);
}
.badge--leaf { background: var(--leaf-soft); color: var(--leaf); }
/* A crowd's backward pass: everybody has answered and each is being asked
whether it disagrees. Quieter than an answer, because most of these are one
line saying "no" -- and deliberately *not* hidden, because the one that says
yes is the whole reason the feature exists. */
.msg--crowd-back .msg__body { color: var(--ink-muted); }
.msg--crowd-back .msg__author { font-weight: 500; }
.badge--success { background: var(--success-soft); color: var(--success); }
.badge--danger { background: var(--danger-soft); color: var(--danger); }
.badge--warning { background: var(--warning-soft); color: var(--warning); }
/* --- Scroll regions --------------------------------------------------------
The four declarations that make an element *the* scroller, written once.
They were spelled out five times -- the sidebar's list, the thread, the
inspector, the canvas and (in admin.css) the tabs and admin pages -- and
agreed on three of them. The fourth, `overscroll-behavior`, was on the
sidebar alone, with a good comment explaining why it was needed there. It is
needed everywhere for the same reason: a flick that reaches the end of a
scroller chains to whatever is behind it, and behind these is the shell,
which does not scroll -- so what the gesture produces is not a scrolled page
but a rubber-band into blank background, which reads as the layout having
come loose.
`min-height: 0` is the half that is load-bearing rather than cosmetic: a flex
child will not shrink below its content without it, so a scroller missing it
grows its parent instead of scrolling inside it. `.thread-scroll` relied on a
scroll container's automatic minimum size to get away with omitting it, which
is true and is not something the next person should have to know.
`position: relative` makes each scroller the containing block for what is in
it, and without it a `.visually-hidden` label is not in it at all. That class
is `position: absolute`, so with no positioned ancestor it is placed against
the *page*, at its static position -- six thousand pixels down the Tools
panel on /admin/prompts -- and the document grew to 6771px behind a root
that is `overflow: hidden`. Nobody can scroll that by hand, but
`scrollIntoView()` and `focus()` scroll every ancestor that can scroll,
and the root can. Switching a tab there lifted the whole shell 56px: the
topbar gone off the top and a strip of bare background under everything. */
.scroll-region,
.sidebar__scroll,
.inspector__body,
.canvas__body,
.thread-scroll,
.admin-scroll,
.main > .tabs > .tabs__body {
position: relative;
flex: 1;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
scrollbar-color: var(--border-strong) transparent;
}
/* --- Application shell ----------------------------------------------------- */
.shell { display: flex; height: 100dvh; overflow: hidden; }
.sidebar {
width: var(--sidebar-width);
flex: none;
display: flex;
flex-direction: column;
background: var(--bg-sunken);
border-right: var(--border-w) solid var(--border);
min-height: 0;
}
.sidebar[hidden] { display: none; }
/*
Two slots with a gap between them, and neither is positioned against the
other. The brand shrinks and truncates because its width is an instance
setting nobody here chose; the rail does not, because it is a whole number of
`--control-h` boxes and is the thing a hand is going for.
`gap` rather than `margin-left: auto` on the last child: auto-margin puts the
rail on the trailing edge only for as long as it happens to be last, and the
moment a second control is added it lands between the brand and the rail
instead of in it.
*/
.sidebar__header {
display: flex;
align-items: center;
gap: var(--sp-2);
height: var(--header-height);
padding: 0 var(--sp-3);
flex: none;
}
.sidebar__brand-slot {
flex: 1 1 auto;
min-width: 0;
display: flex;
align-items: center;
}
/* On the trailing edge, whatever the writing direction, and sized by its
contents rather than by what is left over. */
.sidebar__actions-rail {
flex: none;
display: flex;
align-items: center;
gap: var(--sp-1);
margin-inline-start: auto;
}
.sidebar__brand {
display: flex;
align-items: center;
gap: var(--sp-2);
color: var(--ink);
text-decoration: none;
font-family: var(--font-display);
font-size: var(--text-lg);
font-weight: 600;
letter-spacing: 0.01em;
min-width: 0;
}
.sidebar__brand:hover { color: var(--ink); }
.sidebar__brand .brand-mark { width: 1.65rem; height: 1.65rem; flex: none; }
.sidebar__brand .brand-llm { color: var(--leaf); }
.sidebar__actions {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: 0 var(--sp-3) var(--sp-3);
flex: none;
}
/* A `.scroll-region`; only the padding is its own. */
.sidebar__scroll {
padding: 0 var(--sp-2) var(--sp-3);
}
.sidebar__footer {
flex: none;
padding: var(--sp-2);
display: flex;
flex-direction: column;
gap: var(--sp-1);
/* Ends level with the composer on the other side of the sidebar edge. See
`--footer-height`; `justify-content` keeps the rows at the bottom when the
reader's permissions leave fewer of them than the token allows for. No top
border: the nav above scrolls, and a drawn edge over scrolling content is
a line the content stops dead at rather than passes under. */
min-height: var(--footer-height);
justify-content: flex-end;
}
.sidebar__tools { display: flex; align-items: center; gap: var(--sp-1); }
.main {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
background: var(--bg);
}
/* The inspector, mirroring the sidebar on the other side of .shell. Hidden by
the `hidden` attribute, which the rule at the top of this file forces to win. */
.inspector {
width: var(--inspector-width);
flex: none;
display: flex;
flex-direction: column;
min-height: 0;
background: var(--bg-sunken);
border-left: var(--border-w) solid var(--border);
}
/*
The header a side panel wears. The inspector and the terminal had this
written out twice, identically, and they must stay identical: they sit side
by side in the same slot and one being a pixel off reads as a rendering bug.
Also the same height as .topbar, so the three line up across the shell.
*/
.panel-head {
display: flex;
align-items: center;
gap: var(--sp-2);
/* The same sum as `.topbar`, for the same reason and so the three still line
up across the shell -- which is the whole point of this element. */
height: calc(var(--header-height) + var(--safe-top));
padding-top: var(--safe-top);
flex: none;
padding-right: var(--sp-3);
padding-left: var(--sp-3);
border-bottom: var(--border-w) solid var(--border);
}
.panel-head__title {
display: flex;
align-items: center;
gap: var(--sp-2);
flex: 1;
min-width: 0;
margin: 0;
font-size: var(--text-sm);
font-weight: 600;
color: var(--ink-muted);
}
.inspector__body {
padding: var(--sp-4);
}
.inspector__heading {
display: flex;
align-items: center;
gap: var(--sp-2);
font-size: var(--text-sm);
margin: var(--sp-5) 0 var(--sp-2);
}
.inspector__note {
font-size: var(--text-xs);
color: var(--ink-muted);
line-height: var(--leading-normal);
margin: 0 0 var(--sp-3);
overflow-wrap: anywhere;
}
.inspector__facts {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--sp-1) var(--sp-3);
font-size: var(--text-xs);
margin: 0;
}
.inspector__facts dt { color: var(--ink-faint); }
.inspector__facts dd { margin: 0; overflow-wrap: anywhere; }
.inspector__json {
margin: 0 0 var(--sp-3);
padding: var(--sp-2);
max-height: 22rem;
overflow: auto;
white-space: pre-wrap;
overflow-wrap: anywhere;
background: var(--code-bg);
border: var(--border-w) 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);
}
@media (max-width: 64rem) {
.inspector {
position: fixed;
inset: 0 0 0 auto;
z-index: var(--z-panel);
box-shadow: var(--shadow-lg);
/* Narrower than the panel wants is the normal case here, so the width has
to be allowed to give. `--inspector-width` is a *preference* -- somebody
can drag it to 2400px (LAYOUT_BOUNDS) -- and without this that number
arrives verbatim on a phone. There was no cap at all. */
width: min(var(--inspector-width), 100vw);
min-width: 0;
}
}
/* The terminal panel: a fourth child of .shell, to the left of the inspector.
Built beside it rather than in chat.css because the shell layout lives here,
and the two are the same shape -- a fixed-width column that hides with the
`hidden` attribute. */
.terminal {
width: var(--terminal-width);
min-width: var(--terminal-width-min);
max-width: 80vw;
flex: none;
display: flex;
flex-direction: column;
min-height: 0;
/* So the resize handle can sit on the edge. */
position: relative;
background: var(--bg-sunken);
border-left: var(--border-w) solid var(--border);
}
/* The drag handle on a panel's left edge. Wider than it looks -- a one-pixel
border is a target nobody can hit -- and it sits *outside* the panel's own
padding so it never overlaps what is being resized. */
.panel-resize {
position: absolute;
top: 0;
bottom: 0;
left: -3px;
width: 9px;
z-index: var(--z-handle);
display: flex;
align-items: center;
justify-content: center;
cursor: col-resize;
color: transparent;
touch-action: none;
transition: background var(--transition-fast), color var(--transition-fast);
}
.panel-resize:hover,
.panel-resize:focus-visible,
body.is-resizing .panel-resize {
background: var(--accent-soft);
color: var(--accent);
outline: none;
}
/* While dragging, nothing else may take the pointer -- a text selection
starting mid-drag makes the whole page flicker blue, and the iframe-shaped
hazard is the terminal itself swallowing pointermove. */
body.is-resizing {
cursor: col-resize;
user-select: none;
}
body.is-resizing .terminal__screen,
body.is-resizing .canvas__body { pointer-events: none; }
@media (max-width: 64rem) {
/* A full-height overlay has no edge to drag, and no room to spare. */
.panel-resize { display: none; }
}
.terminal__where {
font-weight: 400;
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--ink-faint);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* The element xterm renders into. It measures itself from this box, so it must
have a size of its own -- min-height: 0 on a flex child is what stops the
terminal growing the panel instead of scrolling inside it. */
.terminal__screen {
flex: 1;
min-height: 0;
padding: var(--sp-2);
background: var(--code-bg);
}
.terminal__screen .xterm { height: 100%; }
.terminal__status {
flex: none;
display: flex;
align-items: center;
gap: var(--sp-2);
padding: var(--sp-2) var(--sp-3);
border-top: var(--border-w) solid var(--border);
font-size: var(--text-xs);
color: var(--ink-faint);
line-height: var(--leading-normal);
}
.terminal__status strong { color: var(--ink-muted); font-weight: 600; }
.terminal__message { flex: 1; min-width: 0; overflow-wrap: anywhere; }
/* What the last command was. Monospace and clipped: it is a command line, and
one long enough to wrap would push the status bar into two rows. */
.terminal__last {
flex: 0 1 auto;
min-width: 0;
max-width: 16rem;
font-family: var(--font-mono);
color: var(--ink-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.terminal__last:empty { display: none; }
.terminal__message--error { color: var(--danger); }
/* --- The canvas panel ------------------------------------------------------
The same shape as the terminal beside it: a fixed-width column that hides
with the `hidden` attribute, and shares .panel-head and .panel-resize. It
sits nearest the conversation, being the widest and the one most likely to
be read alongside it. */
.canvas {
width: var(--canvas-width);
min-width: var(--canvas-width-min);
max-width: 80vw;
flex: none;
display: flex;
flex-direction: column;
min-height: 0;
position: relative;
background: var(--bg-sunken);
border-left: var(--border-w) solid var(--border);
}
.canvas__inner {
display: flex;
flex-direction: column;
min-height: 0;
flex: 1;
}
/* Where the file came from, beside its name. Shrinks and truncates rather than
pushing the close button off the end. */
.canvas__where {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--ink-faint);
font-family: var(--font-mono);
font-size: var(--text-xs);
font-weight: 400;
}
/* One row, always. It scrolls sideways rather than wrapping -- the same rule
the composer's toolbar is built around, and for the same reason: a strip
that wraps to three lines takes the file with it. */
/*
A tab strip read as an editor's rather than as a row of pills.
Three things do that and none of them is decoration. The strip has a sunken
background so the tabs sit *on* something; the tabs square off at the bottom
and meet it; and the active one takes the body's own background with its
bottom border removed, so it joins the file below rather than floating above
it. Without that last part a strip of equally-shaded pills says which tab is
selected only by a slight tint, which is exactly the thing nobody can see in
a theme they did not choose.
*/
.canvas__tabs {
display: flex;
flex: none;
gap: 1px;
padding: var(--sp-1) var(--sp-2) 0;
overflow-x: auto;
scrollbar-width: thin;
background: var(--bg-sunken);
border-bottom: var(--border-w) solid var(--border);
}
.canvas__tab {
display: inline-flex;
align-items: center;
flex: none;
max-width: 14rem;
/* Square at the bottom: a tab is attached to what it opens. */
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
border: var(--border-w) solid transparent;
border-bottom: 0;
/* The strip's own bottom border is 1px; this covers it for the active tab
without moving anything, so the row does not shift by a pixel on switch. */
margin-bottom: -1px;
padding-bottom: 1px;
background: transparent;
transition: background var(--transition-fast), color var(--transition-fast);
}
.canvas__tab:hover { background: var(--surface); }
.canvas__tab.is-active {
background: var(--bg);
border-color: var(--border);
}
.canvas__tab-open {
display: inline-flex;
align-items: center;
gap: var(--sp-1);
min-width: 0;
height: var(--control-h-sm);
padding: 0 var(--sp-1) 0 var(--sp-2);
border: 0;
background: none;
color: var(--ink-muted);
font-size: var(--text-xs);
cursor: pointer;
}
.canvas__tab.is-active .canvas__tab-open { color: var(--ink); }
.canvas__tab-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Unsaved. A dot rather than a colour alone, which nobody can see in a theme
they did not choose. */
.canvas__tab-dot {
flex: none;
width: 6px;
height: 6px;
border-radius: var(--radius-full);
background: var(--accent);
}
.canvas__tab-close {
display: inline-flex;
align-items: center;
padding: 0 var(--sp-1);
border: 0;
background: none;
color: var(--ink-faint);
cursor: pointer;
}
.canvas__tab-close:hover { color: var(--ink); }
/* One row, and the path box is the only thing allowed to shrink -- the same
arrangement the composer's toolbar is built around. */
.canvas__open {
display: flex;
align-items: center;
gap: var(--sp-2);
flex: none;
padding: var(--sp-2) var(--sp-3);
border-bottom: var(--border-w) solid var(--border);
}
.canvas__body {
/* Both axes, unlike every other scroll region: nothing re-wraps a source
line, so it has to be reachable sideways. */
overflow: auto;
padding: var(--sp-3);
}
.canvas__doc { display: flex; flex-direction: column; gap: var(--sp-2); }
.canvas__actions { display: flex; align-items: center; gap: var(--sp-2); }
.canvas__hint, .canvas__empty, .canvas__note {
color: var(--ink-faint);
font-size: var(--text-xs);
}
.canvas__note { display: flex; align-items: center; gap: var(--sp-2); }
.canvas__code {
margin: 0;
padding: var(--sp-3);
border-radius: var(--radius-sm);
background: var(--code-bg);
font-family: var(--font-mono);
font-size: var(--text-xs);
line-height: 1.6;
white-space: pre;
overflow-x: auto;
}
.canvas__form { display: flex; flex-direction: column; gap: var(--sp-2); }
/* No highlighting while typing, and the panel says so rather than pretending.
A mirror behind this would be the composer's trick at two thousand lines,
laying the buffer out twice on every keystroke. */
.canvas__editor {
width: 100%;
min-height: 24rem;
padding: var(--sp-3);
border: var(--border-w) solid var(--border);
border-radius: var(--radius-sm);
background: var(--code-bg);
color: var(--ink);
font-family: var(--font-mono);
font-size: var(--text-xs);
line-height: 1.6;
white-space: pre;
overflow-wrap: normal;
resize: vertical;
}
.canvas__conflict { display: flex; flex-direction: column; gap: var(--sp-3); }
.canvas__theirs summary { cursor: pointer; font-size: var(--text-xs); }
@media (max-width: 64rem) {
.terminal,
.canvas {
position: fixed;
inset: 0 0 0 auto;
z-index: var(--z-panel);
box-shadow: var(--shadow-lg);
}
.terminal { width: min(var(--terminal-width), 100vw); }
.canvas { width: min(var(--canvas-width), 100vw); }
/* 🚨 And the minimum has to give as well, which is the half that was missing.
`min-width` is resolved *after* `width` and `max-width` and wins over both
-- CSS sizes an element by clamping width to max-width and then raising the
result to min-width -- so `width: min(…, 100vw)` above was simply overruled
by `min-width: 24rem`. Both panels were 384px wide on every screen narrower
than that, hanging off the edge with their left-hand content cut away, and
no amount of capping the width would have changed it.
Because they are `position: fixed`, none of this scrolled the page: fixed
overflow does not extend the scrollable area. So the failure was content
you could not reach rather than a scrollbar, which is why it survived a
narrow-width pass that looked for sideways scrolling.
This is the tree's standing rule in another shape: a minimum wider than the
screen is the bug, and the minimum is what must give. */
.terminal,
.canvas,
.inspector {
min-width: 0;
}
}
.topbar {
display: flex;
align-items: center;
gap: var(--sp-3);
/* The bar is `--header-height` of *content* and whatever the device puts
above it. Installed on a phone the page runs under the status bar, so
without this the title and the sidebar toggle sit beneath the clock. */
height: calc(var(--header-height) + var(--safe-top));
padding-top: var(--safe-top);
flex: none;
padding-right: max(var(--sp-4), var(--safe-right));
padding-left: max(var(--sp-4), var(--safe-left));
border-bottom: var(--border-w) solid var(--border);
background: var(--bg);
}
.topbar__title {
font-family: var(--font-display);
font-size: var(--text-md);
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
flex: 1;
}
/*
The controls on the right of the topbar.
`flex: none` on the group with `min-width: 0` inside it: the group keeps the
width its controls need, and the one child whose width is a *name* rather
than a control -- the model picker -- is the thing allowed to give. Without
the second half the group asked for 317px of a 390px bar and the chat's
title, which is `flex: 1`, was squeezed to exactly zero: a heading that had
not been shortened or truncated but had simply ceased to occupy space.
*/
.topbar__actions {
display: flex;
align-items: center;
gap: var(--sp-2);
/* Allowed to give, which it was not. `--topbar__where` used to be the
designated shrinker in this row, and it is `display: none` below 64rem --
so on a phone the group became rigid, asked for 317px of a 390px bar, and
the title (`flex: 1`) was squeezed to exactly zero: a heading that had not
been truncated but had ceased to occupy space.
Nothing inside it shrinks except the model picker: every button here is
`flex: none` because a control's size is not the give in a layout. */
flex: 0 1 auto;
min-width: 0;
}
/* A title identifies the page, so it gets a floor and truncates rather than
disappearing. */
.topbar__title { min-width: 4rem; }
/* The one control in this row whose width is somebody else's decision -- a
model's label is whatever an administrator called it -- so it is the one
that gives, and it gives by truncating its name rather than its avatar or
its chevron. */
.topbar__actions .picker { min-width: 0; }
.topbar__actions .picker__button { max-width: 100%; }
.topbar__actions .picker__label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.picker__avatar, .picker__chevron { flex: none; }
/*
Which machine an agent chat runs on, and where.
Its own flex item between the title and the actions, and it shrinks before
the title does: `flex: 0 1 auto` with `min-width: 0` means a long path gives
up its characters first, which is right -- a truncated title is a chat you
cannot identify, a truncated path is still a path you recognise. Below 64rem
it goes entirely; the terminal button beside it already says which machine.
*/
.topbar__where {
display: inline-flex;
align-items: center;
gap: var(--sp-1);
flex: 0 1 auto;
min-width: 0;
max-width: 24rem;
padding: 0 var(--sp-2);
height: var(--control-h-sm);
border-radius: var(--radius-full);
background: var(--surface-hover);
color: var(--ink-muted);
font-size: var(--text-xs);
white-space: nowrap;
}
.topbar__where-name { flex: none; }
.topbar__where-dir {
font-family: var(--font-mono);
color: var(--ink-faint);
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
@media (max-width: 64rem) {
.topbar__where { display: none; }
}
/* The model picker: an avatar and a select sharing one frame, so it reads as a
single control rather than two things that happen to be adjacent. */
.model-select {
display: flex;
align-items: center;
gap: var(--sp-2);
height: var(--control-h);
padding: 0 var(--sp-1) 0 var(--sp-2);
border: var(--border-w) solid var(--border);
border-radius: var(--radius);
background: var(--surface);
cursor: pointer;
transition: border-color var(--transition-fast);
}
.model-select:hover { border-color: var(--border-strong); }
.model-select:focus-within {
border-color: var(--accent);
box-shadow: var(--ring);
}
.model-select__avatar { width: 1.4rem; height: 1.4rem; border-radius: var(--radius-sm); }
/* Collapsible settings panel, shared by chat settings and anything like it. */
.panel {
flex: none;
border-bottom: var(--border-w) solid var(--border);
background: var(--bg-sunken);
max-height: 60vh;
overflow-y: auto;
}
.panel__inner {
max-width: var(--thread-max-width);
margin: 0 auto;
padding: var(--sp-5);
}
.panel__note {
color: var(--ink-muted);
font-size: var(--text-sm);
font-style: italic;
margin-bottom: var(--sp-4);
}
/* --- Sidebar navigation ---------------------------------------------------- */
.nav-group { margin-bottom: var(--sp-4); }
.nav-group:last-child { margin-bottom: 0; }
/* The pinned group is rendered even with nothing in it, so the Chat/Agent
switch has a target to swap out of band -- htmx says nothing when a target is
missing. Empty, it must take no room. */
.nav-group--pinned:empty { display: none; margin: 0; }
.nav-group__label {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--sp-2) var(--sp-2) var(--sp-1);
font-size: var(--text-xs);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--ink-faint);
}
.nav-item {
display: flex;
align-items: center;
gap: var(--sp-2);
min-height: var(--control-h);
padding: 0 var(--sp-2);
border-radius: var(--radius);
color: var(--ink-muted);
text-decoration: none;
font-size: var(--text-sm);
cursor: pointer;
position: relative;
}
.nav-item:hover { background: var(--surface-hover); color: var(--ink); }
.nav-item.is-active {
background: var(--surface-active);
color: var(--ink);
font-weight: 500;
}
.nav-item__label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
.nav-item__avatar { width: 1.4rem; height: 1.4rem; border-radius: var(--radius-sm); flex: none; }
.nav-item--model .nav-item__label { font-weight: 500; }
/* Row actions stay hidden until hover or focus, so the list reads calmly while
remaining keyboard reachable. */
.nav-item__actions {
display: flex;
align-items: center;
gap: 0.1rem;
flex: none;
opacity: 0;
transition: opacity var(--transition-fast);
}
.nav-item:hover .nav-item__actions,
.nav-item:focus-within .nav-item__actions { opacity: 1; }
/*
There is no hover on a phone, and the row's own tap target is the link -- so
tapping a chat navigated to it and these never appeared at all. Renaming or
deleting a chat from a phone was not difficult, it was impossible.
`hover: none` rather than a width: a touchscreen laptop at 1440px has the same
problem, and a narrow desktop window does not.
*/
@media (hover: none) {
.nav-item__actions { opacity: 1; }
}
/* The archived group. A `<summary>` is a real control, so it takes the row
treatment rather than the label's -- it is something you press. */
.nav-group--archived > summary {
display: flex;
align-items: center;
gap: var(--sp-2);
cursor: pointer;
border-radius: var(--radius);
list-style: none;
}
.nav-group--archived > summary::-webkit-details-marker { display: none; }
.nav-group--archived > summary:hover { background: var(--surface-hover); color: var(--ink-muted); }
.nav-group__count {
margin-left: auto;
font-variant-numeric: tabular-nums;
color: var(--ink-faint);
}
/* Archived rows read as put away rather than as unavailable: dimmed until
they are looked at, never greyed out -- every action on them still works. */
.nav-group--archived .nav-item { opacity: 0.72; }
.nav-group--archived .nav-item:hover,
.nav-group--archived .nav-item:focus-within { opacity: 1; }
.nav-empty {
padding: var(--sp-2);
margin: 0;
font-size: var(--text-xs);
color: var(--ink-faint);
font-style: italic;
}
/* --- Auth screens --------------------------------------------------------- */
.auth {
min-height: 100dvh;
display: grid;
place-items: center;
padding: var(--sp-6);
background:
radial-gradient(ellipse 60% 50% at 50% 0%, var(--leaf-soft), transparent 70%),
var(--bg);
}
.auth__card {
width: 100%;
max-width: 25rem;
background: var(--surface);
border: var(--border-w) solid var(--border);
border-radius: var(--radius-xl);
padding: var(--sp-8);
box-shadow: var(--shadow-lg);
}
.auth__brand { display: grid; place-items: center; gap: var(--sp-3); margin-bottom: var(--sp-6); }
.auth__brand .brand-mark { width: 3.5rem; height: 3.5rem; }
.auth__title {
font-size: var(--text-2xl);
font-family: var(--font-display);
text-align: center;
}
.auth__subtitle {
text-align: center;
color: var(--ink-muted);
font-size: var(--text-sm);
margin-top: var(--sp-2);
}
.auth__footer {
margin-top: var(--sp-5);
padding-top: var(--sp-4);
border-top: var(--border-w) solid var(--border);
text-align: center;
font-size: var(--text-sm);
color: var(--ink-muted);
}
/* --- Empty states --------------------------------------------------------- */
.empty {
flex: 1;
display: grid;
place-content: center;
justify-items: center;
gap: var(--sp-3);
padding: var(--sp-10);
text-align: center;
}
.empty__mark { width: 4.5rem; height: 4.5rem; opacity: 0.85; }
.empty__title { font-size: var(--text-xl); font-family: var(--font-display); }
.empty__text {
color: var(--ink-muted);
font-size: var(--text-sm);
max-width: 32rem;
font-style: italic;
}
/* --- Utilities ------------------------------------------------------------ */
.stack > * + * { margin-top: var(--sp-4); }
.row { display: flex; align-items: center; gap: var(--sp-3); }
.row--between { justify-content: space-between; }
.muted { color: var(--ink-muted); }
.faint { color: var(--ink-faint); }
.text-sm { font-size: var(--text-sm); }
.text-xs { font-size: var(--text-xs); }
.mono { font-family: var(--font-mono); }
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* --- Small screens -------------------------------------------------------- */
/*
--- The sidebar, and the two different things "closed" means ---------------
Above the breakpoint the sidebar is a column and closed means "give the space
to the conversation". Below it the sidebar is an overlay and closed is the
*resting* state -- 280px of opaque drawer over a 390px screen is not a
navigation aid, it is the page gone.
The `hidden` attribute cannot express that, because it is one value for both
widths: it was absent, so the drawer was open on every phone, on every page,
from the first paint -- with its own toggle underneath it. So the state is an
attribute on <html> with three values, and the third is the one that matters:
data-sidebar="open" shown at every width
data-sidebar="closed" hidden at every width
(absent) follow the width -- open wide, closed narrow
Absent is what the server renders, because the server does not know how wide
the window is. See `setSidebar` in app.js, which is also why this panel does
not go through `setPanel` like the three on the other side.
*/
:root[data-sidebar="closed"] .sidebar {
display: none;
}
/* Above the breakpoint the drawer's own furniture has no job. Declared BEFORE
the media query that turns it back on: both rules are one class deep, so
source order is what decides, and this one written afterwards made the close
button `display: none` at every width -- including inside the open drawer,
which is the only place it exists for. */
.sidebar__close {
display: none;
}
@media (max-width: 48rem) {
/* A side panel on a phone is a sheet over the conversation, not a column
beside it. `max-width: 80vw` is right on a tablet -- you can still see what
you were reading -- and wrong here, because 20% of 360px is 72px of
conversation, which is not a view of anything. Full width and dismissible
is what the sidebar already does on the other side.
Set here rather than in the 64rem block so the tablet keeps its column. */
.inspector,
.terminal,
.canvas {
width: 100vw;
max-width: 100vw;
}
/* The bar is the densest row in the application and the one with the least
room: a toggle, a title, a model, and up to four panel buttons. Tighter
padding and a smaller gap buy back about 24px, which is the difference
between a title that truncates and one there is no room for at all. */
.topbar {
gap: var(--sp-2);
padding-right: max(var(--sp-2), var(--safe-right));
padding-left: max(var(--sp-2), var(--safe-left));
}
/* The model's name costs about a hundred pixels and its avatar does not,
and the picker opens onto a list of full names the moment it is touched.
So on a phone the avatar carries the identity and the chat's own title --
which nothing else on the screen tells you -- gets the room back. */
.topbar__actions .picker__label { display: none; }
/* And the menu is the bar's, not the picker's. Anchored to the picker it
opens `right: 0` of a button that sits mid-bar with the panel buttons to
its right, so a 24rem menu ran off the left edge of a 390px phone and cut
every name in half. Taking `position` off the picker makes the bar the
containing block: the menu spans the bar under it, whatever sits where --
up to its usual 24rem, held at the bar's right edge by the auto margin. */
.topbar { position: relative; }
.topbar__actions .picker { position: static; }
.topbar__actions .picker__menu {
left: max(var(--sp-2), var(--safe-left));
right: max(var(--sp-2), var(--safe-right));
width: auto;
max-width: 24rem;
margin-left: auto;
}
.topbar__actions .picker__list { max-height: min(22rem, 60dvh); }
.sidebar {
position: fixed;
inset: 0 auto 0 0;
/* Never the full width, and never wider than the screen: a drawer with no
page showing beside it gives nothing to tap to dismiss it, and reads as
a navigation *page* you have arrived at rather than a layer over the one
you were on. */
width: min(var(--sidebar-width), 84vw);
z-index: var(--z-panel);
box-shadow: var(--shadow-lg);
/* Off-screen rather than `display: none`, so opening it is a movement the
eye can follow from the button that caused it. `visibility` is what
takes it out of the tab order while it is away -- `transform` alone
leaves every control in it focusable, just somewhere nobody can see. */
transform: translateX(-100%);
visibility: hidden;
transition: transform var(--dur-3) var(--ease-out),
visibility var(--dur-3) var(--ease-out);
}
:root:not([data-sidebar="closed"]) .sidebar {
/* `display` must not be the thing that hides it here, or there is nothing
to animate. The attribute rule above is reversed for this width. */
display: flex;
}
:root[data-sidebar="open"] .sidebar {
transform: none;
visibility: visible;
}
/* Its own edges, once it is the thing against the side of the screen. */
.sidebar__header,
.sidebar__actions,
.sidebar__scroll {
padding-left: max(var(--sp-3), var(--safe-left));
}
.sidebar__footer {
padding-bottom: max(var(--sp-2), var(--safe-bottom));
}
.sidebar__close {
display: inline-flex;
}
/* Dismissible by tapping beside it. Without this the only way out is a
button, and a drawer you can only leave deliberately is one people close
by reloading. */
:root[data-sidebar="open"] .sidebar-scrim {
opacity: 1;
pointer-events: auto;
}
}
.sidebar-scrim {
position: fixed;
inset: 0;
z-index: calc(var(--z-panel) - 1);
background: var(--scrim);
opacity: 0;
pointer-events: none;
transition: opacity var(--dur-3) var(--ease-out);
}
/* --- Toasts ----------------------------------------------------------------
Bottom-right, stacked, above dialogs' backdrop but out of the way of the
composer.
*/
.toasts {
position: fixed;
right: var(--sp-4);
bottom: var(--sp-4);
z-index: var(--z-toast);
display: flex;
flex-direction: column;
gap: var(--sp-2);
max-width: min(24rem, calc(100vw - var(--sp-8)));
pointer-events: none;
}
.toast {
display: flex;
align-items: flex-start;
gap: var(--sp-3);
padding: var(--sp-3) var(--sp-3) var(--sp-3) var(--sp-4);
border: var(--border-w) solid var(--border);
border-left: var(--border-w-accent) solid var(--accent);
border-radius: var(--radius);
background: var(--surface-raised);
box-shadow: var(--shadow-lg);
font-size: var(--text-sm);
pointer-events: auto;
opacity: 0;
transform: translateY(0.5rem);
transition: opacity var(--transition), transform var(--transition);
}
.toast.is-in { opacity: 1; transform: none; }
.toast--success { border-left-color: var(--success); }
.toast--error { border-left-color: var(--danger); }
.toast--warning { border-left-color: var(--warning); }
.toast__text { flex: 1; min-width: 0; overflow-wrap: anywhere; }
.toast__close {
flex: none;
/* It had no height at all -- `font-size` and 0.15rem of side padding, which
is about 18x7px. The smallest target in the application, on the one control
somebody reaches for when they are already mildly annoyed. */
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--control-h-sm);
height: var(--control-h-sm);
border: 0;
border-radius: var(--radius-sm);
background: none;
color: var(--ink-faint);
cursor: pointer;
font-size: var(--text-lg);
line-height: 1;
padding: 0;
}
.toast__close:hover { color: var(--ink); }
.toast__action { flex: none; align-self: center; }
/* --- Dialogs ----------------------------------------------------------------
<dialog> gives focus trapping, Escape and page inertness for free.
*/
.dialog {
border: var(--border-w) solid var(--border);
border-radius: var(--radius-xl);
background: var(--surface);
color: var(--ink);
padding: 0;
max-width: min(28rem, calc(100vw - var(--sp-8)));
width: 100%;
box-shadow: var(--shadow-lg);
}
.dialog::backdrop { background: var(--scrim); backdrop-filter: blur(2px); }
.dialog__form { padding: var(--sp-5); display: flex; flex-direction: column; gap: var(--sp-3); }
.dialog__title { font-size: var(--text-lg); }
.dialog__message {
margin: 0;
color: var(--ink-muted);
font-size: var(--text-sm);
line-height: var(--leading-relaxed);
overflow-wrap: anywhere;
}
.dialog__actions {
display: flex;
justify-content: flex-end;
gap: var(--sp-2);
margin-top: var(--sp-2);
}
/* A destructive confirmation needs the weight of a filled button, not the
outline treatment .btn--danger gives a row action. */
.btn--danger-solid {
background: var(--danger);
border-color: var(--danger);
color: var(--ink-inverse);
}
.btn--danger-solid:hover:not(:disabled) {
background: var(--danger-hover);
border-color: var(--danger-hover);
color: var(--ink-inverse);
}
/* --- Model picker ----------------------------------------------------------
Built by hand because a <select> renders only text in an <option> -- no
avatar, no description, no badges.
*/
.picker { position: relative; }
.picker__button {
display: flex;
align-items: center;
gap: var(--sp-2);
height: var(--control-h);
max-width: 16rem;
padding: 0 var(--sp-2);
border: var(--border-w) solid var(--border);
border-radius: var(--radius);
background: var(--surface);
color: var(--ink);
font-size: var(--text-sm);
cursor: pointer;
transition: border-color var(--transition-fast);
}
.picker__button:hover { border-color: var(--border-strong); }
.picker__button[aria-expanded="true"] {
border-color: var(--accent);
box-shadow: var(--ring);
}
.picker__avatar { width: 1.4rem; height: 1.4rem; border-radius: var(--radius-sm); flex: none; }
.picker__label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
font-weight: 500;
}
.picker__chevron { flex: none; color: var(--ink-faint); }
.picker__menu {
position: absolute;
top: calc(100% + var(--sp-1));
right: 0;
z-index: var(--z-dropdown);
width: min(24rem, calc(100vw - var(--sp-8)));
border: var(--border-w) solid var(--border);
border-radius: var(--radius-lg);
background: var(--surface-raised);
box-shadow: var(--shadow-lg);
overflow: hidden;
}
/* The composer's attach menu opens upwards: it sits at the bottom of the
window, so a menu dropping down would be off screen. */
.picker--up .picker__menu {
top: auto;
bottom: calc(100% + var(--sp-1));
left: 0;
right: auto;
}
.picker__menu--compact { width: min(18rem, calc(100vw - var(--sp-8))); padding: var(--sp-1); }
/* "What this chat can use": a list of switches rather than a list of actions.
Wider than the compact menu because a skill's description has to fit, and
scrollable because a library of sixty skills would otherwise run off the top
of the window -- this menu opens upward. */
.picker__menu--scope {
width: min(22rem, calc(100vw - var(--sp-8)));
max-height: min(26rem, 60vh);
overflow-y: auto;
padding: var(--sp-1);
}
.picker__lede {
margin: 0;
padding: var(--sp-2) var(--sp-3);
font-size: var(--text-xs);
color: var(--ink-faint);
}
/* A label, not a button, so several can be set without the menu closing --
ui.js closes on `[role="menuitem"]`, and these deliberately have none. */
.picker__option--toggle { align-items: center; }
.picker__option--toggle input { flex: none; margin: 0; }
.picker__option-note {
font-size: var(--text-xs);
color: var(--ink-faint);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* A dialog that holds a searchable list rather than a question. */
.dialog--wide { width: min(34rem, calc(100vw - var(--sp-6))); }
.dialog__note { margin: 0; font-size: var(--text-sm); color: var(--ink-muted); }
/* Where the directory browser currently stands. Sticky-feeling rather than
sticky: it is above the list, so walking deeper never scrolls it away. */
.dialog__where {
display: flex;
align-items: center;
gap: var(--sp-2);
margin: 0;
min-width: 0;
padding: var(--sp-2) var(--sp-3);
border-bottom: var(--border-w) solid var(--border);
background: var(--bg-sunken);
font-size: var(--text-xs);
color: var(--ink-muted);
}
.dialog__where .mono { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* A row that is shown but cannot be chosen -- a file in a directory picker.
Listed rather than hidden, because a directory of only files would otherwise
look empty. */
.picker__option.is-inert { cursor: default; color: var(--ink-faint); }
.picker__option.is-inert:hover { background: none; }
.dialog__results {
max-height: min(24rem, 50vh);
overflow-y: auto;
scrollbar-width: thin;
border: var(--border-w) solid var(--border);
border-radius: var(--radius);
}
.dialog__results .picker__list { max-height: none; }
.picker__search { padding: var(--sp-2); border-bottom: var(--border-w) solid var(--border); }
/*
Small controls, declared here because this is the file every page loads.
`.select--sm` was used in three places and defined in none: chat.css had a
copy, it was deleted as a duplicate, and the replacement never landed. A
select with only `.select` takes `width: 100%` and `--control-h` -- so in a
flex row every one of them asked for the whole width, all of them shrank
together until each was a few characters wide, and each stood half a rem
taller than the buttons beside it. That is what "the connection switch needs
to be wider" was.
`width: auto` is the important half: a small select should be as wide as its
longest option, not as wide as it can get away with.
*/
.input--sm { height: var(--control-h-sm); font-size: var(--text-xs); }
.select--sm {
height: var(--control-h-sm);
padding: 0 var(--sp-6) 0 var(--control-px-sm);
font-size: var(--text-xs);
width: auto;
max-width: 14rem;
background-position: right 0.75rem center, right 0.5rem center;
}
.picker__list { max-height: 22rem; overflow-y: auto; scrollbar-width: thin; padding: var(--sp-1); }
.picker__option {
display: flex;
align-items: flex-start;
gap: var(--sp-3);
width: 100%;
padding: var(--sp-2);
border: 0;
border-radius: var(--radius);
background: none;
color: var(--ink);
text-align: left;
cursor: pointer;
}
.picker__option:hover, .picker__option:focus-visible { background: var(--surface-hover); }
.picker__option.is-selected { background: var(--accent-soft); }
.picker__option .picker__avatar { width: 1.75rem; height: 1.75rem; margin-top: 0.1rem; }
.picker__option-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 0.15rem; }
.picker__option-name {
display: flex;
align-items: center;
gap: var(--sp-1);
font-size: var(--text-sm);
font-weight: 500;
}
.picker__pin { color: var(--leaf); }
.picker__option-desc {
font-size: var(--text-xs);
color: var(--ink-faint);
line-height: var(--leading-normal);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.picker__option-tags { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
.picker__option-tags:empty { display: none; }
.tag {
font-size: 0.6875rem;
padding: 0 0.35rem;
border-radius: var(--radius-sm);
background: var(--leaf-soft);
color: var(--leaf);
}
.picker__tick { color: var(--accent); flex: none; margin-top: 0.35rem; }
/* The model picker: one row per model on the list's own column tracks, so the
context sizes and the eyes form columns whatever a name's length. Scoped to
the modifier because `.picker__list` is also the @-mention menu's list. */
.picker__list--models {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto auto auto;
column-gap: var(--sp-3);
}
.picker__list--models .picker__option {
grid-column: 1 / -1;
display: grid;
grid-template-columns: subgrid;
align-items: center;
gap: inherit;
}
.picker__list--models .picker__option .picker__avatar { margin-top: 0; }
.picker__list--models .picker__option-name { min-width: 0; }
.model-ctx {
font-size: var(--text-xs);
color: var(--ink-faint);
white-space: nowrap;
}
.model-vision { display: flex; color: var(--ink-faint); min-width: 1rem; }
.picker__list--models .picker__tick { display: flex; margin-top: 0; min-width: 1rem; visibility: hidden; }
.picker__list--models .picker__option.is-selected .picker__tick { visibility: visible; }
.picker__empty {
padding: var(--sp-4);
margin: 0;
text-align: center;
font-size: var(--text-sm);
color: var(--ink-faint);
}
/* --- Saying that something is happening ------------------------------------
A three-pixel bar across the top of the window, above everything including
the panels, because it describes the whole page rather than any part of it.
It never claims to know how far along it is. A request whose length is
unknown and a bar that fills at a constant rate is a lie that gets found out
on every slow request -- so this one travels, and stops when the answer
lands. `transform` only, so it costs no layout on a page that may be
streaming a reply at twelve frames a second underneath it.
*/
.progress {
position: fixed;
top: 0;
left: 0;
right: 0;
height: var(--border-w-accent);
z-index: var(--z-toast);
pointer-events: none;
opacity: 0;
transition: opacity var(--dur-2) var(--ease-out);
}
.progress.is-busy { opacity: 1; }
.progress span {
display: block;
height: 100%;
width: 40%;
border-radius: var(--radius-full);
background: linear-gradient(90deg, transparent, var(--leaf), transparent);
transform: translateX(-100%);
}
.progress.is-busy span { animation: progress-sweep var(--dur-slow) var(--ease-in-out) infinite; }
@keyframes progress-sweep {
0% { transform: translateX(-100%); }
100% { transform: translateX(350%); }
}
/* --- Content that has not arrived yet ---------------------------------------
A shape where the thing will be, rather than a blank. Used with `aria-hidden`
on whatever is waiting, so a screen reader is not read a paragraph of
nothing.
The shimmer is a moving gradient rather than an opacity pulse, because a list
of eight pulsing blocks all at the same phase reads as a fault. */
.skeleton {
border-radius: var(--radius);
background: linear-gradient(
90deg,
var(--surface) 0%,
var(--surface-hover) 50%,
var(--surface) 100%
);
background-size: 200% 100%;
animation: skeleton-sweep var(--dur-slow) var(--ease-in-out) infinite;
}
.skeleton--row { height: var(--control-h); margin-bottom: var(--sp-1); }
.skeleton--line { height: var(--text-base); margin-bottom: var(--sp-2); }
.skeleton--short { width: 60%; }
@keyframes skeleton-sweep {
0% { background-position: 100% 0; }
100% { background-position: -100% 0; }
}
/* --- Press ----------------------------------------------------------------
The tap highlight was removed in the reset, so something has to take its
place: a control that moves under the finger is the cheapest possible
confirmation that the tap landed, and the only one that works before the
request it started has answered. Kept small -- this is feedback, not an
animation somebody has to sit through. */
.btn:active:not(:disabled),
.nav-item:active,
.tabs__tab:active {
transform: translateY(1px);
}
.btn { transition: background var(--transition-fast), border-color var(--transition-fast),
color var(--transition-fast), transform var(--dur-1) var(--ease-out); }
/* --- Arrival ---------------------------------------------------------------
`@starting-style` plus `allow-discrete` is what lets a `display: none`
element animate in with no JavaScript at all and no class to add and remove.
Where it is unsupported the element simply appears, which is what it did
before. */
.dialog {
opacity: 0;
transform: scale(0.97);
transition: opacity var(--dur-2) var(--ease-out),
transform var(--dur-2) var(--ease-spring),
overlay var(--dur-2) allow-discrete,
display var(--dur-2) allow-discrete;
}
.dialog[open] { opacity: 1; transform: none; }
@starting-style {
.dialog[open] { opacity: 0; transform: scale(0.97); }
}
.dialog::backdrop {
opacity: 0;
transition: opacity var(--dur-2) var(--ease-out),
overlay var(--dur-2) allow-discrete,
display var(--dur-2) allow-discrete;
}
.dialog[open]::backdrop { opacity: 1; }
@starting-style {
.dialog[open]::backdrop { opacity: 0; }
}
/* A card lifts a little under the pointer -- only where there is a pointer, and
only where the card is something you can act on. */
@media (hover: hover) {
a.card:hover,
.card--action:hover {
transform: translateY(-2px);
box-shadow: var(--shadow);
}
}
a.card, .card--action { transition: transform var(--dur-2) var(--ease-out),
box-shadow var(--dur-2) var(--ease-out); }