feat(frontend): give failure one voice

There was no app-level notification surface: two components had grown
private toasts and the other 84 catch blocks ended at console.error,
so a user with a moved file, a locked database or an offline network
saw a button that did nothing. Where errors did surface, eight sites
printed the raw Go string.

Four levels, chosen by the call site from one rule — a failure is only
worth interrupting for if the user can do something about it that they
are not already doing: Blocking (data at risk), Persistent (something
asked for that did not happen, worth retrying), Transient (a small
action whose state visibly reverted anyway), Inline (rendered in the
panel that failed).

Three things about it are load-bearing. Coalescing lives in the store,
keyed by (level, region, key) within a window, so 200 unplayable files
are one message with a count and no future caller has to remember that.
An inline notification carries a *region*, because "inline" says not
global, not where. And the bottom band belongs to the player, so the
app-level stack sits under the header — the player's own floating
notice grows upward by however many lines it needs.

`utils/describe-error.ts` maps the causes a user can act on to copy;
`explainError` repeats a backend message when it is one of our own
sentinels rather than a Go wrapping chain, since mapping "a library
with that name already exists" to something generic is a regression.
`confirmAction()` is a wa-dialog, so destructive actions inherit the
focus trap and Escape the hand-rolled overlays do not have.
This commit is contained in:
2026-08-12 01:18:34 -04:00
parent 7acb197daf
commit fbf1eff8f6
6 changed files with 1018 additions and 0 deletions
@@ -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<boolean> {
this.close(false);
this.request = request;
return new Promise<boolean>((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`
<wa-dialog
label=${request.title}
data-testid="confirm-dialog"
@wa-hide=${() => this.close(false)}
>
<p>${request.message}</p>
${request.impact
? html`<p class="impact">${request.impact}</p>`
: nothing}
<div class="actions" slot="footer">
<button
type="button"
data-testid="confirm-cancel"
@click=${() => this.close(false)}
>
${request.cancelLabel ?? 'Cancel'}
</button>
<button
type="button"
class=${request.danger ? 'danger' : ''}
data-testid="confirm-accept"
@click=${() => this.close(true)}
>
${request.confirmLabel ?? 'Continue'}
</button>
</div>
</wa-dialog>
`;
}
}
/** 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<boolean> {
if (!host) {
host = document.createElement('confirm-dialog') as ConfirmDialog;
document.body.append(host);
}
return host.ask(request);
}
@@ -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
* (`<inline-notice region="player">`) 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`
<div role="status" aria-live="polite">
${items.map((n) =>
renderNotice(n, this.handlers, this.testid || undefined),
)}
</div>
`;
}
}
@@ -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<NotificationTone, string> = {
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`
<div
class="notice"
data-tone=${notification.tone}
data-testid=${testid}
data-level=${notification.level}
role=${notification.tone === 'error' ? 'alert' : 'status'}
>
<wa-icon name=${TONE_ICONS[notification.tone]}></wa-icon>
<div class="notice-body">
${notification.title
? html`<div class="notice-title">${notification.title}</div>`
: nothing}
<div class="notice-text">${notification.text}</div>
${notification.action
? html`
<button
type="button"
class="notice-action"
data-testid="notification-action"
@click=${() => handlers.act(notification.id)}
>
${notification.action.label}
</button>
`
: nothing}
</div>
<button
type="button"
class="notice-dismiss"
aria-label="Dismiss message"
@click=${() => handlers.dismiss(notification.id)}
>
×
</button>
</div>
`;
}
@@ -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 (`<inline-notice region="…">`).
*/
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`
<wa-dialog
open
label=${notification.title ?? 'Something needs your attention'}
data-testid="notification-blocking"
@wa-hide=${() => notificationStore.dismiss(notification.id)}
>
<p>${notification.text}</p>
${notification.detail
? html`<p class="blocking-detail">${notification.detail}</p>`
: nothing}
<div class="blocking-actions" slot="footer">
${notification.action
? html`
<button
type="button"
data-testid="notification-action"
@click=${() =>
notificationStore.runAction(notification.id)}
>
${notification.action.label}
</button>
`
: nothing}
<button
type="button"
class="primary"
@click=${() => notificationStore.dismiss(notification.id)}
>
OK
</button>
</div>
</wa-dialog>
`;
}
override render() {
const stacked = [
...notificationStore.byLevel('persistent'),
...notificationStore.byLevel('transient'),
];
return html`
${this.renderBlocking()}
${stacked.length === 0
? nothing
: html`
<div
class="stack"
role="status"
aria-live="polite"
data-testid="notification-stack"
>
${stacked.map((n) => renderNotice(n, this.handlers))}
</div>
`}
`;
}
}
+314
View File
@@ -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<void>;
}
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<Subscriber>();
private notifyScheduled = false;
private timers = new Map<number, number>();
private lastSeen = new Map<string, { id: number; at: number }>();
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<NotifyInput, 'level'>): number {
return this.notify({ ...input, level: 'blocking' });
}
/** Stays until dismissed. Give it an action worth taking. */
persistent(input: Omit<NotifyInput, 'level'>): number {
return this.notify({ ...input, level: 'persistent' });
}
/** A toast. The state has already reverted; this only says so. */
transient(input: Omit<NotifyInput, 'level'>): number {
return this.notify({ ...input, level: 'transient' });
}
/** Rendered by `<inline-notice region="…">`, never as a toast. */
inline(region: string, input: Omit<NotifyInput, 'level' | 'region'>): 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();
+126
View File
@@ -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);
}