diff --git a/frontend/src/components/confirm-dialog/confirm-dialog.ts b/frontend/src/components/confirm-dialog/confirm-dialog.ts new file mode 100644 index 0000000..4689cf8 --- /dev/null +++ b/frontend/src/components/confirm-dialog/confirm-dialog.ts @@ -0,0 +1,160 @@ +/** + * "Are you sure?", once. + * + * Three destructive actions had no confirmation at all: deleting + * playlists (including a multi-select loop that deleted N of them), + * removing a durable download request, and removing a download client + * with its stored credentials (errors.M6, M7, m4). + * + * The shape is the one the codebase already uses twice — say what will + * happen *before* asking (`config-page`'s removal impact), then ask — + * so this is that shape written once rather than a third pattern. It is + * a `wa-dialog`, which brings the focus trap, Escape and focus restore + * that the hand-rolled overlays do not have. + */ +import { LitElement, css, html, nothing } from 'lit'; +import { customElement, query, state } from 'lit/decorators.js'; +import '@awesome.me/webawesome/dist/components/dialog/dialog.js'; + +import { designTokens } from '../../styles/tokens.css'; + +export interface ConfirmRequest { + title: string; + /** What is about to happen, in the user's terms. */ + message: string; + /** The consequence, when it is worth spelling out separately. */ + impact?: string; + confirmLabel?: string; + cancelLabel?: string; + /** Styles the confirm button as destructive. */ + danger?: boolean; +} + +@customElement('confirm-dialog') +export class ConfirmDialog extends LitElement { + @query('wa-dialog') private dialog?: HTMLElement & { open: boolean }; + + @state() private request: ConfirmRequest | null = null; + + private settle: ((ok: boolean) => void) | null = null; + + static override styles = [ + designTokens, + css` + :host { + display: contents; + } + + wa-dialog::part(dialog) { + background: var(--yj-bg-surface, #212529); + color: var(--yj-text-primary, #fff); + } + + .impact { + color: var(--yj-text-secondary, #b3b3b3); + font-size: var(--yj-text-sm, 0.8125rem); + margin-top: 8px; + } + + .actions { + display: flex; + gap: 8px; + justify-content: flex-end; + } + + button { + background: var(--yj-bg-elevated, #343a40); + border: 1px solid var(--yj-border, #495057); + border-radius: 4px; + color: inherit; + cursor: pointer; + font: inherit; + padding: 6px 14px; + } + + button.danger { + background: var(--yj-danger, #e03131); + border-color: var(--yj-danger, #e03131); + color: #fff; + } + `, + ]; + + /** Ask. Resolves true if the user went ahead. */ + ask(request: ConfirmRequest): Promise { + this.close(false); + this.request = request; + + return new Promise((resolve) => { + this.settle = resolve; + void this.updateComplete.then(() => { + if (this.dialog) this.dialog.open = true; + }); + }); + } + + private close(ok: boolean): void { + const settle = this.settle; + + this.settle = null; + + if (this.dialog) this.dialog.open = false; + this.request = null; + settle?.(ok); + } + + override render() { + const request = this.request; + + if (!request) return nothing; + + return html` + this.close(false)} + > +

${request.message}

+ ${request.impact + ? html`

${request.impact}

` + : nothing} +
+ + +
+
+ `; + } +} + +/** The one instance, created on first use and reused after. */ +let host: ConfirmDialog | null = null; + +/** + * Ask the user to confirm something destructive. + * + * Call sites do not mount anything: the dialog attaches itself to the + * document the first time it is needed, which keeps a confirmation from + * being skipped because the host forgot to render it. + */ +export function confirmAction(request: ConfirmRequest): Promise { + if (!host) { + host = document.createElement('confirm-dialog') as ConfirmDialog; + document.body.append(host); + } + + return host.ask(request); +} diff --git a/frontend/src/components/notifications/inline-notice.ts b/frontend/src/components/notifications/inline-notice.ts new file mode 100644 index 0000000..4c194e9 --- /dev/null +++ b/frontend/src/components/notifications/inline-notice.ts @@ -0,0 +1,94 @@ +/** + * The fourth level: a failure rendered in the region that failed. + * + * A track that will not play, a search that did not answer, an index + * whose status could not be read — these belong to one panel, and a + * toast for them would be noise. The region names itself + * (``) and anything that raises + * `notificationStore.inline('player', …)` lands here. + * + * `floating` is for a host with no room in its own layout: the bottom + * bar is a fixed 4em grid row, so a message laid out inside it squeezes + * the transport out of its own footer. + */ +import { LitElement, css, html, nothing } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; + +import { notificationStore } from '@store/notification-store'; +import { designTokens } from '../../styles/tokens.css'; + +import { noticeStyles, renderNotice } from './notice'; + +@customElement('inline-notice') +export class InlineNotice extends LitElement { + /** Which region's messages to render. */ + @property({ type: String }) region = ''; + + /** Render above the host instead of in its flow. */ + @property({ type: Boolean }) floating = false; + + /** Overrides `data-testid` on the notice, for hosts that already + * have a named message element in their specs. */ + @property({ type: String, attribute: 'testid' }) testid = ''; + + @state() private version = 0; + + private unsubscribe?: () => void; + + static override styles = [ + designTokens, + noticeStyles, + css` + :host { + display: block; + } + + /* Left-anchored and no wider than half the window: the + app-level stack sits in the same band on the right, and + a full-width strip overlapped it. */ + :host([floating]) { + position: absolute; + bottom: calc(100% + 4px); + left: 16px; + right: auto; + max-width: min(36em, 48vw); + z-index: 20; + } + + :host(:not([floating])) .notice { + margin: 8px 0; + } + `, + ]; + + override connectedCallback(): void { + super.connectedCallback(); + this.unsubscribe = notificationStore.subscribe(() => { + this.version += 1; + }); + } + + override disconnectedCallback(): void { + super.disconnectedCallback(); + this.unsubscribe?.(); + } + + private handlers = { + dismiss: (id: number) => notificationStore.dismiss(id), + act: (id: number) => notificationStore.runAction(id), + }; + + override render() { + const items = notificationStore.forRegion(this.region); + + if (items.length === 0) return nothing; + + return html` +
+ ${items.map((n) => + renderNotice(n, this.handlers, this.testid || undefined), + )} +
+ `; + } +} diff --git a/frontend/src/components/notifications/notice.ts b/frontend/src/components/notifications/notice.ts new file mode 100644 index 0000000..e95e368 --- /dev/null +++ b/frontend/src/components/notifications/notice.ts @@ -0,0 +1,148 @@ +/** + * The one presentation, shared by the four levels. + * + * A notice looks the same wherever it appears — icon, sentence, an + * optional action, a dismiss — and the level only decides *where* it is + * rendered and how long it stays. Keeping the markup in one place is + * what stops the next level from inventing a fifth look. + */ +import { css, html, nothing, type TemplateResult } from 'lit'; +import '@awesome.me/webawesome/dist/components/icon/icon.js'; + +import type { Notification, NotificationTone } from '@store/notification-store'; + +const TONE_ICONS: Record = { + error: 'circle-exclamation', + warning: 'triangle-exclamation', + info: 'circle-info', + success: 'circle-check', +}; + +export const noticeStyles = css` + .notice { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 8px 10px; + border-radius: 4px; + border: 1px solid var(--yj-border, #495057); + border-left: 3px solid var(--yj-warning, #e0a800); + background: var(--yj-bg-elevated, #343a40); + color: var(--yj-text-primary, #fff); + font-size: var(--yj-text-sm, 0.8125rem); + box-shadow: 0 2px 8px rgb(0 0 0 / 40%); + text-align: left; + } + + .notice[data-tone='error'] { + border-left-color: var(--yj-danger, #e03131); + } + + .notice[data-tone='info'] { + border-left-color: var(--yj-accent, #ffd43b); + } + + .notice[data-tone='success'] { + border-left-color: var(--yj-success, #37b24d); + } + + .notice > wa-icon { + flex-shrink: 0; + margin-top: 1px; + color: var(--yj-warning, #e0a800); + } + + .notice[data-tone='error'] > wa-icon { + color: var(--yj-danger, #e03131); + } + + .notice-body { + flex: 1; + min-width: 0; + } + + .notice-title { + font-weight: 600; + margin-bottom: 2px; + } + + .notice-action { + background: none; + border: 1px solid var(--yj-border, #495057); + border-radius: 3px; + color: inherit; + cursor: pointer; + font: inherit; + margin-top: 6px; + padding: 2px 8px; + } + + .notice-action:hover { + background: var(--yj-bg-surface, #212529); + } + + .notice-dismiss { + background: none; + border: none; + color: inherit; + cursor: pointer; + font: inherit; + line-height: 1; + padding: 0 4px; + } +`; + +export interface NoticeHandlers { + dismiss: (id: number) => void; + act: (id: number) => void; +} + +/** + * One notice, in the shape every level shares. + * + * `testid` exists for hosts whose specs already name their message + * element — the player bar's, which predates this surface. + */ +export function renderNotice( + notification: Notification, + handlers: NoticeHandlers, + testid = 'notification', +): TemplateResult { + return html` +
+ +
+ ${notification.title + ? html`
${notification.title}
` + : nothing} +
${notification.text}
+ ${notification.action + ? html` + + ` + : nothing} +
+ +
+ `; +} diff --git a/frontend/src/components/notifications/notification-host.ts b/frontend/src/components/notifications/notification-host.ts new file mode 100644 index 0000000..3f02a44 --- /dev/null +++ b/frontend/src/components/notifications/notification-host.ts @@ -0,0 +1,176 @@ +/** + * Where the app speaks: the three levels that are not tied to a panel. + * + * Mounted once, in `index.html`, next to the first-run wizard — it has + * to outlive every view, since the failure it reports is usually the + * reason the user is about to navigate somewhere else. + * + * `inline` is the fourth level and is not here: it belongs to the + * region that failed (``). + */ +import { LitElement, css, html, nothing } from 'lit'; +import { customElement, state } from 'lit/decorators.js'; +import '@awesome.me/webawesome/dist/components/dialog/dialog.js'; + +import { notificationStore } from '@store/notification-store'; +import { designTokens } from '../../styles/tokens.css'; + +import { noticeStyles, renderNotice } from './notice'; + +@customElement('notification-host') +export class NotificationHost extends LitElement { + @state() private version = 0; + + private unsubscribe?: () => void; + + static override styles = [ + designTokens, + noticeStyles, + css` + :host { + display: contents; + } + + /* Below the header, not above the player bar: the bottom + band belongs to the player, whose own inline notice + floats there and grows upward by however many lines it + needs — at 800×600 a two-line one reached straight into + a bottom-anchored stack. */ + .stack { + position: fixed; + right: 16px; + top: calc(4em + 12px); + z-index: 60; + display: flex; + flex-direction: column; + gap: 8px; + width: min(30em, calc(100vw - 32px)); + pointer-events: none; + } + + .stack .notice { + pointer-events: auto; + } + + wa-dialog::part(dialog) { + background: var(--yj-bg-surface, #212529); + color: var(--yj-text-primary, #fff); + } + + .blocking-actions { + display: flex; + gap: 8px; + justify-content: flex-end; + margin-top: 16px; + } + + .blocking-actions button { + background: var(--yj-bg-elevated, #343a40); + border: 1px solid var(--yj-border, #495057); + border-radius: 4px; + color: inherit; + cursor: pointer; + font: inherit; + padding: 6px 14px; + } + + .blocking-actions .primary { + background: var(--yj-accent, #ffd43b); + border-color: var(--yj-accent, #ffd43b); + color: #000; + } + + .blocking-detail { + color: var(--yj-text-tertiary, #868e96); + font-size: var(--yj-text-sm, 0.8125rem); + margin-top: 12px; + overflow-wrap: anywhere; + user-select: text; + } + `, + ]; + + override connectedCallback(): void { + super.connectedCallback(); + // Not a cached view: this element is mounted once and never + // navigated away from, so connection really is its lifetime. + this.unsubscribe = notificationStore.subscribe(() => { + this.version += 1; + }); + } + + override disconnectedCallback(): void { + super.disconnectedCallback(); + this.unsubscribe?.(); + } + + private handlers = { + dismiss: (id: number) => notificationStore.dismiss(id), + act: (id: number) => notificationStore.runAction(id), + }; + + /** Blocking is one modal at a time, and it must be acknowledged. */ + private renderBlocking() { + const notification = notificationStore.currentBlocking(); + + if (!notification) return nothing; + + return html` + notificationStore.dismiss(notification.id)} + > +

${notification.text}

+ ${notification.detail + ? html`

${notification.detail}

` + : nothing} +
+ ${notification.action + ? html` + + ` + : nothing} + +
+
+ `; + } + + override render() { + const stacked = [ + ...notificationStore.byLevel('persistent'), + ...notificationStore.byLevel('transient'), + ]; + + return html` + ${this.renderBlocking()} + ${stacked.length === 0 + ? nothing + : html` +
+ ${stacked.map((n) => renderNotice(n, this.handlers))} +
+ `} + `; + } +} diff --git a/frontend/src/store/notification-store.ts b/frontend/src/store/notification-store.ts new file mode 100644 index 0000000..24e3f97 --- /dev/null +++ b/frontend/src/store/notification-store.ts @@ -0,0 +1,314 @@ +/** + * The app's one notification surface. + * + * Before this, 84 `catch` blocks ended at `console.error` and two + * components had grown private, mutually-unaware toasts. The audit's + * ~30 "the failure is invisible" findings are one problem wearing + * thirty hats: there was nowhere to put a message. + * + * Four levels, and the **caller** picks, using one rule: *a failure is + * only worth interrupting for if the user can do something about it + * that they are not already doing.* + * + * | Level | Behaviour | For | + * |--------------|------------------------------------|-----| + * | `blocking` | modal, must be acknowledged | data at risk; must be known before continuing | + * | `persistent` | stays until dismissed, with an action | something asked for did not happen and retrying is meaningful | + * | `transient` | toast, auto-dismisses | small action failed and the state visibly reverted anyway | + * | `inline` | rendered in the region that failed | the failure belongs to one panel; a global message would be noise | + * + * Coalescing lives here, not in the call sites: a queue of 200 + * unplayable files is one message with a count, and that holds for + * every future caller without anyone remembering it. + */ + +export type NotificationLevel = + | 'blocking' + | 'persistent' + | 'transient' + | 'inline'; + +export type NotificationTone = 'error' | 'warning' | 'info' | 'success'; + +export interface NotificationAction { + label: string; + run: () => void | Promise; +} + +export interface NotifyInput { + level: NotificationLevel; + /** The sentence. Written for a person; see `utils/describe-error`. */ + text: string; + /** + * Coalescing identity within a level (and, for `inline`, a region). + * Defaults to the text, which is right for anything that does not + * name a specific file or item. + */ + key?: string; + /** Which region renders this. `inline` only; ignored otherwise. */ + region?: string; + /** A heading, for the levels that have room for one. */ + title?: string; + tone?: NotificationTone; + /** Offered to the user; dismisses the notification when it runs. */ + action?: NotificationAction; + /** Raw error text. Never rendered as the sentence; available for a + * details disclosure and always worth keeping. */ + detail?: string; + /** The sentence to use once this has happened more than once. */ + coalescedText?: (count: number) => string; +} + +export interface Notification extends NotifyInput { + id: number; + key: string; + tone: NotificationTone; + /** How many occurrences this message stands for. */ + count: number; + createdAt: number; +} + +type Subscriber = () => void; + +/** A toast is gone before the user can read it twice. */ +const TransientTimeoutMillis = 6000; + +/** Inline messages are about the panel the user is looking at. */ +const InlineTimeoutMillis = 8000; + +/** Occurrences within this window are one message with a count. */ +const CoalesceWindowMillis = 10_000; + +/** Beyond this the stack is noise; the oldest dismissible one goes. */ +const MaxVisible = 5; + +function coalesceKey(input: NotifyInput, key: string): string { + return `${input.level}\u0000${input.region ?? ''}\u0000${key}`; +} + +class NotificationStore { + private items: Notification[] = []; + private subscribers = new Set(); + private notifyScheduled = false; + private timers = new Map(); + private lastSeen = new Map(); + private seq = 0; + + // =================================================================== + // RAISING + // =================================================================== + + /** Raise a notification, or fold it into the one it repeats. */ + notify(input: NotifyInput): number { + const now = Date.now(); + const key = input.key ?? input.text; + const ck = coalesceKey(input, key); + const previous = this.lastSeen.get(ck); + const existing = + previous && now - previous.at < CoalesceWindowMillis + ? this.items.find((n) => n.id === previous.id) + : undefined; + + if (existing) { + const count = existing.count + 1; + + this.replace({ + ...existing, + ...input, + key, + tone: input.tone ?? existing.tone, + count, + text: input.coalescedText?.(count) ?? input.text, + }); + this.lastSeen.set(ck, { id: existing.id, at: now }); + this.arm(existing.id, input.level); + + return existing.id; + } + + this.seq += 1; + + const notification: Notification = { + ...input, + id: this.seq, + key, + tone: input.tone ?? 'error', + count: 1, + createdAt: now, + }; + + this.items = [...this.items, notification]; + this.lastSeen.set(ck, { id: notification.id, at: now }); + this.trim(); + this.arm(notification.id, input.level); + this.emit(); + + return notification.id; + } + + /** Modal. Rare by construction — argue for a third caller. */ + blocking(input: Omit): number { + return this.notify({ ...input, level: 'blocking' }); + } + + /** Stays until dismissed. Give it an action worth taking. */ + persistent(input: Omit): number { + return this.notify({ ...input, level: 'persistent' }); + } + + /** A toast. The state has already reverted; this only says so. */ + transient(input: Omit): number { + return this.notify({ ...input, level: 'transient' }); + } + + /** Rendered by ``, never as a toast. */ + inline(region: string, input: Omit): number { + return this.notify({ ...input, level: 'inline', region }); + } + + // =================================================================== + // DISMISSING + // =================================================================== + + dismiss(id: number): void { + const before = this.items.length; + + this.items = this.items.filter((n) => n.id !== id); + this.clearTimer(id); + + if (this.items.length !== before) this.emit(); + } + + /** Everything one region is showing, e.g. on navigating away. */ + dismissRegion(region: string): void { + const remaining = this.items.filter((n) => n.region !== region); + + if (remaining.length === this.items.length) return; + + for (const n of this.items) { + if (n.region === region) this.clearTimer(n.id); + } + + this.items = remaining; + this.emit(); + } + + /** Run a notification's action and dismiss it. */ + runAction(id: number): void { + const item = this.items.find((n) => n.id === id); + + if (!item?.action) return; + + this.dismiss(id); + void item.action.run(); + } + + clear(): void { + for (const id of [...this.timers.keys()]) this.clearTimer(id); + this.lastSeen.clear(); + + if (this.items.length === 0) return; + + this.items = []; + this.emit(); + } + + // =================================================================== + // READING + // =================================================================== + + getAll(): readonly Notification[] { + return this.items; + } + + byLevel(level: NotificationLevel): Notification[] { + return this.items.filter((n) => n.level === level); + } + + /** The one modal to show, if any. Blocking is one at a time. */ + currentBlocking(): Notification | null { + return this.items.find((n) => n.level === 'blocking') ?? null; + } + + forRegion(region: string): Notification[] { + return this.items.filter( + (n) => n.level === 'inline' && n.region === region, + ); + } + + // =================================================================== + // SUBSCRIPTION + // =================================================================== + + subscribe(callback: Subscriber): () => void { + this.subscribers.add(callback); + + return () => this.subscribers.delete(callback); + } + + // =================================================================== + // INTERNALS + // =================================================================== + + private replace(next: Notification): void { + this.items = this.items.map((n) => (n.id === next.id ? next : n)); + this.emit(); + } + + /** Start (or restart) the self-dismissal timer for a level that has + * one. Blocking and persistent wait for the user. */ + private arm(id: number, level: NotificationLevel): void { + this.clearTimer(id); + + const timeout = + level === 'transient' + ? TransientTimeoutMillis + : level === 'inline' + ? InlineTimeoutMillis + : 0; + + if (timeout === 0) return; + + this.timers.set( + id, + window.setTimeout(() => { + this.timers.delete(id); + this.dismiss(id); + }, timeout), + ); + } + + private clearTimer(id: number): void { + const timer = this.timers.get(id); + + if (timer !== undefined) { + clearTimeout(timer); + this.timers.delete(id); + } + } + + /** Keep the stack readable: drop the oldest thing the user has not + * been asked to acknowledge. */ + private trim(): void { + while (this.items.length > MaxVisible) { + const victim = this.items.find((n) => n.level !== 'blocking'); + + if (!victim) return; + + this.clearTimer(victim.id); + this.items = this.items.filter((n) => n.id !== victim.id); + } + } + + private emit(): void { + if (this.notifyScheduled) return; + this.notifyScheduled = true; + queueMicrotask(() => { + this.notifyScheduled = false; + for (const sub of this.subscribers) sub(); + }); + } +} + +/** Singleton: one surface, or it is not a surface. */ +export const notificationStore = new NotificationStore(); diff --git a/frontend/src/utils/describe-error.ts b/frontend/src/utils/describe-error.ts new file mode 100644 index 0000000..efea60e --- /dev/null +++ b/frontend/src/utils/describe-error.ts @@ -0,0 +1,126 @@ +/** + * One map from a Go error to a sentence a person can act on. + * + * Eight places in this app used to render `err.Error()` verbatim, so a + * user was shown `Get "https://musicbrainz.org/ws/2/…": context + * deadline exceeded` (errors.M9). Those strings come out of + * `net/http`, `database/sql` and `musicbrainzws2`; they are debugging + * tools, not copy. + * + * The map is deliberately short. It recognises the causes a user can do + * something about and says something generic about everything else, + * because a confidently wrong diagnosis is worse than an honest shrug. + * The raw text belongs in `console.error`, and every call site keeps it + * there. + */ + +/** The fallback when nothing matches: honest, and never a Go string. */ +const GENERIC = 'Something went wrong.'; + +/** + * Ordered, and the order matters: a client timeout says "canceled" and + * a DNS failure says "not found", so the more specific cause has to be + * tested first. + */ +const RULES: Array<[RegExp, string]> = [ + [ + /context deadline exceeded|client\.timeout|i\/o timeout|timed? ?out/i, + 'The request took too long — the server did not answer in time.', + ], + [ + /context canceled|context cancelled|operation was cancell?ed/i, + 'That was cancelled before it finished.', + ], + [ + /no such host|network is unreachable|connection refused|connection reset|dial tcp|no route to host|eai_again/i, + 'Could not reach the network. Check your connection and try again.', + ], + [ + /permission denied|access is denied|operation not permitted|eacces/i, + 'Permission denied — the app is not allowed to read or write there.', + ], + [ + /no such file or directory|cannot find the (file|path)|\b404\b|not found/i, + 'That could not be found — it may have been moved or deleted.', + ], + [ + /database is locked|database table is locked|sqlite_busy|resource busy|device or resource busy/i, + 'The library database is busy. Try again in a moment.', + ], + [ + /no space left on device|disk (is )?full|not enough space/i, + 'There is no space left on the disk.', + ], + [ + /read-only file system|read only file system/i, + 'That location is read-only.', + ], +]; + +/** The text a rejected binding actually carries, whatever its shape. */ +function messageOf(err: unknown): string { + if (typeof err === 'string') return err; + if (err instanceof Error) return err.message; + + if (typeof err === 'object' && err !== null && 'message' in err) { + const { message } = err as { message: unknown }; + + if (typeof message === 'string') return message; + } + + return ''; +} + +/** + * Describe a failure in a sentence. + * + * @param err whatever the rejection carried. + * @param fallback what to say when the cause is not one this knows; + * pass something specific to the operation where the + * caller knows more than this map does. + */ +export function describeError(err: unknown, fallback = GENERIC): string { + const raw = messageOf(err); + + if (raw === '') return fallback; + + for (const [pattern, sentence] of RULES) { + if (pattern.test(raw)) return sentence; + } + + return fallback; +} + +/** + * Some backend errors *are* sentences already — the ones this app + * writes itself, as sentinels, for conditions it defined ("a library + * with that name already exists"). Those are worth showing; a Go + * wrapping chain (`could not rename library: sql: …`) is not. + * + * A message qualifies when it is short and carries none of the markers + * that mean it came from the runtime rather than from us. + */ +const GO_NOISE = + /^(get|post|put|delete|head|patch) "|https?:\/\/|\bsql:|\bdial\b|\bexec\b|\bsyscall\b|goroutine |panic:|0x[0-9a-f]{6}|\.go:\d+|context (deadline|canceled|cancelled)|no such file or directory|\bEOF\b/i; + +export function isPlainSentence(err: unknown): boolean { + const raw = messageOf(err).trim(); + + if (raw === '' || raw.length > 140) return false; + + return !GO_NOISE.test(raw); +} + +/** + * The sentence to show for a failure: the backend's own words when it + * had any worth repeating, otherwise the mapped cause. + */ +export function explainError(err: unknown, fallback = GENERIC): string { + if (isPlainSentence(err)) { + const raw = messageOf(err).trim(); + + return raw.endsWith('.') ? raw : `${raw}.`; + } + + return describeError(err, fallback); +}