feat: v0.6.0 light mode, command palette, dialogs, toasts
- Light mode with the house ☀/◐ toggle and lt_theme preference; light terminal palette; Claude runs with dark-ansi so its colours follow the palette; tmux and the statusLine use ANSI colours. - Command palette (⌕, Ctrl+Shift+P) with session, terminal, view, link and help commands. - .lt-modal dialogs for new/rename/end and a keyboard shortcuts sheet. - .lt-toast results for actions, uploads, copy and CI. - ttyd dropped the reply of fast one-shot commands; replies now wait for the browser to hang up, and the client hangs up as soon as it has one. - Sessions panel re-fetches after an action instead of reusing a stale poll. Closes #31 Closes #32 Closes #33 Closes #34 Closes #35 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+295
@@ -0,0 +1,295 @@
|
||||
// House UI pieces built on web_template's base.css classes, without base.js
|
||||
// (its global key handler swallows Esc, which interrupts Claude, and every toast
|
||||
// beeps): toasts (devdesk#34), dialogs (#33) and the command palette (#32).
|
||||
|
||||
import { el } from './dom.js';
|
||||
|
||||
// ---------------------------------------------------------------- toasts
|
||||
|
||||
const ICONS = { success: '✓', error: '✗', warning: '!', info: 'i' };
|
||||
|
||||
function toastContainer() {
|
||||
let box = document.getElementById('lt-toast-container');
|
||||
if (!box) {
|
||||
box = el('div', { id: 'lt-toast-container', 'aria-live': 'polite' });
|
||||
document.body.append(box);
|
||||
}
|
||||
return box;
|
||||
}
|
||||
|
||||
function dismiss(node) {
|
||||
if (!node.isConnected) return;
|
||||
clearTimeout(node.dismissTimer);
|
||||
node.classList.add('is-leaving');
|
||||
setTimeout(() => node.remove(), 250);
|
||||
}
|
||||
|
||||
export function toast(message, type = 'info', duration = 4000) {
|
||||
const box = toastContainer();
|
||||
// At most four on screen: the oldest goes first.
|
||||
while (box.children.length >= 4) box.firstElementChild.remove();
|
||||
const node = el('div', { class: `lt-toast lt-toast-${type}`, role: type === 'error' ? 'alert' : 'status' },
|
||||
el('span', { class: 'lt-toast-icon', 'aria-hidden': 'true' }, `[${ICONS[type] || 'i'}]`),
|
||||
el('span', { class: 'lt-toast-msg' }, message),
|
||||
el('button', { type: 'button', class: 'lt-toast-close', 'aria-label': 'Dismiss', onclick: () => dismiss(node) }, '✕'),
|
||||
el('div', { class: 'lt-toast-progress', style: `animation-duration:${duration}ms` }));
|
||||
box.append(node);
|
||||
node.dismissTimer = setTimeout(() => dismiss(node), duration);
|
||||
node.addEventListener('mouseenter', () => clearTimeout(node.dismissTimer));
|
||||
node.addEventListener('mouseleave', () => { node.dismissTimer = setTimeout(() => dismiss(node), 1500); });
|
||||
}
|
||||
|
||||
toast.success = (message, duration) => toast(message, 'success', duration);
|
||||
toast.error = (message, duration = 8000) => toast(message, 'error', duration);
|
||||
toast.warning = (message, duration) => toast(message, 'warning', duration);
|
||||
toast.info = (message, duration) => toast(message, 'info', duration);
|
||||
|
||||
// ---------------------------------------------------------------- dialogs
|
||||
|
||||
let openDialogs = 0;
|
||||
export const dialogOpen = () => openDialogs > 0 || document.getElementById('lt-cmd-overlay')?.classList.contains('is-open');
|
||||
|
||||
// A .lt-modal dialog. `body` is a node; `actions` are { label, value, primary,
|
||||
// danger }. Resolves with the chosen action's value (or `submit()`'s value when
|
||||
// a form is submitted), or null when dismissed (✕, Esc, backdrop).
|
||||
export function openDialog({ title, body, actions = [], danger = false, size = '', onOpen, submit }) {
|
||||
return new Promise((resolve) => {
|
||||
const opener = document.activeElement;
|
||||
let done = false;
|
||||
const close = (value) => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
openDialogs -= 1;
|
||||
overlay.remove();
|
||||
if (opener?.isConnected) opener.focus();
|
||||
resolve(value);
|
||||
};
|
||||
const titleId = `dd-dialog-${Date.now()}`;
|
||||
const form = el('form', { class: 'lt-modal-body dd-dialog-body', novalidate: '' }, body);
|
||||
form.addEventListener('submit', (event) => {
|
||||
event.preventDefault();
|
||||
const value = submit ? submit() : true;
|
||||
if (value !== undefined && value !== null && value !== false) close(value);
|
||||
});
|
||||
const buttons = actions.map((action) => el('button', {
|
||||
type: action.primary ? 'submit' : 'button',
|
||||
class: `lt-btn lt-btn-sm${action.primary ? (action.danger ? ' lt-btn-danger' : ' lt-btn-primary') : ' lt-btn-ghost'}`,
|
||||
onclick: action.primary ? null : () => close(action.value ?? null),
|
||||
}, action.label));
|
||||
// The primary button submits the form, so Enter in a field does the same.
|
||||
for (const button of buttons) {
|
||||
if (button.type === 'submit') button.setAttribute('form', titleId);
|
||||
}
|
||||
form.id = titleId;
|
||||
const overlay = el('div', { class: 'lt-modal-overlay is-open dd-dialog', role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': `${titleId}-title` },
|
||||
el('div', { class: `lt-modal${size ? ` lt-modal-${size}` : ''}` },
|
||||
el('div', { class: `lt-modal-header${danger ? ' lt-modal-header--danger' : ''}` },
|
||||
el('h2', { class: 'lt-modal-title', id: `${titleId}-title` }, title),
|
||||
el('button', { type: 'button', class: 'lt-modal-close', 'aria-label': 'Close', onclick: () => close(null) }, '✕')),
|
||||
form,
|
||||
buttons.length ? el('div', { class: 'lt-modal-footer' }, ...buttons) : null));
|
||||
overlay.addEventListener('mousedown', (event) => {
|
||||
if (event.target === overlay) close(null);
|
||||
});
|
||||
overlay.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
close(null);
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Tab') trapFocus(overlay, event);
|
||||
});
|
||||
openDialogs += 1;
|
||||
document.body.append(overlay);
|
||||
(onOpen?.(overlay) ?? overlay.querySelector('input, select, textarea, .lt-btn-primary, .lt-btn-danger, .lt-modal-close'))?.focus?.();
|
||||
});
|
||||
}
|
||||
|
||||
function trapFocus(root, event) {
|
||||
const items = [...root.querySelectorAll('button, input, select, textarea, a[href], [tabindex]:not([tabindex="-1"])')]
|
||||
.filter((node) => !node.disabled && node.offsetParent !== null);
|
||||
if (!items.length) return;
|
||||
const first = items[0];
|
||||
const last = items[items.length - 1];
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
|
||||
export function confirmDialog({ title, text, confirmLabel = 'OK', danger = false }) {
|
||||
return openDialog({
|
||||
title,
|
||||
danger,
|
||||
size: 'sm',
|
||||
body: el('p', { class: 'dd-dialog-text' }, text),
|
||||
actions: [{ label: 'Cancel', value: false }, { label: confirmLabel, primary: true, danger }],
|
||||
}).then(Boolean);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- command palette
|
||||
|
||||
const RECENT_KEY = 'lt_cmd_recent';
|
||||
|
||||
function readRecent() {
|
||||
try {
|
||||
const list = JSON.parse(localStorage.getItem(RECENT_KEY) || '[]');
|
||||
return Array.isArray(list) ? list : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function pushRecent(id) {
|
||||
try {
|
||||
localStorage.setItem(RECENT_KEY, JSON.stringify([id, ...readRecent().filter((x) => x !== id)].slice(0, 5)));
|
||||
} catch {
|
||||
// Private mode: no recents.
|
||||
}
|
||||
}
|
||||
|
||||
// Commands: { id, label, group, icon?, hint?, kbd?, keywords?, run }. `getCommands`
|
||||
// is called on every open, so the list reflects the current sessions and state.
|
||||
export class CommandPalette {
|
||||
constructor(getCommands) {
|
||||
this.getCommands = getCommands;
|
||||
this.commands = [];
|
||||
this.visible = [];
|
||||
this.selected = 0;
|
||||
this.opener = null;
|
||||
this.input = el('input', {
|
||||
class: 'lt-cmd-input', type: 'text', placeholder: 'Type a command or a session name…',
|
||||
autocomplete: 'off', spellcheck: 'false', 'aria-label': 'Command', role: 'combobox',
|
||||
'aria-controls': 'dd-cmd-results', 'aria-expanded': 'true',
|
||||
});
|
||||
this.results = el('div', { class: 'lt-cmd-results', id: 'dd-cmd-results', role: 'listbox' });
|
||||
this.overlay = el('div', { class: 'lt-cmd-overlay', id: 'lt-cmd-overlay', role: 'dialog', 'aria-modal': 'true', 'aria-label': 'Command palette' },
|
||||
el('div', { class: 'lt-cmd-palette', id: 'lt-cmd-palette' },
|
||||
el('div', { class: 'lt-cmd-input-wrap' }, el('span', { class: 'lt-cmd-prompt', 'aria-hidden': 'true' }, '>'), this.input),
|
||||
this.results,
|
||||
el('div', { class: 'lt-cmd-footer' },
|
||||
el('span', {}, el('kbd', {}, '↑'), el('kbd', {}, '↓'), ' move'),
|
||||
el('span', {}, el('kbd', {}, '⏎'), ' run'),
|
||||
el('span', {}, el('kbd', {}, 'Esc'), ' close'))));
|
||||
document.body.append(this.overlay);
|
||||
this.input.addEventListener('input', () => this.render());
|
||||
this.input.addEventListener('keydown', (event) => this.onKey(event));
|
||||
this.overlay.addEventListener('mousedown', (event) => {
|
||||
if (event.target === this.overlay) this.close();
|
||||
});
|
||||
}
|
||||
|
||||
get isOpen() {
|
||||
return this.overlay.classList.contains('is-open');
|
||||
}
|
||||
|
||||
open(query = '') {
|
||||
this.opener = document.activeElement;
|
||||
this.commands = this.getCommands();
|
||||
this.overlay.classList.add('is-open');
|
||||
this.input.value = query;
|
||||
this.render();
|
||||
this.input.focus();
|
||||
}
|
||||
|
||||
close({ restoreFocus = true } = {}) {
|
||||
if (!this.isOpen) return;
|
||||
this.overlay.classList.remove('is-open');
|
||||
if (restoreFocus && this.opener?.isConnected) this.opener.focus();
|
||||
}
|
||||
|
||||
onKey(event) {
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
this.move(event.key === 'ArrowDown' ? 1 : -1);
|
||||
} else if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
this.run(this.visible[this.selected]);
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.close();
|
||||
} else if (event.key === 'Tab') {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
move(step) {
|
||||
if (!this.visible.length) return;
|
||||
this.selected = (this.selected + step + this.visible.length) % this.visible.length;
|
||||
this.highlight();
|
||||
}
|
||||
|
||||
highlight() {
|
||||
const items = this.results.querySelectorAll('.lt-cmd-item');
|
||||
items.forEach((item, i) => {
|
||||
item.classList.toggle('is-selected', i === this.selected);
|
||||
item.setAttribute('aria-selected', String(i === this.selected));
|
||||
});
|
||||
const current = items[this.selected];
|
||||
if (current) {
|
||||
current.scrollIntoView({ block: 'nearest' });
|
||||
this.input.setAttribute('aria-activedescendant', current.id);
|
||||
}
|
||||
}
|
||||
|
||||
run(command) {
|
||||
if (!command) return;
|
||||
pushRecent(command.id);
|
||||
// The command decides where focus goes (usually the terminal).
|
||||
this.close({ restoreFocus: false });
|
||||
command.run();
|
||||
}
|
||||
|
||||
render() {
|
||||
const words = this.input.value.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
||||
const matches = this.commands.filter((c) => {
|
||||
const text = `${c.label} ${c.group} ${c.keywords ?? ''} ${c.hint ?? ''}`.toLowerCase();
|
||||
return words.every((w) => text.includes(w));
|
||||
});
|
||||
const groups = new Map();
|
||||
if (!words.length) {
|
||||
const recent = readRecent().map((id) => matches.find((c) => c.id === id)).filter(Boolean).slice(0, 3);
|
||||
if (recent.length) groups.set('Recent', recent);
|
||||
}
|
||||
for (const command of matches) {
|
||||
if (!groups.has(command.group)) groups.set(command.group, []);
|
||||
groups.get(command.group).push(command);
|
||||
}
|
||||
this.visible = [...groups.values()].flat();
|
||||
this.selected = 0;
|
||||
if (!this.visible.length) {
|
||||
this.results.replaceChildren(el('div', { class: 'lt-cmd-empty' }, `No command matches “${this.input.value.trim()}”`));
|
||||
return;
|
||||
}
|
||||
let index = 0;
|
||||
const nodes = [];
|
||||
for (const [group, commands] of groups) {
|
||||
nodes.push(el('div', { class: 'lt-cmd-group-label', role: 'presentation' }, group));
|
||||
for (const command of commands) {
|
||||
const i = index;
|
||||
nodes.push(el('div', {
|
||||
class: 'lt-cmd-item', id: `dd-cmd-${i}`, role: 'option',
|
||||
onclick: () => this.run(command),
|
||||
onmousemove: () => {
|
||||
if (this.selected !== i) {
|
||||
this.selected = i;
|
||||
this.highlight();
|
||||
}
|
||||
},
|
||||
},
|
||||
el('span', { class: 'lt-cmd-item-icon', 'aria-hidden': 'true' }, command.icon || '◦'),
|
||||
el('span', { class: 'lt-cmd-item-label' }, command.label),
|
||||
command.hint ? el('span', { class: 'dd-cmd-hint' }, command.hint) : null,
|
||||
command.kbd ? el('span', { class: 'lt-cmd-item-kbd' }, command.kbd) : null));
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
this.results.replaceChildren(...nodes);
|
||||
this.highlight();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user