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:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user