/* 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 `` 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 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 ---------------------------------------------------------------- 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