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>
1910 lines
62 KiB
CSS
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); }
|