feat(ui): error & state UX — error surface, view states, map selection, sheet gestures (F4)
Tests · UI / test (push) Waiting to run
Tests · UI / test (pull_request) Failing after 7m13s

- 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:
Ilia Denisov
2026-05-22 13:29:11 +02:00
parent 87d524fb89
commit 8dcaf1c6c6
22 changed files with 788 additions and 37 deletions
+114
View File
@@ -0,0 +1,114 @@
import "@testing-library/jest-dom/vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ConnectError, Code } from "@connectrpc/connect";
import { AuthError } from "../src/api/auth";
import {
classifyError,
isRetryable,
type ErrorKind,
} from "../src/lib/error/classify";
import { errorMessageKey, reportError } from "../src/lib/error/report";
import { toast } from "../src/lib/toast.svelte";
afterEach(() => {
toast.resetForTests();
vi.restoreAllMocks();
});
describe("classifyError", () => {
it("maps HTTP statuses (AuthError) to kinds", () => {
const at = (status: number) =>
classifyError(new AuthError("x", "msg", status));
expect(at(401)).toBe("auth");
expect(at(403)).toBe("forbidden");
expect(at(404)).toBe("notFound");
expect(at(409)).toBe("conflict");
expect(at(429)).toBe("rateLimit");
expect(at(500)).toBe("server");
expect(at(400)).toBe("unknown");
});
it("maps Connect codes to kinds", () => {
const at = (code: Code) => classifyError(new ConnectError("msg", code));
expect(at(Code.Unauthenticated)).toBe("auth");
expect(at(Code.PermissionDenied)).toBe("forbidden");
expect(at(Code.NotFound)).toBe("notFound");
expect(at(Code.FailedPrecondition)).toBe("conflict");
expect(at(Code.ResourceExhausted)).toBe("rateLimit");
expect(at(Code.Unavailable)).toBe("network");
expect(at(Code.Internal)).toBe("server");
});
it("treats a fetch TypeError as a network error", () => {
expect(classifyError(new TypeError("Failed to fetch"))).toBe("network");
});
it("duck-types any object with a numeric status", () => {
expect(classifyError({ status: 409 })).toBe("conflict");
});
it("falls back to unknown for a plain error", () => {
expect(classifyError(new Error("boom"))).toBe("unknown");
});
it("short-circuits to offline when the browser is offline", () => {
Object.defineProperty(navigator, "onLine", {
configurable: true,
value: false,
});
try {
expect(classifyError(new AuthError("x", "m", 500))).toBe("offline");
} finally {
Object.defineProperty(navigator, "onLine", {
configurable: true,
value: true,
});
}
});
it("marks only transient kinds retryable", () => {
const retryable: ErrorKind[] = ["offline", "network", "server"];
const notRetryable: ErrorKind[] = [
"auth",
"forbidden",
"conflict",
"notFound",
"rateLimit",
"unknown",
];
expect(retryable.every(isRetryable)).toBe(true);
expect(notRetryable.some(isRetryable)).toBe(false);
});
});
describe("reportError / errorMessageKey", () => {
it("returns the translated message key for inline use", () => {
expect(errorMessageKey(new AuthError("x", "m", 403))).toBe("error.forbidden");
expect(errorMessageKey(new Error("?"))).toBe("error.unknown");
});
it("raises an auto-dismiss toast for a non-retryable error", () => {
const kind = reportError(new AuthError("x", "m", 403));
expect(kind).toBe("forbidden");
expect(toast.current?.messageKey).toBe("error.forbidden");
expect(toast.current?.actionLabelKey).toBeUndefined();
expect(toast.current?.durationMs).toBe(8000);
});
it("raises a sticky Retry toast for a retryable error with onRetry", () => {
const onRetry = vi.fn();
const kind = reportError(new TypeError("Failed to fetch"), { onRetry });
expect(kind).toBe("network");
expect(toast.current?.messageKey).toBe("error.network");
expect(toast.current?.actionLabelKey).toBe("common.retry");
expect(toast.current?.durationMs).toBeNull();
toast.current?.onAction?.();
expect(onRetry).toHaveBeenCalledOnce();
});
it("does not add a Retry action to a non-retryable error", () => {
reportError(new AuthError("x", "m", 403), { onRetry: vi.fn() });
expect(toast.current?.actionLabelKey).toBeUndefined();
});
});
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import {
computeSelectionRing,
SELECTION_RING_COLOR,
SELECTION_RING_ID,
} from "../src/map/selection-ring";
const planets = [
{ number: 1, x: 10, y: 20 },
{ number: 2, x: 30, y: 40 },
];
describe("computeSelectionRing", () => {
it("returns null when nothing is selected", () => {
expect(computeSelectionRing(planets, null)).toBeNull();
});
it("returns null when the selected planet is absent from the report", () => {
expect(computeSelectionRing(planets, 99)).toBeNull();
});
it("rings the selected planet at its coordinates", () => {
const ring = computeSelectionRing(planets, 2);
expect(ring).toMatchObject({
kind: "circle",
id: SELECTION_RING_ID,
x: 30,
y: 40,
hitSlopPx: 0,
});
expect(ring?.style.strokeColor).toBe(SELECTION_RING_COLOR);
// Sits outside the planet marker (radius 6 world units).
expect(ring?.radius ?? 0).toBeGreaterThan(6);
});
});
+52
View File
@@ -0,0 +1,52 @@
import "@testing-library/jest-dom/vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { sheetDismiss } from "../src/lib/ui/sheet-dismiss";
// Only the tap-outside path is unit-tested; the swipe-down drag is
// pointer-gesture behaviour covered by manual / e2e checks.
describe("sheetDismiss — tap outside", () => {
let cleanup: (() => void) | null = null;
afterEach(() => {
cleanup?.();
cleanup = null;
document.body.innerHTML = "";
});
it("dismisses on a pointer-down outside the sheet", () => {
const node = document.createElement("div");
const outside = document.createElement("button");
document.body.append(node, outside);
const onDismiss = vi.fn();
const action = sheetDismiss(node, { onDismiss });
cleanup = action.destroy;
outside.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true }));
expect(onDismiss).toHaveBeenCalledOnce();
});
it("ignores a pointer-down inside the sheet", () => {
const node = document.createElement("div");
const inner = document.createElement("button");
node.append(inner);
document.body.append(node);
const onDismiss = vi.fn();
const action = sheetDismiss(node, { onDismiss });
cleanup = action.destroy;
inner.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true }));
expect(onDismiss).not.toHaveBeenCalled();
});
it("stops listening after destroy", () => {
const node = document.createElement("div");
const outside = document.createElement("button");
document.body.append(node, outside);
const onDismiss = vi.fn();
sheetDismiss(node, { onDismiss }).destroy();
outside.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true }));
expect(onDismiss).not.toHaveBeenCalled();
});
});
+45
View File
@@ -0,0 +1,45 @@
import "@testing-library/jest-dom/vitest";
import { fireEvent, render } from "@testing-library/svelte";
import { describe, expect, it, vi } from "vitest";
import ViewState from "../src/lib/ui/view-state.svelte";
describe("ViewState", () => {
it("announces an error assertively (role=alert)", () => {
const ui = render(ViewState, {
props: { kind: "error", message: "it broke", testid: "vs" },
});
const el = ui.getByTestId("vs");
expect(el).toHaveAttribute("role", "alert");
expect(el).toHaveAttribute("data-kind", "error");
expect(el).toHaveTextContent("it broke");
});
it("announces loading politely (role=status)", () => {
const ui = render(ViewState, {
props: { kind: "loading", message: "loading…" },
});
expect(ui.getByRole("status")).toHaveTextContent("loading…");
});
it("renders an action button that fires onAction", async () => {
const onAction = vi.fn();
const ui = render(ViewState, {
props: {
kind: "error",
message: "failed",
actionLabel: "retry",
onAction,
},
});
await fireEvent.click(ui.getByText("retry"));
expect(onAction).toHaveBeenCalledOnce();
});
it("omits the action button when no handler is given", () => {
const ui = render(ViewState, {
props: { kind: "empty", message: "nothing here" },
});
expect(ui.queryByRole("button")).toBeNull();
});
});