diff --git a/docs/evidence/retained-ui-bugs-before-after.mp4 b/docs/evidence/retained-ui-bugs-before-after.mp4
new file mode 100644
index 000000000..5b874abdd
Binary files /dev/null and b/docs/evidence/retained-ui-bugs-before-after.mp4 differ
diff --git a/e2e/quality-backlog.spec.ts b/e2e/quality-backlog.spec.ts
new file mode 100644
index 000000000..ab3700a54
--- /dev/null
+++ b/e2e/quality-backlog.spec.ts
@@ -0,0 +1,107 @@
+import { expect, test } from '@playwright/test';
+import { setLocale, stubSupabaseSession } from './helpers/auth';
+
+const locales = ['en', 'es', 'hu'] as const;
+const titledRoutes = ['generate', 'attack', 'brainstorm', 'factoids', 'ask'] as const;
+
+test('Sources does not show the stale consultation date', async ({ page }) => {
+ await page.goto('/expocit/sources');
+
+ await expect(page.getByRole('heading', { name: 'Sources' })).toBeVisible();
+ await expect(page.getByText(/Consulta: 8 de marzo de 2026/i)).toHaveCount(0);
+});
+
+for (const locale of locales) {
+ test(`navigation and page titles agree in ${locale}`, async ({ page }, testInfo) => {
+ await setLocale(page, locale);
+ await stubSupabaseSession(page);
+ await page.addInitScript(() => {
+ window.localStorage.setItem('debate-trainer-onboarding', JSON.stringify({
+ state: { hasCompletedOnboarding: true, isTourRunning: false },
+ version: 0,
+ }));
+ });
+ await page.goto('/app');
+
+ for (const route of titledRoutes) {
+ const desktopLink = page.locator(`[data-tour="nav-${route}"]`).first();
+ const label = await desktopLink.getAttribute('aria-label');
+ expect(label).toBeTruthy();
+ if (testInfo.project.name === 'mobile-chromium') {
+ await page.locator('header button.lg\\:hidden[aria-expanded]').click();
+ await page.getByRole('navigation', { name: 'Mobile navigation' })
+ .getByRole('link')
+ .filter({ hasText: label! })
+ .first()
+ .click();
+ } else {
+ await desktopLink.click();
+ }
+ await expect(page).toHaveURL(new RegExp(`/app/${route}$`));
+ await expect(page.getByRole('heading', { level: 1, name: label!, exact: true }).last()).toBeVisible();
+ }
+ });
+}
+
+test('Factoids names the save action with its claim', async ({ page }) => {
+ await setLocale(page, 'en');
+ await stubSupabaseSession(page);
+ await page.route('**/functions/v1/ai', async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ output: {
+ factoids: [{
+ id: 'fact-1',
+ shortClaim: 'Solar costs fell by 42 percent',
+ keyFigure: '42%',
+ context: 'The official report compares the relevant period.',
+ limitations: 'The result depends on the measured market segment.',
+ citation: {
+ title: 'Official energy statistics',
+ publisher: 'Department of Energy',
+ date: '2024',
+ url: 'https://energy.gov/reports/solar-costs-2024',
+ verified: 'verified',
+ },
+ howToUse: 'Use this as a bounded cost trend.',
+ relevanceScore: 92,
+ }],
+ searchSuggestions: [],
+ warnings: [],
+ verificationStatus: 'complete',
+ },
+ }),
+ });
+ });
+
+ await page.goto('/app/factoids');
+ await page.getByRole('textbox', { name: /statement/i }).fill('Solar energy costs are falling');
+ await page.getByRole('button', { name: /find factoids/i }).click();
+
+ await expect(page.getByRole('button', {
+ name: 'Save Solar costs fell by 42 percent to library',
+ })).toBeVisible();
+});
+
+test('Flowchart confirms selected-node deletion and reports success', async ({ page }, testInfo) => {
+ await setLocale(page, 'en');
+ await stubSupabaseSession(page);
+ await page.goto('/app/flowchart');
+
+ await page.getByText('Argument 1', { exact: true }).click();
+ await page.getByRole('button', { name: 'Delete' }).last().click();
+ await expect(page.getByRole('alertdialog', { name: 'Delete node?' })).toBeVisible();
+ const confirm = page.getByRole('button', { name: 'Delete node' });
+ if (testInfo.project.name === 'mobile-chromium') {
+ const box = await confirm.boundingBox();
+ expect(box).not.toBeNull();
+ await page.touchscreen.tap(box!.x + box!.width / 2, box!.y + box!.height / 2);
+ } else {
+ await confirm.click();
+ }
+
+ await expect(page.getByText('Node deleted', { exact: true })).toBeVisible();
+ await expect(page.getByText('Argument 1', { exact: true })).toHaveCount(0);
+});
diff --git a/src/components/features/factoids/FactoidsResults.tsx b/src/components/features/factoids/FactoidsResults.tsx
index 8e2bce65e..0d601109a 100644
--- a/src/components/features/factoids/FactoidsResults.tsx
+++ b/src/components/features/factoids/FactoidsResults.tsx
@@ -81,6 +81,7 @@ function FactoidCard({ factoid, onSave }: { factoid: Factoid, onSave?: FactoidsR
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-primary"
onClick={() => onSave(factoid.shortClaim, `${factoid.context}\n\nKey Figure: ${factoid.keyFigure}\nLimitations: ${factoid.limitations}`, factoid.citation?.url)}
+ aria-label={t('factoidsResults.saveClaimToLibrary', { claim: factoid.shortClaim })}
title={t('factoidsResults.saveToLibrary')}
>
diff --git a/src/locales/en.json b/src/locales/en.json
index 763d01fee..062f101df 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -747,6 +747,7 @@
"urlNeedsVerification": "URL requires verification",
"howToUse": "How to use:",
"saveToLibrary": "Save to Library",
+ "saveClaimToLibrary": "Save {{claim}} to library",
"discuss": "Discuss Evidence",
"verifyAll": "Verify All Sources",
"verifying": "Verifying Sources...",
@@ -1000,7 +1001,12 @@
"saveFailedToast": "Failed to save flowchart",
"loadedToast": "Flowchart loaded",
"deletedToast": "Flowchart deleted",
- "deleteFailedToast": "Failed to delete flowchart"
+ "deleteFailedToast": "Failed to delete flowchart",
+ "deleteNodeTitle": "Delete node?",
+ "deleteNodeDescription": "{{label}} and all of its connections will be removed. This cannot be undone.",
+ "deleteNodeConfirm": "Delete node",
+ "deleteNodeCancel": "Cancel",
+ "nodeDeletedToast": "Node deleted"
},
"historyPage": {
"title": "History",
diff --git a/src/locales/es.json b/src/locales/es.json
index 9a32000f4..00aa92c6d 100644
--- a/src/locales/es.json
+++ b/src/locales/es.json
@@ -747,6 +747,7 @@
"urlNeedsVerification": "La URL requiere verificación",
"howToUse": "Cómo usar:",
"saveToLibrary": "Guardar en Biblioteca",
+ "saveClaimToLibrary": "Guardar {{claim}} en la biblioteca",
"discuss": "Discutir Evidencia",
"verifyAll": "Verificar Todas las Fuentes",
"verifying": "Verificando Fuentes...",
@@ -1000,7 +1001,12 @@
"saveFailedToast": "Error al guardar diagrama",
"loadedToast": "Diagrama cargado",
"deletedToast": "Diagrama eliminado",
- "deleteFailedToast": "Error al eliminar diagrama"
+ "deleteFailedToast": "Error al eliminar diagrama",
+ "deleteNodeTitle": "¿Eliminar nodo?",
+ "deleteNodeDescription": "Se eliminarán «{{label}}» y todas sus conexiones. Esta acción no se puede deshacer.",
+ "deleteNodeConfirm": "Eliminar nodo",
+ "deleteNodeCancel": "Cancelar",
+ "nodeDeletedToast": "Nodo eliminado"
},
"historyPage": {
"title": "Historial",
diff --git a/src/locales/hu.json b/src/locales/hu.json
index ea5aab57a..9ddcce179 100644
--- a/src/locales/hu.json
+++ b/src/locales/hu.json
@@ -747,6 +747,7 @@
"urlNeedsVerification": "Az URL ellenőrzést igényel",
"howToUse": "Használat:",
"saveToLibrary": "Mentés a Könyvtárba",
+ "saveClaimToLibrary": "„{{claim}}” mentése a könyvtárba",
"discuss": "Bizonyítékok Megvitatása",
"verifyAll": "Összes Forrás Ellenőrzése",
"verifying": "Források Ellenőrzése...",
@@ -1000,7 +1001,12 @@
"saveFailedToast": "Nem sikerült menteni a folyamatábrát",
"loadedToast": "Folyamatábra betöltve",
"deletedToast": "Folyamatábra törölve",
- "deleteFailedToast": "Nem sikerült törölni a folyamatábrát"
+ "deleteFailedToast": "Nem sikerült törölni a folyamatábrát",
+ "deleteNodeTitle": "Csomópont törlése?",
+ "deleteNodeDescription": "A(z) „{{label}}” csomópont és minden kapcsolata törlődik. Ez a művelet nem vonható vissza.",
+ "deleteNodeConfirm": "Csomópont törlése",
+ "deleteNodeCancel": "Mégse",
+ "nodeDeletedToast": "Csomópont törölve"
},
"historyPage": {
"title": "Előzmények",
diff --git a/src/pages/AskPage.tsx b/src/pages/AskPage.tsx
index 766f77c82..a93e09935 100644
--- a/src/pages/AskPage.tsx
+++ b/src/pages/AskPage.tsx
@@ -311,7 +311,7 @@ export default function AskPage() {
- {t('ask.title')}
+ {t('nav.ask')}
-
{t('attack.title')}
+
{t('nav.attack')}
{t('attack.subtitle')}
diff --git a/src/pages/BrainstormPage.tsx b/src/pages/BrainstormPage.tsx
index 3b1c1fd10..724fb8406 100644
--- a/src/pages/BrainstormPage.tsx
+++ b/src/pages/BrainstormPage.tsx
@@ -96,7 +96,7 @@ export default function BrainstormPage() {
-
{t('brainstorm.title')}
+
{t('nav.brainstorm')}
{t('brainstorm.subtitle')}
diff --git a/src/pages/FactoidsPage.tsx b/src/pages/FactoidsPage.tsx
index dfe200d1c..bcbcc9ac3 100644
--- a/src/pages/FactoidsPage.tsx
+++ b/src/pages/FactoidsPage.tsx
@@ -111,7 +111,7 @@ export default function FactoidsPage() {
-
{t('factoids.title')}
+
{t('nav.factoids')}
{t('factoids.subtitle')}
diff --git a/src/pages/FlowchartPage.tsx b/src/pages/FlowchartPage.tsx
index 9ab25dc9f..f04c322b9 100644
--- a/src/pages/FlowchartPage.tsx
+++ b/src/pages/FlowchartPage.tsx
@@ -40,6 +40,16 @@ import {
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from '@/components/ui/alert-dialog';
import {
Plus,
Download,
@@ -219,6 +229,7 @@ export default function FlowchartPage() {
const [newNodeType, setNewNodeType] = useState
('contention');
const [newNodeStrength, setNewNodeStrength] = useState<'strong' | 'moderate' | 'weak'>('moderate');
const [selectedNode, setSelectedNode] = useState(null);
+ const [isDeleteNodeDialogOpen, setIsDeleteNodeDialogOpen] = useState(false);
const [editingNodeId, setEditingNodeId] = useState(null);
const [selectedEdgeType, setSelectedEdgeType] = useState('support');
@@ -300,6 +311,7 @@ export default function FlowchartPage() {
const handleLoadFlowchart = (data: FlowchartData) => {
setNodes(data.nodes);
setEdges(data.edges);
+ setSelectedNode(null);
setIsLoadDialogOpen(false);
toast.success(t('flowchart.loadedToast'));
};
@@ -339,7 +351,9 @@ export default function FlowchartPage() {
setNodes((nds) => nds.filter((n) => n.id !== selectedNode));
setEdges((eds) => eds.filter((e) => e.source !== selectedNode && e.target !== selectedNode));
setSelectedNode(null);
- }, [selectedNode, setNodes, setEdges]);
+ setIsDeleteNodeDialogOpen(false);
+ toast.success(t('flowchart.nodeDeletedToast'));
+ }, [selectedNode, setNodes, setEdges, t]);
const autoLayout = useCallback(() => {
const lanes: Record = {
@@ -583,7 +597,7 @@ export default function FlowchartPage() {
{t('flowchart.edit')}
-
{selectedNode !== 'thesis' && (
-
+ setIsDeleteNodeDialogOpen(true)}>
{t('flowchart.delete')}
@@ -702,6 +716,28 @@ export default function FlowchartPage() {
+
+
+
+ {t('flowchart.deleteNodeTitle')}
+
+ {t('flowchart.deleteNodeDescription', {
+ label: nodes.find((node) => node.id === selectedNode)?.data.label,
+ })}
+
+
+
+ {t('flowchart.deleteNodeCancel')}
+
+ {t('flowchart.deleteNodeConfirm')}
+
+
+
+
+
{/* Legend */}
diff --git a/src/pages/GeneratePage.tsx b/src/pages/GeneratePage.tsx
index bbe58dd2a..f2e8ebe27 100644
--- a/src/pages/GeneratePage.tsx
+++ b/src/pages/GeneratePage.tsx
@@ -329,7 +329,7 @@ export default function GeneratePage() {
-
{t('generate.title')}
+
{t('nav.generate')}
{t('generate.subtitle')}
diff --git a/src/pages/SourcesPage.tsx b/src/pages/SourcesPage.tsx
index 203c6d972..1d60ea86b 100644
--- a/src/pages/SourcesPage.tsx
+++ b/src/pages/SourcesPage.tsx
@@ -151,8 +151,7 @@ export default function SourcesPage() {
className="text-primary underline underline-offset-2 break-words [overflow-wrap:anywhere] hover:text-primary/80 transition-colors"
>
{source.url}
- {" "}
- [Consulta: 8 de marzo de 2026].
+
))}
diff --git a/src/test/components/FactoidsResults.test.tsx b/src/test/components/FactoidsResults.test.tsx
index 70fb97a22..a63864b50 100644
--- a/src/test/components/FactoidsResults.test.tsx
+++ b/src/test/components/FactoidsResults.test.tsx
@@ -51,3 +51,18 @@ test('renders consulted sources with a fallback label for weak sources', () => {
expect(screen.getByRole('link', { name: /broad fallback article/i })).toHaveAttribute('href', discoveredSources[0].url);
expect(screen.getByText('fallback context only')).toBeInTheDocument();
});
+
+test('names the save action with the factoid claim', () => {
+ render(
);
+
+ expect(screen.getByRole('button', {
+ name: 'Save Solar costs fell by 42 percent to library',
+ })).toBeInTheDocument();
+});
diff --git a/src/test/pages/AttackPage.test.tsx b/src/test/pages/AttackPage.test.tsx
index 93d681baf..af3937bae 100644
--- a/src/test/pages/AttackPage.test.tsx
+++ b/src/test/pages/AttackPage.test.tsx
@@ -114,7 +114,7 @@ describe('AttackPage Integration', () => {
);
- expect(screen.getByText('Attack My Case')).toBeInTheDocument();
+ expect(screen.getByRole('heading', { name: 'Attack' })).toBeInTheDocument();
expect(screen.getByTestId('attack-form')).toBeInTheDocument();
expect(screen.queryByTestId('attack-results')).not.toBeInTheDocument();
});
diff --git a/src/test/pages/FlowchartPage.test.tsx b/src/test/pages/FlowchartPage.test.tsx
new file mode 100644
index 000000000..544091879
--- /dev/null
+++ b/src/test/pages/FlowchartPage.test.tsx
@@ -0,0 +1,110 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import { beforeEach, expect, test, vi } from 'vitest';
+import FlowchartPage from '@/pages/FlowchartPage';
+
+const { mockSavedFlowcharts, mockToastSuccess } = vi.hoisted(() => ({
+ mockSavedFlowcharts: [] as Array<{
+ id: string;
+ title: string;
+ createdAt: number;
+ data: { nodes: unknown[]; edges: unknown[] };
+ }>,
+ mockToastSuccess: vi.fn(),
+}));
+
+vi.mock('sonner', () => ({
+ toast: { success: mockToastSuccess, error: vi.fn() },
+}));
+
+vi.mock('html-to-image', () => ({ toPng: vi.fn() }));
+
+vi.mock('@/lib/stores/session-store', () => ({
+ useSessionStore: () => ({
+ pendingFlowchartData: null as null,
+ setPendingFlowchartData: vi.fn(),
+ saveFlowchart: vi.fn(),
+ savedFlowcharts: mockSavedFlowcharts,
+ deleteSavedFlowchart: vi.fn(),
+ }),
+}));
+
+vi.mock('reactflow', async () => {
+ const React = await import('react');
+ return {
+ default: ({ nodes, onNodeClick }: {
+ nodes: Array<{ id: string; data: { label: string } }>;
+ onNodeClick: (event: React.MouseEvent, node: { id: string }) => void;
+ }) =>
{nodes.map((node) => (
+ onNodeClick(event, node)}>{node.data.label}
+ ))}
,
+ useNodesState: (initial: unknown[]) => {
+ const [nodes, setNodes] = React.useState(initial);
+ return [nodes, setNodes, vi.fn()];
+ },
+ useEdgesState: (initial: unknown[]) => {
+ const [edges, setEdges] = React.useState(initial);
+ return [edges, setEdges, vi.fn()];
+ },
+ addEdge: vi.fn(),
+ Controls: (): null => null,
+ Background: (): null => null,
+ MiniMap: (): null => null,
+ Handle: (): null => null,
+ Position: { Top: 'top', Bottom: 'bottom' },
+ MarkerType: { ArrowClosed: 'arrowclosed' },
+ BackgroundVariant: { Dots: 'dots' },
+ };
+});
+
+beforeEach(() => {
+ mockSavedFlowcharts.length = 0;
+ mockToastSuccess.mockClear();
+});
+
+test('keeps a selected node when deletion is cancelled', () => {
+ render(
);
+
+ fireEvent.click(screen.getByRole('button', { name: 'Argument 1' }));
+ fireEvent.click(screen.getAllByRole('button', { name: 'Delete' })[0]);
+
+ expect(screen.getByRole('alertdialog', { name: 'Delete node?' })).toBeInTheDocument();
+ fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
+ expect(screen.getByRole('button', { name: 'Argument 1' })).toBeInTheDocument();
+ expect(mockToastSuccess).not.toHaveBeenCalled();
+});
+
+test('deletes a selected node only after confirmation and reports success', () => {
+ render(
);
+
+ fireEvent.click(screen.getByRole('button', { name: 'Argument 1' }));
+ fireEvent.click(screen.getAllByRole('button', { name: 'Delete' })[0]);
+ fireEvent.click(screen.getByRole('button', { name: 'Delete node' }));
+
+ expect(screen.queryByRole('button', { name: 'Argument 1' })).not.toBeInTheDocument();
+ expect(mockToastSuccess).toHaveBeenCalledWith('Node deleted');
+});
+
+test('clears a stale node selection when a saved flowchart is loaded', () => {
+ mockSavedFlowcharts.push({
+ id: 'saved-1',
+ title: 'Loaded chart',
+ createdAt: Date.now(),
+ data: {
+ nodes: [{
+ id: 'loaded-node',
+ type: 'custom',
+ position: { x: 0, y: 0 },
+ data: { label: 'Loaded argument', type: 'contention' },
+ }],
+ edges: [],
+ },
+ });
+ render(
);
+
+ fireEvent.click(screen.getByRole('button', { name: 'Argument 1' }));
+ fireEvent.click(screen.getByRole('button', { name: 'Load' }));
+ fireEvent.click(screen.getByRole('button', { name: /Loaded chart/ }));
+
+ expect(screen.getByRole('button', { name: 'Loaded argument' })).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Delete' })).not.toBeInTheDocument();
+});
diff --git a/src/test/pages/SourcesPage.test.tsx b/src/test/pages/SourcesPage.test.tsx
new file mode 100644
index 000000000..4285df873
--- /dev/null
+++ b/src/test/pages/SourcesPage.test.tsx
@@ -0,0 +1,9 @@
+import { render, screen } from '@testing-library/react';
+import SourcesPage from '@/pages/SourcesPage';
+
+test('does not present a stale consultation date as current source metadata', () => {
+ render(
);
+
+ expect(screen.queryByText(/Consulta: 8 de marzo de 2026/i)).not.toBeInTheDocument();
+ expect(screen.getByRole('link', { name: /cambridge\.org/i })).toBeInTheDocument();
+});