diff --git a/frontend/src/components/lesson/InteractiveDiffViewer.tsx b/frontend/src/components/lesson/InteractiveDiffViewer.tsx
new file mode 100644
index 00000000..f6d3f7c7
--- /dev/null
+++ b/frontend/src/components/lesson/InteractiveDiffViewer.tsx
@@ -0,0 +1,302 @@
+'use client';
+
+import dynamic from 'next/dynamic';
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { Check, Columns2, CopyPlus, GitCompare, Loader2, Rows2 } from 'lucide-react';
+import { useDiffWorker } from '@/hooks/useDiffWorker';
+import type { DiffChunk } from '@/lib/diff/diffTypes';
+
+/**
+ * InteractiveDiffViewer
+ *
+ * Side-by-side Monaco diff comparison for students to compare their working
+ * contract code against model solutions. Features:
+ *
+ * - **Inline / side-by-side toggles** via Monaco's `renderSideBySide`.
+ * - **Character-level highlighting** — Monaco's inline diff mode highlights
+ * changed characters, and the chunk list renders add/remove segments.
+ * - **Apply Diff Chunk** — each hunk gets a one-click control that merges the
+ * solution's lines for that hunk into the student buffer.
+ * - **Worker-backed diffing** — chunk computation runs in a Web Worker so
+ * large multi-file diffs never stutter the UI thread.
+ * - **Themes** — dark, light, and OLED Monaco themes (OLED defined on mount).
+ *
+ * The `DiffEditor` is pulled in via `next/dynamic` (`ssr: false`) to keep the
+ * server bundle lean, matching `LessonCodeEditor`.
+ */
+
+const DiffEditor = dynamic(() => import('@monaco-editor/react').then((m) => m.DiffEditor), {
+ ssr: false,
+ loading: () => (
+
+
+
+
Loading diff editor…
+
+
+ ),
+});
+
+export type DiffTheme = 'dark' | 'light' | 'oled';
+export type DiffViewMode = 'side-by-side' | 'inline';
+
+export interface InteractiveDiffViewerProps {
+ /** The student's current code (left / original side). */
+ original: string;
+ /** The model solution (right / modified side). */
+ modified: string;
+ /** Monaco language id (defaults to Rust for Soroban contracts). */
+ language?: string;
+ /** Filename shown in the header. */
+ filename?: string;
+ /** Active Monaco theme family. */
+ theme?: DiffTheme;
+ /** Initial view mode. */
+ defaultViewMode?: DiffViewMode;
+ /** Called with the merged buffer whenever the student applies a chunk. */
+ onApplyChunk?: (chunk: DiffChunk, mergedCode: string) => void;
+}
+
+const THEME_TO_MONACO: Record = {
+ dark: 'vs-dark',
+ light: 'light',
+ oled: 'oled-diff-theme',
+};
+
+function themeSurface(theme: DiffTheme): string {
+ if (theme === 'oled') return 'bg-black';
+ if (theme === 'light') return 'bg-zinc-100';
+ return 'bg-[#09090b]';
+}
+
+function themeBorder(theme: DiffTheme): string {
+ if (theme === 'light') return 'border-zinc-300';
+ return 'border-white/10';
+}
+
+function themeText(theme: DiffTheme): string {
+ if (theme === 'light') return 'text-zinc-600';
+ return 'text-gray-400';
+}
+
+export function InteractiveDiffViewer({
+ original,
+ modified,
+ language = 'rust',
+ filename = 'lib.rs',
+ theme = 'dark',
+ defaultViewMode = 'side-by-side',
+ onApplyChunk,
+}: InteractiveDiffViewerProps) {
+ const computeDiff = useDiffWorker();
+ const [viewMode, setViewMode] = useState(defaultViewMode);
+ const [chunks, setChunks] = useState([]);
+ const [computing, setComputing] = useState(false);
+ const [appliedIds, setAppliedIds] = useState>(new Set());
+ const [error, setError] = useState(null);
+ const [buffer, setBuffer] = useState(original);
+
+ useEffect(() => {
+ setBuffer(original);
+ setAppliedIds(new Set());
+ setError(null);
+ }, [original]);
+
+ useEffect(() => {
+ let cancelled = false;
+ setComputing(true);
+ setError(null);
+
+ // Run chunk computation off the main thread; Monaco renders independently.
+ computeDiff(original, modified)
+ .then((result) => {
+ if (cancelled) return;
+ setChunks(result.chunks);
+ })
+ .catch(() => {
+ if (!cancelled) setError('Could not compute the diff.');
+ })
+ .finally(() => {
+ if (!cancelled) setComputing(false);
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [original, modified, computeDiff]);
+
+ const monacoTheme = THEME_TO_MONACO[theme];
+
+ const handleEditorMount = useCallback(
+ (_editor: unknown, monaco: typeof import('@monaco-editor/react') extends never ? never : any) => {
+ // Define the OLED theme once: pure black background, dim gray foreground.
+ if (!monaco.editor.getTheme || !monaco.editor.defineTheme) return;
+ if (!monaco.editor.getTheme('oled-diff-theme')) {
+ monaco.editor.defineTheme('oled-diff-theme', {
+ base: 'vs-dark',
+ inherit: true,
+ rules: [{ token: '', foreground: '9ca3af' }],
+ colors: {
+ 'editor.background': '#000000',
+ 'editor.foreground': '#9ca3af',
+ 'diffEditor.insertedTextBackground': '#052e16',
+ 'diffEditor.removedTextBackground': '#450a0a',
+ 'editor.lineHighlightBackground': '#000000',
+ },
+ });
+ }
+ },
+ []
+ );
+
+ const handleApplyChunk = useCallback(
+ (chunk: DiffChunk) => {
+ if (appliedIds.has(chunk.id)) return;
+ const nextApplied = new Set(appliedIds);
+ nextApplied.add(chunk.id);
+ setAppliedIds(nextApplied);
+
+ // Replace the chunk's original lines with the solution lines in the buffer.
+ const originalLines = buffer.split('\n');
+ const start = chunk.startLineOriginal - 1;
+ const end = start + chunk.originalLines.length;
+ const replacement = chunk.modifiedLines.map((line) => line.replace(/\n$/, ''));
+ const merged = [...originalLines.slice(0, start), ...replacement, ...originalLines.slice(end)].join('\n');
+ setBuffer(merged);
+ onApplyChunk?.(chunk, merged);
+ },
+ [appliedIds, buffer, onApplyChunk]
+ );
+
+ const remainingCount = useMemo(
+ () => chunks.filter((c) => !appliedIds.has(c.id)).length,
+ [chunks, appliedIds]
+ );
+
+ return (
+
+ {/* Toolbar */}
+
+
+
+
+ {filename}
+
+ {computing && }
+
+
+
+ {/* View mode toggle */}
+
+
+
+
+
+
+ {remainingCount === 0 ? 'All changes applied' : `${remainingCount} change${remainingCount === 1 ? '' : 's'} left`}
+
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {/* Monaco diff */}
+
+
+
+
+ {/* Apply-chunk controls */}
+ {chunks.length > 0 && (
+
+ )}
+
+ );
+}
+
+export default InteractiveDiffViewer;
diff --git a/frontend/src/components/lesson/__tests__/InteractiveDiffViewer.test.tsx b/frontend/src/components/lesson/__tests__/InteractiveDiffViewer.test.tsx
new file mode 100644
index 00000000..c3912f9d
--- /dev/null
+++ b/frontend/src/components/lesson/__tests__/InteractiveDiffViewer.test.tsx
@@ -0,0 +1,120 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { cleanup, render, screen, fireEvent, waitFor } from '@testing-library/react';
+import React from 'react';
+
+// Replace the dynamically-imported Monaco DiffEditor with a lightweight stand-in.
+vi.mock('next/dynamic', () => ({
+ default: () => {
+ const MockDiffEditor = (props: any) => (
+
+
+
+ );
+ MockDiffEditor.displayName = 'MockDiffEditor';
+ return MockDiffEditor;
+ },
+}));
+
+vi.mock('@/hooks/useDiffWorker', () => ({
+ useDiffWorker: () => vi.fn(),
+}));
+
+import { InteractiveDiffViewer, type DiffChunk } from '../InteractiveDiffViewer';
+import { useDiffWorker } from '@/hooks/useDiffWorker';
+
+const chunks: DiffChunk[] = [
+ {
+ id: 'chunk-0',
+ kind: 'replace',
+ startLineOriginal: 2,
+ startLineModified: 2,
+ originalLines: [' let a = 1;\n'],
+ modifiedLines: [' let a = 2;\n'],
+ segments: [{ text: 'let a =', kind: 'same' }, { text: '2', kind: 'add' }],
+ },
+];
+
+const mockedComputeDiff = vi.mocked(vi.fn());
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ (useDiffWorker as ReturnType).mockReturnValue(mockedComputeDiff);
+ mockedComputeDiff.mockResolvedValue({ chunks, identical: false });
+});
+
+afterEach(() => cleanup());
+
+describe('InteractiveDiffViewer', () => {
+ it('renders the Monaco diff with the requested language and theme', () => {
+ render(
+
+ );
+ const diff = screen.getByTestId('monaco-diff');
+ expect(diff).toHaveAttribute('data-language', 'rust');
+ expect(diff).toHaveAttribute('data-theme', 'vs-dark');
+ expect(diff).toHaveAttribute('data-side-by-side', 'true');
+ });
+
+ it('toggles between side-by-side and inline views', () => {
+ render();
+ fireEvent.click(screen.getByRole('button', { name: /inline/i }));
+ expect(screen.getByTestId('monaco-diff')).toHaveAttribute('data-side-by-side', 'false');
+ fireEvent.click(screen.getByRole('button', { name: /side-by-side/i }));
+ expect(screen.getByTestId('monaco-diff')).toHaveAttribute('data-side-by-side', 'true');
+ });
+
+ it('maps the OLED theme to the custom Monaco theme', () => {
+ render();
+ expect(screen.getByTestId('monaco-diff')).toHaveAttribute('data-theme', 'oled-diff-theme');
+ });
+
+ it('computes chunks via the diff worker', async () => {
+ render();
+ await waitFor(() => {
+ expect(mockedComputeDiff).toHaveBeenCalledWith('let a = 1;', 'let a = 2;');
+ });
+ expect(await screen.findByRole('button', { name: /apply chunk at line 2/i })).toBeInTheDocument();
+ });
+
+ it('applies a chunk and merges solution lines into the buffer', async () => {
+ const onApplyChunk = vi.fn();
+ render(
+
+ );
+
+ const applyButton = await screen.findByRole('button', { name: /apply chunk at line 2/i });
+ fireEvent.click(applyButton);
+
+ await waitFor(() => {
+ expect(onApplyChunk).toHaveBeenCalled();
+ const [, merged] = onApplyChunk.mock.calls[0] as [DiffChunk, string];
+ expect(merged).toContain('let a = 2;');
+ });
+ // Button flips to the applied state.
+ expect(screen.getByRole('button', { name: /applied/i })).toBeInTheDocument();
+ });
+
+ it('marks the diff as fully applied when every chunk is applied', async () => {
+ render();
+ const applyButton = await screen.findByRole('button', { name: /apply chunk at line 2/i });
+ fireEvent.click(applyButton);
+ expect(await screen.findByText(/all changes applied/i)).toBeInTheDocument();
+ });
+
+ it('shows an error banner when the worker fails', async () => {
+ mockedComputeDiff.mockRejectedValue(new Error('boom'));
+ render();
+ expect(await screen.findByRole('alert')).toHaveTextContent(/could not compute the diff/i);
+ });
+});
diff --git a/frontend/src/components/quiz/QuizEngine.tsx b/frontend/src/components/quiz/QuizEngine.tsx
index 21f37544..fa96853d 100644
--- a/frontend/src/components/quiz/QuizEngine.tsx
+++ b/frontend/src/components/quiz/QuizEngine.tsx
@@ -83,6 +83,23 @@ export default function QuizEngine() {
event.preventDefault();
};
+ const handleReorderKey = (
+ event: React.KeyboardEvent,
+ index: number
+ ) => {
+ const { dragOrder } = current.context;
+ if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return;
+
+ const targetIndex =
+ event.key === 'ArrowUp' ? index - 1 : index + 1;
+ if (targetIndex < 0 || targetIndex >= dragOrder.length) return;
+
+ event.preventDefault();
+ const nextOrder = [...dragOrder];
+ [nextOrder[index], nextOrder[targetIndex]] = [nextOrder[targetIndex], nextOrder[index]];
+ send({ type: 'UPDATE_ORDER', order: nextOrder });
+ };
+
return (
@@ -212,7 +229,9 @@ export default function QuizEngine() {
onDragStart={(event) => handleDragStart(event, index)}
onDragOver={handleDragOver}
onDrop={(event) => handleDrop(event, index)}
- className="rounded-3xl border border-white/10 bg-white/5 px-5 py-4 text-left text-base text-gray-200 transition hover:border-red-500/40 hover:bg-white/10"
+ onKeyDown={(event) => handleReorderKey(event, index)}
+ aria-label={`${segment}. Step ${index + 1} of ${current.context.dragOrder.length}. Use Arrow Up or Arrow Down to reorder.`}
+ className="rounded-3xl border border-white/10 bg-white/5 px-5 py-4 text-left text-base text-gray-200 transition hover:border-red-500/40 hover:bg-white/10 focus:border-red-500 focus:outline-none"
>
Step {index + 1}
@@ -315,13 +334,26 @@ export default function QuizEngine() {
Time Remaining
-
+
-
{timeLeft}s
+
+ {timeLeft}s
+
diff --git a/frontend/src/components/simulator/BlockchainExplorer.tsx b/frontend/src/components/simulator/BlockchainExplorer.tsx
index 3f3ecf22..b465db1b 100644
--- a/frontend/src/components/simulator/BlockchainExplorer.tsx
+++ b/frontend/src/components/simulator/BlockchainExplorer.tsx
@@ -52,13 +52,22 @@ function ConnectionDot({ status }: { status: string }) {
function TxRow({ tx }: { tx: ExplorerTransaction }) {
const [expanded, setExpanded] = useState(false);
+ const toggleExpanded = () => setExpanded((v) => !v);
+
return (
<>
setExpanded((v) => !v)}
+ onClick={toggleExpanded}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ toggleExpanded();
+ }
+ }}
+ tabIndex={0}
aria-expanded={expanded}
- aria-label={`Transaction ${tx.hash}, status ${tx.status}`}
+ aria-label={`Transaction ${tx.hash}, status ${tx.status}. Activate to ${expanded ? 'collapse' : 'expand'} details.`}
>
| {tx.hash.slice(0, 8)}… |
{tx.operation} |
diff --git a/frontend/src/components/simulator/NodeDetailPanel.tsx b/frontend/src/components/simulator/NodeDetailPanel.tsx
index fa95e6f3..cffff909 100644
--- a/frontend/src/components/simulator/NodeDetailPanel.tsx
+++ b/frontend/src/components/simulator/NodeDetailPanel.tsx
@@ -1,6 +1,6 @@
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion';
import { Activity, ExternalLink, Shield, Wallet, X } from 'lucide-react';
-import React from 'react';
+import React, { useEffect, useRef } from 'react';
import { NetworkNode } from '../../lib/visualization/ForceSimulation';
interface NodeDetailPanelProps {
@@ -8,12 +8,62 @@ interface NodeDetailPanelProps {
onClose: () => void;
}
+const FOCUSABLE_SELECTOR =
+ 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
+
export const NodeDetailPanel: React.FC = ({ node, onClose }) => {
const shouldReduceMotion = useReducedMotion();
+ const panelRef = useRef(null);
+ const onCloseRef = useRef(onClose);
+ onCloseRef.current = onClose;
+
+ // Keyboard operability: move focus into the panel on open, trap Tab
+ // inside it, restore focus on close, and let Escape dismiss the dialog.
+ useEffect(() => {
+ const panel = panelRef.current;
+ if (!panel) return;
+
+ const previouslyFocused = document.activeElement as HTMLElement | null;
+ const closeButton = panel.querySelector('button[aria-label^="Close"]');
+ closeButton?.focus();
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === 'Escape') {
+ event.preventDefault();
+ onCloseRef.current();
+ return;
+ }
+
+ if (event.key !== 'Tab') return;
+
+ const focusable = Array.from(panel.querySelectorAll(FOCUSABLE_SELECTOR));
+ if (focusable.length === 0) return;
+
+ const first = focusable[0];
+ const last = focusable[focusable.length - 1];
+ const active = document.activeElement;
+
+ if (event.shiftKey && active === first) {
+ event.preventDefault();
+ last.focus();
+ } else if (!event.shiftKey && active === last) {
+ event.preventDefault();
+ first.focus();
+ }
+ };
+
+ panel.addEventListener('keydown', handleKeyDown);
+
+ return () => {
+ panel.removeEventListener('keydown', handleKeyDown);
+ previouslyFocused?.focus();
+ };
+ }, []);
return (
;
+ return (
+ <>
+
+ {/* Visually hidden live region so screen readers announce value changes */}
+
+ {`${label}: ${Math.round(value)}${unit}`}
+
+ >
+ );
}
diff --git a/frontend/src/components/stellar-scp/SCPVisualizer.tsx b/frontend/src/components/stellar-scp/SCPVisualizer.tsx
index f589b583..9e4500ae 100644
--- a/frontend/src/components/stellar-scp/SCPVisualizer.tsx
+++ b/frontend/src/components/stellar-scp/SCPVisualizer.tsx
@@ -366,11 +366,28 @@ export const SCPVisualizer: React.FC = () => {
scpState.nodes.filter((n) => !n.failed).length <
Math.ceil(scpState.nodes.length * 0.66);
+ const handleToggleNodeFailureKey = (
+ event: React.KeyboardEvent,
+ nodeId: string
+ ) => {
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault();
+ handleToggleNodeFailure(nodeId);
+ }
+ };
+
return (
+ {/* Screen reader announcements for simulation state changes */}
+
+ {`${scpState.phase} phase, round ${scpState.round}, step ${scpState.step}.`}
+ {consensusReached ? ' Consensus reached.' : ''}
+ {consensusFailed ? ' Consensus failed.' : ''}
+
+
{/* Header */}
@@ -456,10 +473,14 @@ export const SCPVisualizer: React.FC = () => {
{/* Speed Control */}
-
{/* Node Status */}
@@ -480,8 +503,13 @@ export const SCPVisualizer: React.FC = () => {
{scpState.nodes.map((node) => (
handleToggleNodeFailure(node.id)}
+ onKeyDown={(event) => handleToggleNodeFailureKey(event, node.id)}
+ aria-pressed={node.failed}
+ aria-label={`${node.label}. Currently ${node.failed ? 'failed' : node.state}. Activate to toggle failure state.`}
title={`Click to toggle failure state. Currently: ${node.failed ? 'FAILED' : node.state.toUpperCase()}`}
>
Promise
{
+ const workerRef = useRef(null);
+ const pendingRef = useRef(new Map void>());
+ const requestIdRef = useRef(0);
+
+ const getWorker = useCallback((): Worker => {
+ if (workerRef.current) return workerRef.current;
+ const worker = new Worker(new URL('../lib/diff/diff.worker.ts', import.meta.url), {
+ type: 'module',
+ });
+ worker.addEventListener('message', (event: MessageEvent) => {
+ const msg = event.data;
+ if (msg.type === 'diff-result') {
+ const resolve = pendingRef.current.get(msg.requestId);
+ if (resolve) {
+ pendingRef.current.delete(msg.requestId);
+ resolve(msg.result);
+ }
+ } else if (msg.type === 'diff-error') {
+ const resolve = pendingRef.current.get(msg.requestId);
+ if (resolve) {
+ pendingRef.current.delete(msg.requestId);
+ resolve({ chunks: [], identical: true });
+ }
+ }
+ });
+ workerRef.current = worker;
+ return worker;
+ }, []);
+
+ // Terminate the worker on unmount so no work leaks into the next page.
+ useEffect(() => {
+ const worker = workerRef.current;
+ return () => {
+ worker?.terminate();
+ pendingRef.current.clear();
+ };
+ }, []);
+
+ return useCallback(
+ (original: string, modified: string): Promise => {
+ if (original === modified) {
+ return Promise.resolve({ chunks: [], identical: true });
+ }
+ const worker = getWorker();
+ const requestId = `diff-${++requestIdRef.current}`;
+ return new Promise((resolve) => {
+ pendingRef.current.set(requestId, resolve);
+ const payload: DiffWorkerRequest = { type: 'diff', requestId, original, modified };
+ worker.postMessage(payload);
+ });
+ },
+ [getWorker]
+ );
+}
diff --git a/frontend/src/lib/diff/diff.worker.ts b/frontend/src/lib/diff/diff.worker.ts
new file mode 100644
index 00000000..ced53022
--- /dev/null
+++ b/frontend/src/lib/diff/diff.worker.ts
@@ -0,0 +1,132 @@
+///
+
+import { diff_match_patch as DiffMatchPatch } from 'diff-match-patch';
+import type {
+ DiffChunk,
+ DiffSegment,
+ DiffWorkerRequest,
+ DiffWorkerResponse,
+} from './diffTypes';
+
+type WorkerSelf = typeof self & { postMessage(message: DiffWorkerResponse): void };
+const workerSelf = self as WorkerSelf;
+
+const dmp = new DiffMatchPatch();
+
+/** Split a diff op's text into lines, keeping trailing newlines on each line. */
+function splitLines(text: string): string[] {
+ if (text.length === 0) return [];
+ return text.split(/(?<=\n)/);
+}
+
+/**
+ * Compute character-level segments for a chunk by re-diffing its removed and
+ * added text. Kept separate so Monaco decorations and the chunk list can both
+ * render inline add/remove highlights.
+ */
+function charSegments(originalText: string, modifiedText: string): DiffSegment[] {
+ const ops = dmp.diff_main(originalText, modifiedText);
+ const segments: DiffSegment[] = [];
+ for (const [op, text] of ops) {
+ if (!text) continue;
+ if (op === 0) segments.push({ text, kind: 'same' });
+ else if (op === 1) segments.push({ text, kind: 'add' });
+ else segments.push({ text, kind: 'remove' });
+ }
+ return segments;
+}
+
+/**
+ * Compute a line-level chunk model from two code strings.
+ *
+ * Uses `diff_linesToChars` (O(ND) on lines) so large multi-file comparisons
+ * stay fast, then maps the line tokens back to concrete strings. Equal
+ * (context) regions advance both line counters; contiguous deletions and
+ * insertions between context regions merge into a single `replace` chunk,
+ * which is what the "apply chunk" control treats as a unit.
+ */
+export function computeDiff(original: string, modified: string): {
+ chunks: DiffChunk[];
+ identical: boolean;
+} {
+ if (original === modified) {
+ return { chunks: [], identical: true };
+ }
+
+ const chars = dmp.diff_linesToChars(original, modified);
+ const lineOps = dmp.diff_main(chars.chars1, chars.chars2, false);
+ dmp.diff_charsToLines(lineOps, chars.lineArray);
+
+ const chunks: DiffChunk[] = [];
+ let originalLine = 1;
+ let modifiedLine = 1;
+
+ let regionStartOriginal = 1;
+ let regionStartModified = 1;
+ let pendingOriginal: string[] = [];
+ let pendingModified: string[] = [];
+
+ const flush = (): void => {
+ if (pendingOriginal.length === 0 && pendingModified.length === 0) return;
+ const kind =
+ pendingOriginal.length > 0 && pendingModified.length > 0
+ ? 'replace'
+ : pendingModified.length > 0
+ ? 'add'
+ : 'remove';
+ chunks.push({
+ id: `chunk-${chunks.length}`,
+ kind,
+ startLineOriginal: regionStartOriginal,
+ startLineModified: regionStartModified,
+ originalLines: pendingOriginal,
+ modifiedLines: pendingModified,
+ segments: charSegments(pendingOriginal.join(''), pendingModified.join('')),
+ });
+ pendingOriginal = [];
+ pendingModified = [];
+ };
+
+ for (const [op, text] of lineOps) {
+ const lines = splitLines(text);
+ if (op === 0) {
+ // Context region: flush any pending changes, then advance both counters.
+ flush();
+ originalLine += lines.length;
+ modifiedLine += lines.length;
+ continue;
+ }
+ if (pendingOriginal.length === 0 && pendingModified.length === 0) {
+ regionStartOriginal = originalLine;
+ regionStartModified = modifiedLine;
+ }
+ if (op === 1) {
+ pendingModified.push(...lines);
+ modifiedLine += lines.length;
+ } else {
+ pendingOriginal.push(...lines);
+ originalLine += lines.length;
+ }
+ }
+ flush();
+
+ return { chunks, identical: false };
+}
+
+workerSelf.addEventListener('message', (event: MessageEvent) => {
+ const msg = event.data;
+ if (msg.type !== 'diff') return;
+ const { requestId, original, modified } = msg;
+ try {
+ const result = computeDiff(original, modified);
+ workerSelf.postMessage({ type: 'diff-result', requestId, result });
+ } catch (err) {
+ workerSelf.postMessage({
+ type: 'diff-error',
+ requestId,
+ message: err instanceof Error ? err.message : 'Diff computation failed.',
+ });
+ }
+});
+
+export {};
diff --git a/frontend/src/lib/diff/diffTypes.ts b/frontend/src/lib/diff/diffTypes.ts
new file mode 100644
index 00000000..ba3e8a39
--- /dev/null
+++ b/frontend/src/lib/diff/diffTypes.ts
@@ -0,0 +1,52 @@
+/**
+ * Shared message and result types for the interactive diff viewer.
+ *
+ * Diff computation is offloaded to a Web Worker (`diff.worker.ts`) so large
+ * multi-file comparisons never block the UI thread. The worker returns line
+ * chunks with character-level segments; Monaco renders the visual diff while
+ * the chunk list powers the one-click "Apply Diff Chunk" controls.
+ */
+
+/** Character-level segment inside a chunk (used for inline highlighting). */
+export interface DiffSegment {
+ text: string;
+ kind: 'same' | 'add' | 'remove';
+}
+
+/** A contiguous hunk of changed lines between original and modified code. */
+export interface DiffChunk {
+ id: string;
+ /** `replace` when the hunk has both removed and added lines. */
+ kind: 'add' | 'remove' | 'replace';
+ /** 1-based start line in the original document. */
+ startLineOriginal: number;
+ /** 1-based start line in the modified document. */
+ startLineModified: number;
+ /** Lines removed from the original (empty for pure adds). */
+ originalLines: string[];
+ /** Lines added in the modified document (empty for pure removes). */
+ modifiedLines: string[];
+ /** Character-level segments spanning the hunk. */
+ segments: DiffSegment[];
+}
+
+/** Result of a diff computation. */
+export interface DiffResult {
+ /** Line hunks, in document order. */
+ chunks: DiffChunk[];
+ /** Whether the two inputs are identical. */
+ identical: boolean;
+}
+
+/** Inbound request sent to the diff worker. */
+export interface DiffWorkerRequest {
+ type: 'diff';
+ requestId: string;
+ original: string;
+ modified: string;
+}
+
+/** Outbound response posted by the diff worker. */
+export type DiffWorkerResponse =
+ | { type: 'diff-result'; requestId: string; result: DiffResult }
+ | { type: 'diff-error'; requestId: string; message: string };
diff --git a/frontend/src/test/simulator-accessibility.test.tsx b/frontend/src/test/simulator-accessibility.test.tsx
new file mode 100644
index 00000000..eda98556
--- /dev/null
+++ b/frontend/src/test/simulator-accessibility.test.tsx
@@ -0,0 +1,145 @@
+/**
+ * Accessibility tests for the interactive simulators.
+ *
+ * Covers the WCAG 2.1 AA requirements from the simulator accessibility
+ * milestone: axe-core scans for critical/serious violations, aria-live
+ * announcements for real-time updates, and keyboard operability (focus
+ * management + Escape dismissal) for the slide-out node detail dialog.
+ *
+ * Run: npm test
+ */
+import { fireEvent, render, screen } from "@testing-library/react";
+import { axe, toHaveNoViolations } from "jest-axe";
+import React from "react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+expect.extend(toHaveNoViolations);
+
+// ---------------------------------------------------------------------------
+// Module mocks – must appear before any component imports
+// ---------------------------------------------------------------------------
+
+vi.mock("framer-motion", async () => {
+ const actual = await vi.importActual("framer-motion");
+ return {
+ ...actual,
+ AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}>,
+ // jsdom has no matchMedia, so useReducedMotion would throw – stub it.
+ useReducedMotion: () => false,
+ motion: {
+ div: ({ children, ...props }: any) => {children}
,
+ },
+ };
+});
+
+// ---------------------------------------------------------------------------
+// Import components after mocks are set up
+// ---------------------------------------------------------------------------
+import { BlockchainExplorer } from "@/components/simulator/BlockchainExplorer";
+import { NodeDetailPanel } from "@/components/simulator/NodeDetailPanel";
+import { ResourceGauge } from "@/components/simulator/ResourceGauge";
+import type { NetworkNode } from "@/lib/visualization/ForceSimulation";
+
+// ---------------------------------------------------------------------------
+// Fixtures
+// ---------------------------------------------------------------------------
+
+const mockNode: NetworkNode = {
+ id: "GBRPYHIL2CI3FYQMWVUGE62KMGOBQKLCYJ3HLKBUBIW5VZH4S4MNOWT",
+ type: "account",
+ label: "Test Account",
+};
+
+// ---------------------------------------------------------------------------
+// 1. BlockchainExplorer – live region + axe scan
+// ---------------------------------------------------------------------------
+describe("BlockchainExplorer – live updates and automated scan", () => {
+ it("announces ledger updates via an aria-live region", () => {
+ render();
+ const table = screen.getByRole("table", { name: /transaction list/i });
+ expect(table).toHaveAttribute("aria-live", "polite");
+ });
+
+ it("exposes the connection status to assistive technology", () => {
+ render();
+ const status = screen.getByRole("status");
+ expect(status).toHaveAttribute("aria-live", "polite");
+ });
+
+ it("has no axe violations", async () => {
+ const { container } = render();
+ const results = await axe(container);
+ expect(results).toHaveNoViolations();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// 2. ResourceGauge – live value announcement + axe scan
+// ---------------------------------------------------------------------------
+describe("ResourceGauge – live value announcements and automated scan", () => {
+ it("announces value changes through a polite live region", () => {
+ render();
+ expect(screen.getByText("CPU: 62%")).toBeInTheDocument();
+ expect(screen.getByRole("status")).toHaveAttribute("aria-live", "polite");
+ });
+
+ it("labels the canvas as a graphical object", () => {
+ render();
+ expect(screen.getByRole("img", { name: /cpu resource gauge/i })).toBeInTheDocument();
+ });
+
+ it("has no axe violations", async () => {
+ const { container } = render(
+
+ );
+ const results = await axe(container);
+ expect(results).toHaveNoViolations();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// 3. NodeDetailPanel – dialog focus management + Escape dismissal
+// ---------------------------------------------------------------------------
+describe("NodeDetailPanel – keyboard operability", () => {
+ const onClose = vi.fn();
+
+ beforeEach(() => {
+ onClose.mockClear();
+ });
+
+ it("moves focus to the close button when the dialog opens", () => {
+ render();
+ expect(screen.getByRole("button", { name: /close account details panel/i })).toHaveFocus();
+ });
+
+ it("closes the dialog when Escape is pressed", () => {
+ render();
+ fireEvent.keyDown(screen.getByRole("dialog"), { key: "Escape", code: "Escape" });
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it("traps Tab focus within the dialog", () => {
+ render();
+ const dialog = screen.getByRole("dialog");
+
+ // Focus the last focusable element (View on Explorer)…
+ const viewButton = screen.getByRole("button", { name: /view account .* on stellar explorer/i });
+ viewButton.focus();
+ expect(viewButton).toHaveFocus();
+
+ // …and Tab should wrap back to the first focusable element (close).
+ fireEvent.keyDown(dialog, { key: "Tab", code: "Tab" });
+ expect(screen.getByRole("button", { name: /close account details panel/i })).toHaveFocus();
+ });
+
+ it("marks the dialog as modal for assistive technology", () => {
+ render();
+ expect(screen.getByRole("dialog")).toHaveAttribute("aria-modal", "true");
+ });
+
+ it("has no axe violations", async () => {
+ const { container } = render();
+ const results = await axe(container);
+ expect(results).toHaveNoViolations();
+ });
+});