Feedback sweep: spinners on every async button, toasts for action outcomes, top activity bar, loading states on every page, terminal connecting overlay, dashboard save indicator, task pane pinned above the package list with auto-scroll and completion toast

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-17 18:04:45 -07:00
co-authored by Claude Fable 5.1
parent d08b246b5f
commit 724f30c02b
13 changed files with 261 additions and 93 deletions
+70
View File
@@ -0,0 +1,70 @@
import { ReactNode, useEffect, useState } from 'react'
// ---- spinners ----
export function Spinner({ size = 14 }: { size?: number }) {
return <span className="spin" style={{ width: size, height: size }} aria-hidden="true" />
}
export function Loading({ label = 'Loading…' }: { label?: string }) {
return <div className="center muted loading"><Spinner size={22} /><div>{label}</div></div>
}
// Button that shows a spinner while its async handler runs; `confirm` asks first.
export function Btn({ onClick, busy, confirm, className = 'btn', disabled, title, children }: {
onClick: () => unknown | Promise<unknown>; busy?: boolean; confirm?: string; className?: string; disabled?: boolean; title?: string; children: ReactNode
}) {
const [inner, setInner] = useState(false)
const b = !!busy || inner
return (
<button className={className + (b ? ' busy' : '')} disabled={disabled || b} title={title}
onClick={async () => {
if (confirm && !window.confirm(confirm)) return
const r = onClick()
if (r && typeof (r as Promise<unknown>).then === 'function') {
setInner(true)
try { await r } finally { setInner(false) }
}
}}>
{b && <Spinner />}{children}
</button>
)
}
// ---- toasts: short feedback for actions ----
interface Toast { id: number; kind: 'ok' | 'err' | 'info'; msg: string }
const listeners = new Set<(t: Toast[]) => void>()
let toasts: Toast[] = []
let seq = 0
function emit() { listeners.forEach((l) => l(toasts)) }
export function dismissToast(id: number) { toasts = toasts.filter((t) => t.id !== id); emit() }
export function toast(msg: string, kind: Toast['kind'] = 'ok') {
const t = { id: ++seq, kind, msg }
toasts = [...toasts.slice(-4), t]
emit()
window.setTimeout(() => dismissToast(t.id), kind === 'err' ? 8000 : 3500)
}
export function Toasts() {
const [list, setList] = useState(toasts)
useEffect(() => { listeners.add(setList); return () => { listeners.delete(setList) } }, [])
return (
<div className="toasts">
{list.map((t) => <div key={t.id} className={'toast ' + t.kind} onClick={() => dismissToast(t.id)}>{t.msg}</div>)}
</div>
)
}
// act runs an action and toasts its outcome. Returns whether it succeeded.
export async function act(label: string, fn: () => Promise<unknown>): Promise<boolean> {
try { await fn(); toast(label); return true }
catch (e) { toast(`${label} — ${e instanceof Error ? e.message : 'failed'}`, 'err'); return false }
}
// ---- thin activity bar at the top while any request is in flight ----
export function NetBar() {
const [n, setN] = useState(0)
useEffect(() => {
const h = (e: Event) => setN((e as CustomEvent<number>).detail)
window.addEventListener('hamlab:inflight', h)
return () => window.removeEventListener('hamlab:inflight', h)
}, [])
return <div className={'netbar' + (n > 0 ? ' on' : '')} />
}