Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changelog/unreleased/853-857-design-system-accessibility.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 8 additions & 0 deletions .changelog/unreleased/858-trade-accessibility.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 8 additions & 0 deletions .changelog/unreleased/859-indexer-configuration.md
Original file line number Diff line number Diff line change
@@ -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.
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 9 additions & 3 deletions apps/web/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────────────────────────────────────────────────────────
Expand Down
6 changes: 6 additions & 0 deletions apps/web/.env.mainnet
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions apps/web/.env.testnet
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 47 additions & 0 deletions apps/web/src/app/config/indexer.test.ts
Original file line number Diff line number Diff line change
@@ -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/)
})
})
33 changes: 31 additions & 2 deletions apps/web/src/app/config/indexer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -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})`)
}
}
6 changes: 5 additions & 1 deletion apps/web/src/features/design-system/design-system-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -308,7 +308,9 @@ function TableExamples() {
<CardHeader>
<Heading level={3}>Distribution history</Heading>
</CardHeader>
<Table>
{/* The name is what the scroll container announces when the table is
too wide to fit and becomes a keyboard-reachable region. */}
<Table aria-label="Distribution history">
<TableHeader>
<TableHeadRow>
<TableHead>Epoch</TableHead>
Expand Down Expand Up @@ -434,6 +436,7 @@ export function DesignSystemPage() {
</div>
<div className="flex items-center gap-4">
<select
aria-label="Theme"
value={theme}
onChange={(e) => setTheme(e.target.value as "light" | "dark" | "system")}
className="rounded border border-border bg-background px-2 py-1 text-sm"
Expand All @@ -443,6 +446,7 @@ export function DesignSystemPage() {
<option value="system">System</option>
</select>
<select
aria-label="Preview viewport"
value={viewport}
onChange={(e) => setViewport(e.target.value as "mobile" | "tablet" | "desktop")}
className="rounded border border-border bg-background px-2 py-1 text-sm"
Expand Down
28 changes: 24 additions & 4 deletions apps/web/src/features/gallery/components/gallery-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,17 @@ export function GalleryPage() {

<Section title="Slider">
<div className="max-w-sm">
{/* Base UI's slider control is an <input type="range"> inside the
thumb, which a <label htmlFor> cannot target. Name it with
`aria-labelledby` pointing at the visible text instead. */}
<span
id="gallery-slider-label"
className="mb-2 block text-13 text-foreground"
>
Example value
</span>
<Slider
aria-labelledby="gallery-slider-label"
value={sliderValue}
onValueChange={(value) =>
setSliderValue(Array.isArray(value) ? [...value] : [value])
Expand Down Expand Up @@ -284,6 +294,8 @@ export function GalleryPage() {
Vertical — edge shadows follow scroll position
</p>
<ScrollArea
focusable
aria-label="Vertical scroll example"
className="max-h-40 rounded-md border border-border"
tone="card"
>
Expand All @@ -303,6 +315,8 @@ export function GalleryPage() {
</p>
<ScrollArea
orientation="horizontal"
focusable
aria-label="Horizontal scroll example"
className="rounded-md border border-border"
tone="card"
>
Expand Down Expand Up @@ -362,9 +376,10 @@ export function GalleryPage() {

<Section title="Tooltip">
<Tooltip>
<TooltipTrigger>
<Button variant="outline">Hover me</Button>
</TooltipTrigger>
{/* Base UI renders its own <button> for the trigger. A custom
element must go through `render`, or the result is a <button>
nested inside a <button> (hydration error + nested-interactive). */}
<TooltipTrigger render={<Button variant="outline">Hover me</Button>} />
<TooltipContent>A tooltip, positioned automatically.</TooltipContent>
</Tooltip>
</Section>
Expand Down Expand Up @@ -632,7 +647,12 @@ export function GalleryPage() {
{PROGRESS_TONES.map((tone) => (
<div key={`${size}-${tone}`} className="mb-3 space-y-1">
<span className="text-10 text-text-tertiary">{tone}</span>
<ProgressIndicator value={65} size={size} tone={tone} />
<ProgressIndicator
value={65}
size={size}
tone={tone}
label={`Progress example — ${size}, ${tone}`}
/>
</div>
))}
</div>
Expand Down
7 changes: 6 additions & 1 deletion apps/web/src/features/trade/components/chart/TVChart.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useRef } from "react"
import { cn } from "@workspace/ui/lib/utils"
import { useChartPreferencesStore } from "../../store/chart-preferences-store"
import { useMarkets } from "../../hooks/useMarkets"
import { ChartHeader } from "./ChartHeader"
import { TVChartContainer } from "./TVChartContainer"
import type { Period } from "../../store/chart-preferences-store"
Expand All @@ -15,6 +16,10 @@ type Props = {
export function TVChart({ symbol, onSelectToken }: Props) {
const period = useChartPreferencesStore((s) => s.period)
const setPeriod = useChartPreferencesStore((s) => s.setPeriod)
const { markets } = useMarkets()
// `symbol` is the index token's contract address. Resolve the market's
// display name so labels read "BTC/USD" rather than the raw address.
const marketLabel = markets.find((m) => m.indexTokenAddress === symbol)?.name
const buttonRefs = useRef<Map<Period, HTMLButtonElement>>(new Map())

// When the period changes, persist preference and invalidate the candles cache
Expand Down Expand Up @@ -83,7 +88,7 @@ export function TVChart({ symbol, onSelectToken }: Props) {
{/* Chart */}
<div className="min-h-0 flex-1">
{symbol ? (
<TVChartContainer symbol={symbol} period={period} />
<TVChartContainer symbol={symbol} period={period} marketLabel={marketLabel} />
) : (
<div role="status" className="flex h-full items-center justify-center text-xs text-muted-foreground">
Select a market
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,16 @@ vi.mock("../../hooks/usePositions", () => ({
usePositions: () => ({ data: mockPositions }),
}))

// The component also draws resting-order price lines. Without this the real
// hook runs `useQuery` outside a QueryClientProvider and every test in this
// file throws before rendering.
const mockOrders: Array<Record<string, unknown>> = []

vi.mock("../../hooks/useOrders", () => ({
useOrders: () => ({ data: mockOrders }),
hasFrozenOrders: () => false,
}))

// JSDOM lacks ResizeObserver / MutationObserver
const mockObserverDisconnect = vi.fn()
class ObserverStub {
Expand Down Expand Up @@ -137,8 +147,28 @@ describe("TVChartContainer", () => {
mockCandles = SAMPLE_CANDLES
render(<TVChartContainer {...defaultProps} />)

const chart = screen.getByRole("img", { name: /price chart for btc/i })
// Issue #858: the canvas is pannable and zoomable, so it must not be
// announced as a static image (axe nested-interactive).
const chart = screen.getByRole("region", { name: /price chart for btc/i })
expect(chart).toBeInTheDocument()
expect(screen.queryByRole("img")).not.toBeInTheDocument()
})

it("labels the chart with the market name rather than the raw address", () => {
mockCandles = SAMPLE_CANDLES
render(
<TVChartContainer
symbol="CCFTOPHUPSUDO2MB4X5D3XYJ2HRJ7NJPAW4UVPAVN7ZLE63EZLSMXDUO"
period="5m"
marketLabel="BTC/USD"
/>,
)

expect(
screen.getByRole("region", { name: "Price chart for BTC/USD" }),
).toBeInTheDocument()
expect(screen.getByText(/5m chart for BTC\/USD/)).toBeInTheDocument()
expect(screen.queryByText(/CCFTOPHUPSUDO/)).not.toBeInTheDocument()
})

it("describes the chart as linked from the summary", () => {
Expand Down
Loading
Loading