diff --git a/apps/web/src/features/trade/components/chart/TVChart.tsx b/apps/web/src/features/trade/components/chart/TVChart.tsx
index e8954c0..77b69ae 100644
--- a/apps/web/src/features/trade/components/chart/TVChart.tsx
+++ b/apps/web/src/features/trade/components/chart/TVChart.tsx
@@ -50,7 +50,7 @@ export function TVChart({ symbol, onSelectToken }: Props) {
{symbol ? (
) : (
-
+
Select a market
)}
diff --git a/apps/web/src/features/trade/components/chart/TVChartContainer.test.tsx b/apps/web/src/features/trade/components/chart/TVChartContainer.test.tsx
new file mode 100644
index 0000000..e42d750
--- /dev/null
+++ b/apps/web/src/features/trade/components/chart/TVChartContainer.test.tsx
@@ -0,0 +1,330 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
+import { cleanup, render, screen } from "@testing-library/react"
+import { TVChartContainer } from "./TVChartContainer"
+
+// ── Mocks ────────────────────────────────────────────────────────────────────
+
+vi.mock("lightweight-charts", () => ({
+ CandlestickSeries: Symbol("CandlestickSeries"),
+ LineStyle: { Dashed: 0, LargeDashed: 1 },
+ createChart: vi.fn(() => ({
+ addSeries: vi.fn(() => ({
+ setData: vi.fn(),
+ update: vi.fn(),
+ createPriceLine: vi.fn(() => ({ applyOptions: vi.fn() })),
+ removePriceLine: vi.fn(),
+ applyOptions: vi.fn(),
+ })),
+ remove: vi.fn(),
+ applyOptions: vi.fn(),
+ timeScale: vi.fn(() => ({ fitContent: vi.fn() })),
+ })),
+}))
+
+let mockCandles: Array
> = []
+let mockIsLoading = false
+let mockIsError = false
+let mockLiveBar: Record | null = null
+let mockPositions: Array> = []
+
+vi.mock("../../hooks/useOracleCandles", () => ({
+ useOracleCandles: () => ({
+ data: mockCandles,
+ isLoading: mockIsLoading,
+ isError: mockIsError,
+ }),
+}))
+
+vi.mock("../../hooks/useLiveBar", () => ({
+ useLiveBar: () => mockLiveBar,
+}))
+
+vi.mock("../../hooks/usePositions", () => ({
+ usePositions: () => ({ data: mockPositions }),
+}))
+
+// JSDOM lacks ResizeObserver / MutationObserver
+class ObserverStub {
+ observe = vi.fn()
+ unobserve = vi.fn()
+ disconnect = vi.fn()
+}
+globalThis.ResizeObserver = ObserverStub as unknown as typeof ResizeObserver
+globalThis.MutationObserver = ObserverStub as unknown as typeof MutationObserver
+
+// ── Helpers ───────────────────────────────────────────────────────────────────
+
+const SAMPLE_CANDLES = [
+ { time: 1_700_000_000, open: 100, high: 110, low: 90, close: 105 },
+ { time: 1_700_003_600, open: 105, high: 115, low: 95, close: 110 },
+ { time: 1_700_007_200, open: 110, high: 120, low: 100, close: 115 },
+]
+
+// ── Suite ────────────────────────────────────────────────────────────────────
+
+describe("TVChartContainer", () => {
+ afterEach(() => {
+ cleanup()
+ vi.clearAllMocks()
+ })
+
+ beforeEach(() => {
+ mockCandles = []
+ mockIsLoading = false
+ mockIsError = false
+ mockLiveBar = null
+ mockPositions = []
+ })
+
+ const defaultProps = { symbol: "BTC", period: "5m" }
+
+ // ═════════════════════════════════════════════════════════════════════════
+ // Summary content
+ // ═════════════════════════════════════════════════════════════════════════
+
+ it("renders an accessible summary with symbol, period, and latest price", () => {
+ mockCandles = SAMPLE_CANDLES
+ render()
+
+ const summary = screen.getByText(/5m chart for BTC/i)
+ expect(summary).toBeInTheDocument()
+
+ expect(screen.getByText(/Latest:/)).toBeInTheDocument()
+ })
+
+ it("does not render a summary when there is no candle data", () => {
+ mockCandles = []
+ render()
+
+ expect(screen.queryByText(/chart for BTC/i)).not.toBeInTheDocument()
+ })
+
+ it("renders chart container with accessible role and label", () => {
+ mockCandles = SAMPLE_CANDLES
+ render()
+
+ const chart = screen.getByRole("img", { name: /price chart for btc/i })
+ expect(chart).toBeInTheDocument()
+ })
+
+ it("describes the chart as linked from the summary", () => {
+ mockCandles = SAMPLE_CANDLES
+ const { container } = render()
+
+ const chart = container.querySelector('[aria-describedby="chart-desc"]')
+ expect(chart).toBeInTheDocument()
+ })
+
+ it('identifies prices with "upward" when close is significantly higher than open', () => {
+ mockCandles = [
+ { time: 1_700_000_000, open: 100, high: 150, low: 90, close: 145 },
+ ]
+ render()
+
+ expect(screen.getByText(/upward/i)).toBeInTheDocument()
+ })
+
+ it('identifies prices with "downward" when close is significantly lower than open', () => {
+ mockCandles = [
+ { time: 1_700_000_000, open: 100, high: 102, low: 50, close: 55 },
+ ]
+ render()
+
+ expect(screen.getByText(/downward/i)).toBeInTheDocument()
+ })
+
+ it('identifies prices as "sideways" when change is small', () => {
+ mockCandles = [
+ { time: 1_700_000_000, open: 100, high: 101, low: 99, close: 100.5 },
+ ]
+ render()
+
+ expect(screen.getByText(/sideways/i)).toBeInTheDocument()
+ })
+
+ // ═════════════════════════════════════════════════════════════════════════
+ // Data-table semantics
+ // ═════════════════════════════════════════════════════════════════════════
+
+ it("provides a toggle button to show OHLC data", () => {
+ mockCandles = SAMPLE_CANDLES
+ render()
+
+ const toggle = screen.getByRole("button", { name: /BTC OHLC Data/i })
+ expect(toggle).toBeInTheDocument()
+ expect(toggle).toHaveAttribute("aria-expanded", "false")
+ })
+
+ it("shows the data table when toggled", async () => {
+ mockCandles = SAMPLE_CANDLES
+ render()
+
+ const toggle = screen.getByRole("button", { name: /BTC OHLC Data/i })
+ toggle.click()
+
+ const headers = screen.getAllByRole("columnheader")
+ expect(headers).toHaveLength(5)
+ expect(headers[0]).toHaveTextContent(/time|date/i)
+ expect(headers[1]).toHaveTextContent("Open")
+ expect(headers[2]).toHaveTextContent("High")
+ expect(headers[3]).toHaveTextContent("Low")
+ expect(headers[4]).toHaveTextContent("Close")
+
+ const rows = screen.getAllByRole("row")
+ // header row + data rows
+ expect(rows.length).toBeGreaterThanOrEqual(4)
+ })
+
+ it("renders semantic table elements", () => {
+ mockCandles = SAMPLE_CANDLES
+ const { container } = render()
+
+ screen.getByRole("button", { name: /BTC OHLC Data/i }).click()
+
+ expect(container.querySelector("table")).toBeInTheDocument()
+ expect(container.querySelector("thead")).toBeInTheDocument()
+ expect(container.querySelector("tbody")).toBeInTheDocument()
+ })
+
+ it("renders rows in chronological order", () => {
+ mockCandles = SAMPLE_CANDLES
+ render()
+
+ screen.getByRole("button", { name: /BTC OHLC Data/i }).click()
+
+ const cells = screen.getAllByRole("cell")
+ const timeCells = cells.filter(
+ (c) => c.textContent && /\d/.test(c.textContent),
+ )
+
+ const times = timeCells.map((c) => new Date(c.textContent!).getTime())
+ for (let i = 1; i < times.length; i++) {
+ expect(times[i]).toBeGreaterThanOrEqual(times[i - 1])
+ }
+ })
+
+ it("toggles the table hidden state correctly", () => {
+ mockCandles = SAMPLE_CANDLES
+ render()
+
+ const toggle = screen.getByRole("button", { name: /BTC OHLC Data/i })
+ expect(toggle).toHaveAttribute("aria-expanded", "false")
+
+ toggle.click()
+ expect(toggle).toHaveAttribute("aria-expanded", "true")
+ expect(screen.getByText("Open")).toBeInTheDocument()
+
+ toggle.click()
+ expect(toggle).toHaveAttribute("aria-expanded", "false")
+ })
+
+ it("limits representative rows to avoid giant tables", () => {
+ const manyCandles = Array.from({ length: 200 }, (_, i) => ({
+ time: 1_700_000_000 + i * 60,
+ open: 100 + i,
+ high: 110 + i,
+ low: 90 + i,
+ close: 105 + i,
+ }))
+ mockCandles = manyCandles
+ render()
+
+ screen.getByRole("button", { name: /BTC OHLC Data/i }).click()
+
+ // header row + max 15 data rows
+ const rows = screen.getAllByRole("row")
+ expect(rows.length).toBeLessThanOrEqual(16)
+ })
+
+ // ═════════════════════════════════════════════════════════════════════════
+ // Announcement throttling
+ // ═════════════════════════════════════════════════════════════════════════
+
+ it("announces price updates via live region", () => {
+ mockCandles = SAMPLE_CANDLES
+ mockLiveBar = { time: 1_700_010_000, open: 112, high: 118, low: 108, close: 115 }
+ render()
+
+ const region = screen.getByRole("status")
+ expect(region).toBeInTheDocument()
+ expect(region).toHaveAttribute("aria-live", "polite")
+ })
+
+ it("does not make a new announcement within the throttle window", () => {
+ vi.useFakeTimers()
+ mockCandles = SAMPLE_CANDLES
+
+ const { rerender } = render()
+
+ // First live bar triggers announcement
+ mockLiveBar = { time: 1_700_010_000, open: 112, high: 118, low: 108, close: 115 }
+ rerender()
+
+ const region = screen.getByRole("status")
+ const textAfterFirst = region.textContent ?? ""
+ expect(textAfterFirst).toMatch(/BTC/)
+
+ // Second live bar within 5 seconds — no new announcement
+ mockLiveBar = { time: 1_700_010_001, open: 113, high: 119, low: 109, close: 116 }
+ rerender()
+
+ expect(region.textContent).toBe(textAfterFirst)
+
+ vi.useRealTimers()
+ })
+
+ it("makes a new announcement after the throttle window elapses", () => {
+ vi.useFakeTimers()
+ mockCandles = SAMPLE_CANDLES
+
+ const { rerender } = render()
+
+ // First
+ mockLiveBar = { time: 1_700_010_000, open: 112, high: 118, low: 108, close: 115 }
+ rerender()
+
+ const region = screen.getByRole("status")
+ const textAfterFirst = region.textContent ?? ""
+
+ // Advance past 5s throttle
+ vi.advanceTimersByTime(6000)
+
+ // Second live bar after throttle window
+ mockLiveBar = { time: 1_700_010_001, open: 113, high: 119, low: 109, close: 116 }
+ rerender()
+
+ expect(region.textContent).not.toBe(textAfterFirst)
+
+ vi.useRealTimers()
+ })
+
+ // ═════════════════════════════════════════════════════════════════════════
+ // Loading, empty, and error states
+ // ═════════════════════════════════════════════════════════════════════════
+
+ it("shows loading state with accessible text", () => {
+ mockIsLoading = true
+ mockCandles = []
+ render()
+
+ const loading = screen.getByRole("status", { name: /loading 5m chart data for btc/i })
+ expect(loading).toBeInTheDocument()
+ })
+
+ it("shows empty state when no data and not loading", () => {
+ mockCandles = []
+ render()
+
+ const empty = screen.getByRole("status")
+ expect(empty).toHaveTextContent(/no trading data available for btc/i)
+ })
+
+ it("shows error state with alert role", () => {
+ mockIsError = true
+ mockCandles = []
+ render()
+
+ const error = screen.getByRole("alert")
+ expect(error).toHaveTextContent(/unable to load chart data for btc/i)
+ })
+})
diff --git a/apps/web/src/features/trade/components/chart/TVChartContainer.tsx b/apps/web/src/features/trade/components/chart/TVChartContainer.tsx
index bdb6c7e..6b6935b 100644
--- a/apps/web/src/features/trade/components/chart/TVChartContainer.tsx
+++ b/apps/web/src/features/trade/components/chart/TVChartContainer.tsx
@@ -1,6 +1,17 @@
import { CandlestickSeries, LineStyle, createChart } from "lightweight-charts"
-import { useEffect, useRef, useState } from "react"
+import { useEffect, useMemo, useRef, useState } from "react"
import { Skeleton } from "@workspace/ui/components/skeleton"
+import { VisuallyHidden } from "@workspace/ui/components/visually-hidden"
+import { LiveRegion, useAnnouncer } from "@workspace/ui/components/live-region"
+import {
+ Table,
+ TableHeader,
+ TableBody,
+ TableHeadRow,
+ TableHead,
+ TableRow,
+ TableCell,
+} from "@workspace/ui/components/table"
import { useOracleCandles } from "../../hooks/useOracleCandles"
import { useLiveBar } from "../../hooks/useLiveBar"
import { usePositions } from "../../hooks/usePositions"
@@ -10,6 +21,7 @@ import {
getChartPalette,
positionLineColor,
} from "../../lib/chart-theme"
+import { formatUsd } from "@/shared/lib/format"
import type {CandlestickData, IChartApi, IPriceLine, ISeriesApi, UTCTimestamp} from "lightweight-charts";
import type { OhlcBar } from "../../lib/oracle"
@@ -26,6 +38,52 @@ type Props = {
period: string
}
+type ChartSummary = {
+ timeRange: string
+ latestPrice: string
+ trend: string
+ description: string
+}
+
+function getChartSummary(
+ candles: Array,
+ liveBar: OhlcBar | null,
+ symbol: string,
+ period: string,
+): ChartSummary | null {
+ if (candles.length === 0) return null
+ const first = candles[0]
+ const last = candles[candles.length - 1]
+ const latest = liveBar ?? last
+ const start = new Date(first.time * 1000).toLocaleDateString()
+ const end = new Date(last.time * 1000).toLocaleDateString()
+ const timeRange = first.time === last.time ? start : `${start} – ${end}`
+ const latestPrice = formatUsd(latest.close, { decimals: 4 })
+ const mid = (candles[0].open + last.close) / 2
+ const change = mid === 0 ? 0 : ((last.close - candles[0].open) / mid) * 100
+ const trend = change > 0.5 ? "upward" : change < -0.5 ? "downward" : "sideways"
+ const description =
+ `${period} chart for ${symbol}. ` +
+ `Range: ${timeRange}. ` +
+ `Latest: ${latestPrice}. ` +
+ `Trend: ${trend}.`
+ return { timeRange, latestPrice, trend, description }
+}
+
+function getRepresentativeRows(
+ candles: Array,
+ maxRows = 15,
+): Array {
+ if (candles.length <= maxRows) return candles
+ const step = Math.floor((candles.length - 2) / (maxRows - 2))
+ const rows: Array = [candles[0]]
+ for (let i = 1; i < maxRows - 1; i++) {
+ rows.push(candles[i * step])
+ }
+ rows.push(candles[candles.length - 1])
+ return rows
+}
+
function toChartBar(bar: OhlcBar): CandlestickData {
return {
time: bar.time as UTCTimestamp,
@@ -45,13 +103,22 @@ export function TVChartContainer({ symbol, period }: Props) {
// Prevents series.update() from firing against an empty or stale series.
const hasDataRef = useRef(false)
- const { data: candles = [], isLoading } = useOracleCandles(symbol, period)
+ const { data: candles = [], isLoading, isError } = useOracleCandles(symbol, period)
const liveBar = useLiveBar(symbol, period)
const { data: positions = [] } = usePositions()
+ const [showTable, setShowTable] = useState(false)
+ const lastAnnounceRef = useRef(0)
+ const announcer = useAnnouncer()
+
// Bumped by the theme observer below so colour-dependent effects re-run.
const [themeVersion, setThemeVersion] = useState(0)
+ const hasData = candles.length > 0
+ const isIdle = !isLoading && !hasData && !isError
+ const summary = useMemo(() => getChartSummary(candles, liveBar, symbol, period), [candles, liveBar, symbol, period])
+ const tableRows = useMemo(() => getRepresentativeRows(candles), [candles])
+
// ── Mount chart once ───────────────────────────────────────────────────────
useEffect(() => {
if (!containerRef.current) return
@@ -197,14 +264,115 @@ export function TVChartContainer({ symbol, period }: Props) {
})
}, [positions, symbol, themeVersion])
+ // ── Throttled live-price announcements ────────────────────────────────────
+ const THROTTLE_MS = 5000
+ useEffect(() => {
+ if (!liveBar || !summary) return
+ const now = Date.now()
+ if (now - lastAnnounceRef.current < THROTTLE_MS) return
+ lastAnnounceRef.current = now
+ announcer.announce(`${symbol} ${summary.latestPrice}`)
+ }, [liveBar, summary, symbol, announcer])
+
return (
-
- {isLoading && (
-
-
+
+ {/* ═══ Chart area ═══════════════════════════════════════════════════════ */}
+
+ {/* Accessible summary for screen readers */}
+ {summary && (
+
+ {summary.description}
+
+ )}
+
+ {/* Loading skeleton */}
+ {isLoading && (
+
+
+ Loading {period} chart data for {symbol}…
+
+ )}
+
+ {/* Empty state */}
+ {isIdle && (
+
+ No trading data available for {symbol}
+
+ )}
+
+ {/* Error state */}
+ {isError && (
+
+ Unable to load chart data for {symbol}
+
+ )}
+
+ {/* Lightweight Charts canvas — hidden from AT; the VisuallyHidden summary replaces it */}
+
+
+
+ {/* ═══ Data-table fallback ══════════════════════════════════════════════ */}
+ {hasData && tableRows.length > 0 && (
+
+
+
+ {showTable && (
+
+
+
+
+
+ Time
+ Date
+
+ Open
+ High
+ Low
+ Close
+
+
+
+ {tableRows.map((bar) => (
+
+
+ {new Date(bar.time * 1000).toLocaleString()}
+
+ {formatUsd(bar.open, { decimals: 4 })}
+ {formatUsd(bar.high, { decimals: 4 })}
+ {formatUsd(bar.low, { decimals: 4 })}
+ {formatUsd(bar.close, { decimals: 4 })}
+
+ ))}
+
+
+
+ )}
)}
-
+
+ {/* ═══ Live price announcer (throttled, polite) ════════════════════════ */}
+
)
}