Live Markdown, stop, rewind, custom picker, dialogs

Seven things.

**Reasoning starts closed.** The answer is what the reader is waiting
for; the thinking is one click away.

**Image borders.** .attachments__image was a block-level <a>, so its
border stretched the full column around a narrow picture. inline-block,
and the frame is the picture. Same fix for the composer thumbnail.

**Markdown now renders during the stream.** The generator re-renders the
answer so far and sends it as a `render` event at most every 100ms,
swapped with innerHTML, instead of appending escaped tokens and
formatting everything at the end. Re-rendering whole rather than
appending is the point: a list or a code fence is only correct once its
context exists, and partial syntax resolves itself as more arrives.
Measured against a live model: 29 render events, formatting visible from
the first content token.

**Stop button.** A stop request goes into an in-process set the
generator checks between chunks; whatever arrived is kept, because a
half-written answer the reader chose to cut short is still worth having.
Measured: stream ended 0.2s after the request, 1155 characters
preserved, message marked stopped rather than errored. Navigating away
does the same thing via CancelledError.

**Rewind and edit.** Edit one of your own turns and everything after it
is deleted, then the conversation runs on from there. Deliberately not
branching: that needs a UI for choosing between versions, and "go back
and try again from here" is what was asked for. The form states how many
messages will be discarded before you confirm.

**Custom model picker.** A <select> renders only text in an <option>, so
it can never show an avatar. Built from buttons and a hidden input, with
descriptions, capability tags, a filter box past eight models, and
arrow-key navigation written out by hand since there is no native widget
doing it.

**Notification system.** lembas.notify/confirm/prompt in ui.js, built on
<dialog> so focus trapping, Escape and page inertness come from the
browser. htmx:confirm is intercepted, so every existing hx-confirm gets
the themed dialog with no change at the call site; the browser's grey
confirm() is gone from every template.

230 tests, ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-07-21 14:33:04 +02:00
parent 476812f119
commit 5f020ef33f
19 changed files with 1122 additions and 53 deletions
+197
View File
@@ -578,3 +578,200 @@ button, input, textarea, select {
}
.sidebar[data-collapsed="true"] { display: none; }
}
/* --- 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: 60;
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: 1px solid var(--border);
border-left: 3px 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;
border: 0;
background: none;
color: var(--ink-faint);
cursor: pointer;
font-size: var(--text-lg);
line-height: 1;
padding: 0 0.15rem;
}
.toast__close:hover { color: var(--ink); }
/* --- Dialogs ----------------------------------------------------------------
<dialog> gives focus trapping, Escape and page inertness for free.
*/
.dialog {
border: 1px 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: 1px 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: 0 0 0 3px var(--accent-soft);
}
.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: 30;
width: min(24rem, calc(100vw - var(--sp-8)));
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--surface-raised);
box-shadow: var(--shadow-lg);
overflow: hidden;
}
.picker__search { padding: var(--sp-2); border-bottom: 1px solid var(--border); }
.input--sm { height: var(--control-h-sm); font-size: var(--text-xs); }
.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(--gold); }
.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(--gold-soft);
color: var(--gold);
}
.picker__tick { color: var(--accent); flex: none; margin-top: 0.35rem; }
.picker__empty {
padding: var(--sp-4);
margin: 0;
text-align: center;
font-size: var(--text-sm);
color: var(--ink-faint);
}