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%;