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
@@ -11,6 +11,7 @@ surfaces the resulting 403 inline.
import { i18n } from "$lib/i18n/index.svelte";
import { trapFocus } from "$lib/a11y/focus-trap";
import { errorMessageKey } from "$lib/error";
import { mailStore } from "$lib/mail-store.svelte";
import {
RENDERED_REPORT_CONTEXT_KEY,
@@ -82,7 +83,7 @@ surfaces the resulting 403 inline.
await mailStore.composeBroadcast({ subject, body: bodyText });
onSent(null);
} catch (err) {
error = err instanceof Error ? err.message : String(err);
error = i18n.t(errorMessageKey(err));
} finally {
sending = false;
}
+15 -2
View File
@@ -32,6 +32,7 @@ preference the store already manages.
import { buildCargoRouteLines } from "../../map/cargo-routes";
import { buildPendingSendLines } from "../../map/pending-send-routes";
import { computeReachCircles } from "../../map/reach-circles";
import { computeSelectionRing } from "../../map/selection-ring";
import { reachStore } from "$lib/calculator/reach.svelte";
import {
reportToWorld,
@@ -202,6 +203,8 @@ preference the store already manages.
// redraw as the design or the selected planet changes.
void reachStore.origin;
void reachStore.speedPerTurn;
// Redraw the selected-planet ring when the selection changes.
void selection?.selected;
// Phase 29 visibility derivation. Cargo routes and pending-
// Send overlay are extras (no Pixi remount on flip); the
@@ -231,9 +234,11 @@ preference the store already manages.
reachOrigin === null
? ""
: `${reachOrigin.x},${reachOrigin.y},${reachStore.speedPerTurn}`;
const selectedPlanetId =
selection?.selected?.kind === "planet" ? selection.selected.id : null;
const extrasFingerprint =
`cr=${toggles.cargoRoutes ? "1" : "0"}|hp=${hiddenPlanetFingerprint}|` +
`reach=${reachFingerprint}|` +
`reach=${reachFingerprint}|sel=${selectedPlanetId ?? ""}|` +
computeRoutesFingerprint(report.routes) +
"|" +
computePendingSendFingerprint(draftCommands, draftStatuses);
@@ -329,7 +334,15 @@ preference the store already manages.
mode,
)
: [];
return [...cargo, ...pending, ...reach];
const selectedPlanetId =
selection?.selected?.kind === "planet" ? selection.selected.id : null;
const selectionRing = computeSelectionRing(report.planets, selectedPlanetId);
return [
...cargo,
...pending,
...reach,
...(selectionRing === null ? [] : [selectionRing]),
];
}
function applyVisibilityState(
@@ -34,6 +34,7 @@ data fetching is performed here — the layout is responsible.
OrderDraftStore,
} from "../../sync/order-draft.svelte";
import type { Relation } from "../../sync/order-types";
import ViewState from "$lib/ui/view-state.svelte";
type SortColumn =
| "name"
@@ -227,13 +228,17 @@ data fetching is performed here — the layout is responsible.
</header>
{#if !reportLoaded}
<p class="status" data-testid="races-loading">
{i18n.t("game.table.races.loading")}
</p>
<ViewState
kind="loading"
testid="races-loading"
message={i18n.t("game.table.races.loading")}
/>
{:else if races.length === 0}
<p class="status" data-testid="races-empty">
{i18n.t("game.table.races.empty")}
</p>
<ViewState
kind="empty"
testid="races-empty"
message={i18n.t("game.table.races.empty")}
/>
{:else}
<table class="grid" data-testid="races-table">
<thead>
@@ -388,11 +393,6 @@ data fetching is performed here — the layout is responsible.
flex: 1 1 12rem;
min-width: 8rem;
}
.status {
margin: 0;
color: var(--color-text-muted);
font-size: 0.9rem;
}
.grid {
border-collapse: collapse;
width: 100%;
@@ -23,6 +23,7 @@ data fetching is performed here — the layout is responsible.
import type { ScienceSummary } from "../../api/game-state";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
import ViewState from "$lib/ui/view-state.svelte";
import {
RENDERED_REPORT_CONTEXT_KEY,
type RenderedReportSource,
@@ -160,13 +161,17 @@ data fetching is performed here — the layout is responsible.
</header>
{#if !reportLoaded}
<p class="status" data-testid="sciences-loading">
{i18n.t("game.table.sciences.loading")}
</p>
<ViewState
kind="loading"
testid="sciences-loading"
message={i18n.t("game.table.sciences.loading")}
/>
{:else if localScience.length === 0}
<p class="status" data-testid="sciences-empty">
{i18n.t("game.table.sciences.empty")}
</p>
<ViewState
kind="empty"
testid="sciences-empty"
message={i18n.t("game.table.sciences.empty")}
/>
{:else}
<table class="grid" data-testid="sciences-table">
<thead>
@@ -271,11 +276,6 @@ data fetching is performed here — the layout is responsible.
color: var(--color-text);
border-color: var(--color-accent);
}
.status {
margin: 0;
color: var(--color-text-muted);
font-size: 0.9rem;
}
.grid {
border-collapse: collapse;
width: 100%;
@@ -26,6 +26,7 @@ data fetching is performed here — the layout is responsible.
OrderDraftStore,
} from "../../sync/order-draft.svelte";
import { calculatorLoadRequest } from "$lib/calculator/load-request.svelte";
import ViewState from "$lib/ui/view-state.svelte";
type SortColumn =
| "name"
@@ -154,13 +155,17 @@ data fetching is performed here — the layout is responsible.
</header>
{#if !reportLoaded}
<p class="status" data-testid="ship-classes-loading">
{i18n.t("game.table.ship_classes.loading")}
</p>
<ViewState
kind="loading"
testid="ship-classes-loading"
message={i18n.t("game.table.ship_classes.loading")}
/>
{:else if localShipClass.length === 0}
<p class="status" data-testid="ship-classes-empty">
{i18n.t("game.table.ship_classes.empty")}
</p>
<ViewState
kind="empty"
testid="ship-classes-empty"
message={i18n.t("game.table.ship_classes.empty")}
/>
{:else}
<table class="grid" data-testid="ship-classes-table">
<thead>
@@ -262,11 +267,6 @@ data fetching is performed here — the layout is responsible.
color: var(--color-text);
border-color: var(--color-accent);
}
.status {
margin: 0;
color: var(--color-text-muted);
font-size: 0.9rem;
}
.grid {
border-collapse: collapse;
width: 100%;
+93
View File
@@ -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);
}
+8
View File
@@ -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";
+57
View File
@@ -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;
}
+13
View File
@@ -9,10 +9,23 @@ const en = {
"common.loading": "loading…",
"common.dismiss": "dismiss",
"common.skip_to_content": "skip to main content",
"common.retry": "retry",
"common.browser_not_supported_title": "browser not supported",
"common.browser_not_supported_body":
"Galaxy requires Ed25519 in WebCrypto. See supported browsers.",
"error.offline":
"You appear to be offline. Check your connection and try again.",
"error.network": "Couldn't reach the server. Please try again.",
"error.auth": "Your session has expired. Please sign in again.",
"error.forbidden": "You don't have permission to do that.",
"error.conflict":
"This changed since you loaded it. Refresh and try again.",
"error.not_found": "Not found — it may have been removed.",
"error.rate_limit": "Too many requests. Wait a moment and try again.",
"error.server": "The server ran into a problem. Please try again.",
"error.unknown": "Something went wrong.",
"game.events.turn_ready.message": "turn {turn} is ready",
"game.events.turn_ready.action": "view now",
"game.events.signature_failed": "verification failed, reconnecting…",
+13
View File
@@ -10,10 +10,23 @@ const ru: Record<keyof typeof en, string> = {
"common.loading": "загрузка…",
"common.dismiss": "закрыть",
"common.skip_to_content": "к основному содержимому",
"common.retry": "повторить",
"common.browser_not_supported_title": "браузер не поддерживается",
"common.browser_not_supported_body":
"Galaxy требует поддержки Ed25519 в WebCrypto. См. список поддерживаемых браузеров.",
"error.offline":
"Похоже, вы офлайн. Проверьте соединение и повторите попытку.",
"error.network": "Не удалось связаться с сервером. Повторите попытку.",
"error.auth": "Сессия истекла. Войдите снова.",
"error.forbidden": "Недостаточно прав для этого действия.",
"error.conflict":
"Данные изменились с момента загрузки. Обновите и повторите.",
"error.not_found": "Не найдено — возможно, удалено.",
"error.rate_limit": "Слишком много запросов. Подождите немного и повторите.",
"error.server": "На сервере произошла ошибка. Повторите попытку.",
"error.unknown": "Что-то пошло не так.",
"game.events.turn_ready.message": "ход {turn} готов",
"game.events.turn_ready.action": "открыть",
"game.events.signature_failed": "подпись повреждена, переподключение…",
@@ -20,6 +20,7 @@ dismiss from the IA section §6 land in Phase 35 polish.
ShipClassSummary,
} from "../../api/game-state";
import { i18n } from "$lib/i18n/index.svelte";
import { sheetDismiss } from "$lib/ui/sheet-dismiss";
import Planet from "./planet.svelte";
type Props = {
@@ -59,7 +60,9 @@ dismiss from the IA section §6 land in Phase 35 polish.
class="sheet"
aria-label={i18n.t("game.sidebar.tab.inspector")}
data-testid="inspector-planet-sheet"
use:sheetDismiss={{ onDismiss: onClose }}
>
<div class="grabber" data-sheet-handle aria-hidden="true"></div>
<button
type="button"
class="close"
@@ -105,6 +108,16 @@ dismiss from the IA section §6 land in Phase 35 polish.
z-index: 40;
}
}
.grabber {
display: block;
width: 2.5rem;
height: 0.25rem;
margin: 0.4rem auto 0.2rem;
border-radius: var(--radius-pill);
background: var(--color-border-strong);
touch-action: none;
cursor: grab;
}
.close {
position: absolute;
top: 0.4rem;
@@ -12,6 +12,7 @@ mounted by the in-game shell layout only while the active tool is
ShipClassSummary,
} from "../../api/game-state";
import { i18n } from "$lib/i18n/index.svelte";
import { sheetDismiss } from "$lib/ui/sheet-dismiss";
import ShipGroup, { type ShipGroupSelection } from "./ship-group.svelte";
type Props = {
@@ -51,7 +52,9 @@ mounted by the in-game shell layout only while the active tool is
class="sheet"
aria-label={i18n.t("game.sidebar.tab.inspector")}
data-testid="inspector-ship-group-sheet"
use:sheetDismiss={{ onDismiss: onClose }}
>
<div class="grabber" data-sheet-handle aria-hidden="true"></div>
<button
type="button"
class="close"
@@ -97,6 +100,16 @@ mounted by the in-game shell layout only while the active tool is
z-index: 40;
}
}
.grabber {
display: block;
width: 2.5rem;
height: 0.25rem;
margin: 0.4rem auto 0.2rem;
border-radius: var(--radius-pill);
background: var(--color-border-strong);
touch-action: none;
cursor: grab;
}
.close {
position: absolute;
top: 0.4rem;
+77
View File
@@ -0,0 +1,77 @@
/**
* Mobile bottom-sheet dismissal gestures, hand-rolled on pointer events
* (no dependency). A Svelte action for the sheet element:
*
* - **tap-outside** — a pointer-down anywhere outside the sheet dismisses
* it (the sheet is non-modal, so this is a convenience, not a guard);
* - **swipe-down** — dragging the sheet's `[data-sheet-handle]` grabber
* down past a threshold dismisses it. The swipe starts only from the
* handle, so it never fights the sheet's own content scrolling.
*
* `onDismiss` is re-read on `update`, so a changing handler stays current.
*/
export interface SheetDismissOptions {
onDismiss: () => void;
}
/** Downward drag distance (px) past which release dismisses the sheet. */
const SWIPE_DISMISS_PX = 80;
export function sheetDismiss(
node: HTMLElement,
options: SheetDismissOptions,
): { update(next: SheetDismissOptions): void; destroy(): void } {
let opts = options;
const handle = node.querySelector<HTMLElement>("[data-sheet-handle]") ?? node;
function onDocumentPointerDown(event: PointerEvent): void {
const target = event.target;
if (target instanceof Node && node.contains(target)) return;
opts.onDismiss();
}
let dragging = false;
let startY = 0;
let pointerId = -1;
function onHandleDown(event: PointerEvent): void {
dragging = true;
startY = event.clientY;
pointerId = event.pointerId;
handle.setPointerCapture?.(event.pointerId);
}
function onPointerMove(event: PointerEvent): void {
if (!dragging || event.pointerId !== pointerId) return;
const dy = Math.max(0, event.clientY - startY);
node.style.transform = dy > 0 ? `translateY(${dy}px)` : "";
}
function onPointerUp(event: PointerEvent): void {
if (!dragging || event.pointerId !== pointerId) return;
const dy = event.clientY - startY;
dragging = false;
node.style.transform = "";
if (dy > SWIPE_DISMISS_PX) opts.onDismiss();
}
document.addEventListener("pointerdown", onDocumentPointerDown, true);
handle.addEventListener("pointerdown", onHandleDown);
window.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", onPointerUp);
window.addEventListener("pointercancel", onPointerUp);
return {
update(next: SheetDismissOptions): void {
opts = next;
},
destroy(): void {
document.removeEventListener("pointerdown", onDocumentPointerDown, true);
handle.removeEventListener("pointerdown", onHandleDown);
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerup", onPointerUp);
window.removeEventListener("pointercancel", onPointerUp);
},
};
}
+91
View File
@@ -0,0 +1,91 @@
<!--
Shared loading / empty / error placeholder for a view or panel. Gives
every surface one consistent treatment and a11y semantics:
- `loading` and `empty` announce politely (`role="status"`);
- `error` announces assertively (`role="alert"`) and may carry a retry /
recovery action.
Callers pass an already-translated `message` (and `actionLabel`); the
component owns layout, the spinner, and the live-region role only.
-->
<script lang="ts">
type Props = {
kind: "loading" | "empty" | "error";
message: string;
actionLabel?: string;
onAction?: () => void;
testid?: string;
};
let { kind, message, actionLabel, onAction, testid }: Props = $props();
</script>
<div
class="view-state {kind}"
data-testid={testid}
data-kind={kind}
role={kind === "error" ? "alert" : "status"}
>
{#if kind === "loading"}
<span class="spinner" aria-hidden="true"></span>
{/if}
<p class="message">{message}</p>
{#if actionLabel !== undefined && onAction !== undefined}
<button type="button" class="action" onclick={onAction}>
{actionLabel}
</button>
{/if}
</div>
<style>
.view-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-3);
padding: var(--space-6) var(--space-4);
text-align: center;
color: var(--color-text-muted);
font-family: var(--font-sans);
}
.message {
margin: 0;
max-width: 32rem;
line-height: var(--leading-normal);
}
.view-state.error .message {
color: var(--color-danger);
}
.action {
font: inherit;
padding: var(--space-2) var(--space-4);
background: var(--color-surface-raised);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
}
.action:hover {
background: var(--color-surface-hover);
border-color: var(--color-border-strong);
}
.spinner {
width: 1.5rem;
height: 1.5rem;
border: 2px solid var(--color-border);
border-top-color: var(--color-accent);
border-radius: var(--radius-pill);
animation: view-state-spin 0.8s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
.spinner {
animation-duration: 2s;
}
}
@keyframes view-state-spin {
to {
transform: rotate(360deg);
}
}
</style>