feat(ui): error & state UX — error surface, view states, map selection, sheet gestures (F4)
- lib/error/: classify any caught error into a stable ErrorKind from the transport signal (HTTP status / Connect Code / fetch TypeError / navigator.onLine); map to translated error.* messages via reportError (sticky Retry toast for retryable kinds) or errorMessageKey (inline). Mail compose now surfaces the translated 403/error inline. - lib/ui/view-state.svelte: shared loading/empty/error placeholder with the right live-region role + optional action; entity tables (races/sciences/ship-classes) migrated, rest adopt incrementally. - map/selection-ring.ts: accent ring around the selected planet, fed into the map buildExtras alongside the reach circles. - lib/ui/sheet-dismiss.ts: tap-outside + drag-handle swipe-down dismissal for the planet/ship-group bottom-sheets (hand-rolled pointer events). Tests: error, view-state, selection-ring, sheet-dismiss (761 total). Docs: ui/docs/error-state-ux.md (+ index); F4 marked done. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Error classification.
|
||||
*
|
||||
* Server `result_code`s are downstream-opaque (only `"ok"` is a stable
|
||||
* contract — see `docs/ARCHITECTURE.md`), so the client cannot key UX off
|
||||
* them. Instead every caught error is collapsed into a small, stable set
|
||||
* of client-side kinds, derived from the transport-level signal that IS
|
||||
* reliable: HTTP status (`AuthError`), the Connect `Code`, a fetch
|
||||
* `TypeError`, or the live `navigator.onLine` flag.
|
||||
*/
|
||||
|
||||
import { ConnectError, Code } from "@connectrpc/connect";
|
||||
import { AuthError } from "../../api/auth";
|
||||
|
||||
/** A stable, user-facing classification of any caught error. */
|
||||
export type ErrorKind =
|
||||
| "offline"
|
||||
| "network"
|
||||
| "auth"
|
||||
| "forbidden"
|
||||
| "conflict"
|
||||
| "notFound"
|
||||
| "rateLimit"
|
||||
| "server"
|
||||
| "unknown";
|
||||
|
||||
function fromHttpStatus(status: number): ErrorKind {
|
||||
if (status === 401) return "auth";
|
||||
if (status === 403) return "forbidden";
|
||||
if (status === 404) return "notFound";
|
||||
if (status === 409) return "conflict";
|
||||
if (status === 429) return "rateLimit";
|
||||
if (status >= 500) return "server";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function fromConnectCode(code: Code): ErrorKind {
|
||||
switch (code) {
|
||||
case Code.Unauthenticated:
|
||||
return "auth";
|
||||
case Code.PermissionDenied:
|
||||
return "forbidden";
|
||||
case Code.NotFound:
|
||||
return "notFound";
|
||||
case Code.AlreadyExists:
|
||||
case Code.Aborted:
|
||||
case Code.FailedPrecondition:
|
||||
return "conflict";
|
||||
case Code.ResourceExhausted:
|
||||
return "rateLimit";
|
||||
case Code.Unavailable:
|
||||
return "network";
|
||||
case Code.Internal:
|
||||
case Code.DataLoss:
|
||||
case Code.Unknown:
|
||||
return "server";
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
function hasNumericStatus(err: unknown): err is { status: number } {
|
||||
return (
|
||||
typeof err === "object" &&
|
||||
err !== null &&
|
||||
typeof (err as { status?: unknown }).status === "number"
|
||||
);
|
||||
}
|
||||
|
||||
/** Collapse any caught value into a stable {@link ErrorKind}. */
|
||||
export function classifyError(err: unknown): ErrorKind {
|
||||
if (typeof navigator !== "undefined" && navigator.onLine === false) {
|
||||
return "offline";
|
||||
}
|
||||
if (err instanceof AuthError) return fromHttpStatus(err.status);
|
||||
if (err instanceof ConnectError) return fromConnectCode(err.code);
|
||||
if (err instanceof TypeError && /fetch|network|load failed/i.test(err.message)) {
|
||||
return "network";
|
||||
}
|
||||
if (hasNumericStatus(err)) return fromHttpStatus(err.status);
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
const RETRYABLE: ReadonlySet<ErrorKind> = new Set<ErrorKind>([
|
||||
"offline",
|
||||
"network",
|
||||
"server",
|
||||
]);
|
||||
|
||||
/** Whether retrying the same operation could plausibly succeed. */
|
||||
export function isRetryable(kind: ErrorKind): boolean {
|
||||
return RETRYABLE.has(kind);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Central error surface: classify caught errors into stable client kinds
|
||||
* and turn them into translated, actionable feedback. See `classify.ts`
|
||||
* for the taxonomy and `report.ts` for the toast/inline helpers.
|
||||
*/
|
||||
|
||||
export { classifyError, isRetryable, type ErrorKind } from "./classify";
|
||||
export { reportError, errorMessageKey, type ReportErrorOptions } from "./report";
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Error reporting surface.
|
||||
*
|
||||
* Maps a classified {@link ErrorKind} to a translated, actionable message
|
||||
* and either raises it as a toast (with a Retry action for retryable
|
||||
* kinds) or hands the message key back for a view to render inline.
|
||||
*/
|
||||
|
||||
import { toast } from "../toast.svelte";
|
||||
import type { TranslationKey } from "../i18n/index.svelte";
|
||||
import { classifyError, isRetryable, type ErrorKind } from "./classify";
|
||||
|
||||
const MESSAGE_KEY: Record<ErrorKind, TranslationKey> = {
|
||||
offline: "error.offline",
|
||||
network: "error.network",
|
||||
auth: "error.auth",
|
||||
forbidden: "error.forbidden",
|
||||
conflict: "error.conflict",
|
||||
notFound: "error.not_found",
|
||||
rateLimit: "error.rate_limit",
|
||||
server: "error.server",
|
||||
unknown: "error.unknown",
|
||||
};
|
||||
|
||||
/** The translated message key for an error, for inline rendering. */
|
||||
export function errorMessageKey(err: unknown): TranslationKey {
|
||||
return MESSAGE_KEY[classifyError(err)];
|
||||
}
|
||||
|
||||
export interface ReportErrorOptions {
|
||||
/**
|
||||
* Retry callback. When supplied and the error kind is retryable, the
|
||||
* toast gains a Retry action and stays until dismissed; otherwise the
|
||||
* toast auto-dismisses.
|
||||
*/
|
||||
onRetry?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify `err`, raise a translated toast, and return the kind. A
|
||||
* retryable error with an `onRetry` handler shows a sticky toast with a
|
||||
* Retry action; everything else auto-dismisses.
|
||||
*/
|
||||
export function reportError(
|
||||
err: unknown,
|
||||
options: ReportErrorOptions = {},
|
||||
): ErrorKind {
|
||||
const kind = classifyError(err);
|
||||
const withRetry = options.onRetry !== undefined && isRetryable(kind);
|
||||
toast.show({
|
||||
messageKey: MESSAGE_KEY[kind],
|
||||
actionLabelKey: withRetry ? "common.retry" : undefined,
|
||||
onAction: withRetry ? options.onRetry : undefined,
|
||||
durationMs: withRetry ? null : 8000,
|
||||
});
|
||||
return kind;
|
||||
}
|
||||
Reference in New Issue
Block a user