diff --git a/ui/PLAN-finalize.md b/ui/PLAN-finalize.md
index efd3e37..523555d 100644
--- a/ui/PLAN-finalize.md
+++ b/ui/PLAN-finalize.md
@@ -91,7 +91,18 @@ Acceptance: no untranslated visible strings; missing-translation test is
green; locale persists. Tests: Vitest i18n bundle-structure +
missing-key detection.
-## F4 — Error & state UX
+## F4 — Error & state UX — done
+
+Central error surface `src/lib/error/` (classify any error into a stable
+`ErrorKind` from the transport signal; map to translated `error.*`
+messages via `reportError` toast — sticky + Retry for retryable kinds —
+or `errorMessageKey` for inline). Shared `ViewState` placeholder
+(loading/empty/error with a11y roles), adopted by the entity tables.
+Selected-planet ring on the map (`map/selection-ring.ts`, fed into
+`buildExtras`). Bottom-sheet tap-outside + swipe-down dismissal
+(`lib/ui/sheet-dismiss.ts`, hand-rolled pointer events). Error-surface
+and `ViewState` adoption continue incrementally across the remaining
+views. Docs: `ui/docs/error-state-ux.md`.
(From Phase 35, plus the deferred Phase 13/35 items.) Goal: consistent,
actionable feedback everywhere.
diff --git a/ui/docs/README.md b/ui/docs/README.md
index 01650ca..626853d 100644
--- a/ui/docs/README.md
+++ b/ui/docs/README.md
@@ -15,6 +15,9 @@ lives in [`../PLAN.md`](../PLAN.md), the active web finalization in
- [a11y.md](a11y.md) — the WCAG 2.2 AA approach: axe + keyboard test
gates, the shared a11y primitives, coverage by area, and the map-canvas
alternative.
+- [error-state-ux.md](error-state-ux.md) — the central error surface
+ (taxonomy → translated/actionable messages), the shared `ViewState`
+ placeholder, the selected-planet ring, and bottom-sheet dismissal.
- [navigation.md](navigation.md) — routes, the sidebar tabs, and the
state-preservation rules across view/tab switches.
- [storage.md](storage.md) — the `KeyStore` and `Cache` abstractions and
diff --git a/ui/docs/error-state-ux.md b/ui/docs/error-state-ux.md
new file mode 100644
index 0000000..b0cfd4b
--- /dev/null
+++ b/ui/docs/error-state-ux.md
@@ -0,0 +1,51 @@
+# Error & state UX
+
+How the client gives consistent, actionable feedback for failures and for
+loading / empty / error states.
+
+## Error surface (`src/lib/error/`)
+
+Server `result_code`s are downstream-opaque (only `"ok"` is contractual —
+`docs/ARCHITECTURE.md`), so the client keys UX off the reliable
+transport-level signal instead.
+
+- [`classify.ts`](../frontend/src/lib/error/classify.ts) collapses any
+ caught value into a stable `ErrorKind` (`offline`, `network`, `auth`,
+ `forbidden`, `conflict`, `notFound`, `rateLimit`, `server`, `unknown`)
+ from `navigator.onLine`, an `AuthError` HTTP status, a Connect `Code`,
+ or a fetch `TypeError`. `isRetryable` marks the transient kinds.
+- [`report.ts`](../frontend/src/lib/error/report.ts) maps each kind to a
+ translated `error.*` message and either:
+ - `reportError(err, { onRetry? })` — raises a toast (a sticky toast
+ with a **Retry** action for a retryable kind that supplies `onRetry`,
+ otherwise auto-dismiss); or
+ - `errorMessageKey(err)` — returns the translated key for a view to
+ render the message inline (e.g. the mail compose 403).
+
+Messages live under `error.*` (en + ru); the action label is
+`common.retry`. New call sites adopt this surface incrementally; the mail
+compose dialog is the worked inline example.
+
+## View states (`src/lib/ui/view-state.svelte`)
+
+``
+is the one placeholder for a view or panel: a spinner for loading, an
+accent action button when given, and the right live-region role
+(`status` for loading/empty, `alert` for error). The entity tables
+(races / sciences / ship-classes) use it; remaining views adopt it
+incrementally.
+
+## Selected-planet marker (`src/map/selection-ring.ts`)
+
+When the `SelectionStore` holds a planet, the map draws one accent ring
+tight around it (`computeSelectionRing`, fed into the map's `buildExtras`
+alongside the reach circles). Ship-group selection is not ringed — groups
+are addressed by report index and have no single stable map coordinate.
+
+## Mobile bottom-sheet dismissal (`src/lib/ui/sheet-dismiss.ts`)
+
+`use:sheetDismiss={{ onDismiss }}` adds two hand-rolled (no-dependency)
+gestures to the planet / ship-group bottom-sheets: tap anywhere outside
+the sheet, or drag its `[data-sheet-handle]` grabber down past a
+threshold. The swipe starts only from the grabber so it never fights the
+sheet's own content scrolling.
diff --git a/ui/frontend/src/lib/active-view/mail/compose.svelte b/ui/frontend/src/lib/active-view/mail/compose.svelte
index ae9f5f8..722623d 100644
--- a/ui/frontend/src/lib/active-view/mail/compose.svelte
+++ b/ui/frontend/src/lib/active-view/mail/compose.svelte
@@ -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;
}
diff --git a/ui/frontend/src/lib/active-view/map.svelte b/ui/frontend/src/lib/active-view/map.svelte
index 04070b5..3ad8b9a 100644
--- a/ui/frontend/src/lib/active-view/map.svelte
+++ b/ui/frontend/src/lib/active-view/map.svelte
@@ -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(
diff --git a/ui/frontend/src/lib/active-view/table-races.svelte b/ui/frontend/src/lib/active-view/table-races.svelte
index 936d012..61f1e91 100644
--- a/ui/frontend/src/lib/active-view/table-races.svelte
+++ b/ui/frontend/src/lib/active-view/table-races.svelte
@@ -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.
{#if !reportLoaded}
-
- {i18n.t("game.table.races.loading")}
-
+
{:else if races.length === 0}
-
- {i18n.t("game.table.races.empty")}
-
+
{:else}
@@ -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%;
diff --git a/ui/frontend/src/lib/active-view/table-sciences.svelte b/ui/frontend/src/lib/active-view/table-sciences.svelte
index 6eb6898..516263f 100644
--- a/ui/frontend/src/lib/active-view/table-sciences.svelte
+++ b/ui/frontend/src/lib/active-view/table-sciences.svelte
@@ -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.
{#if !reportLoaded}
-
- {i18n.t("game.table.sciences.loading")}
-
+
{:else if localScience.length === 0}
-
- {i18n.t("game.table.sciences.empty")}
-
+
{:else}
@@ -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%;
diff --git a/ui/frontend/src/lib/active-view/table-ship-classes.svelte b/ui/frontend/src/lib/active-view/table-ship-classes.svelte
index 93c4bd5..0cb02af 100644
--- a/ui/frontend/src/lib/active-view/table-ship-classes.svelte
+++ b/ui/frontend/src/lib/active-view/table-ship-classes.svelte
@@ -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.
{#if !reportLoaded}
-
- {i18n.t("game.table.ship_classes.loading")}
-
+
{:else if localShipClass.length === 0}
-
- {i18n.t("game.table.ship_classes.empty")}
-
+
{:else}
@@ -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%;
diff --git a/ui/frontend/src/lib/error/classify.ts b/ui/frontend/src/lib/error/classify.ts
new file mode 100644
index 0000000..6644653
--- /dev/null
+++ b/ui/frontend/src/lib/error/classify.ts
@@ -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 = new Set([
+ "offline",
+ "network",
+ "server",
+]);
+
+/** Whether retrying the same operation could plausibly succeed. */
+export function isRetryable(kind: ErrorKind): boolean {
+ return RETRYABLE.has(kind);
+}
diff --git a/ui/frontend/src/lib/error/index.ts b/ui/frontend/src/lib/error/index.ts
new file mode 100644
index 0000000..7183316
--- /dev/null
+++ b/ui/frontend/src/lib/error/index.ts
@@ -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";
diff --git a/ui/frontend/src/lib/error/report.ts b/ui/frontend/src/lib/error/report.ts
new file mode 100644
index 0000000..dcb6fbb
--- /dev/null
+++ b/ui/frontend/src/lib/error/report.ts
@@ -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 = {
+ 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;
+}
diff --git a/ui/frontend/src/lib/i18n/locales/en.ts b/ui/frontend/src/lib/i18n/locales/en.ts
index 4c503c9..6d68426 100644
--- a/ui/frontend/src/lib/i18n/locales/en.ts
+++ b/ui/frontend/src/lib/i18n/locales/en.ts
@@ -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…",
diff --git a/ui/frontend/src/lib/i18n/locales/ru.ts b/ui/frontend/src/lib/i18n/locales/ru.ts
index c48bb41..7c71957 100644
--- a/ui/frontend/src/lib/i18n/locales/ru.ts
+++ b/ui/frontend/src/lib/i18n/locales/ru.ts
@@ -10,10 +10,23 @@ const ru: Record = {
"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": "подпись повреждена, переподключение…",
diff --git a/ui/frontend/src/lib/inspectors/planet-sheet.svelte b/ui/frontend/src/lib/inspectors/planet-sheet.svelte
index 695cb18..3dbc780 100644
--- a/ui/frontend/src/lib/inspectors/planet-sheet.svelte
+++ b/ui/frontend/src/lib/inspectors/planet-sheet.svelte
@@ -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 }}
>
+