diff --git a/.changelog/unreleased/853-857-design-system-accessibility.md b/.changelog/unreleased/853-857-design-system-accessibility.md
new file mode 100644
index 00000000..56e3bdbb
--- /dev/null
+++ b/.changelog/unreleased/853-857-design-system-accessibility.md
@@ -0,0 +1,8 @@
+---
+type: fixed
+area: docs
+pr: 853
+breaking: false
+---
+
+The component gallery no longer nests a button inside the tooltip trigger's own button, which caused a hydration error and an axe `nested-interactive` violation; the `render` pattern it should have used is now documented in the `@workspace/ui` docs. The gallery's slider, progress examples and scroll areas, and the design-system page's theme and viewport pickers, all have accessible names.
diff --git a/.changelog/unreleased/858-trade-accessibility.md b/.changelog/unreleased/858-trade-accessibility.md
new file mode 100644
index 00000000..2615a1a7
--- /dev/null
+++ b/.changelog/unreleased/858-trade-accessibility.md
@@ -0,0 +1,8 @@
+---
+type: fixed
+area: trade
+pr: 858
+breaking: false
+---
+
+The order book's view switcher is now a real tab list with arrow-key navigation, the price chart is announced as a region named after the market (for example "BTC/USD") instead of an image named after a raw contract address, the recent-trades list can be scrolled from the keyboard, and the leverage slider has an accessible name.
diff --git a/.changelog/unreleased/859-indexer-configuration.md b/.changelog/unreleased/859-indexer-configuration.md
new file mode 100644
index 00000000..d1c1fcba
--- /dev/null
+++ b/.changelog/unreleased/859-indexer-configuration.md
@@ -0,0 +1,8 @@
+---
+type: changed
+area: docs
+pr: 859
+breaking: false
+---
+
+`VITE_INDEXER_GRAPHQL_URL` is now documented in the README and in every env file, including how to run the SubQuery indexer locally and what degrades without it. With no indexer configured the app logs its warning once per runtime instead of on every route.
diff --git a/README.md b/README.md
index 7b3faf20..948155f3 100644
--- a/README.md
+++ b/README.md
@@ -160,6 +160,43 @@ bun run --cwd apps/s03-indexer start
Run `bun run check:integration` before pushing to mirror the CI matrix.
+### Indexed history (SubQuery)
+
+Position and order history, executed fills, and fee/funding activity are read
+from the SubQuery indexer in `apps/s03-indexer`, not from the chain. The web app
+finds it through a single variable:
+
+| Variable | Scope | Default |
+|---|---|---|
+| `VITE_INDEXER_GRAPHQL_URL` | `apps/web` | unset |
+
+**No hosted indexer is deployed for testnet or mainnet yet**, so
+`apps/web/.env.testnet` and `apps/web/.env.mainnet` ship with the variable
+commented out. With it unset the indexer is disabled: the app starts normally,
+logs a single `Indexer disabled: …` warning, and the affected panels — Positions,
+Orders, Order history, Trades, Funding & Fees — say the data is unavailable
+instead of rendering empty tables.
+
+To develop against indexed data, run the stack locally:
+
+```bash
+# codegen + build + docker compose up (Postgres, indexer node, GraphQL engine).
+# Requires Docker.
+bun run indexer:dev
+```
+
+The GraphQL engine and its playground listen on
+[http://localhost:3000](http://localhost:3000) — the same port the web dev
+server uses — so run the web app elsewhere and point it at the indexer:
+
+```bash
+VITE_INDEXER_GRAPHQL_URL=http://localhost:3000 \
+ bun run --cwd apps/web dev -- --port 3001
+```
+
+See [`apps/s03-indexer/README.md`](./apps/s03-indexer/README.md) for the full
+indexer workflow.
+
### Running the development server
```bash
diff --git a/apps/web/.env.example b/apps/web/.env.example
index 2756ac3f..dd1e6702 100644
--- a/apps/web/.env.example
+++ b/apps/web/.env.example
@@ -27,9 +27,15 @@ VITE_HORIZON_URL=https://horizon-testnet.stellar.org
# ── SubQuery Indexer ──────────────────────────────────────────────────────────
# SubQuery GraphQL endpoint for indexed historical data.
-# Optional — when not set, indexer is disabled and app uses degraded fallback behavior.
-# Local development default: http://localhost:3000
-# Testnet default: (add testnet indexer URL when available)
+# Optional — when not set the indexer is disabled, and position/order history,
+# executed fills and fee activity show an explicit "unavailable" state instead
+# of data. See "Indexed history (SubQuery)" in the root README.
+#
+# There is no hosted endpoint yet for either network, so this is commented out
+# in .env.testnet and .env.mainnet. Run the stack locally with
+# `bun run --cwd apps/s03-indexer start` and point this at it. The GraphQL
+# engine also listens on 3000, so start the web app on another port
+# (`bun run --cwd apps/web dev -- --port 3001`).
# VITE_INDEXER_GRAPHQL_URL=http://localhost:3000
# ── Contract addresses ────────────────────────────────────────────────────────
diff --git a/apps/web/.env.mainnet b/apps/web/.env.mainnet
index f478d97c..4dfe2998 100644
--- a/apps/web/.env.mainnet
+++ b/apps/web/.env.mainnet
@@ -15,6 +15,12 @@ VITE_HORIZON_URL=https://horizon.stellar.org
# Optional — remove to fall back to GMX infra
# VITE_ORACLE_URL=https://arbitrum-api.gmxinfra.io
+# ── SubQuery indexer ──────────────────────────────────────────────────────────
+# Set this to the deployed mainnet SubQuery GraphQL endpoint. While unset the
+# indexer is disabled and the history panels say so instead of showing empty
+# tables.
+# VITE_INDEXER_GRAPHQL_URL=https://your-mainnet-indexer.example.com
+
# Mainnet contract addresses — replace with deployed IDs
VITE_CONTRACT_EXCHANGE_ROUTER=CXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
VITE_CONTRACT_SYNTHETICS_READER=CXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
diff --git a/apps/web/.env.testnet b/apps/web/.env.testnet
index 6aa7ef61..3d1980ea 100644
--- a/apps/web/.env.testnet
+++ b/apps/web/.env.testnet
@@ -12,6 +12,14 @@ VITE_HORIZON_URL=https://horizon-testnet.stellar.org
# Optional — remove to fall back to GMX infra
# VITE_ORACLE_URL=https://arbitrum-api.gmxinfra.io
+# ── SubQuery indexer ──────────────────────────────────────────────────────────
+# No hosted testnet indexer is deployed yet. Left unset on purpose: the app
+# then shows an explicit "unavailable" state in the history panels rather than
+# empty tables. To develop against a local stack, run
+# `bun run --cwd apps/s03-indexer start` and uncomment the line below (start
+# the web app on another port — the GraphQL engine also uses 3000).
+# VITE_INDEXER_GRAPHQL_URL=http://localhost:3000
+
# ── Core contracts ─────────────────────────────────────────────────────────────
VITE_CONTRACT_EXCHANGE_ROUTER=CBD6BQSQFROWIIT5QCYN7KL5LJJWUIH7CEWUSZIFMUJO6NPXE6CVGYNW
VITE_CONTRACT_SYNTHETICS_READER=CC6OZUHF3LVO6PNP3V2EB36ORB3YSVYSH3LWD3RFLO4NUO3BYCXSWSYC
diff --git a/apps/web/src/app/config/indexer.test.ts b/apps/web/src/app/config/indexer.test.ts
new file mode 100644
index 00000000..8a7c6964
--- /dev/null
+++ b/apps/web/src/app/config/indexer.test.ts
@@ -0,0 +1,47 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
+
+import {
+ INDEXER_CONFIG,
+ resetIndexerConfigReporting,
+ validateIndexerConfig,
+} from "./indexer"
+
+// Issue #859: the app renders server-side, so the module that validates the
+// indexer config is evaluated once per request as well as once in the browser.
+// A disabled indexer used to warn on every route the user visited.
+describe("validateIndexerConfig logging", () => {
+ beforeEach(() => {
+ resetIndexerConfigReporting()
+ })
+
+ afterEach(() => {
+ vi.restoreAllMocks()
+ resetIndexerConfigReporting()
+ })
+
+ it("is disabled when VITE_INDEXER_GRAPHQL_URL is not set", () => {
+ expect(INDEXER_CONFIG.enabled).toBe(false)
+ expect(INDEXER_CONFIG.graphqlUrl).toBeNull()
+ })
+
+ it("warns once about a disabled indexer, however often it is called", () => {
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {})
+
+ validateIndexerConfig()
+ validateIndexerConfig()
+ validateIndexerConfig()
+
+ expect(warn).toHaveBeenCalledTimes(1)
+ expect(warn.mock.calls[0]?.[0]).toMatch(
+ /Indexer disabled: VITE_INDEXER_GRAPHQL_URL not set/,
+ )
+ })
+
+ it("names the fallback behaviour so the warning is actionable", () => {
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {})
+
+ validateIndexerConfig()
+
+ expect(warn.mock.calls[0]?.[0]).toMatch(/contract-only data/)
+ })
+})
diff --git a/apps/web/src/app/config/indexer.ts b/apps/web/src/app/config/indexer.ts
index 059d6726..5ea012cd 100644
--- a/apps/web/src/app/config/indexer.ts
+++ b/apps/web/src/app/config/indexer.ts
@@ -52,13 +52,40 @@ export const INDEXER_CONFIG: IndexerConfig = {
// Validation
// ─────────────────────────────────────────────────────────────────────────────
+/**
+ * Whether this runtime has already reported the indexer's state.
+ *
+ * The app renders server-side, so the module that calls this runs once per
+ * request as well as once in the browser — without this latch, a disabled
+ * indexer logged the same warning on every route the user visited.
+ * Validation itself is pure, so skipping the repeats loses nothing.
+ */
+let hasReported = false
+
+/** Reset the once-per-runtime log latch. Test-only. */
+export function resetIndexerConfigReporting(): void {
+ hasReported = false
+}
+
/**
* Validate indexer config at startup.
* Throws descriptive error if required vars are missing.
+ *
+ * Safe to call more than once: the configuration errors below are raised every
+ * time, but the informational logs are emitted only on the first call.
*/
export function validateIndexerConfig(): void {
+ const report = !hasReported
+ hasReported = true
+
if (!INDEXER_CONFIG.enabled) {
- console.warn("Indexer disabled: VITE_INDEXER_GRAPHQL_URL not set")
+ if (report) {
+ console.warn(
+ "Indexer disabled: VITE_INDEXER_GRAPHQL_URL not set. " +
+ "Position and order history, fills, and fee activity fall back to " +
+ "contract-only data. See apps/web/.env.example.",
+ )
+ }
return
}
@@ -70,5 +97,7 @@ export function validateIndexerConfig(): void {
throw new Error(`Invalid network: ${INDEXER_CONFIG.network}`)
}
- console.log(`Indexer enabled: ${INDEXER_CONFIG.graphqlUrl} (${INDEXER_CONFIG.network})`)
+ if (report) {
+ console.log(`Indexer enabled: ${INDEXER_CONFIG.graphqlUrl} (${INDEXER_CONFIG.network})`)
+ }
}
diff --git a/apps/web/src/features/design-system/design-system-page.tsx b/apps/web/src/features/design-system/design-system-page.tsx
index 2b8617dd..cb9c9498 100644
--- a/apps/web/src/features/design-system/design-system-page.tsx
+++ b/apps/web/src/features/design-system/design-system-page.tsx
@@ -308,7 +308,9 @@ function TableExamples() {
Distribution history
-
+ {/* The name is what the scroll container announces when the table is
+ too wide to fit and becomes a keyboard-reachable region. */}
+
Epoch
@@ -434,6 +436,7 @@ export function DesignSystemPage() {