diff --git a/.claude/skills/verify-debatelab/features/library-sources.md b/.claude/skills/verify-debatelab/features/library-sources.md index 6630ba93c..d5c2a04cb 100644 --- a/.claude/skills/verify-debatelab/features/library-sources.md +++ b/.claude/skills/verify-debatelab/features/library-sources.md @@ -22,11 +22,11 @@ Preconditions: - Doctor passes. The user is signed in (`signedIn` fixture). A Current Debate exists: `await startDebate(page, motion)`. Without one, `Add Source` stays disabled. -- **Add.** Click the button named `Add Source`. Fill the textbox named `Source title...`, fill the textbox named `Paste source content...` with a unique marker, and click `Save to Library`. The switch named `Toggle ` is checked. `sources` has a row with `is_active: true` and the debate's `debate_id`. +- **Add.** Click the button named `Add Source`. Fill the textbox named `Title` (exact), fill the textbox named `Content` (exact) with a unique marker, and click `Save to Library`. Leaving either empty shows an inline error under the field and moves focus to it. The switch named `Toggle <title>` is checked. `sources` has a row with `is_active: true` and the debate's `debate_id`. - **Toggle.** Click the switch named `Toggle <title>`. It becomes not checked. - **Ground.** Go to `/app/brainstorm`; the motion is prefilled from the debate. Run it (see [ai-tools](./ai-tools.md)). `providerCall("brainstorm").system` contains the active marker and does not contain the paused marker. -- **Delete.** Click the button named `Delete <title>` (use `exact`). The switch count drops to 0, and stays 0 after `page.reload()`. `sources` no longer has the title. -- **Import.** In the add panel, fill `Import from URL` and click `Import URL`. `Source Ready` appears, then `Save imported source` stores a row whose `origin` is the URL. +- **Delete.** Click the button named `Delete <title>` (use `exact`). It deletes at once, with no confirmation, and a toast `Deleted <title>` offers `Undo`, which saves the source again. The switch count drops to 0, and stays 0 after `page.reload()`. `sources` no longer has the title. +- **Import.** In the add panel, fill `Import from URL` and click `Import URL`. The `Import preview` panel appears, then `Save imported source` stores a row whose `origin` is the URL. - **Erase.** On Settings, click `Delete All My Data`. In the dialog, fill `Type your email to confirm` with `user.email` and click the dialog's `Delete All My Data`. The text `Data Deleted` appears, and `sources` for the user is `[]`. ## Gotchas diff --git a/.claude/skills/verify-debatelab/recipes/account-data.spec.ts b/.claude/skills/verify-debatelab/recipes/account-data.spec.ts index 48680fecc..982f93e4f 100644 --- a/.claude/skills/verify-debatelab/recipes/account-data.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/account-data.spec.ts @@ -12,8 +12,8 @@ const accountUrl = `${process.env.LOCAL_SUPABASE_URL}/functions/v1/account`; async function addSource(page: Page, title: string, content: string) { await page.goto("/app/library"); await page.getByRole("button", { name: "Add Source" }).click(); - await page.getByRole("textbox", { name: "Source title..." }).fill(title); - await page.getByRole("textbox", { name: "Paste source content..." }).fill(content); + await page.getByRole("textbox", { name: "Title", exact: true }).fill(title); + await page.getByRole("textbox", { name: "Content", exact: true }).fill(content); await page.getByRole("button", { name: "Save to Library" }).click(); await expect(page.getByRole("switch", { name: `Toggle ${title}` })).toBeChecked(); } diff --git a/.claude/skills/verify-debatelab/recipes/current-debate.spec.ts b/.claude/skills/verify-debatelab/recipes/current-debate.spec.ts index a7918eca8..fadeb463a 100644 --- a/.claude/skills/verify-debatelab/recipes/current-debate.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/current-debate.spec.ts @@ -14,8 +14,8 @@ const LIVE_AWARE_TIMEOUT = LIVE ? 900_000 : 150_000; async function addSource(page: Page, title: string, content: string) { await page.goto("/app/library"); await page.getByRole("button", { name: "Add Source" }).click(); - await page.getByRole("textbox", { name: "Source title..." }).fill(title); - await page.getByRole("textbox", { name: "Paste source content..." }).fill(content); + await page.getByRole("textbox", { name: "Title", exact: true }).fill(title); + await page.getByRole("textbox", { name: "Content", exact: true }).fill(content); await page.getByRole("button", { name: "Save to Library" }).click(); await expect(page.getByRole("switch", { name: `Toggle ${title}` })).toBeChecked(); } diff --git a/.claude/skills/verify-debatelab/recipes/library.spec.ts b/.claude/skills/verify-debatelab/recipes/library.spec.ts index 879b2e713..a3f14c812 100644 --- a/.claude/skills/verify-debatelab/recipes/library.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/library.spec.ts @@ -7,8 +7,8 @@ const motion = "Public transport should be free"; async function addSource(page: Page, title: string, content: string) { await page.getByRole("button", { name: "Add Source" }).click(); - await page.getByRole("textbox", { name: "Source title..." }).fill(title); - await page.getByRole("textbox", { name: "Paste source content..." }).fill(content); + await page.getByRole("textbox", { name: "Title", exact: true }).fill(title); + await page.getByRole("textbox", { name: "Content", exact: true }).fill(content); await page.getByRole("button", { name: "Save to Library" }).click(); await expect(page.getByRole("switch", { name: `Toggle ${title}` })).toBeChecked(); } @@ -37,13 +37,28 @@ test("lib-add, lib-toggle, lib-ground, lib-delete: only active sources ground AI await evidence.json("02-grounding", { activeInPrompt: true, pausedInPrompt: false }); await page.goto("/app/library"); + const userId = await user.userId(); + const pausedRow = async () => + ((await admin.from("sources").select("id,created_at,is_active").eq("user_id", userId).eq("title", "Paused study")).data ?? []) + // The client keeps milliseconds; Postgres keeps microseconds. + .map((row) => ({ id: row.id, at: Math.floor(Date.parse(row.created_at)), active: row.is_active })); + const before = await pausedRow(); + expect(before).toHaveLength(1); + // Deleting a source acts at once; the toast's Undo puts the same row back (same id, so a + // per-request exclusion still applies, and the same date, so it keeps its place). + await page.getByRole("button", { name: "Delete Paused study", exact: true }).click(); + await expect(page.getByRole("switch", { name: "Toggle Paused study" })).toHaveCount(0); + await expect.poll(pausedRow).toEqual([]); + await page.getByRole("button", { name: "Undo" }).click(); + await expect(page.getByRole("switch", { name: "Toggle Paused study" })).toHaveCount(1); + await expect.poll(pausedRow).toEqual(before); + await evidence.step("Deleted Paused study, then restored the same row with Undo"); await page.getByRole("button", { name: "Delete Paused study", exact: true }).click(); await expect(page.getByRole("switch", { name: "Toggle Paused study" })).toHaveCount(0); await page.reload(); await expect(page.getByRole("switch", { name: "Toggle Active study" })).toBeVisible(); await expect(page.getByRole("switch", { name: "Toggle Paused study" })).toHaveCount(0); // Live research can save the web sources it found, so check only the two added here. - const userId = await user.userId(); const { data: debate } = await admin.from("debates").select("id").eq("user_id", userId).eq("motion", motion).single(); const { data } = await admin.from("sources").select("title,is_active,debate_id").eq("user_id", userId).in("title", ["Active study", "Paused study"]); expect(data).toEqual([{ title: "Active study", is_active: true, debate_id: debate?.id }]); @@ -58,7 +73,7 @@ test("lib-import: a web page imports into the Library", async ({ signedIn: page, await page.getByRole("button", { name: "Add Source" }).click(); await page.getByRole("textbox", { name: "Import from URL" }).fill(url); await page.getByRole("button", { name: "Import URL" }).click(); - await expect(page.getByText("Source Ready", { exact: true })).toBeVisible({ timeout: AI_TIMEOUT }); + await expect(page.getByText("Import preview", { exact: true })).toBeVisible({ timeout: AI_TIMEOUT }); await evidence.shot("01-import-ready"); await page.getByRole("button", { name: "Save imported source" }).click(); await expect.poll(async () => { diff --git a/agentic/replay/c6cf102fb2172d14552e85bf19abb2d80a6075794717cc7c0c2cd0610bb9fe37.json b/agentic/replay/c6cf102fb2172d14552e85bf19abb2d80a6075794717cc7c0c2cd0610bb9fe37.json index 88482039c..fc0f7acb7 100644 --- a/agentic/replay/c6cf102fb2172d14552e85bf19abb2d80a6075794717cc7c0c2cd0610bb9fe37.json +++ b/agentic/replay/c6cf102fb2172d14552e85bf19abb2d80a6075794717cc7c0c2cd0610bb9fe37.json @@ -1,6 +1,6 @@ { "schemaVersion": "trace-1", - "createdAt": "2026-10-06T08:42:12.424Z", + "createdAt": "2026-10-09T07:51:18.781Z", "payload": { "actions": [ { @@ -54,20 +54,20 @@ }, { "name": "type", - "summary": "type \"Plastic bag levy study\" into textbox \"Source title...\"", + "summary": "type \"Plastic bag levy study\" into textbox \"Title\"", "target": { "role": "textbox", - "name": "Source title...", + "name": "Title", "placeholder": "Source title..." }, "value": "Plastic bag levy study" }, { "name": "type", - "summary": "type \"After a 5p levy, single-use bag sales a…\" into textbox \"Paste source content...\"", + "summary": "type \"After a 5p levy, single-use bag sales a…\" into textbox \"Content\"", "target": { "role": "textbox", - "name": "Paste source content...", + "name": "Content", "placeholder": "Paste source content..." }, "value": "After a 5p levy, single-use bag sales at major retailers fell by more than 95 percent." @@ -93,26 +93,21 @@ "callIndex": 0, "agent": "default" }, - "summary": "Started the debate “This House would ban single-use plastics” and added “Plastic bag levy study” to the Library with the provided content. The Library shows the saved source and a “Source Added” confirmation.", + "summary": "Started debate on 'This House would ban single-use plastics', opened Library, and added source 'Plastic bag levy study' with content about 5p levy reducing bag sales by 95%. Source now appears in library with 1 source active and grounding enabled.", "startPath": "/app", "endPath": "/app/library", "endAnchors": [ - { - "role": "button", - "name": "Close" - }, - { - "text": "Source Added" - }, - { - "text": "Your source is now available for grounding." - }, { "text": "Current debate" }, { "text": "This House would ban single-use plastics" }, + { + "role": "button", + "name": "Current debate: This House would ban single-use plastics", + "testId": "current-debate-mobile" + }, { "role": "heading", "name": "Source Library" @@ -122,7 +117,14 @@ "name": "Change" }, { - "text": "All folders" + "text": "1 sources · 1 active · 1 folders" + }, + { + "text": "Smart" + }, + { + "role": "heading", + "name": "Your Knowledge Base" } ], "goneAnchors": [ @@ -131,6 +133,10 @@ "name": "Start debate", "testId": "current-debate-mobile" }, + { + "role": "heading", + "name": "What are you working on?" + }, { "text": "No current debate" }, @@ -149,11 +155,8 @@ }, { "text": "Brainstorm" - }, - { - "text": "Explore angles for a motion." } ], - "endWaitMs": 12722 + "endWaitMs": 13153 } } diff --git a/agentic/replay/cc6903d8aafb702ef5a496a05b9c45338886c52fc41ea6a7c0f6fd1435dfbfec.json b/agentic/replay/cc6903d8aafb702ef5a496a05b9c45338886c52fc41ea6a7c0f6fd1435dfbfec.json index f1d904802..8f3f7580b 100644 --- a/agentic/replay/cc6903d8aafb702ef5a496a05b9c45338886c52fc41ea6a7c0f6fd1435dfbfec.json +++ b/agentic/replay/cc6903d8aafb702ef5a496a05b9c45338886c52fc41ea6a7c0f6fd1435dfbfec.json @@ -1,6 +1,6 @@ { "schemaVersion": "trace-1", - "createdAt": "2026-10-06T08:52:21.869Z", + "createdAt": "2026-10-09T07:48:57.404Z", "payload": { "actions": [ { @@ -32,7 +32,7 @@ "callIndex": 0, "agent": "default" }, - "summary": "Changed the Interface Language setting to Español. The settings page and navigation are now displayed in Spanish, including “Ajustes” and “Idioma de Interfaz.”.", + "summary": "The interface language has been switched to Español. The settings page now displays entirely in Spanish (Ajustes, Herramientas, Biblioteca, Historial, Idioma de Interfaz), and the combobox shows Español as the selected value. The language switch took effect successfully.", "startPath": "/app/settings", "endPath": "/app/settings", "endAnchors": [ @@ -41,7 +41,7 @@ "name": "Logotipo de DebateLab" }, { - "text": "Inicio" + "text": "Herramientas" }, { "role": "button", @@ -72,7 +72,7 @@ "name": "DebateLab logo" }, { - "text": "Home" + "text": "Tools" }, { "role": "button", @@ -97,6 +97,6 @@ "name": "Settings" } ], - "endWaitMs": 17439 + "endWaitMs": 32208 } } diff --git a/agentic/replay/d15dccac4ac18fdabdb7d4c086d281513bd8832a317f7d3d65ac987fec417dc4.json b/agentic/replay/d15dccac4ac18fdabdb7d4c086d281513bd8832a317f7d3d65ac987fec417dc4.json index 13f86910a..7d4ec48c6 100644 --- a/agentic/replay/d15dccac4ac18fdabdb7d4c086d281513bd8832a317f7d3d65ac987fec417dc4.json +++ b/agentic/replay/d15dccac4ac18fdabdb7d4c086d281513bd8832a317f7d3d65ac987fec417dc4.json @@ -1,6 +1,6 @@ { "schemaVersion": "trace-1", - "createdAt": "2026-10-06T08:52:18.268Z", + "createdAt": "2026-10-09T07:52:16.070Z", "payload": { "actions": [ { @@ -32,7 +32,7 @@ "callIndex": 0, "agent": "default" }, - "summary": "Changed the Interface Language setting to Español. The settings page now displays Spanish labels, including “Idioma de Interfaz” with “Español” selected.", + "summary": "The interface language has been switched to Español. The settings page now displays all labels in Spanish (Ajustes, Idioma de Interfaz, Suscripción, Cuenta, Zona de peligro, etc.) and the Interface Language combobox shows Español as the selected value.", "startPath": "/app/settings", "endPath": "/app/settings", "endAnchors": [ @@ -40,9 +40,6 @@ "role": "image", "name": "Logotipo de DebateLab" }, - { - "text": "Cambiar tema" - }, { "role": "button", "name": "Iniciar debate", @@ -56,51 +53,52 @@ "role": "heading", "name": "Suscripción" }, - { - "text": "Pro" - }, { "text": "El acceso a la IA está activo. Pueden aplicarse límites de uso." }, { "role": "heading", "name": "Ajustes globales" + }, + { + "text": "Idioma de Interfaz" + }, + { + "text": "Español" } ], "goneAnchors": [ - { - "role": "status", - "name": "Opening Settings" - }, { "role": "image", "name": "DebateLab logo" }, - { - "text": "Toggle theme" - }, { "role": "button", "name": "Start debate", "testId": "current-debate-mobile" }, { - "role": "link", - "name": "Terms" + "role": "heading", + "name": "Settings" }, { - "role": "link", - "name": "Privacy" + "role": "heading", + "name": "Subscription" }, { - "role": "link", - "name": "DebateLab logo DebateLab" + "text": "AI access is active. Usage limits may apply." }, { - "role": "button", - "name": "Language" + "role": "heading", + "name": "Global Settings" + }, + { + "text": "Interface Language" + }, + { + "text": "AI Output Language" } ], - "endWaitMs": 12650 + "endWaitMs": 28157 } } diff --git a/agentic/replay/f6d6a30f85648dfe2446aa2888ca7fba9df68b2746461d556a193ac4ed89d357.json b/agentic/replay/f6d6a30f85648dfe2446aa2888ca7fba9df68b2746461d556a193ac4ed89d357.json index 11923333c..3dae399b0 100644 --- a/agentic/replay/f6d6a30f85648dfe2446aa2888ca7fba9df68b2746461d556a193ac4ed89d357.json +++ b/agentic/replay/f6d6a30f85648dfe2446aa2888ca7fba9df68b2746461d556a193ac4ed89d357.json @@ -1,18 +1,15 @@ { "schemaVersion": "trace-1", - "createdAt": "2026-10-06T08:51:36.896Z", + "createdAt": "2026-10-09T08:24:01.480Z", "payload": { "actions": [ { "name": "tap", - "summary": "tap button \"Start debate\" (2 of 2)", + "summary": "tap button \"Start debate\"", "target": { "role": "button", "name": "Start debate", - "position": { - "index": 1, - "of": 2 - } + "testId": "current-debate-desktop" } }, { @@ -57,20 +54,20 @@ }, { "name": "type", - "summary": "type \"Plastic bag levy study\" into textbox \"Source title...\"", + "summary": "type \"Plastic bag levy study\" into textbox \"Title\"", "target": { "role": "textbox", - "name": "Source title...", + "name": "Title", "placeholder": "Source title..." }, "value": "Plastic bag levy study" }, { "name": "type", - "summary": "type \"After a 5p levy, single-use bag sales a…\" into textbox \"Paste source content...\"", + "summary": "type \"After a 5p levy, single-use bag sales a…\" into textbox \"Content\"", "target": { "role": "textbox", - "name": "Paste source content...", + "name": "Content", "placeholder": "Paste source content..." }, "value": "After a 5p levy, single-use bag sales at major retailers fell by more than 95 percent." @@ -96,22 +93,17 @@ "callIndex": 0, "agent": "default" }, - "summary": "Started the debate “This House would ban single-use plastics” and added “Plastic bag levy study” to the Library with the requested content. The Library shows the source and a “Source Added” confirmation.", + "summary": "Successfully started a debate on 'This House would ban single-use plastics' and added a source titled 'Plastic bag levy study' with the specified content in the Library. The library now shows 1 source, and the source card displays the correct title and content.", "startPath": "/app", "endPath": "/app/library", "endAnchors": [ { - "role": "button", - "name": "Close" - }, - { - "text": "Source Added" - }, - { - "text": "Your source is now available for grounding." + "text": "Current debate" }, { - "text": "Current debate" + "role": "button", + "name": "Current debate: This House would ban single-use plastics", + "testId": "current-debate-desktop" }, { "role": "heading", @@ -125,7 +117,14 @@ "name": "Change" }, { - "text": "All folders" + "text": "1 sources · 1 active · 1 folders" + }, + { + "text": "Smart" + }, + { + "role": "heading", + "name": "Your Knowledge Base" } ], "goneAnchors": [ @@ -134,6 +133,10 @@ "name": "Start debate", "testId": "current-debate-desktop" }, + { + "role": "heading", + "name": "What are you working on?" + }, { "text": "No current debate" }, @@ -152,11 +155,8 @@ }, { "text": "Brainstorm" - }, - { - "text": "Explore angles for a motion." } ], - "endWaitMs": 14336 + "endWaitMs": 12889 } } diff --git a/e2e/admin-announcements.spec.ts b/e2e/admin-announcements.spec.ts index 812cb7ccc..52557b32a 100644 --- a/e2e/admin-announcements.spec.ts +++ b/e2e/admin-announcements.spec.ts @@ -112,8 +112,8 @@ test('keeps announcements stable across routes and refreshes on a full reload', await openAppNav(page, 'Library'); await expect(page.getByRole('heading', { name: 'Source Library', exact: true })).toBeVisible(); - await openAppNav(page, 'Home'); - await expect(page.getByRole('main').getByRole('heading', { name: 'DebateLab', exact: true })).toBeVisible(); + await openAppNav(page, 'Tools'); + await expect(page.getByRole('main').getByRole('heading', { name: 'What are you working on?', exact: true })).toBeVisible(); expect(listRequestCount()).toBe(1); expect(listResponseCount).toBe(1); await expect(page.getByRole('heading', { name: 'Scheduled maintenance' })).toHaveCount(0); diff --git a/e2e/full-stack/account-data.spec.ts b/e2e/full-stack/account-data.spec.ts index 1d14955fe..acb8c8f93 100644 --- a/e2e/full-stack/account-data.spec.ts +++ b/e2e/full-stack/account-data.spec.ts @@ -5,8 +5,8 @@ test.skip(!stackReady, "Local full stack is required (run ./.superset/db-gate.sh async function addSource(page: Page, title: string, content: string) { await page.getByRole("button", { name: "Add Source" }).click(); - await page.getByRole("textbox", { name: "Source title..." }).fill(title); - await page.getByRole("textbox", { name: "Paste source content..." }).fill(content); + await page.getByRole("textbox", { name: "Title", exact: true }).fill(title); + await page.getByRole("textbox", { name: "Content", exact: true }).fill(content); await page.getByRole("button", { name: "Save to Library" }).click(); await expect(page.getByRole("switch", { name: `Toggle ${title}` })).toBeChecked(); } diff --git a/e2e/full-stack/account-deletion.spec.ts b/e2e/full-stack/account-deletion.spec.ts index 5a963d67f..759f5d2ee 100644 --- a/e2e/full-stack/account-deletion.spec.ts +++ b/e2e/full-stack/account-deletion.spec.ts @@ -10,8 +10,8 @@ const accountUrl = `${process.env.LOCAL_SUPABASE_URL}/functions/v1/account`; async function addSource(page: Page, title: string, content: string) { await page.goto("/app/library"); await page.getByRole("button", { name: "Add Source" }).click(); - await page.getByRole("textbox", { name: "Source title..." }).fill(title); - await page.getByRole("textbox", { name: "Paste source content..." }).fill(content); + await page.getByRole("textbox", { name: "Title", exact: true }).fill(title); + await page.getByRole("textbox", { name: "Content", exact: true }).fill(content); await page.getByRole("button", { name: "Save to Library" }).click(); await expect(page.getByRole("switch", { name: `Toggle ${title}` })).toBeChecked(); } diff --git a/e2e/home-navigation.spec.ts b/e2e/home-navigation.spec.ts index 67b9aac5f..b6c6637d5 100644 --- a/e2e/home-navigation.spec.ts +++ b/e2e/home-navigation.spec.ts @@ -16,7 +16,7 @@ test('home groups the eight main tools and keeps utility routes visible', async await page.goto('/app', { waitUntil: 'domcontentloaded' }); const main = page.locator('main'); - await expect(main.getByRole('heading', { name: 'DebateLab' })).toBeVisible({ timeout: 15_000 }); + await expect(main.getByRole('heading', { name: 'What are you working on?', level: 1 })).toBeVisible({ timeout: 15_000 }); await expect(main.getByRole('heading', { name: 'Prepare' })).toBeVisible(); await expect(main.getByRole('heading', { name: 'Practice' })).toBeVisible(); await expect(main.getByRole('heading', { name: 'Utilities' })).toBeVisible(); @@ -65,8 +65,8 @@ test('onboarding keeps its original start schedule after a delayed lazy chunk', const response = await route.fetch(); const source = await response.text(); const body = source.replace( - /const\s+timer\s*=\s*setTimeout\(\(\)\s*=>\s*\{/, - 'window.localStorage.setItem("__onboarding-auto-start-delay", String(autoStartDelay));\n const timer = setTimeout(() => {', + /const\s+deadline\s*=\s*startedAt\s*\+\s*autoStartDelay;/, + 'window.localStorage.setItem("__onboarding-auto-start-delay", String(autoStartDelay));\n const deadline = startedAt + autoStartDelay;', ); if (body === source) throw new Error('Unable to instrument the onboarding auto-start delay'); await new Promise((resolve) => setTimeout(resolve, 750)); @@ -110,12 +110,11 @@ test('header keeps the brand and controls inside a 320px viewport', async ({ pag await page.goto('/app', { waitUntil: 'domcontentloaded' }); await expect(page.getByRole('heading', { name: 'Prepare' })).toBeVisible({ timeout: 15_000 }); - // Mobile keeps the logo, language, theme, and menu on the first row; Settings lives in the menu. + // Mobile keeps the logo, language, and menu on the first row; Settings and the theme live in the menu and Settings. const languageControl = page.getByRole('button', { name: 'Language' }); - const themeControl = page.getByRole('button', { name: 'Toggle theme' }); const menuControl = page.getByRole('button', { name: 'Open menu' }); const debateControl = page.getByTestId('current-debate-mobile'); - const controls = [languageControl, themeControl, menuControl, debateControl]; + const controls = [languageControl, menuControl, debateControl]; await expect(page.getByAltText('DebateLab logo')).toBeVisible(); for (const control of controls) await expect(control).toBeVisible(); @@ -138,7 +137,7 @@ test('desktop header fits without internal or document overflow at supported wid } }); -test('mobile menu holds only Home, Current debate, Library, History, and Settings', async ({ page }) => { +test('mobile menu holds only Tools, Current debate, Library, History, and Settings', async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); await page.goto('/app', { waitUntil: 'domcontentloaded' }); await page.getByRole('button', { name: 'Open menu' }).click(); diff --git a/e2e/hungarian-parity.spec.ts b/e2e/hungarian-parity.spec.ts index d2fedeb0f..c51d40948 100644 --- a/e2e/hungarian-parity.spec.ts +++ b/e2e/hungarian-parity.spec.ts @@ -87,7 +87,7 @@ test.describe('Hungarian parity', () => { ['factoids', 'Tények'], ['ask', 'Kérdezés'], ] as const) { - // Tools open from Home; the header keeps only Home, Current debate, Library and History. + // Tools open from the Tools page; the header keeps only Tools, Current debate, Library and History. // Navigate in-app: a reload would rerun the init script and restart the tour. await page.locator('header a[href="/app"]').first().click(); await page.locator(`main [data-tour="mode-${route}"]`).click(); @@ -134,11 +134,19 @@ test.describe('Hungarian parity', () => { await page.getByRole('button', { name: 'Forrás Hozzáadása' }).click(); await expect(page.getByText('Importálás URL-ből')).toBeVisible(); await page.getByRole('button', { name: 'Mentés a Könyvtárba' }).click(); - await expect(page.locator('[data-sonner-toast] [data-close-button]')).toHaveAttribute('aria-label', 'Bezárás'); + // Required fields are checked inline, in Hungarian. + await expect(page.getByRole('alert').filter({ hasText: 'Adj meg egy címet.' })).toBeVisible(); await page.goto('/app/flowchart', { waitUntil: 'domcontentloaded' }); await page.getByRole('button', { name: 'Betöltés' }).click(); await expect(page.getByRole('dialog', { name: 'Mentett Folyamatábrák' }).getByRole('button', { name: 'Bezárás' })).toBeVisible(); + await page.keyboard.press('Escape'); + // Deleting a node raises a toast, whose close button is localized too. + await page.getByText('1. érv', { exact: true }).click(); + await page.getByRole('button', { name: 'Törlés' }).last().click(); + const confirmNodeDelete = page.getByRole('button', { name: 'Csomópont törlése' }); + if (await confirmNodeDelete.isVisible().catch(() => false)) await confirmNodeDelete.click(); + await expect(page.locator('[data-sonner-toast] [data-close-button]')).toHaveAttribute('aria-label', 'Bezárás'); await page.goto('/app/timer', { waitUntil: 'domcontentloaded' }); await expect(page.getByRole('timer', { name: 'Időmérő kijelző' })).toBeVisible(); diff --git a/e2e/live/research.spec.ts b/e2e/live/research.spec.ts index 4ecff7837..85b0f8d85 100644 --- a/e2e/live/research.spec.ts +++ b/e2e/live/research.spec.ts @@ -70,7 +70,7 @@ test("a web page imports into the Library", async ({ signedIn: page, user }) => await page.getByRole("button", { name: "Add Source" }).click(); await page.getByRole("textbox", { name: "Import from URL" }).fill(url); await page.getByRole("button", { name: "Import URL" }).click(); - await expect(page.getByText("Source Ready", { exact: true })).toBeVisible({ timeout: 60_000 }); + await expect(page.getByText("Import preview", { exact: true })).toBeVisible({ timeout: 60_000 }); await page.getByRole("button", { name: "Save imported source" }).click(); await expect.poll(async () => { diff --git a/e2e/navigation-performance.spec.ts b/e2e/navigation-performance.spec.ts index c421e266e..2f0b0e273 100644 --- a/e2e/navigation-performance.spec.ts +++ b/e2e/navigation-performance.spec.ts @@ -21,7 +21,7 @@ const publicRoutes = [ ] as const; const privateRoutes = [ - ['/app', { selector: 'main h1', text: 'DebateLab' }], + ['/app', { selector: 'main h1', text: 'What are you working on?' }], ['/app/generate', { selector: 'main h1', text: 'Generate' }], ['/app/attack', { selector: 'main h1', text: 'Attack' }], ['/app/flowchart', { selector: 'main h3', text: 'Argument Flowchart' }], @@ -49,7 +49,7 @@ async function skipOnboarding(page: Page) { }); } -// Tools open from Home; the header carries only Home, Current debate, Library and History. +// Tools open from the Tools page; the header carries only Tools, Current debate, Library and History. function homeToolLink(page: Page, tool: string) { return page.locator(`main [data-tour="mode-${tool}"]`); } @@ -57,9 +57,9 @@ function homeToolLink(page: Page, tool: string) { async function openHome(page: Page) { if ((page.viewportSize()?.width ?? 1280) < 1024) { await page.getByRole('button', { name: 'Open menu' }).click(); - await page.getByRole('navigation', { name: 'Mobile navigation' }).getByRole('link', { name: /^Home/ }).click(); + await page.getByRole('navigation', { name: 'Mobile navigation' }).getByRole('link', { name: /^Tools/ }).click(); } else { - await page.getByRole('navigation', { name: 'Main navigation' }).getByRole('link', { name: 'Home' }).click(); + await page.getByRole('navigation', { name: 'Main navigation' }).getByRole('link', { name: 'Tools' }).click(); } await expect(page).toHaveURL(/\/app$/); } @@ -91,7 +91,7 @@ test('workspaces stay unavailable without a collaboration endpoint', async ({ pa await setLocale(page, 'en'); await stubSupabaseSession(page); await page.goto('/app', { waitUntil: 'domcontentloaded' }); - await waitForContent(page, { selector: 'main h1', text: 'DebateLab' }); + await waitForContent(page, { selector: 'main h1', text: 'What are you working on?' }); await expect(page.locator('a[href="/app/workspaces"]')).toHaveCount(0); await page.goto('/app/workspaces', { waitUntil: 'domcontentloaded' }); @@ -119,7 +119,7 @@ test('a delayed destination chunk exposes the route fallback without replacing t await stubSupabaseSession(page); await skipOnboarding(page); await page.goto('/app', { waitUntil: 'domcontentloaded' }); - await waitForContent(page, { selector: 'main h1', text: 'DebateLab' }); + await waitForContent(page, { selector: 'main h1', text: 'What are you working on?' }); await page.evaluate(() => { window.__baselineAppLayout = document.querySelector('header')?.parentElement; @@ -187,7 +187,7 @@ test('a failed route chunk recovers after the offered reload', async ({ page }) await skipOnboarding(page); await page.goto('/app', { waitUntil: 'domcontentloaded' }); - await waitForContent(page, { selector: 'main h1', text: 'DebateLab' }); + await waitForContent(page, { selector: 'main h1', text: 'What are you working on?' }); await homeToolLink(page, 'attack').click(); await expect(page.getByRole('heading', { name: 'Unable to load this page' })).toBeVisible(); @@ -202,7 +202,7 @@ async function gotoHome(page: Page) { await stubSupabaseSession(page); await skipOnboarding(page); await page.goto('/app', { waitUntil: 'domcontentloaded' }); - await waitForContent(page, { selector: 'main h1', text: 'DebateLab' }); + await waitForContent(page, { selector: 'main h1', text: 'What are you working on?' }); } async function attackLink(page: Page) { diff --git a/e2e/startup-shell.spec.ts b/e2e/startup-shell.spec.ts index 4fd7f0461..d7a010daa 100644 --- a/e2e/startup-shell.spec.ts +++ b/e2e/startup-shell.spec.ts @@ -37,7 +37,7 @@ test('mounts the app before Jam recorder modules resolve', { tag: '@smoke' }, as }); await page.goto('/app', { waitUntil: 'domcontentloaded' }); - await expect(page.getByRole('main').getByRole('heading', { name: 'DebateLab', exact: true, level: 1 })).toBeVisible({ timeout: 5_000 }); + await expect(page.getByRole('main').getByRole('heading', { name: 'What are you working on?', exact: true, level: 1 })).toBeVisible({ timeout: 5_000 }); expect(await page.evaluate(() => { const jam = (window as Window & { __jam__?: { exports?: { metadata?: unknown } } }).__jam__; return typeof jam?.exports?.metadata === 'function'; diff --git a/package-lock.json b/package-lock.json index 545dd24d0..676679f65 100644 --- a/package-lock.json +++ b/package-lock.json @@ -70,6 +70,7 @@ "sonner": "^2.0.8", "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7", + "vaul": "1.1.2", "y-protocols": "1.0.7", "yjs": "13.6.32", "zod": "^4.3.6", @@ -13675,6 +13676,19 @@ "base64-arraybuffer": "^1.0.2" } }, + "node_modules/vaul": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz", + "integrity": "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-dialog": "^1.1.1" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", diff --git a/package.json b/package.json index 862ded4f7..342df9ebe 100644 --- a/package.json +++ b/package.json @@ -97,6 +97,7 @@ "sonner": "^2.0.8", "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7", + "vaul": "1.1.2", "y-protocols": "1.0.7", "yjs": "13.6.32", "zod": "^4.3.6", diff --git a/plans/2026-10-09-design-audit.md b/plans/2026-10-09-design-audit.md index 921914a9d..d8b1a999c 100644 --- a/plans/2026-10-09-design-audit.md +++ b/plans/2026-10-09-design-audit.md @@ -54,7 +54,7 @@ These are ordered by impact per line changed. 4. ✅ PR1. Gate hover: `future: { hoverOnlyWhenSupported: true }` in `tailwind.config.ts`, and wrap the hover rules in `src/index.css` in `@media (hover:hover) and (pointer:fine)`. 5. ✅ PR1. Replace the blanket reduced-motion kill (`src/index.css:1071-1098`) with one that keeps opacity and colour transitions and turns spinners into an opacity pulse. 6. ✅ PR1. Add `origin-[--radix-…-content-transform-origin]` to Popover, Select and Tooltip, and set exits shorter than enters. -7. Wayfinding: make "Current debate" a link that is active on `/app/debate` and every tool route. Rename "Home". Make the brand a `<span>`. +7. ✅ PR2. Wayfinding: make "Current debate" a link that is active on `/app/debate` and every tool route. Rename "Home". Make the brand a `<span>`. 8. One delete rule: reversible deletes act at once and show an Undo toast; irreversible bulk deletes use an AlertDialog with a destructive-styled confirm. 9. Ask: scroll only when the user is at the bottom, keep the input editable while streaming, add Stop. Give the four form tools a Cancel. 10. Landing: remove the infinite loops and shimmer, split the framer reveal from the hover onto separate elements, and switch the `x`/`y` shorthands to full `transform` strings. @@ -75,14 +75,14 @@ Rows follow Emil Kowalski's required Before / After / Why format. The tiers foll | ✅ PR1 · `index.css:846-849` | Unlayered `.tabular-nums { transition: transform .2s }` | Delete it; Tailwind already ships `.tabular-nums` | It resets `transition-property` on 19 elements. The Timer's red warning snaps instead of easing. | | ✅ PR1 · `index.css:451-489` | `.border-dashed:hover` marching-ants infinite animation | Delete it; use an explicit `.dropzone[data-drag-over]` class if a drop zone needs it | It takes over a utility name, so every dashed border (22 uses, including non-interactive empty states) animates and changes shape on hover. | | ✅ PR1 · `index.css:604-607` | `[role=progressbar] > div { transition: width .5s }` | Delete it; `progress.tsx:16` `transition-transform duration-300 ease-out-strong`; clocks `transform 1000ms linear` | Radix moves the bar with `transform`, so every progress bar jumps (Timer, Sparring, Attack, Factoids). | -| `AppLayout.tsx:196`, `:209`, `index.css:569-595` | Active nav underline animates `scaleX(0)→1` with a glow on every navigation, on top of `bg-primary/10` | Static `bg-primary/10 text-primary` only | Starts from scale 0, fires tens of times a day including from the keyboard, and doubles the active signal. | -| `AppLayout.tsx:249-250` | Mobile menu `motion-reveal` 420ms, rising upward while it drops down below the header, no exit | No animation, or opacity plus `translateY(-4px)` at 180ms with a matching exit | Over budget, wrong direction, asymmetric path (Apple §7). | +| ✅ PR2 · `AppLayout.tsx:196`, `:209`, `index.css:569-595` | Active nav underline animates `scaleX(0)→1` with a glow on every navigation, on top of `bg-primary/10` | Static `bg-primary/10 text-primary` only | Starts from scale 0, fires tens of times a day including from the keyboard, and doubles the active signal. | +| ✅ PR2 · `AppLayout.tsx:249-250` | Mobile menu `motion-reveal` 420ms, rising upward while it drops down below the header, no exit | No animation, or opacity plus `translateY(-4px)` at 180ms with a matching exit | Over budget, wrong direction, asymmetric path (Apple §7). | | `AskChat.tsx:380-382`, `AskPage.tsx:334-343` | `scrollIntoView({behavior:'smooth'})` on every 30ms stream flush; it also scrolls the window | Track `atBottom` (within 80px); scroll instantly only then; otherwise show a "Jump to latest" pill | It pulls the user down when they scroll up to read (Apple §3: never lock out input). | | `AskPage.tsx:176`, `session-store.ts:944` | The streaming bubble is keyed by request id; the stored message gets a new id, so it remounts and `message-enter` replays | Reuse the streaming id for the stored message | The answer jumps and fades in again as the user reads it. | | `AskChat.tsx:88`, `index.css:654-667` | 300ms `message-enter` keyframe on every bubble, including the user's own message sent with Enter | No entry animation on user bubbles; assistant bubbles get opacity plus `translateY(4px)` at 150ms | Keyboard-initiated and frequent. | | `TimerPage.tsx:330` | Ring `transition-all duration-1000` with the default ease | `transition-[clip-path] duration-1000 ease-linear` | Constant motion is linear (measured surge-and-stop). | -| `HistoryPage.tsx:156`, `tabs.tsx:45` | Each tab switch replays a slide-in plus a 350ms `stagger-enter` plus the empty state's blur swap | 150ms fade on the tab content only; no stagger | Tens of times a day, and Radix tabs switch on arrow keys. | -| `HomePage.tsx:61`, `CurrentDebatePage.tsx:198`, `AppLayout.tsx:44`, `:259`, `:300-301`, `CurrentDebateMenu.tsx:27` | `.motion-press` button lift on list rows, nav links and footer text links | `hover:bg-accent` only on rows and links | Hairline rows lift and cast shadows like cards. | +| ✅ PR2 · `HistoryPage.tsx:156`, `tabs.tsx:45` | Each tab switch replays a slide-in plus a 350ms `stagger-enter` plus the empty state's blur swap | 150ms fade on the tab content only; no stagger | Tens of times a day, and Radix tabs switch on arrow keys. | +| ✅ PR2 · `HomePage.tsx:61`, `CurrentDebatePage.tsx:198`, `AppLayout.tsx:44`, `:259`, `:300-301`, `CurrentDebateMenu.tsx:27` | `.motion-press` button lift on list rows, nav links and footer text links | `hover:bg-accent` only on rows and links | Hairline rows lift and cast shadows like cards. | | `landing/WhatsNew.tsx:41-47`, `Pricing.tsx:48-53`, `:81-86`, `Features.tsx:56-62`, `UseCases.tsx:49-55` | Framer `whileInView` `y` on the same node as `hover:-translate-y-1` and `transition-all duration-300/500` | Reveal on an outer `motion.div`, hover on an inner div; drop the lift and tilt; transition only `box-shadow` and `border-color` | Confirmed above: the hovers never render, and `transition-all` drags out framer's own reveal. | | `index.css:174-201` (`Hero.tsx:74`, `:81`, `Navbar.tsx:57`, `:98`, `Pricing.tsx:75`, `:113`) | `.btn-primary-gradient` and `.btn-secondary-outline` have no `:active`; they are Links, so `.motion-press` never applies | `:active { transform: scale(0.97) }` with `transition: transform 160ms var(--motion-ease-fluid)` | The main conversion buttons give no feedback on press. | | `landing/Footer.tsx:11`, `:24`, `:27` | `<a href>` | `<Link to>` | Every footer click reloads the whole SPA through the startup shell. | @@ -104,8 +104,8 @@ Rows follow Emil Kowalski's required Before / After / Why format. The tiers foll | `TimerPage.tsx:334` | `animate-pulse` on the digits in the last 30s | Static `text-destructive` | Pulses the number the speaker is watching. | | `FlowchartPage.tsx:251-253`, `:305` | `animated: true` marching edges | Static edges; a dashed stroke for rebuttals | Constant motion on a work canvas. | | `CrossfirePage.tsx:865-1021`, `SparringRound.tsx:192`, `:249-254` | The transcript remounts per phase and all turns replay; the AI wait shows a pulsing background plus bouncing dots plus text | Hoist the transcript and animate only the newest turn; one indicator | A wall of cards fades in on each phase. | -| `AppLayout.tsx:44`, `:218-219` | `nav-glow` radial hover on top of `hover:bg-accent`; theme toggle in the mobile header and again in Settings | Remove `nav-glow`; drop ThemeToggle from the mobile header | Doubled hover effects; a crowded sticky header. | -| `LibraryPage.tsx:495-506` vs `:777-790`; `:675-684` vs `:723-733`; `:583-596` | Two folder filters; two Folder selects in one form; three stat tiles repeating a badge count | One chip row; one Folder select; one meta line under the h1 | Duplicate controls bound to the same state. | +| ✅ PR2 · `AppLayout.tsx:44`, `:218-219` | `nav-glow` radial hover on top of `hover:bg-accent`; theme toggle in the mobile header and again in Settings | Remove `nav-glow`; drop ThemeToggle from the mobile header | Doubled hover effects; a crowded sticky header. | +| ✅ PR2 · `LibraryPage.tsx:495-506` vs `:777-790`; `:675-684` vs `:723-733`; `:583-596` | Two folder filters; two Folder selects in one form; three stat tiles repeating a badge count | One chip row; one Folder select; one meta line under the h1 | Duplicate controls bound to the same state. | | `LibraryPage.tsx:248`, `:404-407`, `:435-438` and success toasts at `AttackPage.tsx:49`, `BrainstormPage.tsx:48`, `GeneratePage.tsx:97`, `FactoidsPage.tsx:47` | Toasts confirming things already visible | Drop them; keep toasts for off-screen or irreversible results | Over-feedback trains people to ignore feedback (Apple §13). | | `ExportToolbar.tsx:15`, `:54`, `AutoSavedSourcesUndo.tsx` | `showUndo` is never passed by any caller | Delete both | Dead code with a misleading label. | | `landing/WhatsNew.tsx:27-33`, `SocialProof.tsx:7-11` | Pill and h2 both say "What's New"; "2 Plans / 1 Unified Workspace" styled as metrics | Remove the pill; remove the section or use real facts | Repetition; pseudo social proof (Apple Responsibility). | @@ -120,21 +120,21 @@ Rows follow Emil Kowalski's required Before / After / Why format. The tiers foll | `Hero.tsx:167-178`, `WhatsNew.tsx:47`, `Pricing.tsx:53`, `:86`, `index.css:165`, `:220`, `:271` | Nested translucent blur layers over animated backgrounds; grid animates `background-position` | Solid cards; blur only on the nav; static grid | Never stack light translucent surfaces (Apple §12); full-section repaints every frame. | | `AskPage.tsx:25-42`, `:334-340`, `AskChat.tsx:82`; `AttackPage.tsx:26`, `BrainstormPage.tsx:24`, `GeneratePage.tsx:35`, `FactoidsPage.tsx:24` | `useSessionStore()` with no selector; Ask remaps messages so `React.memo` never holds | `useShallow` selectors; pass messages through; a separate `StreamingBubble` | Every bubble re-parses Markdown on every 30ms flush. | | `index.css:984-1001`, `:1052-1061`; `CrossfirePage.tsx:421-426` | Caret blink animates `box-shadow`; dead `max-height`/`padding` transition; `setNow` every 500ms re-renders the whole Crossfire page | Opacity-only caret with `steps(2)`; delete the dead rule; a `<CrossfireClock>` with its own interval | Repaints while streaming; full renders during a live round. | -| `SettingsPage.tsx:324-326`, `AppLayout.tsx:276-285` | A 160px pulsing block for every user while the admin check runs; the announcement banner pushes content down after a fetch | Render nothing while pending; overlay the banner or render it from cache | Layout shift for 100% of users. | +| ✅ PR2 · `SettingsPage.tsx:324-326`, `AppLayout.tsx:276-285` | A 160px pulsing block for every user while the admin check runs; the announcement banner pushes content down after a fetch | Render nothing while pending; overlay the banner or render it from cache | Layout shift for 100% of users. | ### 4. Interruptibility and timing | Where | Before | After | Why | | --- | --- | --- | --- | | `AskChat.tsx:703`, `:706`; `CrossfirePage.tsx:968`; `AttackPage.tsx:36-62`, `BrainstormPage.tsx:37-61`, `GeneratePage.tsx:78-116`, `FactoidsPage.tsx:34-60` | The input is disabled while the AI works; no Stop; the form tools have no AbortController | Keep composers editable and disable only submit; a Stop button that aborts and returns focus; Cancel under the tool status | Apple §3: thought and gesture run in parallel. | -| `sheet.tsx:33`, `:37-42` | Open 500ms, close 300ms, bare `transition ease-in-out`; full-width right sheet on phones; no drag | Open 300ms, close 200ms, `ease-drawer`; on mobile a bottom sheet (Vaul) with velocity dismiss above about 0.11 px/ms and rubber-banding | Over budget; a sheet you can't grab doesn't feel physical (Apple §2–6, §9). | +| ✅ PR2 · `sheet.tsx:33`, `:37-42` | Open 500ms, close 300ms, bare `transition ease-in-out`; full-width right sheet on phones; no drag | Open 300ms, close 200ms, `ease-drawer`; on mobile a bottom sheet (Vaul) with velocity dismiss above about 0.11 px/ms and rubber-banding | Over budget; a sheet you can't grab doesn't feel physical (Apple §2–6, §9). | | ✅ PR1 · `dialog.tsx:42`, `alert-dialog.tsx:37`, `popover.tsx:20`, `select.tsx:69`, `tooltip.tsx:20` | Symmetric 150ms keyframes, `zoom-out-95` on exit | Enter 200ms, exit 120ms fade only | A reopen during the close doesn't visibly jump; exit faster than enter. | | ✅ PR1 · `index.css:741-743` | `[role=option]` background fades 100ms | Instant highlight; keep only the `:active` transform | Arrow-key list navigation shouldn't fade. | | ✅ PR1 · `accordion.tsx:31` vs `tailwind.config.ts:77-78`; `index.css:355-370`, `AttackForm.tsx:121`, `GenerateForm.tsx:220` | Chevron 300ms against content 200ms; options expand 260ms plus translateY with a 300ms chevron | Everything at 200ms on one curve; chevron 150ms | Coordinated motions finish out of sync. | | `TimerPage.tsx:183` | Resume uses whole seconds rounded up | Store the remaining milliseconds on pause | Each pause gives the speaker up to a second. | | `FlowchartPage.tsx:442-444`, `:749-762`, `:343` | Auto layout clicks a fit-view button that doesn't exist; wheel zoom captures page scroll; new nodes at random positions | `rf.fitView({ duration: 250, padding: 0.2 })` with a temporary node transform transition; `zoomOnScroll={false} preventScrolling={false}`; place at the pane centre | Teleporting nodes; trapped scroll; things appearing from nowhere. | -| `AppLayout.tsx:155-173`; `SettingsPage.tsx:89-104`, `:143-150`; `DebateDialogs.tsx:53-70`; `LibraryPage.tsx:260-283` | Announcement dismiss waits for the server; no pending state on delete-all, switch or delete debate, sign out, or file import | Optimistic dismiss; per-action busy labels and disabled state; reset the file input | A tap with no visible response (Apple §1). | -| `OnboardingTour.tsx:105-111` | A fixed 500ms start delay | Start once the target exists (rAF or MutationObserver, 500ms cap) | An artificial delay. | +| ✅ PR2 · `AppLayout.tsx:155-173`; `SettingsPage.tsx:89-104`, `:143-150`; `DebateDialogs.tsx:53-70`; `LibraryPage.tsx:260-283` | Announcement dismiss waits for the server; no pending state on delete-all, switch or delete debate, sign out, or file import | Optimistic dismiss; per-action busy labels and disabled state; reset the file input | A tap with no visible response (Apple §1). | +| ✅ PR2 · `OnboardingTour.tsx:105-111` | A fixed 500ms start delay | Start once the target exists (rAF or MutationObserver, 500ms cap) | An artificial delay. | | ✅ PR1 · `index.css:1065-1068` | The transition is declared only inside `:hover`, so leaving snaps back | Transition on the base rule; gate the hover | An asymmetric snap. PR1 deleted the rule instead, with the other icon hijacks. | ### 5. Origin, physicality and cohesion @@ -142,13 +142,13 @@ Rows follow Emil Kowalski's required Before / After / Why format. The tiers foll | Where | Before | After | Why | | --- | --- | --- | --- | | ✅ PR1 · `popover.tsx:20`, `select.tsx:69`, `tooltip.tsx:20` | `zoom-in-95` from the centre (measured) | `origin-[--radix-popover-content-transform-origin]`, and the same for Select and Tooltip | Trigger-anchored content scales from its trigger; modals stay centred. | -| `index.css:153`, `:271`, `AppLayout.tsx:178` | Sticky translucent header with a hard 1px border and a shadow | A scroll-edge fade, or a border only when `scrollY > 0` | Apple §12. | +| ✅ PR2 · `index.css:153`, `:271`, `AppLayout.tsx:178` | Sticky translucent header with a hard 1px border and a shadow | A scroll-edge fade, or a border only when `scrollY > 0` | Apple §12. | | `index.css:491-847` overall | About 20 unlayered global selectors keyed on Radix internals | Move what's worth keeping into the primitives and delete the layer | They break silently on upgrades and override primitive classes. | | ✅ PR1 · `index.css:156-159`, `ThemeProvider.tsx:8-23` | `html`/`body` ease over 420ms, components at 160 or 260ms, everything else snaps | `document.startViewTransition` with a 200ms crossfade, or suppress transitions for one frame | The theme change ripples at three speeds. | | `AskChat.tsx:693`, `GenerateForm.tsx:142-154`, `VoiceMicControl.tsx:41`; `TimerPage.tsx:334`, `SparringRound.tsx:150`, `:157`, `CrossfirePage.tsx:924-926` | Three listening indicators and three countdown urgency treatments; the Timer goes calm again at 0:00 | One `<ListeningIndicator>` and one `<RoundClock>`: amber at 30s or less, destructive at 0:00 | Things that look the same should behave the same. | | `TimerPage.tsx:334` | `font-mono` (Space Mono) with `tracking-tighter` on the digits | Inter with `tabular-nums` and `tracking-[-0.02em]` | The wide colon reads "4 : 00"; negative tracking on a monospace face fights its design. | | `landing/*` section h2s; `Features.tsx:37` | Different sizes and tracking per section; the orb's `-translate-y-1/2` is overridden by its keyframe | One `.section-heading`; position the orb with `top` | Cohesion; the orb jumps under reduced motion. | -| Login (`LoginPage.tsx:157-161`), app header logo | The login card shows a generic sparkle tile; the app shows the tree-and-scales logo in a white tile in both themes | The real mark on Login; a dark-mode variant of the logo tile | Brand consistency across surfaces. | +| ➖ PR2 · Login (`LoginPage.tsx:157-161`), app header logo | The login card shows a generic sparkle tile; the app shows the tree-and-scales logo in a white tile in both themes | The real mark on Login; a dark-mode variant of the logo tile | Brand consistency across surfaces. PR2 puts the real mark on Login and the public pages. The dark-mode tile is not done: `favicon-72.png` is RGB with no alpha, so the white is baked into the mark and a dark variant needs a new asset. | ### 6. Accessibility @@ -162,20 +162,20 @@ Rows follow Emil Kowalski's required Before / After / Why format. The tiers foll | ✅ PR1 · `index.css:598-601`, `:502-507`; `dialog.tsx:48`, `sheet.tsx:63`, `select.tsx:20` | `!important` input focus is a 10%-alpha glow (about 1.1:1); global focus changes `border-radius`; some primitives use `focus:` not `focus-visible:` | One system: `outline: 2px solid hsl(var(--ring)); outline-offset: 2px` on `:focus-visible` | Fails WCAG 2.4.11; three focus systems compete. PR1: the global outline is the default, and primitives that draw `focus-visible:ring-*` turn it off, so nothing gets two rings. | | ✅ PR1 · `index.css:23-24`, `:35`, `:39`, `:104-108` | Light primary label 3.85:1; destructive labels 4.43:1 (light) and 3.43:1 (dark); `--input` borders 1.38:1 and 2.32:1 | `--primary: 200 98% 33%`; light `--destructive: 0 72% 47%`; dark `--destructive-foreground: 0 86% 10%`; light `--input: 215 16% 55%` | AA text and 3:1 for control boundaries. | | `SettingsPage.tsx:37-42`, `:206`; `DeepDive.tsx:63`, `:78`; `LoginPage.tsx:268`, `:273` | White on amber or green-500 tier badges (about 2:1); near-white `text-accent` on a light card (about 2.5:1); hard-coded `text-green-600` (3.15:1) | Tinted badges (`bg-amber-500/15 text-amber-800 dark:text-amber-300`); `text-foreground`; a `--success` token | Contrast failures. | -| `LibraryPage.tsx:69-147`, `:627-638`, `:705-975` | A `role=button` row containing a link, a button and a switch; an invisible file input with no focus ring; labels without `htmlFor` | The title is the button; `has-[:focus-visible]:ring-2` on the label; matching `id`/`htmlFor` pairs | Nested interactive controls; invisible focus; detached labels. | +| ✅ PR2 · `LibraryPage.tsx:69-147`, `:627-638`, `:705-975` | A `role=button` row containing a link, a button and a switch; an invisible file input with no focus ring; labels without `htmlFor` | The title is the button; `has-[:focus-visible]:ring-2` on the label; matching `id`/`htmlFor` pairs | Nested interactive controls; invisible focus; detached labels. | | ✅ PR1 · `AskChat.tsx:102`, `FactoidsResults.tsx:59` | The bookmark and external-link icons appear only on hover | Always visible at 60% opacity, full on focus | Invisible on touch and to the keyboard. | | `TimerPage.tsx:329`, `SparringRound.tsx:157`, `streaming-indicator.tsx:56` | `aria-live` on a clock that ticks every second; fake stage text announced every 3s | Announce 1:00, 0:30 and 0:00 only; `role="timer"`; announce only real status changes | Constant screen-reader chatter. | -| `LibraryPage.tsx:95-124`, `ModelSelector.tsx:76-86` (about 30 `text-[9-11px]` uses) | 9–11px text in px | `text-xs` (0.75rem) as the floor | Below a legible floor; doesn't scale with the user's font size. | -| `ThemeToggle.tsx:21`, `tooltip.tsx` (0 importers) | Icon-only header controls use `title`; the theme icon is a laptop in "system" mode | Wrap them in Tooltip, which gets an instant skip delay inside the provider | `title` doesn't show on touch or focus; the laptop icon is ambiguous. | +| ✅ PR2 · `LibraryPage.tsx:95-124`, `ModelSelector.tsx:76-86` (about 30 `text-[9-11px]` uses) | 9–11px text in px | `text-xs` (0.75rem) as the floor | Below a legible floor; doesn't scale with the user's font size. | +| ✅ PR2 · `ThemeToggle.tsx:21`, `tooltip.tsx` (0 importers) | Icon-only header controls use `title`; the theme icon is a laptop in "system" mode | Wrap them in Tooltip, which gets an instant skip delay inside the provider | `title` doesn't show on touch or focus; the laptop icon is ambiguous. | ### 7. Foundations: feedback, wayfinding, agency, typography | Where | Before | After | Why | | --- | --- | --- | --- | -| `AppLayout.tsx:31-41`, `:185`, `:190-215`, `:199`; `HomePage.tsx:155` | Nav is Home, Current debate (a popover, never active), Library, History, Settings. Nothing is active on the eight tool routes. The brand is an `<h1>`; Home's `<h1>` is also "DebateLab" | "Current debate" becomes a link, active on `/app/debate` and every tool route, with the chevron as a separate trigger. Rename Home to "Tools". The brand becomes a `<span>`. Home's h1 describes the page | "Where am I?" is unanswered on most screens; specific labels beat generic ones (Apple §16). | -| `NotFound.tsx:11-13`, `SourcesPage.tsx:129-162`, `App.tsx:153-158`, `PrivacyPage.tsx`, `TermsPage.tsx`, `LoginPage.tsx:150-284` | The 404 reloads to the marketing page; Sources, Legal and Login have no way home; the route error offers only Reload | `<Link to={user ? '/app' : '/'}>`; a header or back link on public pages; "Go to Home" on the error | Never trap the user. | -| `LibraryPage.tsx:129-140` vs `:1025-1044`; `DebateDialogs.tsx:104-110`; `HistoryPage.tsx:352-379`; `SettingsPage.tsx:386-454`; `LibraryPage.tsx:421`; `FlowchartPage.tsx:382-390` vs `:784-804`; `AdminPage.tsx:997-1012` | Five confirmation models. A source row deletes instantly, while the same source in the sheet asks. Saved flowcharts delete instantly, while nodes ask. `window.confirm`. Admin Ban and Make admin are one click | Reversible deletes act at once with `toast({ action: { label: 'Undo' } })`; irreversible ones get an AlertDialog with `buttonVariants({ variant: 'destructive' })`; admin role changes use the existing `pendingAction` dialog | Apple §16 Agency: undo for slips, confirmations used sparingly and consistently. | -| `LibraryPage.tsx:218-233`, `:442-444`; `LoginPage.tsx:74-133`, `:277-281`, `:57-143` | Required-field errors arrive as toasts after submit; login errors appear below the tabs, not tied to the field; raw backend `error.message` in English | Inline `role="alert"` under the field with `aria-describedby` and focus moved to it (as `DebateForm.tsx:115-118` already does); map Supabase error codes to i18n | Validate inline; localized errors. | +| ✅ PR2 · `AppLayout.tsx:31-41`, `:185`, `:190-215`, `:199`; `HomePage.tsx:155` | Nav is Home, Current debate (a popover, never active), Library, History, Settings. Nothing is active on the eight tool routes. The brand is an `<h1>`; Home's `<h1>` is also "DebateLab" | "Current debate" becomes a link, active on `/app/debate` and every tool route, with the chevron as a separate trigger. Rename Home to "Tools". The brand becomes a `<span>`. Home's h1 describes the page | "Where am I?" is unanswered on most screens; specific labels beat generic ones (Apple §16). | +| ✅ PR2 · `NotFound.tsx:11-13`, `SourcesPage.tsx:129-162`, `App.tsx:153-158`, `PrivacyPage.tsx`, `TermsPage.tsx`, `LoginPage.tsx:150-284` | The 404 reloads to the marketing page; Sources, Legal and Login have no way home; the route error offers only Reload | `<Link to={user ? '/app' : '/'}>`; a header or back link on public pages; "Go to Home" on the error | Never trap the user. | +| `LibraryPage.tsx:129-140` vs `:1025-1044`; `DebateDialogs.tsx:104-110`; `HistoryPage.tsx:352-379`; `SettingsPage.tsx:386-454`; `LibraryPage.tsx:421`; `FlowchartPage.tsx:382-390` vs `:784-804`; `AdminPage.tsx:997-1012` | Five confirmation models. A source row deletes instantly, while the same source in the sheet asks. Saved flowcharts delete instantly, while nodes ask. `window.confirm`. Admin Ban and Make admin are one click | Reversible deletes act at once with `toast({ action: { label: 'Undo' } })`; irreversible ones get an AlertDialog with `buttonVariants({ variant: 'destructive' })`; admin role changes use the existing `pendingAction` dialog | Apple §16 Agency: undo for slips, confirmations used sparingly and consistently. PR2 draws the line at whether the app can put the item back exactly: Library sources delete at once with Undo (the flowchart's nodes and saved flowcharts follow in PR3); saved cases, sparring rounds, Clear Library and the Settings deletes use a destructive-styled confirm; a debate keeps its inline confirm, which already names what goes with it; admin role changes confirm through a dialog. | +| ✅ PR2 · `LibraryPage.tsx:218-233`, `:442-444`; `LoginPage.tsx:74-133`, `:277-281`, `:57-143` | Required-field errors arrive as toasts after submit; login errors appear below the tabs, not tied to the field; raw backend `error.message` in English | Inline `role="alert"` under the field with `aria-describedby` and focus moved to it (as `DebateForm.tsx:115-118` already does); map Supabase error codes to i18n | Validate inline; localized errors. | | `GenerateResults.tsx:55-57`, Attack, Brainstorm, Ask without citations | No AI caveat anywhere; Generate evidence reads as fact unless `needsSource` is set | One line under every result header: "AI-generated. Check facts and sources before using them in a round."; label evidence "Unverified" unless a researched source backs it | Apple §16.3 Responsibility. | | `AskChat.tsx:64-70`, `:384-394`, `:118`; `streaming-indicator.tsx` | Timed status copy cycles ("Almost there…" back to "Thinking…"); "Typing" shows while web research runs | Real phases ("Searching the web…", "Writing"); otherwise "Working…" plus elapsed time after 5s | Status should be true. | | Error handling in Attack, Brainstorm, Factoids and Generate; `error-display.ts:31`, `:41`; `SparringPage.tsx:26-29`, `:297-307`; `AskPage.tsx:155`, `:280`; `AskChat.tsx:405-407` | Toast-only errors with no Retry; hard-coded English; Retry duplicates the user's bubble; oversized input is cleared before it's rejected | An inline error panel with Retry, like `CrossfirePage.tsx:943-958`; i18n; retry without re-adding the message; clear only on accept | Recoverable feedback; forgiveness. | @@ -184,9 +184,9 @@ Rows follow Emil Kowalski's required Before / After / Why format. The tiers foll | Results in Attack, Brainstorm, Generate and Factoids; `HomePage.tsx:53-73` vs `CurrentDebatePage.tsx:194-204` | The export toolbar sits in a different place per tool; copy exists only in Generate; only Generate scrolls to results; the tool list is built twice with different labels | One `<ResultHeader>` (meta row, actions row, sources disclosure); `useRevealResults()` on mobile; one shared `ToolLink` | The same tool family should share a layout. | | `GeneratePage.tsx` (screenshot) | "No current debate" appears twice (page banner and form notice); h1 "Generate", card title "Generate Debate Case" and button "Generate Debate Case"; an emoji 🎯 as the empty-state art next to Lucide icons elsewhere | One debate notice; drop the card title; use the `EmptyState` Lucide component everywhere | Redundant copy; mixed iconography. | | `AskChat.tsx:517`, `:582-637`; `CrossfirePage.tsx:915-932`, `:679`, `:693`; `SparringRound.tsx:203-295`; `TimerPage.tsx:87`, `:203`, `:258-261`, `:378-382` | Chat height `100vh`; four switches above the input; the live Crossfire header never shows the resolution; the opponent's latest speech sits below a 240px composer; no wake lock; R and Escape reset the timer with no undo; keyboard hints shown on touch | `100dvh` and an Options popover; show the resolution; pin the last speech above the composer; `navigator.wakeLock` while running, with vibration and sound on the same frame at 0:00; reset only while paused or with Undo; hide kbd hints under `pointer:coarse` | Wayfinding, Apple §13 harmony, forgiveness. | -| `HistoryPage.tsx:136-379`, `LibraryPage.tsx:39`, `:880`, `CurrentDebatePage.tsx:69-206` | The tab resets after Back; fixed 500–600px inner scroll boxes trap scrolling on phones; lists are cut at 4 or 6 with no "View all"; three equal outline buttons and no primary action on the debate page | The tab in `useSearchParams`; `max-h` with page scroll on mobile; "View all (N)"; move the tools up and make them primary | "What's there?" and hierarchy. | -| `DebateDialogs.tsx:167-225`, `PricingModal.tsx:96`, `LimitReachedModal.tsx:39-47` | The 5-day idle gate blocks Escape on every route including Settings; "Current" is always on Free; the limit modal offers only Upgrade | Allow Escape to mean "Continue"; badge the real tier; add "Not now" | Never trap; true status; agency. | -| `SharedPage.tsx:75-148`, legal pages | Shared items render as raw JSON in a 600px scroll box; "Import to Account" is a dead end that also loses the item on sign-in; legal text at 14px and about 100 characters per line | Render with the result components at `max-w-[65ch] text-base leading-7`; remove Import until it exists; legal pages at `text-base leading-7 max-w-[65ch]` | Recipients get developer output; reading measure. | +| ✅ PR2 · `HistoryPage.tsx:136-379`, `LibraryPage.tsx:39`, `:880`, `CurrentDebatePage.tsx:69-206` | The tab resets after Back; fixed 500–600px inner scroll boxes trap scrolling on phones; lists are cut at 4 or 6 with no "View all"; three equal outline buttons and no primary action on the debate page | The tab in `useSearchParams`; `max-h` with page scroll on mobile; "View all (N)"; move the tools up and make them primary | "What's there?" and hierarchy. | +| ✅ PR2 · `DebateDialogs.tsx:167-225`, `PricingModal.tsx:96`, `LimitReachedModal.tsx:39-47` | The 5-day idle gate blocks Escape on every route including Settings; "Current" is always on Free; the limit modal offers only Upgrade | Allow Escape to mean "Continue"; badge the real tier; add "Not now" | Never trap; true status; agency. | +| ✅ PR2 · `SharedPage.tsx:75-148`, legal pages | Shared items render as raw JSON in a 600px scroll box; "Import to Account" is a dead end that also loses the item on sign-in; legal text at 14px and about 100 characters per line | Render with the result components at `max-w-[65ch] text-base leading-7`; remove Import until it exists; legal pages at `text-base leading-7 max-w-[65ch]` | Recipients get developer output; reading measure. | | ✅ PR1 · `index.html:16`, `public/manifest.webmanifest:9` | `theme-color` fixed at `#0f172a` | Light and dark `theme-color` metas, updated by ThemeProvider | Browser chrome stays dark over the light UI. | | `src/locales/es.json`, `hu.json`; `Hero.tsx:41-51` | English Title Case carried into Spanish and Hungarian; the hero h1 steps from `text-4xl` to `text-7xl` with one tracking value; a 132-character Spanish announcement in a `rounded-full` pill | Sentence case in es and hu; `text-[clamp(2.25rem,1.2rem+4.5vw,4.5rem)] leading-[1.05] tracking-[-0.03em]`; `rounded-2xl` or shorter copy | Reads as machine-translated; size-specific tracking. | diff --git a/scripts/test-service-worker-offline.mjs b/scripts/test-service-worker-offline.mjs index 70420d5b6..561f2700b 100644 --- a/scripts/test-service-worker-offline.mjs +++ b/scripts/test-service-worker-offline.mjs @@ -168,7 +168,7 @@ async function main() { const page = await context.newPage(); await prepare(page); await page.goto(`${origin}/app`, { waitUntil: 'networkidle' }); - await page.locator('main h1', { hasText: 'DebateLab' }).waitFor({ timeout: CONTENT_TIMEOUT_MS }); + await page.locator('main h1', { hasText: 'What are you working on?' }).waitFor({ timeout: CONTENT_TIMEOUT_MS }); await page.evaluate(async () => navigator.serviceWorker.ready); await page.waitForFunction(async (paths) => { const urls = (await Promise.all((await caches.keys()).map(async (key) => @@ -181,7 +181,7 @@ async function main() { assert.equal(firstVisitCache.includes(attackPath), false, 'unvisited Attack chunk was cached'); await context.setOffline(true); await page.reload({ waitUntil: 'domcontentloaded' }); - await page.locator('main h1', { hasText: 'DebateLab' }).waitFor({ timeout: CONTENT_TIMEOUT_MS }); + await page.locator('main h1', { hasText: 'What are you working on?' }).waitFor({ timeout: CONTENT_TIMEOUT_MS }); const logo = page.locator('header a[href="/app"] img'); assert.equal(await logo.getAttribute('src'), '/favicon-72.png'); assert.ok(await logo.evaluate((image) => image.naturalWidth > 0), 'offline logo did not load'); @@ -193,7 +193,7 @@ async function main() { const prefetchedPage = await context.newPage(); await prepare(prefetchedPage); await prefetchedPage.goto(`${origin}/app`, { waitUntil: 'networkidle' }); - await prefetchedPage.locator('main h1', { hasText: 'DebateLab' }).waitFor({ timeout: CONTENT_TIMEOUT_MS }); + await prefetchedPage.locator('main h1', { hasText: 'What are you working on?' }).waitFor({ timeout: CONTENT_TIMEOUT_MS }); const attackLink = prefetchedPage.locator('main [data-tour="mode-attack"]'); await attackLink.hover(); await prefetchedPage.waitForFunction(async (path) => { diff --git a/src/App.tsx b/src/App.tsx index 3a855c0cc..62bab0f70 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,7 +6,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { BrowserRouter, Routes, Route, useLocation } from "react-router-dom"; import { MotionConfig } from "framer-motion"; import { AppLayout } from "@/components/layout/AppLayout"; -import { AuthProvider } from "@/lib/supabase/AuthProvider"; +import { AuthProvider, useAuth } from "@/lib/supabase/AuthProvider"; import { RequireAuth } from "@/components/auth/RequireAuth"; import { AccountQueryCache } from "@/components/auth/AccountQueryCache"; import { ThemeProvider } from "@/components/ThemeProvider"; @@ -98,7 +98,7 @@ const ROUTE_FALLBACKS: Record<string, RouteFallback> = { "/privacy": { variant: "public", label: "Privacy", labelKey: "footer.privacy" }, "/expocit/sources": { variant: "public", label: "Sources", labelKey: "crossfire.sources" }, "/login": { variant: "public", label: "Sign in", labelKey: "login.title" }, - "/app": { variant: "home-grid", label: "Home", labelKey: "nav.home" }, + "/app": { variant: "home-grid", label: "Tools", labelKey: "nav.tools" }, "/app/debate": { variant: "settings/list", label: "Current debate", labelKey: "nav.currentDebate" }, "/app/workspaces": { variant: "settings/list", label: "Workspaces", labelKey: "nav.workspaces" }, "/app/generate": { variant: "tool-form", label: "Generate", labelKey: "nav.generate" }, @@ -133,6 +133,8 @@ interface RouteErrorBoundaryProps { children: ReactNode; heading?: string; reloadLabel?: string; + homeLabel?: string; + homeHref?: string; } export class RouteErrorBoundary extends Component<RouteErrorBoundaryProps, { failed: boolean }> { @@ -152,9 +154,15 @@ export class RouteErrorBoundary extends Component<RouteErrorBoundaryProps, { fai return ( <div className="flex min-h-[60vh] flex-col items-center justify-center gap-4 p-6 text-center"> <h1 className="text-xl font-semibold">{this.props.heading ?? 'Unable to load this page'}</h1> - <button className="rounded-md bg-primary px-4 py-2 text-primary-foreground" onClick={() => window.location.reload()}> - {this.props.reloadLabel ?? 'Reload'} - </button> + <div className="flex flex-wrap justify-center gap-2"> + <button className="rounded-md bg-primary px-4 py-2 text-primary-foreground" onClick={() => window.location.reload()}> + {this.props.reloadLabel ?? 'Reload'} + </button> + {/* A full navigation, so a broken route can't trap the user. */} + <a href={this.props.homeHref ?? '/'} className="rounded-md border border-input px-4 py-2 text-foreground"> + {this.props.homeLabel ?? 'Go to Home'} + </a> + </div> </div> ); } @@ -164,8 +172,9 @@ export class RouteErrorBoundary extends Component<RouteErrorBoundaryProps, { fai export function LocalizedRouteErrorBoundary({ children }: { children: ReactNode }) { const { t } = useTranslation(); + const { user } = useAuth(); return ( - <RouteErrorBoundary heading={t('routeError.heading')} reloadLabel={t('routeError.reload')}> + <RouteErrorBoundary heading={t('routeError.heading')} reloadLabel={t('routeError.reload')} homeLabel={t('routeError.home')} homeHref={user ? '/app' : '/'}> {children} </RouteErrorBoundary> ); diff --git a/src/components/LanguageSwitcher.tsx b/src/components/LanguageSwitcher.tsx index 14cd245e6..d6e4e68c7 100644 --- a/src/components/LanguageSwitcher.tsx +++ b/src/components/LanguageSwitcher.tsx @@ -1,61 +1,62 @@ import { useTranslation } from 'react-i18next'; -import { Globe } from 'lucide-react'; +import { Check, Globe } from 'lucide-react'; import { useSettingsStore } from '@/lib/stores/settings-store'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Button } from '@/components/ui/button'; -import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; -import { Label } from '@/components/ui/label'; -import { APP_LANGUAGES, isAppLanguage } from '@/lib/languages'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { HeaderTooltip } from '@/components/layout/HeaderTooltip'; +import { APP_LANGUAGES, type AppLanguage } from '@/lib/languages'; +import { cn } from '@/lib/utils'; +const LANGUAGE_ENTRIES = Object.entries(APP_LANGUAGES) as [AppLanguage, (typeof APP_LANGUAGES)[AppLanguage]][]; + +function LanguageChoices({ label, value, onChange }: { label: string; value: AppLanguage; onChange: (language: AppLanguage) => void }) { + return ( + <div role="radiogroup" aria-label={label} className="space-y-1"> + <p className="px-2 text-xs font-medium text-muted-foreground">{label}</p> + {LANGUAGE_ENTRIES.map(([code, language]) => { + const selected = code === value; + return ( + <button + key={code} + type="button" + role="radio" + aria-checked={selected} + lang={code} + onClick={() => onChange(code)} + className={cn( + 'flex w-full items-center justify-between rounded-md px-2 py-2 text-left text-sm transition-colors pointer-coarse:min-h-11', + 'hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', + selected && 'font-medium text-primary', + )} + > + {language.label} + {selected && <Check className="h-4 w-4" aria-hidden="true" />} + </button> + ); + })} + </div> + ); +} + +/** Two taps to change language: open, pick. The AI output language sits underneath. */ export function LanguageSwitcher() { const { t } = useTranslation(); const { language, outputLanguage, setLanguage, setOutputLanguage } = useSettingsStore(); return ( - <Dialog> - <DialogTrigger asChild> - <Button variant="ghost" size="icon" className="text-muted-foreground hover:text-foreground" aria-label={t('langSwitcher.label', 'Language settings')}> - <Globe className="h-5 w-5" /> - </Button> - </DialogTrigger> - <DialogContent> - <DialogHeader> - <DialogTitle>{t('langSwitcher.title', 'Language')}</DialogTitle> - <DialogDescription>{t('langSwitcher.description', 'Choose the app language and the language used in AI outputs.')}</DialogDescription> - </DialogHeader> - <div className="grid gap-5 py-2"> - <div className="space-y-2"> - <Label>{t('langSwitcher.uiLanguage', 'Interface language')}</Label> - <Select value={language} onValueChange={(value) => isAppLanguage(value) && setLanguage(value)}> - <SelectTrigger aria-label={t('langSwitcher.uiLanguage', 'Interface language')}> - <SelectValue /> - </SelectTrigger> - <SelectContent> - {Object.entries(APP_LANGUAGES).map(([value, language]) => ( - <SelectItem key={value} value={value}> - {language.label} - </SelectItem> - ))} - </SelectContent> - </Select> - </div> - <div className="space-y-2"> - <Label>{t('langSwitcher.outputLanguage', 'AI output language')}</Label> - <Select value={outputLanguage} onValueChange={(value) => isAppLanguage(value) && setOutputLanguage(value)}> - <SelectTrigger aria-label={t('langSwitcher.outputLanguage', 'AI output language')}> - <SelectValue /> - </SelectTrigger> - <SelectContent> - {Object.entries(APP_LANGUAGES).map(([value, language]) => ( - <SelectItem key={value} value={value}> - {language.label} - </SelectItem> - ))} - </SelectContent> - </Select> - </div> - </div> - </DialogContent> - </Dialog> + <Popover> + <HeaderTooltip label={t('langSwitcher.title', 'Language')}> + <PopoverTrigger asChild> + <Button variant="ghost" size="icon" className="text-muted-foreground hover:text-foreground" aria-label={t('langSwitcher.label', 'Language settings')}> + <Globe className="h-5 w-5" /> + </Button> + </PopoverTrigger> + </HeaderTooltip> + <PopoverContent align="end" className="w-60 space-y-3 p-2"> + <LanguageChoices label={t('langSwitcher.uiLanguage', 'Interface language')} value={language} onChange={setLanguage} /> + <div className="border-t" /> + <LanguageChoices label={t('langSwitcher.outputLanguage', 'AI output language')} value={outputLanguage} onChange={setOutputLanguage} /> + </PopoverContent> + </Popover> ); } diff --git a/src/components/ModelSelector.tsx b/src/components/ModelSelector.tsx index 6e0149c65..1ea0e0368 100644 --- a/src/components/ModelSelector.tsx +++ b/src/components/ModelSelector.tsx @@ -80,10 +80,10 @@ export function ModelSelector({ feature, className, showLabel = false }: ModelSe <span> <span className="flex items-center gap-2"> {m.name} - {m.isProOnly && <Badge variant="outline" className="text-[10px] px-1 py-0">Pro</Badge>} + {m.isProOnly && <Badge variant="outline" className="text-xs px-1 py-0">Pro</Badge>} </span> {m.isAuto && ( - <span className="mt-0.5 block text-[11px] text-muted-foreground"> + <span className="mt-0.5 block text-xs text-muted-foreground"> {m.id === 'auto-fast' ? t('modelSelector.autoFastDescription', 'Fast routing, usually GLM') : t('modelSelector.autoProDescription', 'Routes among the strongest models')} diff --git a/src/components/ThemeToggle.tsx b/src/components/ThemeToggle.tsx index 406690328..b2f67edbd 100644 --- a/src/components/ThemeToggle.tsx +++ b/src/components/ThemeToggle.tsx @@ -1,27 +1,30 @@ -import { Moon, Sun, Laptop } from "lucide-react"; +import { Moon, Sun, SunMoon } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { HeaderTooltip } from "@/components/layout/HeaderTooltip"; import { useSettingsStore } from "@/lib/stores/settings-store"; import { useTranslation } from 'react-i18next'; export function ThemeToggle() { const { theme, setTheme } = useSettingsStore(); const { t } = useTranslation(); - const Icon = theme === 'system' ? Laptop : theme === 'dark' ? Moon : Sun; + // "System" gets its own sun-and-moon mark rather than a device icon. + const Icon = theme === 'system' ? SunMoon : theme === 'dark' ? Moon : Sun; return ( - <Button - variant="ghost" - size="icon" - className="text-muted-foreground hover:text-foreground" - onClick={() => { - if (theme === 'light') setTheme('dark'); - else if (theme === 'dark') setTheme('system'); - else setTheme('light'); - }} - title={t('themeToggle.current', 'Current theme: {{theme}}. Click to cycle.', { theme: t(`settings.${theme}`, theme) })} - > - <Icon className="h-[1.2rem] w-[1.2rem]" /> - <span className="sr-only">{t('themeToggle.toggle', 'Toggle theme')}</span> - </Button> + <HeaderTooltip label={t('themeToggle.current', 'Current theme: {{theme}}. Click to cycle.', { theme: t(`settings.${theme}`, theme) })}> + <Button + variant="ghost" + size="icon" + className="text-muted-foreground hover:text-foreground" + onClick={() => { + if (theme === 'light') setTheme('dark'); + else if (theme === 'dark') setTheme('system'); + else setTheme('light'); + }} + > + <Icon className="h-[1.2rem] w-[1.2rem]" /> + <span className="sr-only">{t('themeToggle.toggle', 'Toggle theme')}</span> + </Button> + </HeaderTooltip> ); } diff --git a/src/components/features/ask/AskChat.tsx b/src/components/features/ask/AskChat.tsx index 7a2952072..e04323beb 100644 --- a/src/components/features/ask/AskChat.tsx +++ b/src/components/features/ask/AskChat.tsx @@ -145,7 +145,7 @@ const MessageBubble = React.memo(function MessageBubble({ message, onSave }: { m <div className="flex items-start justify-between gap-2"> <span className="font-medium text-foreground">{citation.claim}</span> <span className={cn( - 'shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium', + 'shrink-0 rounded px-1.5 py-0.5 text-xs font-medium', citation.verified ? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400' : 'bg-amber-500/10 text-amber-600 dark:text-amber-400' )}> {citation.verified ? t('askChat.verified') : t('askChat.unverified')} @@ -189,16 +189,16 @@ const MessageBubble = React.memo(function MessageBubble({ message, onSave }: { m <li key={`${source.url}-${i}`} className="rounded-md border border-border/60 bg-muted/20 p-2 text-xs"> <div className="flex flex-wrap items-center gap-1.5"> {source.qualityLabel && ( - <span className={cn('rounded px-1.5 py-0.5 text-[10px] font-medium', sourceQualityClass(source.qualityLabel))}> + <span className={cn('rounded px-1.5 py-0.5 text-xs font-medium', sourceQualityClass(source.qualityLabel))}> {t(`sourceQuality.labels.${source.qualityLabel}`, source.qualityLabel)} </span> )} {source.sourceType && ( - <span className="rounded bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground"> + <span className="rounded bg-muted px-1.5 py-0.5 text-xs font-medium text-muted-foreground"> {t(`sourceQuality.types.${source.sourceType}`, source.sourceType)} </span> )} - <span className="rounded bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground"> + <span className="rounded bg-muted px-1.5 py-0.5 text-xs font-medium text-muted-foreground"> {source.verified ? t('askChat.verified') : t('askChat.unverified')} </span> </div> @@ -278,7 +278,7 @@ const MessageBubble = React.memo(function MessageBubble({ message, onSave }: { m </div> )} - <span className="text-[10px] text-muted-foreground"> + <span className="text-xs text-muted-foreground"> {(() => { try { const parts: string[] = []; diff --git a/src/components/features/debate/CurrentDebateMenu.tsx b/src/components/features/debate/CurrentDebateMenu.tsx index bb915637d..bdef996f7 100644 --- a/src/components/features/debate/CurrentDebateMenu.tsx +++ b/src/components/features/debate/CurrentDebateMenu.tsx @@ -1,17 +1,19 @@ import { useState } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { Link, useLocation, useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { ChevronDown } from 'lucide-react'; import { Button } from '@/components/ui/button'; -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { formatRelativeDate } from '@/lib/date-format'; import { cn } from '@/lib/utils'; import { useCurrentDebate, useDebateStore } from '@/lib/stores/debate-store'; import { useDebateCounts } from './CurrentDebateStrip'; +import { navSectionFor } from '@/components/layout/nav-sections'; /** - * Header control for the Current Debate. Desktop shows only the label; the - * mobile row also shows the motion on one truncated line. + * Header control for the Current Debate: a link to the debate dashboard, active on + * the dashboard and every tool route, plus a separate chevron that opens the debate + * actions. Desktop shows only the label; the mobile row also shows the motion. */ export function CurrentDebateMenu({ variant }: { variant: 'desktop' | 'mobile' }) { const { t } = useTranslation(); @@ -21,17 +23,23 @@ export function CurrentDebateMenu({ variant }: { variant: 'desktop' | 'mobile' } const openDialog = useDebateStore((state) => state.openDialog); const setOneOff = useDebateStore((state) => state.setOneOff); const counts = useDebateCounts(debate?.id); + const { pathname } = useLocation(); const [open, setOpen] = useState(false); + const active = navSectionFor(pathname, Boolean(debate)) === 'debate'; - const triggerClass = cn( - 'motion-press nav-glow flex min-w-0 items-center gap-1.5 rounded-md px-2 py-2 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', - variant === 'desktop' && 'xl:px-3 whitespace-nowrap', - variant === 'mobile' && 'w-full', + const itemClass = cn( + 'flex min-w-0 items-center gap-1.5 rounded-md py-2 text-sm font-medium transition-colors pointer-coarse:min-h-11 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', + active ? 'bg-primary/10 text-primary' : 'text-muted-foreground hover:bg-accent hover:text-foreground', ); if (!debate) { return ( - <button type="button" className={triggerClass} onClick={() => openDialog('create')} data-testid={`current-debate-${variant}`}> + <button + type="button" + className={cn(itemClass, 'px-2', variant === 'desktop' && 'xl:px-3 whitespace-nowrap', variant === 'mobile' && 'w-full')} + onClick={() => openDialog('create')} + data-testid={`current-debate-${variant}`} + > {t('debate.startDebate', 'Start debate')} </button> ); @@ -44,18 +52,28 @@ export function CurrentDebateMenu({ variant }: { variant: 'desktop' | 'mobile' } return ( <Popover open={open} onOpenChange={setOpen}> - <PopoverTrigger asChild> - <button - type="button" - className={triggerClass} - aria-label={t('debate.menuAria', { motion: debate.motion, defaultValue: 'Current debate: {{motion}}' })} - data-testid={`current-debate-${variant}`} - > - <span className="shrink-0 text-foreground">{t('nav.currentDebate', 'Current debate')}</span> - <ChevronDown className="h-4 w-4 shrink-0" aria-hidden="true" /> - {variant === 'mobile' && <span className="min-w-0 flex-1 truncate text-left font-normal">{debate.motion}</span>} - </button> - </PopoverTrigger> + <PopoverAnchor asChild> + <div className={cn('flex min-w-0 items-center', variant === 'mobile' && 'w-full')}> + <Link + to="/app/debate" + aria-current={pathname === '/app/debate' ? 'page' : undefined} + className={cn(itemClass, 'rounded-r-none pl-2 pr-1.5', variant === 'desktop' && 'xl:pl-3 whitespace-nowrap', variant === 'mobile' && 'flex-1')} + > + <span className="shrink-0">{t('nav.currentDebate', 'Current debate')}</span> + {variant === 'mobile' && <span className="min-w-0 flex-1 truncate text-left font-normal text-muted-foreground">{debate.motion}</span>} + </Link> + <PopoverTrigger asChild> + <button + type="button" + className={cn(itemClass, 'shrink-0 justify-center rounded-l-none px-1.5 pointer-coarse:min-w-11')} + aria-label={t('debate.menuAria', { motion: debate.motion, defaultValue: 'Current debate: {{motion}}' })} + data-testid={`current-debate-${variant}`} + > + <ChevronDown className="h-4 w-4 shrink-0" aria-hidden="true" /> + </button> + </PopoverTrigger> + </div> + </PopoverAnchor> <PopoverContent align="start" className="w-[min(22rem,calc(100vw-2rem))] space-y-3"> <div className="space-y-1"> <p className="break-words font-semibold leading-snug">{debate.motion}</p> diff --git a/src/components/features/debate/DebateDialogs.tsx b/src/components/features/debate/DebateDialogs.tsx index 15ea8fec8..8d46743aa 100644 --- a/src/components/features/debate/DebateDialogs.tsx +++ b/src/components/features/debate/DebateDialogs.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Check, Trash2 } from 'lucide-react'; +import { Check, Loader2, Trash2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { @@ -44,6 +44,8 @@ export function DebatePicker({ onPicked, excludeCurrent = false }: { onPicked: ( const deleteDebate = useDebateStore((state) => state.deleteDebate); const { switchTo } = useDebateNavigationActions(); const [confirmingId, setConfirmingId] = useState<string | null>(null); + // One action at a time, shown on the row that is busy. + const [busy, setBusy] = useState<{ id: string; action: 'switch' | 'delete' } | null>(null); const visible = excludeCurrent ? debates.filter((debate) => debate.id !== currentDebateId) : debates; if (visible.length === 0) { @@ -51,21 +53,29 @@ export function DebatePicker({ onPicked, excludeCurrent = false }: { onPicked: ( } const pick = async (debate: Debate) => { + if (busy) return; + setBusy({ id: debate.id, action: 'switch' }); try { await switchTo(debate.id); onPicked(); } catch { toast({ title: t('debate.switchFailed', 'Could not switch debates'), variant: 'destructive' }); + } finally { + setBusy(null); } }; const remove = async (debate: Debate) => { + if (busy) return; + setBusy({ id: debate.id, action: 'delete' }); try { await deleteDebate(debate.id); setConfirmingId(null); toast({ title: t('debate.deleted', 'Debate deleted') }); } catch { toast({ title: t('debate.deleteFailed', 'Could not delete the debate'), variant: 'destructive' }); + } finally { + setBusy(null); } }; @@ -78,13 +88,18 @@ export function DebatePicker({ onPicked, excludeCurrent = false }: { onPicked: ( <div className="flex items-start gap-3"> <button type="button" - className="min-w-0 flex-1 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm" + className="min-w-0 flex-1 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm disabled:opacity-60" + disabled={busy !== null} + aria-busy={busy?.id === debate.id && busy.action === 'switch'} onClick={() => void pick(debate)} aria-label={t('debate.switchToAria', { motion: debate.motion, defaultValue: 'Switch to {{motion}}' })} > <span className="block break-words font-medium">{debate.motion}</span> - <span className="block text-xs text-muted-foreground"> - {t('debate.lastActivity', { when: formatRelativeDate(debate.lastActivityAt), defaultValue: 'Last activity {{when}}' })} + <span className="flex items-center gap-1.5 text-xs text-muted-foreground"> + {busy?.id === debate.id && busy.action === 'switch' && <Loader2 className="h-3 w-3 animate-spin" aria-hidden="true" />} + {busy?.id === debate.id && busy.action === 'switch' + ? t('debate.switching', 'Switching…') + : t('debate.lastActivity', { when: formatRelativeDate(debate.lastActivityAt), defaultValue: 'Last activity {{when}}' })} </span> </button> {isCurrent ? ( @@ -93,7 +108,8 @@ export function DebatePicker({ onPicked, excludeCurrent = false }: { onPicked: ( <Button variant="ghost" size="icon" - className="h-8 w-8 shrink-0 text-destructive" + className="h-9 w-9 shrink-0 text-destructive" + disabled={busy !== null} onClick={() => setConfirmingId(debate.id)} aria-label={t('debate.deleteAria', { motion: debate.motion, defaultValue: 'Delete {{motion}}' })} > @@ -104,8 +120,10 @@ export function DebatePicker({ onPicked, excludeCurrent = false }: { onPicked: ( {confirmingId === debate.id && ( <div className="mt-3 flex flex-wrap items-center justify-end gap-2 border-t pt-3"> <span className="mr-auto text-xs text-muted-foreground">{t('debate.deleteWarning', 'Deletes its sources and saved work.')}</span> - <Button size="sm" variant="outline" onClick={() => setConfirmingId(null)}>{t('common.cancel', 'Cancel')}</Button> - <Button size="sm" variant="destructive" onClick={() => void remove(debate)}>{t('common.delete', 'Delete')}</Button> + <Button size="sm" variant="outline" onClick={() => setConfirmingId(null)} disabled={busy !== null}>{t('common.cancel', 'Cancel')}</Button> + <Button size="sm" variant="destructive" onClick={() => void remove(debate)} disabled={busy !== null}> + {busy?.id === debate.id && busy.action === 'delete' ? t('debate.deleting', 'Deleting…') : t('common.delete', 'Delete')} + </Button> </div> )} </li> @@ -172,21 +190,34 @@ export function InactiveDebateGate() { const continueDebate = useDebateStore((state) => state.continueDebate); const { create } = useDebateNavigationActions(); const [mode, setMode] = useState<'ask' | 'create' | 'switch'>('ask'); + const [continuing, setContinuing] = useState(false); const open = Boolean(hydrated && debate && isDebateInactive(debate)); if (!open || !debate) return null; const handleContinue = async () => { + if (continuing) return; + setContinuing(true); try { await continueDebate(); } catch { toast({ title: t('debate.continueFailed', 'Could not update the debate'), variant: 'destructive' }); + } finally { + setContinuing(false); } }; return ( <AlertDialog open> - <AlertDialogContent className="max-w-lg" onEscapeKeyDown={(event) => event.preventDefault()}> + {/* Escape is "Continue": the gate never traps, on any route. */} + <AlertDialogContent + className="max-w-lg" + onEscapeKeyDown={(event) => { + event.preventDefault(); + if (mode === 'ask') void handleContinue(); + else setMode('ask'); + }} + > <AlertDialogHeader> <AlertDialogTitle>{t('debate.gateTitle', 'Still preparing this debate?')}</AlertDialogTitle> <AlertDialogDescription asChild> @@ -200,7 +231,7 @@ export function InactiveDebateGate() { <div className="flex flex-col gap-2 sm:flex-row sm:justify-end"> <Button variant="outline" onClick={() => setMode('switch')}>{t('debate.switch', 'Switch debate')}</Button> <Button variant="outline" onClick={() => setMode('create')}>{t('debate.startNew', 'Start new debate')}</Button> - <Button onClick={() => void handleContinue()}>{t('debate.continue', 'Continue')}</Button> + <Button onClick={() => void handleContinue()} disabled={continuing}>{t('debate.continue', 'Continue')}</Button> </div> )} {mode === 'create' && ( diff --git a/src/components/features/factoids/FactoidsForm.tsx b/src/components/features/factoids/FactoidsForm.tsx index 31286d4ae..6de12e403 100644 --- a/src/components/features/factoids/FactoidsForm.tsx +++ b/src/components/features/factoids/FactoidsForm.tsx @@ -144,7 +144,7 @@ export function FactoidsForm({ onSubmit, isLoading, debateContext = true }: Fact step={1} onValueChange={([value]) => setValue('sourceCount', value, { shouldDirty: true })} /> - <div className="flex justify-between text-[10px] text-muted-foreground"> + <div className="flex justify-between text-xs text-muted-foreground"> <span>3</span> <span>15</span> </div> diff --git a/src/components/features/generate/GenerateForm.tsx b/src/components/features/generate/GenerateForm.tsx index 4bea23cd5..32dfd3681 100644 --- a/src/components/features/generate/GenerateForm.tsx +++ b/src/components/features/generate/GenerateForm.tsx @@ -272,7 +272,7 @@ export function GenerateForm({ onSubmit, isLoading, debatePrefill = true }: Gene { value: 'aggressive', label: t('generateForm.toneAggressive') }, ]} /> - <p className="text-[11px] text-muted-foreground">{t('generateForm.styleNote')}</p> + <p className="text-xs text-muted-foreground">{t('generateForm.styleNote')}</p> </div> </div> </div> diff --git a/src/components/features/generate/GenerateResults.tsx b/src/components/features/generate/GenerateResults.tsx index 9859fd2a7..ef91f1611 100644 --- a/src/components/features/generate/GenerateResults.tsx +++ b/src/components/features/generate/GenerateResults.tsx @@ -81,6 +81,8 @@ export function GenerateResults({ result, discoveredSources, onRegenerateSection const { t } = useTranslation(); const [isExportingGDocs, setIsExportingGDocs] = useState(false); const [showDriveAuth, setShowDriveAuth] = useState(false); + // Read-only views (a shared link) pass no handler, so the cards show no regenerate button. + const regenerate = (section: string) => (onRegenerateSection ? () => onRegenerateSection(section) : undefined); const regenerateState = (section: string) => ({ isLoading: regeneratingSection === section, regenerateDisabled: regeneratingSection !== null, @@ -256,7 +258,7 @@ export function GenerateResults({ result, discoveredSources, onRegenerateSection title={t('generateResults.exordio')} description={t('generateResults.exordioDesc')} icon={<Sparkles className="h-5 w-5" />} - onRegenerate={() => onRegenerateSection?.('exordio')} + onRegenerate={regenerate('exordio')} {...regenerateState('exordio')} > <div className="space-y-4"> @@ -278,7 +280,7 @@ export function GenerateResults({ result, discoveredSources, onRegenerateSection title={t('generateResults.caseStructure')} description={t('generateResults.caseStructureDesc')} icon={<Target className="h-5 w-5" />} - onRegenerate={() => onRegenerateSection?.('structure')} + onRegenerate={regenerate('structure')} {...regenerateState('structure')} > <div className="space-y-4"> @@ -312,7 +314,7 @@ export function GenerateResults({ result, discoveredSources, onRegenerateSection title={t('generateResults.arguments')} description={`${result.contentions?.length || 0} ${t('generateResults.structuredArguments')}`} icon={<Shield className="h-5 w-5" />} - onRegenerate={() => onRegenerateSection?.('contentions')} + onRegenerate={regenerate('contentions')} {...regenerateState('contentions')} > <div className="space-y-4"> @@ -327,7 +329,7 @@ export function GenerateResults({ result, discoveredSources, onRegenerateSection title={t('generateResults.predictedOpponent')} description={t('generateResults.predictedOpponentDesc')} icon={<Swords className="h-5 w-5" />} - onRegenerate={() => onRegenerateSection?.('opponent')} + onRegenerate={regenerate('opponent')} {...regenerateState('opponent')} > <div className="space-y-4"> @@ -370,7 +372,7 @@ export function GenerateResults({ result, discoveredSources, onRegenerateSection <SectionCard title={t('generateResults.preparedCounters')} description={t('generateResults.preparedCountersDesc')} - onRegenerate={() => onRegenerateSection?.('counters')} + onRegenerate={regenerate('counters')} {...regenerateState('counters')} > <div className="space-y-4"> diff --git a/src/components/features/onboarding/OnboardingTour.tsx b/src/components/features/onboarding/OnboardingTour.tsx index 894c74103..b3b09f6bb 100644 --- a/src/components/features/onboarding/OnboardingTour.tsx +++ b/src/components/features/onboarding/OnboardingTour.tsx @@ -101,24 +101,33 @@ export function OnboardingTour({ eligibleAt }: { eligibleAt?: number } = {}) { useEffect(() => { if (hasCompletedOnboarding || location.pathname !== '/app') return; - // Small delay to ensure DOM is ready on the mode overview. - const autoStartDelay = typeof performance === 'undefined' || eligibleAt === undefined - ? 500 - : Math.max(0, 500 - (performance.now() - eligibleAt)); - const timer = setTimeout(() => { - prepareTour(); - startTour(); - }, autoStartDelay); - return () => clearTimeout(timer); + // Start as soon as the first target exists, not after a fixed delay; give up waiting after 500ms. + const startedAt = typeof performance === 'undefined' ? Date.now() : performance.now(); + const waitedBefore = typeof performance === 'undefined' || eligibleAt === undefined ? 0 : startedAt - eligibleAt; + const autoStartDelay = Math.max(0, 500 - waitedBefore); + const deadline = startedAt + autoStartDelay; + let frame = 0; + const tryStart = () => { + const now = typeof performance === 'undefined' ? Date.now() : performance.now(); + if (document.querySelector('[data-tour="current-debate"]') || now >= deadline) { + prepareTour(); + startTour(); + return; + } + frame = requestAnimationFrame(tryStart); + }; + frame = requestAnimationFrame(tryStart); + return () => cancelAnimationFrame(frame); }, [eligibleAt, hasCompletedOnboarding, location.pathname, prepareTour, startTour]); useEffect(() => { if (isTourRunning) prepareTour(); }, [isTourRunning, location.pathname, prepareTour]); - // Prevent broken tooltips if user navigates away from the app mid-tour + // The tour belongs to the Tools page. Leaving it pauses the tour, so a step never + // floats over another page's form pointing at nothing; it resumes on return. useEffect(() => { - if (isTourRunning && !location.pathname.startsWith('/app')) { + if (isTourRunning && location.pathname !== '/app') { stopTour(); } }, [isTourRunning, location.pathname, stopTour]); diff --git a/src/components/features/shared/SourceQualityList.tsx b/src/components/features/shared/SourceQualityList.tsx index ec5ae50a1..803077540 100644 --- a/src/components/features/shared/SourceQualityList.tsx +++ b/src/components/features/shared/SourceQualityList.tsx @@ -53,12 +53,12 @@ export function SourceQualityList({ sources, className }: SourceQualityListProps <span className="font-medium">{source.title || source.url}</span> )} {source.sourceType && ( - <Badge variant="outline" className="text-[11px] px-1.5 py-0"> + <Badge variant="outline" className="text-xs px-1.5 py-0"> {t(`sourceQuality.types.${source.sourceType}`, source.sourceType)} </Badge> )} {source.qualityLabel && ( - <Badge variant="outline" className={`text-[11px] px-1.5 py-0 ${QUALITY_STYLES[source.qualityLabel] ?? ''}`}> + <Badge variant="outline" className={`text-xs px-1.5 py-0 ${QUALITY_STYLES[source.qualityLabel] ?? ''}`}> {t(`sourceQuality.labels.${source.qualityLabel}`, source.qualityLabel)} </Badge> )} @@ -66,17 +66,17 @@ export function SourceQualityList({ sources, className }: SourceQualityListProps <span className="text-xs text-muted-foreground">{t('sourceQuality.fallbackOnly')}</span> )} {semantic?.lastUsedInCase && ( - <Badge variant="outline" className="border-primary/30 bg-primary/10 px-1.5 py-0 text-[11px] text-primary"> + <Badge variant="outline" className="border-primary/30 bg-primary/10 px-1.5 py-0 text-xs text-primary"> {t('sourceQuality.usedInCase')} </Badge> )} {semantic && ( - <Badge variant="outline" className="px-1.5 py-0 text-[11px]"> + <Badge variant="outline" className="px-1.5 py-0 text-xs"> {t('sourceQuality.importance', { level: t(`sourceQuality.levels.${semantic.importance}`) })} </Badge> )} {semantic && ( - <Badge variant="outline" className="px-1.5 py-0 text-[11px]"> + <Badge variant="outline" className="px-1.5 py-0 text-xs"> {t('sourceQuality.strength', { level: t(`sourceQuality.levels.${semantic.strength}`) })} </Badge> )} diff --git a/src/components/features/sparring/SparringRound.tsx b/src/components/features/sparring/SparringRound.tsx index cb783a20f..42c3ec944 100644 --- a/src/components/features/sparring/SparringRound.tsx +++ b/src/components/features/sparring/SparringRound.tsx @@ -310,7 +310,7 @@ export function SparringRound({ const current = index === session.currentTurnIndex && session.phase === 'round'; return ( <div key={turn.id} className={cn('flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm transition-colors', current && 'bg-primary/10 text-foreground', complete && 'text-muted-foreground', !current && !complete && 'text-muted-foreground/70')}> - <span className={cn('flex h-6 w-6 shrink-0 items-center justify-center rounded-full border text-[11px] font-mono tabular-nums', complete ? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400' : current ? 'border-primary bg-primary text-primary-foreground' : 'border-border text-muted-foreground')}> + <span className={cn('flex h-6 w-6 shrink-0 items-center justify-center rounded-full border text-xs font-mono tabular-nums', complete ? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400' : current ? 'border-primary bg-primary text-primary-foreground' : 'border-border text-muted-foreground')}> {complete ? <Check className="h-3.5 w-3.5" aria-label={t('sparring.round.completeStatus', 'Complete')} /> : index + 1} </span> <span className="min-w-0 flex-1"> diff --git a/src/components/features/sparring/SparringScorecard.tsx b/src/components/features/sparring/SparringScorecard.tsx index 1271cb469..72060e4da 100644 --- a/src/components/features/sparring/SparringScorecard.tsx +++ b/src/components/features/sparring/SparringScorecard.tsx @@ -105,7 +105,7 @@ export function SparringScorecard({ session, scorecard, onRestart, onExport, onE <Card> <CardHeader> - <CardTitle className="text-xl">{t('sparring.scorecard.categoryNotes', 'Category notes')}</CardTitle> + <CardTitle>{t('sparring.scorecard.categoryNotes', 'Category notes')}</CardTitle> <CardDescription>{spokenRound ? t('voiceRound.scorecard.categoryNotesDescription') : t('sparring.scorecard.categoryNotesDescription', 'Specific reasons behind every score. Delivery is judged from the written speech, not audio.')}</CardDescription> </CardHeader> <CardContent className="grid gap-4 md:grid-cols-2"> @@ -121,8 +121,8 @@ export function SparringScorecard({ session, scorecard, onRestart, onExport, onE </div> </div> <div className="mt-3 space-y-2" aria-hidden="true"> - <div className="flex items-center gap-2"><span className="w-12 text-[11px] text-muted-foreground">{t('sparring.user', 'You')}</span><div className="h-1.5 flex-1 rounded-full bg-muted"><div className="h-full rounded-full bg-primary" style={{ width: `${(category.userScore / 25) * 100}%` }} /></div></div> - <div className="flex items-center gap-2"><span className="w-12 text-[11px] text-muted-foreground">{t('sparring.scorecard.aiShort', 'AI')}</span><div className="h-1.5 flex-1 rounded-full bg-muted"><div className="h-full rounded-full bg-muted-foreground/50" style={{ width: `${(category.aiScore / 25) * 100}%` }} /></div></div> + <div className="flex items-center gap-2"><span className="w-12 text-xs text-muted-foreground">{t('sparring.user', 'You')}</span><div className="h-1.5 flex-1 rounded-full bg-muted"><div className="h-full rounded-full bg-primary" style={{ width: `${(category.userScore / 25) * 100}%` }} /></div></div> + <div className="flex items-center gap-2"><span className="w-12 text-xs text-muted-foreground">{t('sparring.scorecard.aiShort', 'AI')}</span><div className="h-1.5 flex-1 rounded-full bg-muted"><div className="h-full rounded-full bg-muted-foreground/50" style={{ width: `${(category.aiScore / 25) * 100}%` }} /></div></div> </div> <p className="mt-4 text-sm leading-6 text-muted-foreground">{category.feedback}</p> </article> diff --git a/src/components/features/sparring/SparringSetup.tsx b/src/components/features/sparring/SparringSetup.tsx index 39fcb8dfd..6961dbef2 100644 --- a/src/components/features/sparring/SparringSetup.tsx +++ b/src/components/features/sparring/SparringSetup.tsx @@ -87,7 +87,7 @@ export function SparringSetup({ onStart, isStarting = false, initialValues, bann <Swords className="h-5 w-5" aria-hidden="true" /> </div> <div> - <CardTitle className="text-xl">{t('sparring.setup.setMotion', 'Set the motion')}</CardTitle> + <CardTitle>{t('sparring.setup.setMotion', 'Set the motion')}</CardTitle> </div> </div> </CardHeader> @@ -114,7 +114,7 @@ export function SparringSetup({ onStart, isStarting = false, initialValues, bann <Card> <CardHeader className="pb-3"> - <CardTitle className="text-lg">{t('sparring.setup.pickSide', 'Pick your side')}</CardTitle> + <CardTitle>{t('sparring.setup.pickSide', 'Pick your side')}</CardTitle> </CardHeader> <CardContent className="grid gap-2.5 sm:grid-cols-2"> {(['pro', 'con'] as const).map((side) => { @@ -145,7 +145,7 @@ export function SparringSetup({ onStart, isStarting = false, initialValues, bann <Card> <CardHeader className="pb-3"> - <CardTitle className="text-lg">{t('sparring.setup.choosePressure', 'Choose the pressure')}</CardTitle> + <CardTitle>{t('sparring.setup.choosePressure', 'Choose the pressure')}</CardTitle> </CardHeader> <CardContent className="grid gap-2.5 md:grid-cols-3"> {DIFFICULTIES.map((level) => { diff --git a/src/components/features/subscription/LimitReachedModal.tsx b/src/components/features/subscription/LimitReachedModal.tsx index cf336f901..616e2e324 100644 --- a/src/components/features/subscription/LimitReachedModal.tsx +++ b/src/components/features/subscription/LimitReachedModal.tsx @@ -42,6 +42,9 @@ export function LimitReachedModal({ open, onOpenChange, variant, limit, current, <Zap className="h-4 w-4 mr-2" /> {t('limitModal.upgrade')} </Button> + <Button variant="ghost" className="w-full" onClick={() => onOpenChange(false)}> + {t('limitModal.notNow', 'Not now')} + </Button> <p className="text-xs text-muted-foreground text-center mt-1"> {t('limitModal.upgradeNote')} </p> diff --git a/src/components/features/subscription/PricingModal.tsx b/src/components/features/subscription/PricingModal.tsx index 1b2be573e..a427c98f8 100644 --- a/src/components/features/subscription/PricingModal.tsx +++ b/src/components/features/subscription/PricingModal.tsx @@ -7,6 +7,7 @@ import { Badge } from '@/components/ui/badge'; import { Check, Sparkles, Zap, Crown } from 'lucide-react'; import { redirectToCheckout, redeemPromoCode } from '@/lib/stripe'; import { useToast } from '@/hooks/use-toast'; +import { useSubscription } from '@/hooks/use-subscription'; interface PricingModalProps { open: boolean; @@ -78,6 +79,10 @@ export function PricingModal({ open, onOpenChange, onPromoRedeemed }: PricingMod } }; + // Badge the plan the user is actually on, not Free for everyone. + const { tier } = useSubscription(); + const onFree = tier === 'legacy_free' || tier === 'expired'; + return ( <Dialog open={open} onOpenChange={onOpenChange}> <DialogContent className="sm:max-w-[600px]"> @@ -96,7 +101,7 @@ export function PricingModal({ open, onOpenChange, onPromoRedeemed }: PricingMod <div className="rounded-lg border p-4 space-y-3"> <div className="flex items-center justify-between"> <h3 className="font-semibold">{t('pricingModal.free')}</h3> - <Badge variant="secondary">{t('pricingModal.current')}</Badge> + {onFree && <Badge variant="secondary">{t('pricingModal.current')}</Badge>} </div> <p className="text-2xl font-bold">{t('pricingModal.freePrice')}<span className="text-sm font-normal text-muted-foreground">{t('pricingModal.freePeriod')}</span></p> <ul className="space-y-2 text-sm"> @@ -119,6 +124,8 @@ export function PricingModal({ open, onOpenChange, onPromoRedeemed }: PricingMod </div> <div className="flex items-center justify-between mt-1"> <h3 className="font-semibold">{t('pricingModal.pro')}</h3> + {tier === 'pro' && <Badge variant="secondary">{t('pricingModal.current')}</Badge>} + {tier === 'trial' && <Badge variant="secondary">{t('settings.tierTrial')}</Badge>} </div> <p className="text-2xl font-bold">{t('pricingModal.proPrice')}<span className="text-sm font-normal text-muted-foreground">{t('pricingModal.proPeriod')}</span></p> <ul className="space-y-2 text-sm"> diff --git a/src/components/features/tools/tool-links.tsx b/src/components/features/tools/tool-links.tsx new file mode 100644 index 000000000..5892fc885 --- /dev/null +++ b/src/components/features/tools/tool-links.tsx @@ -0,0 +1,91 @@ +import { Link } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { + ArrowRight, + BookOpen, + Clock, + Crosshair, + Database, + Dumbbell, + GitBranch, + Lightbulb, + MessageCircle, + Sparkles, + Swords, + Timer, + type LucideIcon, +} from 'lucide-react'; +import { useRoutePrefetch } from '@/hooks/use-route-prefetch'; +import { cn } from '@/lib/utils'; + +export type Tool = { + key: string; + href: string; + icon: LucideIcon; +}; + +// One tool list, labelled with the same nav.* strings the tool pages use as their h1. +export const PREPARE_TOOLS = [ + { key: 'generate', href: '/app/generate', icon: Sparkles }, + { key: 'brainstorm', href: '/app/brainstorm', icon: Lightbulb }, + { key: 'factoids', href: '/app/factoids', icon: BookOpen }, + { key: 'flowchart', href: '/app/flowchart', icon: GitBranch }, +] satisfies readonly Tool[]; + +export const PRACTICE_TOOLS = [ + { key: 'attack', href: '/app/attack', icon: Swords }, + { key: 'ask', href: '/app/ask', icon: MessageCircle }, + { key: 'crossfire', href: '/app/crossfire', icon: Crosshair }, + { key: 'sparring', href: '/app/sparring', icon: Dumbbell }, +] satisfies readonly Tool[]; + +export const UTILITY_TOOLS = [ + { key: 'library', href: '/app/library', icon: Database }, + { key: 'timer', href: '/app/timer', icon: Timer }, + { key: 'history', href: '/app/history', icon: Clock }, +] satisfies readonly Tool[]; + +/** + * A link to a tool. `row` shows the description (Tools page lists); `tile` shows only + * the label (the debate dashboard and the utility strip). + */ +export function ToolLink({ tool, variant, tourId }: { tool: Tool; variant: 'row' | 'tile'; tourId?: string }) { + const { t } = useTranslation(); + const Icon = tool.icon; + const prefetchHandlers = useRoutePrefetch(tool.href); + + if (variant === 'tile') { + return ( + <Link + to={tool.href} + {...prefetchHandlers} + data-tour={tourId} + className={cn( + 'flex min-h-11 items-center gap-2 rounded-md border border-border bg-card px-3 py-2 text-sm font-medium text-foreground transition-colors', + 'hover:border-primary/40 hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2', + )} + > + <Icon className="h-4 w-4 shrink-0 text-primary" aria-hidden="true" /> + <span className="flex-1">{t(`nav.${tool.key}`)}</span> + </Link> + ); + } + + return ( + <Link + to={tool.href} + {...prefetchHandlers} + data-tour={tourId} + className="group flex items-center gap-3 rounded-md border-b border-border/60 px-3 py-3 transition-colors last:border-b-0 hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" + > + <span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary"> + <Icon className="h-5 w-5" aria-hidden="true" /> + </span> + <span className="min-w-0 flex-1"> + <span className="block font-medium text-foreground">{t(`nav.${tool.key}`)}</span> + <span className="block text-xs leading-5 text-muted-foreground sm:text-sm">{t(`home.${tool.key}Desc`, '')}</span> + </span> + <ArrowRight className="h-4 w-4 shrink-0 text-muted-foreground transition-colors group-hover:text-primary" aria-hidden="true" /> + </Link> + ); +} diff --git a/src/components/layout/AnnouncementBanner.tsx b/src/components/layout/AnnouncementBanner.tsx index 7b2fa8496..3e2531d8c 100644 --- a/src/components/layout/AnnouncementBanner.tsx +++ b/src/components/layout/AnnouncementBanner.tsx @@ -1,4 +1,4 @@ -import { Loader2, Megaphone, X } from 'lucide-react'; +import { Megaphone, X } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { Button } from '@/components/ui/button'; import type { UserAnnouncement } from '@/lib/announcements'; @@ -7,7 +7,6 @@ interface AnnouncementBannerProps { announcement: UserAnnouncement; position: number; total: number; - dismissing: boolean; error: string | null; onDismiss: () => void; } @@ -16,7 +15,6 @@ export function AnnouncementBanner({ announcement, position, total, - dismissing, error, onDismiss, }: AnnouncementBannerProps) { @@ -50,14 +48,9 @@ export function AnnouncementBanner({ size="icon" className="h-11 w-11 shrink-0" onClick={onDismiss} - disabled={dismissing} aria-label={t('announcements.dismissLabel', { title: announcement.title })} > - {dismissing ? ( - <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" /> - ) : ( - <X className="h-4 w-4" aria-hidden="true" /> - )} + <X className="h-4 w-4" aria-hidden="true" /> </Button> </div> </section> diff --git a/src/components/layout/AppLayout.tsx b/src/components/layout/AppLayout.tsx index a00e02ee4..2d2f1c974 100644 --- a/src/components/layout/AppLayout.tsx +++ b/src/components/layout/AppLayout.tsx @@ -2,8 +2,9 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { Link, Outlet, useLocation } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; +import * as DialogPrimitive from '@radix-ui/react-dialog'; import { - Home, + LayoutGrid, Settings, Menu, X, @@ -13,6 +14,7 @@ import { } from 'lucide-react'; import { NavLink } from '@/components/NavLink'; import { Button } from '@/components/ui/button'; +import { HeaderTooltip } from '@/components/layout/HeaderTooltip'; import { cn } from '@/lib/utils'; import { ThemeToggle } from '@/components/ThemeToggle'; import { LanguageSwitcher } from '@/components/LanguageSwitcher'; @@ -20,6 +22,8 @@ import { AnnouncementBanner } from '@/components/layout/AnnouncementBanner'; import { useRoutePrefetch } from '@/hooks/use-route-prefetch'; import { useAuth } from '@/lib/supabase/AuthProvider'; import { CurrentDebateMenu } from '@/components/features/debate/CurrentDebateMenu'; +import { useCurrentDebate } from '@/lib/stores/debate-store'; +import { navSectionFor } from '@/components/layout/nav-sections'; import { DebateDialogs, InactiveDebateGate } from '@/components/features/debate/DebateDialogs'; import { dismissAnnouncement, @@ -27,23 +31,56 @@ import { type UserAnnouncement, } from '@/lib/announcements'; -// Features live on Home and the Current Debate dashboard, not in the header. +// Features live on Tools and the Current Debate dashboard, not in the header. const libraryNavItems = [ { path: '/app/library', key: 'library', icon: Database }, { path: '/app/history', key: 'history', icon: Clock }, ]; const mobileNavItems = [ - { path: '/app', key: 'home', icon: Home }, + { path: '/app', key: 'tools', icon: LayoutGrid }, { path: '/app/debate', key: 'currentDebate', icon: Target }, ...libraryNavItems, { path: '/app/settings', key: 'settings', icon: Settings }, ]; const desktopLinkClass = cn( - "motion-press nav-glow flex items-center gap-1.5 px-2 xl:px-3 py-2 rounded-md text-sm font-medium whitespace-nowrap", + "flex items-center gap-1.5 px-2 xl:px-3 py-2 rounded-md text-sm font-medium whitespace-nowrap transition-colors", "text-muted-foreground hover:text-foreground hover:bg-accent" ); +const activeLinkClass = "bg-primary/10 text-primary hover:bg-primary/10 hover:text-primary"; + +const ANNOUNCEMENT_CACHE_KEY = 'debatelab-announcements'; + +// The banner renders from the last fetch on reload, so it doesn't push the page down later. +function readCachedAnnouncements(userId: string | null): UserAnnouncement[] { + if (!userId) return []; + try { + const cached = JSON.parse(sessionStorage.getItem(ANNOUNCEMENT_CACHE_KEY) ?? 'null') as { userId: string; announcements: UserAnnouncement[] } | null; + return cached?.userId === userId ? filterVisibleAnnouncements(cached.announcements, Date.now()) : []; + } catch { + return []; + } +} + +function writeCachedAnnouncements(userId: string, announcements: UserAnnouncement[]) { + try { + sessionStorage.setItem(ANNOUNCEMENT_CACHE_KEY, JSON.stringify({ userId, announcements })); + } catch { + // Storage can be full or blocked; the banner then just waits for the fetch. + } +} + +function useScrolled() { + const [scrolled, setScrolled] = useState(() => typeof window !== 'undefined' && window.scrollY > 0); + useEffect(() => { + const update = () => setScrolled(window.scrollY > 0); + update(); + window.addEventListener('scroll', update, { passive: true }); + return () => window.removeEventListener('scroll', update); + }, []); + return scrolled; +} const ANNOUNCEMENTS_STALE_MS = 60_000; @@ -57,19 +94,22 @@ function filterVisibleAnnouncements(announcements: UserAnnouncement[], now: numb export function AppLayout() { const [mobileMenuOpen, setMobileMenuOpen] = useState(false); - const [announcements, setAnnouncements] = useState<UserAnnouncement[]>([]); - const [announcementsUserId, setAnnouncementsUserId] = useState<string | null>(null); - const [dismissingId, setDismissingId] = useState<string | null>(null); + const { t } = useTranslation(); + const { user } = useAuth(); + const userId = user?.id ?? null; + const [announcements, setAnnouncements] = useState<UserAnnouncement[]>(() => readCachedAnnouncements(userId)); + const [announcementsUserId, setAnnouncementsUserId] = useState<string | null>(userId); const [dismissError, setDismissError] = useState<{ announcementId: string; message: string } | null>(null); const dismissedAnnouncementIds = useRef(new Set<string>()); const mobileMenuTriggerRef = useRef<HTMLButtonElement>(null); + const headerRef = useRef<HTMLElement>(null); const activeRequestRef = useRef<AbortController | null>(null); const lastRequestAtRef = useRef<number | null>(null); const activeUserIdRef = useRef<string | null>(null); - const { t } = useTranslation(); - const { user } = useAuth(); - const userId = user?.id ?? null; const location = useLocation(); + const hasDebate = Boolean(useCurrentDebate()); + const section = navSectionFor(location.pathname, hasDebate); + const scrolled = useScrolled(); const homePrefetch = useRoutePrefetch('/app'); const termsPrefetch = useRoutePrefetch('/terms'); const privacyPrefetch = useRoutePrefetch('/privacy'); @@ -92,11 +132,13 @@ export function AppLayout() { try { const active = await listActiveAnnouncements(controller.signal); if (controller.signal.aborted || activeUserIdRef.current !== userId) return; - setAnnouncementsUserId(userId); - setAnnouncements(filterVisibleAnnouncements( + const visible = filterVisibleAnnouncements( active.filter((announcement) => !dismissedAnnouncementIds.current.has(announcement.id)), Date.now(), - )); + ); + setAnnouncementsUserId(userId); + setAnnouncements(visible); + writeCachedAnnouncements(userId, visible); } catch (error) { if (!controller.signal.aborted && activeUserIdRef.current === userId && activeRequestRef.current === controller) { lastRequestAtRef.current = null; @@ -115,8 +157,7 @@ export function AppLayout() { lastRequestAtRef.current = null; dismissedAnnouncementIds.current.clear(); setAnnouncementsUserId(userId); - setAnnouncements([]); - setDismissingId(null); + setAnnouncements(readCachedAnnouncements(userId)); setDismissError(null); if (userId) void loadAnnouncements(true); @@ -130,6 +171,16 @@ export function AppLayout() { void loadAnnouncements(); }, [loadAnnouncements, location.pathname]); + // The menu is `lg:hidden`: widening the window would hide it while it still holds the page modal. + useEffect(() => { + if (!mobileMenuOpen) return; + const wide = window.matchMedia('(min-width: 1024px)'); + const close = () => { if (wide.matches) setMobileMenuOpen(false); }; + close(); + wide.addEventListener('change', close); + return () => wide.removeEventListener('change', close); + }, [mobileMenuOpen]); + useEffect(() => { if (announcementsUserId !== userId) return; const nextExpiry = Math.min(...announcements @@ -142,62 +193,80 @@ export function AppLayout() { return () => window.clearTimeout(timer); }, [announcements, announcementsUserId, userId]); + // The mobile menu opens just below the whole header (two rows on phones), whatever its height. useEffect(() => { - const closeMenus = (event: KeyboardEvent) => { - if (event.key !== 'Escape') return; - setMobileMenuOpen(false); - if (mobileMenuOpen) mobileMenuTriggerRef.current?.focus(); + const header = headerRef.current; + if (!header || typeof ResizeObserver === 'undefined') return; + const root = document.documentElement; + const update = () => root.style.setProperty('--app-header-bottom', `${header.getBoundingClientRect().bottom}px`); + update(); + const observer = new ResizeObserver(update); + observer.observe(header); + window.addEventListener('scroll', update, { passive: true }); + return () => { + observer.disconnect(); + window.removeEventListener('scroll', update); }; - document.addEventListener('keydown', closeMenus); - return () => document.removeEventListener('keydown', closeMenus); - }, [mobileMenuOpen]); + }, []); + // Dismiss is optimistic: the banner goes at once and comes back only if the server refuses. const handleDismissAnnouncement = async (announcementId: string) => { const requestUserId = userId; + const dismissed = announcements.find((announcement) => announcement.id === announcementId); + if (!dismissed || !requestUserId) return; + dismissedAnnouncementIds.current.add(announcementId); + setDismissError(null); + const remaining = announcements.filter((announcement) => announcement.id !== announcementId); + setAnnouncements(remaining); + writeCachedAnnouncements(requestUserId, remaining); try { - setDismissingId(announcementId); - setDismissError(null); await dismissAnnouncement(announcementId); - if (activeUserIdRef.current !== requestUserId) return; - dismissedAnnouncementIds.current.add(announcementId); - setAnnouncements((current) => current.filter((announcement) => announcement.id !== announcementId)); } catch (error) { if (activeUserIdRef.current !== requestUserId) return; + dismissedAnnouncementIds.current.delete(announcementId); + setAnnouncements((current) => { + const restored = [dismissed, ...current.filter((announcement) => announcement.id !== announcementId)]; + // The cache is what the next page load renders first, so it has to come back too. + writeCachedAnnouncements(requestUserId, restored); + return restored; + }); setDismissError({ announcementId, message: error instanceof Error ? error.message : t('announcements.dismissError'), }); - } finally { - if (activeUserIdRef.current === requestUserId) setDismissingId(null); } }; return ( <div className="native-safe-screen flex flex-col bg-background"> - {/* Header */} - <header className="native-safe-top sticky top-[var(--staging-banner-height,0px)] z-50 border-b border-border/60 bg-card/70 backdrop-blur-xl shadow-[0_1px_3px_hsl(var(--foreground)/0.04)]"> + {/* Header: the edge appears once content scrolls under it. */} + <header + ref={headerRef} + className={cn( + "native-safe-top sticky top-[var(--staging-banner-height,0px)] z-50 border-b bg-card/80 backdrop-blur-xl transition-colors", + scrolled ? "border-border/60" : "border-transparent", + )} + > <div className="container flex h-16 items-center justify-between px-4 max-[359px]:px-2"> - <Link to="/app" {...homePrefetch} onClick={() => setMobileMenuOpen(false)} className="flex items-center gap-3 shrink-0"> - <div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10 overflow-hidden shrink-0"> + <Link to="/app" {...homePrefetch} onClick={() => setMobileMenuOpen(false)} className="flex items-center gap-3 shrink-0 rounded-md pointer-coarse:min-h-11"> + <div className="flex h-9 w-9 items-center justify-center rounded-lg bg-white dark:bg-slate-800 overflow-hidden shrink-0"> <img src="/favicon-72.png" alt={t('common.logoAlt')} width={36} height={36} className="h-full w-full object-cover" /> </div> - <div> - <h1 className="text-lg font-semibold tracking-tight text-foreground whitespace-nowrap">DebateLab</h1> - </div> + <span className="text-lg font-semibold tracking-tight text-foreground whitespace-nowrap">DebateLab</span> </Link> {/* Desktop Nav */} <nav aria-label={t('nav.mainNavigation')} className="hidden lg:flex min-w-0 flex-1 items-center gap-0.5 xl:gap-1 ml-4"> - <NavLink + <Link to="/app" - end + {...homePrefetch} data-tour="nav-home" - className={desktopLinkClass} - activeClassName="bg-primary/10 text-primary nav-active-pill" + aria-current={location.pathname === '/app' ? 'page' : undefined} + className={cn(desktopLinkClass, section === 'tools' && activeLinkClass)} > - <Home className="h-4 w-4 shrink-0" aria-hidden="true" /> - <span>{t('nav.home')}</span> - </NavLink> + <LayoutGrid className="h-4 w-4 shrink-0" aria-hidden="true" /> + <span>{t('nav.tools')}</span> + </Link> <CurrentDebateMenu variant="desktop" /> {libraryNavItems.map((item) => ( <NavLink @@ -206,7 +275,7 @@ export function AppLayout() { end data-tour={`nav-${item.key}`} className={desktopLinkClass} - activeClassName="bg-primary/10 text-primary nav-active-pill" + activeClassName={activeLinkClass} > <item.icon className="h-4 w-4 shrink-0" aria-hidden="true" /> <span>{t(`nav.${item.key}`)}</span> @@ -216,27 +285,71 @@ export function AppLayout() { <div className="flex items-center gap-1.5 max-[359px]:gap-1 shrink-0"> <LanguageSwitcher /> - <ThemeToggle /> - <NavLink - to="/app/settings" - aria-label={t('nav.settings', 'Settings')} - data-tour="nav-settings" - className="motion-press hidden lg:inline-flex h-10 w-10 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" - activeClassName="bg-primary/10 text-primary" - > - <Settings className="h-5 w-5" aria-hidden="true" /> - </NavLink> - <Button - ref={mobileMenuTriggerRef} - variant="ghost" - size="icon" - className="lg:hidden text-muted-foreground" - onClick={() => setMobileMenuOpen(!mobileMenuOpen)} - aria-label={mobileMenuOpen ? t('nav.closeMenu') : t('nav.openMenu')} - aria-expanded={mobileMenuOpen} - > - {mobileMenuOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />} - </Button> + <div className="hidden lg:block"> + <ThemeToggle /> + </div> + <HeaderTooltip label={t('nav.settings', 'Settings')}> + <NavLink + to="/app/settings" + aria-label={t('nav.settings', 'Settings')} + data-tour="nav-settings" + className="hidden lg:inline-flex h-10 w-10 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" + activeClassName={activeLinkClass} + > + <Settings className="h-5 w-5" aria-hidden="true" /> + </NavLink> + </HeaderTooltip> + <DialogPrimitive.Root open={mobileMenuOpen} onOpenChange={setMobileMenuOpen}> + <DialogPrimitive.Trigger asChild> + <Button + ref={mobileMenuTriggerRef} + variant="ghost" + size="icon" + className="lg:hidden text-muted-foreground" + aria-label={mobileMenuOpen ? t('nav.closeMenu') : t('nav.openMenu')} + aria-expanded={mobileMenuOpen} + > + {mobileMenuOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />} + </Button> + </DialogPrimitive.Trigger> + {/* Mobile Nav: a modal panel under the header, with a scrim over the page. */} + <DialogPrimitive.Portal> + <DialogPrimitive.Overlay className="fixed inset-0 z-40 bg-black/40 lg:hidden data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0 ease-out-strong data-[state=open]:duration-180 data-[state=closed]:duration-120" /> + <DialogPrimitive.Content + aria-describedby={undefined} + className="native-scroll-y native-mobile-menu fixed inset-x-0 top-[var(--app-header-bottom,8rem)] z-40 overflow-y-auto border-b border-border bg-card p-4 shadow-lg lg:hidden data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0 data-[state=open]:slide-in-from-top-1 data-[state=closed]:slide-out-to-top-1 ease-out-strong data-[state=open]:duration-180 data-[state=closed]:duration-120" + > + <DialogPrimitive.Title className="sr-only">{t('nav.mobileNavigation')}</DialogPrimitive.Title> + <nav aria-label={t('nav.mobileNavigation')}> + <div className="grid gap-1 pb-[var(--native-safe-bottom,0px)]"> + {mobileNavItems.map((item) => { + const active = item.key === 'tools' ? section === 'tools' + : item.key === 'currentDebate' ? section === 'debate' + : location.pathname === item.path; + return ( + <Link + key={item.path} + to={item.path} + aria-current={location.pathname === item.path ? 'page' : undefined} + onClick={() => setMobileMenuOpen(false)} + className={cn( + "flex min-h-11 items-center gap-3 px-3 py-2.5 rounded-lg transition-colors", + active ? "bg-primary/10 text-primary font-semibold" : "text-muted-foreground hover:text-foreground hover:bg-accent", + )} + > + <item.icon className="h-5 w-5" aria-hidden="true" /> + <div> + <div className="font-medium">{t(`nav.${item.key}`)}</div> + <div className="text-xs opacity-70">{t(`navDesc.${item.key}`)}</div> + </div> + </Link> + ); + })} + </div> + </nav> + </DialogPrimitive.Content> + </DialogPrimitive.Portal> + </DialogPrimitive.Root> </div> </div> @@ -244,33 +357,6 @@ export function AppLayout() { <div className="container min-w-0 border-t border-border/60 px-4 py-1 max-[359px]:px-2 lg:hidden"> <CurrentDebateMenu variant="mobile" /> </div> - - {/* Mobile Nav */} - {mobileMenuOpen && ( - <nav className="motion-reveal native-scroll-y native-mobile-menu overflow-y-auto border-t border-border bg-card p-4 lg:hidden" aria-label={t('nav.mobileNavigation')}> - <div className="grid gap-1 pb-[var(--native-safe-bottom,0px)]"> - {mobileNavItems.map((item) => ( - <NavLink - key={item.path} - to={item.path} - end - onClick={() => setMobileMenuOpen(false)} - className={cn( - "motion-press flex items-center gap-3 px-3 py-2.5 rounded-lg", - "text-muted-foreground hover:text-foreground hover:bg-accent" - )} - activeClassName="bg-primary/10 text-primary font-semibold" - > - <item.icon className="h-5 w-5" /> - <div> - <div className="font-medium">{t(`nav.${item.key}`)}</div> - <div className="text-xs opacity-70">{t(`navDesc.${item.key}`)}</div> - </div> - </NavLink> - ))} - </div> - </nav> - )} </header> {announcementsUserId === userId && announcements[0] && ( @@ -278,7 +364,6 @@ export function AppLayout() { announcement={announcements[0]} position={1} total={announcements.length} - dismissing={dismissingId === announcements[0].id} error={dismissError?.announcementId === announcements[0].id ? dismissError.message : null} onDismiss={() => handleDismissAnnouncement(announcements[0].id)} /> @@ -297,8 +382,8 @@ export function AppLayout() { <div className="container flex flex-col items-center justify-between gap-2 px-4 text-sm text-muted-foreground md:flex-row"> <p className="font-medium">DebateLab</p> <div className="flex items-center gap-4"> - <Link to="/terms" {...termsPrefetch} className="motion-press rounded-sm hover:text-foreground transition-colors">{t('footer.terms')}</Link> - <Link to="/privacy" {...privacyPrefetch} className="motion-press rounded-sm hover:text-foreground transition-colors">{t('footer.privacy')}</Link> + <Link to="/terms" {...termsPrefetch} className="inline-flex items-center justify-center rounded-sm transition-colors hover:text-foreground pointer-coarse:min-h-11 pointer-coarse:min-w-11">{t('footer.terms')}</Link> + <Link to="/privacy" {...privacyPrefetch} className="inline-flex items-center rounded-sm transition-colors hover:text-foreground pointer-coarse:min-h-11">{t('footer.privacy')}</Link> </div> </div> </footer> diff --git a/src/components/layout/HeaderTooltip.tsx b/src/components/layout/HeaderTooltip.tsx new file mode 100644 index 000000000..c3bcd814e --- /dev/null +++ b/src/components/layout/HeaderTooltip.tsx @@ -0,0 +1,11 @@ +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; + +/** Icon-only header control with a label that shows on hover and keyboard focus, not just as a title. */ +export function HeaderTooltip({ label, children }: { label: string; children: React.ReactNode }) { + return ( + <Tooltip> + <TooltipTrigger asChild>{children}</TooltipTrigger> + <TooltipContent side="bottom">{label}</TooltipContent> + </Tooltip> + ); +} diff --git a/src/components/layout/PageHeader.tsx b/src/components/layout/PageHeader.tsx new file mode 100644 index 000000000..03ae763bd --- /dev/null +++ b/src/components/layout/PageHeader.tsx @@ -0,0 +1,32 @@ +import type { ReactNode } from 'react'; +import { cn } from '@/lib/utils'; + +interface PageHeaderProps { + title: ReactNode; + /** Small label above the title, such as "Current debate". */ + eyebrow?: ReactNode; + /** One line under the title: a description or counts. */ + meta?: ReactNode; + /** Buttons aligned with the title on wide screens, below it on phones. */ + actions?: ReactNode; + icon?: ReactNode; + className?: string; + titleTestId?: string; +} + +/** The one page header every app page uses, so h1 size, weight and tracking match everywhere. */ +export function PageHeader({ title, eyebrow, meta, actions, icon, className, titleTestId }: PageHeaderProps) { + return ( + <header className={cn('mb-6 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between', className)}> + <div className="min-w-0 space-y-1"> + {eyebrow && <p className="text-sm font-medium text-muted-foreground">{eyebrow}</p>} + <div className="flex items-center gap-3"> + {icon && <span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary [&_svg]:h-5 [&_svg]:w-5">{icon}</span>} + <h1 className="break-words text-3xl font-bold leading-tight tracking-tight text-foreground" data-testid={titleTestId}>{title}</h1> + </div> + {meta && <div className="text-sm text-muted-foreground">{meta}</div>} + </div> + {actions && <div className="flex shrink-0 flex-wrap gap-2">{actions}</div>} + </header> + ); +} diff --git a/src/components/layout/PublicPageShell.tsx b/src/components/layout/PublicPageShell.tsx new file mode 100644 index 000000000..0268e448c --- /dev/null +++ b/src/components/layout/PublicPageShell.tsx @@ -0,0 +1,36 @@ +import type { ReactNode } from 'react'; +import { Link } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { useAuth } from '@/lib/supabase/AuthProvider'; +import { cn } from '@/lib/utils'; + +/** Where "home" is for this visitor: the app when signed in, the landing page otherwise. */ +// eslint-disable-next-line react-refresh/only-export-components +export function useHomePath() { + const { user } = useAuth(); + return user ? '/app' : '/'; +} + +/** + * Frame for public reading pages (legal, sources, shared items, not found): a way home + * at the top and a readable measure for long text. + */ +export function PublicPageShell({ children, className }: { children: ReactNode; className?: string }) { + const { t } = useTranslation(); + const home = useHomePath(); + return ( + <div className="min-h-screen bg-background"> + <header className="border-b border-border/60"> + <div className="container flex h-14 items-center px-4"> + <Link to={home} className="flex items-center gap-2 rounded-md pointer-coarse:min-h-11"> + <span className="flex h-8 w-8 items-center justify-center overflow-hidden rounded-md bg-white dark:bg-slate-800"> + <img src="/favicon-72.png" alt={t('common.logoAlt')} width={32} height={32} className="h-full w-full object-cover" /> + </span> + <span className="font-semibold tracking-tight text-foreground">DebateLab</span> + </Link> + </div> + </header> + <main className={cn('container mx-auto px-4 py-10', className)}>{children}</main> + </div> + ); +} diff --git a/src/components/layout/nav-sections.ts b/src/components/layout/nav-sections.ts new file mode 100644 index 000000000..7ec2f2608 --- /dev/null +++ b/src/components/layout/nav-sections.ts @@ -0,0 +1,23 @@ +// Which header item is active, so every app route answers "where am I?". + +/** Tools that work inside the Current Debate. */ +const DEBATE_TOOL_ROUTES = new Set([ + '/app/generate', + '/app/attack', + '/app/brainstorm', + '/app/factoids', + '/app/ask', + '/app/crossfire', + '/app/sparring', + '/app/flowchart', +]); + +export type NavSection = 'tools' | 'debate' | null; + +export function navSectionFor(pathname: string, hasDebate: boolean): NavSection { + const path = pathname.replace(/\/+$/, '') || '/'; + if (path === '/app/debate') return 'debate'; + if (DEBATE_TOOL_ROUTES.has(path)) return hasDebate ? 'debate' : 'tools'; + if (path === '/app' || path === '/app/timer') return 'tools'; + return null; +} diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx index e93535505..ee1622766 100644 --- a/src/components/ui/card.tsx +++ b/src/components/ui/card.tsx @@ -16,7 +16,7 @@ CardHeader.displayName = "CardHeader"; const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>( ({ className, ...props }, ref) => ( - <h3 ref={ref} className={cn("text-2xl font-semibold leading-none tracking-tight", className)} {...props} /> + <h3 ref={ref} className={cn("text-lg font-semibold leading-tight tracking-[-0.014em]", className)} {...props} /> ), ); CardTitle.displayName = "CardTitle"; diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index cc6f8f472..fa24f9365 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -71,7 +71,7 @@ const DialogTitle = React.forwardRef< >(({ className, ...props }, ref) => ( <DialogPrimitive.Title ref={ref} - className={cn("text-lg font-semibold leading-none tracking-tight", className)} + className={cn("text-lg font-semibold leading-tight tracking-[-0.014em]", className)} {...props} /> )); diff --git a/src/components/ui/popover.tsx b/src/components/ui/popover.tsx index f39152138..b003aa2af 100644 --- a/src/components/ui/popover.tsx +++ b/src/components/ui/popover.tsx @@ -7,6 +7,8 @@ const Popover = PopoverPrimitive.Root; const PopoverTrigger = PopoverPrimitive.Trigger; +const PopoverAnchor = PopoverPrimitive.Anchor; + const PopoverContent = React.forwardRef< React.ElementRef<typeof PopoverPrimitive.Content>, React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> @@ -26,4 +28,4 @@ const PopoverContent = React.forwardRef< )); PopoverContent.displayName = PopoverPrimitive.Content.displayName; -export { Popover, PopoverTrigger, PopoverContent }; +export { Popover, PopoverAnchor, PopoverTrigger, PopoverContent }; diff --git a/src/components/ui/section-card.tsx b/src/components/ui/section-card.tsx index b75f702e9..5507e1788 100644 --- a/src/components/ui/section-card.tsx +++ b/src/components/ui/section-card.tsx @@ -37,7 +37,7 @@ export function SectionCard({ <div className="flex items-center gap-2"> {icon && <div className="text-primary">{icon}</div>} <div> - <CardTitle className="text-lg">{title}</CardTitle> + <CardTitle>{title}</CardTitle> {description && <CardDescription>{description}</CardDescription>} </div> </div> diff --git a/src/components/ui/sheet.tsx b/src/components/ui/sheet.tsx index 66e590ad0..95512df33 100644 --- a/src/components/ui/sheet.tsx +++ b/src/components/ui/sheet.tsx @@ -3,10 +3,31 @@ import { cva, type VariantProps } from "class-variance-authority"; import { X } from "lucide-react"; import * as React from "react"; import { useTranslation } from "react-i18next"; +import { Drawer } from "vaul"; import { cn } from "@/lib/utils"; -const Sheet = SheetPrimitive.Root; +// On phones a side sheet becomes a bottom sheet you can drag down to dismiss (Vaul: +// velocity-based dismissal and rubber-banding); wider screens keep the side sheet. +const PHONE_QUERY = "(max-width: 639px)"; +const subscribePhone = (onChange: () => void) => { + const query = window.matchMedia(PHONE_QUERY); + query.addEventListener("change", onChange); + return () => query.removeEventListener("change", onChange); +}; +const isPhone = () => window.matchMedia(PHONE_QUERY).matches; + +const SheetDrawerContext = React.createContext(false); + +const Sheet = ({ children, ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) => { + const phone = React.useSyncExternalStore(subscribePhone, isPhone, () => false); + return ( + <SheetDrawerContext.Provider value={phone}> + {/* Vaul leaves focus on the opener by default; move it into the sheet like the Radix branch does. */} + {phone ? <Drawer.Root autoFocus {...props}>{children}</Drawer.Root> : <SheetPrimitive.Root {...props}>{children}</SheetPrimitive.Root>} + </SheetDrawerContext.Provider> + ); +}; const SheetTrigger = SheetPrimitive.Trigger; @@ -55,15 +76,42 @@ interface SheetContentProps const SheetContent = React.forwardRef<React.ElementRef<typeof SheetPrimitive.Content>, SheetContentProps>( ({ side = "right", className, children, ...props }, ref) => { const { t } = useTranslation(); + const drawer = React.useContext(SheetDrawerContext); + const close = ( + <SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity data-[state=open]:bg-secondary hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none pointer-coarse:after:absolute pointer-coarse:after:-inset-3.5"> + <X className="h-4 w-4" /> + <span className="sr-only">{t('common.close')}</span> + </SheetPrimitive.Close> + ); + + if (drawer) { + return ( + <Drawer.Portal> + <Drawer.Overlay className="fixed inset-0 z-50 bg-black/80" /> + <Drawer.Content + ref={ref} + className={cn( + "fixed inset-x-0 bottom-0 z-50 flex max-h-[85dvh] flex-col rounded-t-xl border-t bg-background shadow-lg", + className, + // Whatever width the side sheet asked for, the bottom sheet spans the screen. + "inset-x-0 w-full max-w-none pb-[var(--native-safe-bottom,0px)]", + )} + {...props} + > + <div aria-hidden="true" className="mx-auto mb-1 mt-2.5 h-1.5 w-12 shrink-0 rounded-full bg-muted-foreground/30" /> + {children} + {close} + </Drawer.Content> + </Drawer.Portal> + ); + } + return ( <SheetPortal> <SheetOverlay /> <SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}> {children} - <SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity data-[state=open]:bg-secondary hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none pointer-coarse:after:absolute pointer-coarse:after:-inset-3.5"> - <X className="h-4 w-4" /> - <span className="sr-only">{t('common.close')}</span> - </SheetPrimitive.Close> + {close} </SheetPrimitive.Content> </SheetPortal> ); diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx index 7548175d2..4d995ffb2 100644 --- a/src/components/ui/tooltip.tsx +++ b/src/components/ui/tooltip.tsx @@ -17,7 +17,7 @@ const TooltipContent = React.forwardRef< ref={ref} sideOffset={sideOffset} className={cn( - "z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md transition-none origin-[--radix-tooltip-content-transform-origin] ease-out-strong duration-200 data-[state=closed]:duration-120 animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + "z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md transition-none origin-[--radix-tooltip-content-transform-origin] ease-out-strong data-[state=delayed-open]:duration-200 data-[state=instant-open]:duration-200 data-[state=closed]:duration-120 animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", className, )} {...props} diff --git a/src/index.css b/src/index.css index 45cb35a42..05b900c35 100644 --- a/src/index.css +++ b/src/index.css @@ -438,7 +438,7 @@ All colors MUST be HSL. } .native-mobile-menu { - max-height: calc(100dvh - 4rem - var(--native-safe-top, 0px)); + max-height: calc(100dvh - var(--app-header-bottom, 8rem)); } } @@ -488,80 +488,23 @@ All colors MUST be HSL. scroll-margin-top: 5rem; } -/* Staggered list entrance */ -.stagger-enter > * { - animation: stagger-item var(--motion-duration-normal) var(--motion-ease-fluid) both; +/* Vaul's bottom sheet: 300ms open, 200ms close on the drawer curve (its default is 500ms both ways). */ +[data-vaul-drawer][data-vaul-drawer-direction='bottom'][data-state='open'] { + animation-duration: 300ms; + animation-timing-function: cubic-bezier(0.32, 0.72, 0, 1); } -.stagger-enter > *:nth-child(1) { animation-delay: 0ms; } -.stagger-enter > *:nth-child(2) { animation-delay: 50ms; } -.stagger-enter > *:nth-child(3) { animation-delay: 100ms; } -.stagger-enter > *:nth-child(4) { animation-delay: 150ms; } -.stagger-enter > *:nth-child(5) { animation-delay: 200ms; } -.stagger-enter > *:nth-child(6) { animation-delay: 250ms; } -.stagger-enter > *:nth-child(7) { animation-delay: 300ms; } -.stagger-enter > *:nth-child(8) { animation-delay: 350ms; } - -@keyframes stagger-item { - from { - opacity: 0; - transform: translateY(8px) scale(0.98); - } - to { - opacity: 1; - transform: translateY(0) scale(1); - } -} - -/* Nav item hover glow */ -.nav-glow { - position: relative; -} - -.nav-glow::after { - content: ''; - position: absolute; - inset: 0; - border-radius: inherit; - opacity: 0; - background: radial-gradient(ellipse at center, hsl(var(--primary) / 0.12), transparent 70%); - transition: opacity var(--motion-duration-fast) var(--motion-ease-fluid); - pointer-events: none; -} - -@media (hover: hover) and (pointer: fine) { - .nav-glow:hover::after { - opacity: 1; - } -} - -/* Active nav indicator pill */ -.nav-active-pill { - position: relative; +[data-vaul-drawer][data-vaul-drawer-direction='bottom'][data-state='closed'] { + animation-duration: 200ms; + animation-timing-function: cubic-bezier(0.32, 0.72, 0, 1); } -.nav-active-pill::before { - content: ''; - position: absolute; - bottom: -2px; - left: 25%; - right: 25%; - height: 2px; - border-radius: 1px; - background: hsl(var(--primary)); - box-shadow: 0 0 6px hsl(var(--primary) / 0.4); - animation: nav-pill-in var(--motion-duration-normal) var(--motion-ease-fluid) both; +[data-vaul-overlay][data-state='open'] { + animation-duration: 300ms; } -@keyframes nav-pill-in { - from { - transform: scaleX(0); - opacity: 0; - } - to { - transform: scaleX(1); - opacity: 1; - } +[data-vaul-overlay][data-state='closed'] { + animation-duration: 200ms; } /* Sonner's mobile toaster sets width: 100% plus a left offset, which pushes @@ -839,8 +782,7 @@ All colors MUST be HSL. [data-state='active'][class*='animate-in'], .motion-reveal, .motion-swap, - .message-enter, - .stagger-enter > * { + .message-enter { animation-name: motion-fade-in !important; } diff --git a/src/lib/auth-errors.ts b/src/lib/auth-errors.ts new file mode 100644 index 000000000..d93451da3 --- /dev/null +++ b/src/lib/auth-errors.ts @@ -0,0 +1,38 @@ +import type { TFunction } from 'i18next'; + +// Server messages from the demo-code-login Edge Function, mapped to catalog keys. +const DEMO_CODE_MESSAGES: Record<string, string> = { + 'Demo code not found.': 'login.errors.demoNotFound', + 'Enter a valid demo code.': 'login.invalidDemoCode', + 'This demo code cannot be used.': 'login.errors.demoUnusable', + 'This demo code has been disabled.': 'login.errors.demoDisabled', + 'This demo code has expired.': 'login.errors.demoExpired', + 'This demo code has no uses left.': 'login.errors.demoUsedUp', + 'Too many attempts. Try again later.': 'login.errors.rateLimited', +}; + +// Supabase Auth error codes that a person can act on. +const AUTH_CODES: Record<string, string> = { + over_email_send_rate_limit: 'login.errors.rateLimited', + over_request_rate_limit: 'login.errors.rateLimited', + otp_expired: 'login.errors.codeExpired', + email_address_invalid: 'login.invalidEmail', + validation_failed: 'login.invalidEmail', +}; + +/** + * Turns a sign-in failure into a message in the user's language. Unknown errors get the + * caller's fallback instead of raw English from the backend. + */ +export function localizeAuthError(error: unknown, t: TFunction, fallbackKey: string, fallback: string): string { + if (error instanceof Error) { + const demoKey = DEMO_CODE_MESSAGES[error.message.trim()]; + if (demoKey) return t(demoKey, error.message); + + const { code, status } = error as Error & { code?: unknown; status?: unknown }; + if (typeof code === 'string' && AUTH_CODES[code]) return t(AUTH_CODES[code]); + if (status === 429) return t('login.errors.rateLimited'); + if (error.name === 'TypeError' || /failed to fetch|network/i.test(error.message)) return t('login.errors.network'); + } + return t(fallbackKey, fallback); +} diff --git a/src/lib/stores/session-store.ts b/src/lib/stores/session-store.ts index 7918535ea..b69b4a866 100644 --- a/src/lib/stores/session-store.ts +++ b/src/lib/stores/session-store.ts @@ -208,7 +208,8 @@ export interface SessionState { syncAskChat: (debateId: string | null) => Promise<void>; deleteSavedFlowchart: (id: string) => Promise<void>; - addSource: (source: Omit<Source, 'id' | 'createdAt' | 'isActive'> & { isActive?: boolean }) => Promise<void>; + /** `id` and `createdAt` are only for Undo, which puts a deleted source back exactly. */ + addSource: (source: Omit<Source, 'id' | 'createdAt' | 'isActive'> & { isActive?: boolean; id?: string; createdAt?: number }) => Promise<void>; createSourceFolder: (folder: string) => Promise<void>; updateSource: (id: string, patch: Partial<Omit<Source, 'id' | 'createdAt'>>) => Promise<void>; applySourceAssessments: (assessments: SourceAssessmentUpdate[]) => Promise<void>; @@ -496,18 +497,21 @@ export const useSessionStore = create<SessionState>()( serverHydration: 'pending', setPendingFlowchartData: (data) => set({ pendingFlowchartData: data }), - addSource: async (source) => { + addSource: async ({ id: restoredId, createdAt: restoredAt, ...source }) => { const folder = normalizeFolder(source.folder); const isActive = source.isActive ?? true; // An omitted debate means the tool's scope, which is none in one-off mode. const debateId = source.debateId !== undefined ? source.debateId : activeDebateId(); if (!debateId) throw new DebateRequiredError(); - const localItem: Source = { ...source, folder, debateId, id: generateId(), createdAt: Date.now(), isActive }; + const localItem: Source = { ...source, folder, debateId, id: restoredId ?? generateId(), createdAt: restoredAt ?? Date.now(), isActive }; + // The library is newest first; a restored source goes back to its old place. + const place = (list: Source[], item: Source) => + restoredId ? [...list, item].sort((x, y) => y.createdAt - x.createdAt) : [item, ...list]; const userId = await getUserId(); if (!userId) { set((state) => ({ sourceFolders: mergeSourceFolders(state.sourceFolders, [localItem]), - sourceLibrary: [localItem, ...state.sourceLibrary], + sourceLibrary: place(state.sourceLibrary, localItem), })); return; } @@ -519,6 +523,7 @@ export const useSessionStore = create<SessionState>()( const { data, error } = await supabase .from('sources') .insert({ + ...(restoredId ? { id: restoredId, created_at: new Date(localItem.createdAt).toISOString() } : {}), user_id: userId, debate_id: debateId, title: source.title, @@ -548,7 +553,7 @@ export const useSessionStore = create<SessionState>()( }; set((state) => ({ sourceFolders: mergeSourceFolders(state.sourceFolders, [savedSource]), - sourceLibrary: [savedSource, ...state.sourceLibrary], + sourceLibrary: place(state.sourceLibrary, savedSource), })); markDebateActivity(debateId); }, diff --git a/src/locales/en.json b/src/locales/en.json index a38fa34c7..c19ae9b69 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1,6 +1,5 @@ { "nav": { - "home": "Home", "workspaces": "Workspaces", "generate": "Generate", "attack": "Attack", @@ -24,10 +23,10 @@ "more": "More", "mainNavigation": "Main navigation", "mobileNavigation": "Mobile navigation", - "currentDebate": "Current debate" + "currentDebate": "Current debate", + "tools": "Tools" }, "navDesc": { - "home": "Choose a mode", "workspaces": "Write cases together", "generate": "Build arguments", "attack": "Find weaknesses", @@ -41,7 +40,8 @@ "flowchart": "Map arguments", "history": "Saved items", "currentDebate": "Motion, sources, and results", - "settings": "Language, account, and data" + "settings": "Language, account, and data", + "tools": "Choose a tool" }, "workspaces": { "title": "Workspaces", @@ -244,7 +244,8 @@ "copy": "Copy", "deleted": "Deleted", "discuss": "Discuss", - "retry": "Retry" + "retry": "Retry", + "undo": "Undo" }, "aiClient": { "inputShortened": "Input was shortened", @@ -355,7 +356,8 @@ "flowchart": "Flowchart", "flowchartDesc": "Map claims and rebuttals.", "history": "History", - "historyDesc": "Reopen saved cases, chats, and previous prep sessions." + "historyDesc": "Reopen saved cases, chats, and previous prep sessions.", + "heading": "What are you working on?" }, "generate": { "emptyTitle": "Ready to Generate", @@ -501,7 +503,9 @@ "deletingAccount": "Deleting...", "deleteAccountSuccess": "Account deleted", "deleteAccountSuccessDesc": "Your account and all of its data have been permanently deleted.", - "deleteAccountFailed": "Account not deleted" + "deleteAccountFailed": "Account not deleted", + "signingOut": "Signing out…", + "deletingData": "Deleting…" }, "login": { "title": "Sign in to DebateLab", @@ -538,7 +542,18 @@ "demoCodeHint": "Enter the code provided by your event host.", "redirecting": "Redirecting...", "failed": "Could not start sign in", - "tryAgain": "Please try again." + "tryAgain": "Please try again.", + "backHome": "Back to DebateLab", + "errors": { + "demoNotFound": "Demo code not found.", + "demoUnusable": "This demo code cannot be used.", + "demoDisabled": "This demo code has been disabled.", + "demoExpired": "This demo code has expired.", + "demoUsedUp": "This demo code has no uses left.", + "rateLimited": "Too many attempts. Try again later.", + "codeExpired": "That code has expired. Send a new one.", + "network": "Can't reach DebateLab. Check your connection and try again." + } }, "admin": { "demoAccess": "Demo access", @@ -670,7 +685,14 @@ "user": "User", "admin": "Admin", "banned": "Banned" - } + }, + "confirmMakeAdmin": "Make {{email}} an admin?", + "confirmMakeAdminDesc": "Admins can open the admin page and change other users' roles and plans.", + "confirmBan": "Ban {{email}}?", + "confirmBanDesc": "They can't use DebateLab until you unban them.", + "confirmUnban": "Unban {{email}}?", + "confirmRemoveAdmin": "Remove admin from {{email}}?", + "confirmRoleDesc": "They become a regular user." }, "promos": { "createTitle": "Create Promo Code", @@ -1344,7 +1366,8 @@ "limitReached": "Daily Access Paused", "limitReachedDesc": "You've used {{current}} of {{limit}} included AI uses today. Access refreshes at midnight UTC.", "upgrade": "Upgrade to Pro - $5/mo", - "upgradeNote": "More AI prep access + high reasoning" + "upgradeNote": "More AI prep access + high reasoning", + "notNow": "Not now" }, "modelModal": { "title": "Pro Reasoning", @@ -1622,12 +1645,8 @@ "sortOldest": "Date added (oldest)", "sortTitle": "Name (A–Z)", "sortFolder": "Folder", - "folderCreated": "Folder created", - "folderCreatedDesc": "New sources can now be saved there.", "allFolders": "All folders", "importUrlRequired": "Enter a URL to import.", - "importReady": "Source Ready", - "importReadyDesc": "Review the extracted text before saving.", "importFailed": "Import Failed", "importFailedDesc": "Could not import this source.", "duplicateSource": "Source already in library", @@ -1636,9 +1655,6 @@ "saveToLibrary": "Save to Library", "addNewTitle": "Add New Source", "yourKBTitle": "Your Knowledge Base", - "totalSources": "Total sources", - "activeSources": "Active sources", - "syncedFolders": "Synced folders", "manualSource": "Manual source", "searchSources": "Search sources", "searchPlaceholder": "Search sources, folders, or content...", @@ -1656,9 +1672,6 @@ "groundingDisabled": "Grounding Disabled", "groundingActiveDesc": "AI responses use your sources", "groundingDisabledDesc": "AI responses are not grounded", - "errorRequired": "Title and content are required", - "sourceAdded": "Source Added", - "sourceAddedDesc": "Your source is now available for grounding.", "clearFailed": "Failed to clear sources", "clearConfirm": "Are you sure you want to clear all sources?", "activateFolder": "Activate", @@ -1667,14 +1680,8 @@ "deactivateFolder": "Deactivate", "deleteFolderDesc": "Sources in this folder will move to General. The sources themselves will not be deleted.", "deleteFolderTitle": "Delete folder?", - "deleteSourceDesc": "This removes the source from your synced library.", - "deleteSourceTitle": "Delete source?", "folderActions": "Folder actions", "folderActionsDesc": "Rename, delete, or change grounding for every source in this folder.", - "folderActivated": "Folder activated", - "folderActivatedDesc": "All sources in this folder are active.", - "folderDeactivated": "Folder deactivated", - "folderDeactivatedDesc": "Sources stay saved but will not ground AI outputs.", "folderDeleted": "Folder deleted", "folderDeletedDesc": "Sources were moved to General.", "folderMergeConfirm": "A folder with this name already exists. Merge sources into it?", @@ -1684,13 +1691,23 @@ "noMatchingSourcesDesc": "Try a different search or folder.", "renameFolder": "Rename folder", "sourceCopied": "Source copied to clipboard.", - "sourceDeleted": "Source removed.", "sourceUpdated": "Source updated", "sourceUpdatedDesc": "Your source changes were saved.", "typeLabel": "Type", "untitledSource": "Untitled source", "useForGrounding": "Use for AI grounding", - "useForGroundingDesc": "Inactive sources stay saved but are ignored by generation." + "useForGroundingDesc": "Inactive sources stay saved but are ignored by generation.", + "titleRequired": "Add a title.", + "contentRequired": "Add the source text.", + "sourceDeletedTitle": "Deleted {{title}}", + "restoreFailed": "Could not restore the source", + "deleteFailed": "Could not delete the source.", + "readingFile": "Reading file…", + "summary": "{{total}} sources · {{active}} active · {{folders}} folders", + "mergeFolderTitle": "Merge into {{name}}?", + "mergeFolders": "Merge folders", + "mergeFailed": "Could not merge the folders. Try again.", + "openOrigin": "Open {{title}} in a new tab" }, "autoSavedSources": { "undo": "Undo auto-saved sources", @@ -1710,15 +1727,9 @@ "loadFailed": "Failed to load shared content", "contentNotFound": "This shared content is no longer available", "goToApp": "Go to App", - "importToAccount": "Import to Account", "openApp": "Open App", "sharedOn": "Shared on", - "views": "views", - "loginRequired": "Login Required", - "loginRequiredDesc": "Sign in to import this content to your account.", - "importNotAvailable": "Importing isn't available yet", - "importNotAvailableDesc": "Saving shared content to your account is coming soon.", - "importFailed": "Failed to import content" + "views": "views" }, "privacyPage": { "title": "Privacy Policy", @@ -2075,7 +2086,6 @@ "langSwitcher": { "label": "Language", "title": "Language", - "description": "Choose the app language and the language used in AI outputs.", "uiLanguage": "Interface language", "outputLanguage": "AI output language" }, @@ -2181,7 +2191,8 @@ }, "routeError": { "heading": "Unable to load this page", - "reload": "Reload" + "reload": "Reload", + "home": "Go to Home" }, "sourcesPage": { "title": "Sources", @@ -2321,7 +2332,10 @@ "offerDesc": "A debate keeps this motion, its sources, and your results together, and every tool uses it.", "offerNotNow": "Not now, use it once", "itemCount_one": "{{count}} item", - "itemCount_other": "{{count}} items" + "itemCount_other": "{{count}} items", + "viewAll": "View all ({{total}})", + "switching": "Switching…", + "deleting": "Deleting…" }, "debateContext": { "using": "Using {{list}}", diff --git a/src/locales/es.json b/src/locales/es.json index c7cab3d13..4d339a815 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -1,6 +1,5 @@ { "nav": { - "home": "Inicio", "workspaces": "Espacios", "generate": "Generar", "attack": "Atacar", @@ -24,10 +23,10 @@ "more": "Más", "mainNavigation": "Navegación principal", "mobileNavigation": "Navegación móvil", - "currentDebate": "Debate actual" + "currentDebate": "Debate actual", + "tools": "Herramientas" }, "navDesc": { - "home": "Elegir un modo", "workspaces": "Redactar casos en equipo", "generate": "Construir argumentos", "attack": "Encontrar debilidades", @@ -41,7 +40,8 @@ "flowchart": "Mapear argumentos", "history": "Elementos guardados", "currentDebate": "Moción, fuentes y resultados", - "settings": "Idioma, cuenta y datos" + "settings": "Idioma, cuenta y datos", + "tools": "Elegir una herramienta" }, "workspaces": { "title": "Espacios de trabajo", @@ -244,7 +244,8 @@ "copy": "Copiar", "deleted": "Eliminado", "discuss": "Discutir", - "retry": "Reintentar" + "retry": "Reintentar", + "undo": "Deshacer" }, "aiClient": { "inputShortened": "La entrada se acortó", @@ -355,7 +356,8 @@ "flowchart": "Diagrama", "flowchartDesc": "Mapea afirmaciones y refutaciones.", "history": "Historial", - "historyDesc": "Reabre casos guardados, chats y sesiones de preparación anteriores." + "historyDesc": "Reabre casos guardados, chats y sesiones de preparación anteriores.", + "heading": "¿En qué estás trabajando?" }, "generate": { "emptyTitle": "Tu Caso Te Espera", @@ -501,7 +503,9 @@ "deletingAccount": "Eliminando...", "deleteAccountSuccess": "Cuenta eliminada", "deleteAccountSuccessDesc": "Tu cuenta y todos sus datos se han eliminado para siempre.", - "deleteAccountFailed": "La cuenta no se eliminó" + "deleteAccountFailed": "La cuenta no se eliminó", + "signingOut": "Cerrando sesión…", + "deletingData": "Eliminando…" }, "login": { "title": "Iniciar Sesión en DebateLab", @@ -538,7 +542,18 @@ "demoCodeHint": "Introduce el código que te proporcionó el organizador del evento.", "redirecting": "Redirigiendo...", "failed": "No se pudo iniciar sesión", - "tryAgain": "Inténtalo de nuevo." + "tryAgain": "Inténtalo de nuevo.", + "backHome": "Volver a DebateLab", + "errors": { + "demoNotFound": "No se encontró el código de demostración.", + "demoUnusable": "Este código de demostración no se puede usar.", + "demoDisabled": "Este código de demostración está desactivado.", + "demoExpired": "Este código de demostración ha caducado.", + "demoUsedUp": "Este código de demostración ya no tiene usos.", + "rateLimited": "Demasiados intentos. Inténtalo más tarde.", + "codeExpired": "Ese código ha caducado. Solicita uno nuevo.", + "network": "No se puede conectar con DebateLab. Revisa tu conexión e inténtalo de nuevo." + } }, "admin": { "demoAccess": "Acceso demo", @@ -670,7 +685,14 @@ "user": "Usuario", "admin": "Administrador", "banned": "Bloqueado" - } + }, + "confirmMakeAdmin": "¿Hacer administrador a {{email}}?", + "confirmMakeAdminDesc": "Los administradores pueden abrir la página de administración y cambiar los roles y planes de otros usuarios.", + "confirmBan": "¿Bloquear a {{email}}?", + "confirmBanDesc": "No podrá usar DebateLab hasta que lo desbloquees.", + "confirmUnban": "¿Desbloquear a {{email}}?", + "confirmRemoveAdmin": "¿Quitar el rol de administrador a {{email}}?", + "confirmRoleDesc": "Pasará a ser un usuario normal." }, "promos": { "createTitle": "Crear código promocional", @@ -1344,7 +1366,8 @@ "limitReached": "Acceso diario pausado", "limitReachedDesc": "Has usado {{current}} de {{limit}} usos de IA incluidos hoy. El acceso se renueva a medianoche UTC.", "upgrade": "Actualizar a Pro - $5/mes", - "upgradeNote": "Más acceso a preparación con IA + razonamiento alto" + "upgradeNote": "Más acceso a preparación con IA + razonamiento alto", + "notNow": "Ahora no" }, "modelModal": { "title": "Razonamiento Pro", @@ -1620,12 +1643,8 @@ "sortOldest": "Fecha de adición (más antigua)", "sortTitle": "Nombre (A–Z)", "sortFolder": "Carpeta", - "folderCreated": "Carpeta creada", - "folderCreatedDesc": "Ahora puedes guardar nuevas fuentes ahí.", "allFolders": "Todas las carpetas", "importUrlRequired": "Introduce una URL para importar.", - "importReady": "Fuente lista", - "importReadyDesc": "Revisa el texto extraído antes de guardarlo.", "importFailed": "Error al importar", "importFailedDesc": "No se pudo importar esta fuente.", "duplicateSource": "La fuente ya está en la biblioteca", @@ -1648,16 +1667,10 @@ "groundingDisabled": "Fundamentación Desactivada", "groundingActiveDesc": "Las respuestas de IA usan tus fuentes", "groundingDisabledDesc": "Las respuestas de IA no están fundamentadas", - "errorRequired": "El título y contenido son obligatorios", - "sourceAdded": "Fuente Añadida", - "sourceAddedDesc": "Tu fuente está disponible para fundamentación.", "clearFailed": "Error al borrar fuentes", "clearConfirm": "¿Estás seguro de que quieres borrar todas las fuentes?", "importUrlHelp": "La importación automática crea un borrador editable. Revísalo antes de guardarlo en tu biblioteca.", "unsupportedFile": "Archivo no compatible", - "totalSources": "Fuentes totales", - "activeSources": "Fuentes activas", - "syncedFolders": "Carpetas sincronizadas", "manualSource": "Fuente manual", "searchSources": "Buscar fuentes", "searchPlaceholder": "Buscar en fuentes, carpetas o contenido...", @@ -1667,14 +1680,8 @@ "deactivateFolder": "Desactivar", "deleteFolderDesc": "Las fuentes de esta carpeta se moverán a General. Las fuentes no se eliminarán.", "deleteFolderTitle": "¿Eliminar carpeta?", - "deleteSourceDesc": "Esto eliminará la fuente de tu biblioteca sincronizada.", - "deleteSourceTitle": "¿Eliminar fuente?", "folderActions": "Acciones de carpeta", "folderActionsDesc": "Cambia el nombre, elimina la carpeta o ajusta el uso de todas sus fuentes para fundamentar las respuestas de la IA.", - "folderActivated": "Carpeta activada", - "folderActivatedDesc": "Todas las fuentes de esta carpeta están activas.", - "folderDeactivated": "Carpeta desactivada", - "folderDeactivatedDesc": "Las fuentes siguen guardadas, pero no se usarán para fundamentar las respuestas de la IA.", "folderDeleted": "Carpeta eliminada", "folderDeletedDesc": "Las fuentes se movieron a General.", "folderMergeConfirm": "Ya existe una carpeta con ese nombre. ¿Quieres combinar las fuentes en ella?", @@ -1684,13 +1691,23 @@ "noMatchingSourcesDesc": "Prueba otra búsqueda o carpeta.", "renameFolder": "Cambiar nombre de carpeta", "sourceCopied": "Fuente copiada al portapapeles.", - "sourceDeleted": "Fuente eliminada.", "sourceUpdated": "Fuente actualizada", "sourceUpdatedDesc": "Se guardaron los cambios de la fuente.", "typeLabel": "Tipo", "untitledSource": "Fuente sin título", "useForGrounding": "Usar para fundamentar respuestas de la IA", - "useForGroundingDesc": "Las fuentes inactivas siguen guardadas, pero no se usan al generar contenido." + "useForGroundingDesc": "Las fuentes inactivas siguen guardadas, pero no se usan al generar contenido.", + "titleRequired": "Añade un título.", + "contentRequired": "Añade el texto de la fuente.", + "sourceDeletedTitle": "Se eliminó {{title}}", + "restoreFailed": "No se pudo restaurar la fuente", + "deleteFailed": "No se pudo eliminar la fuente.", + "readingFile": "Leyendo el archivo…", + "summary": "{{total}} fuentes · {{active}} activas · {{folders}} carpetas", + "mergeFolderTitle": "¿Fusionar con {{name}}?", + "mergeFolders": "Fusionar carpetas", + "mergeFailed": "No se pudieron fusionar las carpetas. Inténtalo de nuevo.", + "openOrigin": "Abrir {{title}} en una pestaña nueva" }, "autoSavedSources": { "undo": "Deshacer fuentes autoguardadas", @@ -1710,15 +1727,9 @@ "loadFailed": "Error al cargar contenido compartido", "contentNotFound": "Este contenido compartido ya no está disponible", "goToApp": "Ir a la App", - "importToAccount": "Importar a Cuenta", "openApp": "Abrir App", "sharedOn": "Compartido el", - "views": "vistas", - "loginRequired": "Inicio de Sesión Requerido", - "loginRequiredDesc": "Inicia sesión para importar este contenido a tu cuenta.", - "importNotAvailable": "La importación aún no está disponible", - "importNotAvailableDesc": "Guardar contenido compartido en tu cuenta estará disponible próximamente.", - "importFailed": "Error al importar contenido" + "views": "vistas" }, "privacyPage": { "title": "Política de Privacidad", @@ -2075,7 +2086,6 @@ "langSwitcher": { "label": "Idioma", "title": "Idioma", - "description": "Elige el idioma de la app y el idioma usado en las respuestas de IA.", "uiLanguage": "Idioma de la interfaz", "outputLanguage": "Idioma de salida de IA" }, @@ -2181,7 +2191,8 @@ }, "routeError": { "heading": "No se pudo cargar esta página", - "reload": "Recargar" + "reload": "Recargar", + "home": "Ir al inicio" }, "sourcesPage": { "title": "Fuentes", @@ -2321,7 +2332,10 @@ "offerDesc": "Un debate mantiene juntos la moción, sus fuentes y tus resultados, y todas las herramientas lo usan.", "offerNotNow": "Ahora no, usarla una vez", "itemCount_one": "{{count}} elemento", - "itemCount_other": "{{count}} elementos" + "itemCount_other": "{{count}} elementos", + "viewAll": "Ver todo ({{total}})", + "switching": "Cambiando…", + "deleting": "Eliminando…" }, "debateContext": { "using": "Usando {{list}}", diff --git a/src/locales/hu.json b/src/locales/hu.json index baec19ca5..5f75d4035 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -1,6 +1,5 @@ { "nav": { - "home": "Kezdőlap", "workspaces": "Munkaterek", "generate": "Generálás", "attack": "Támadás", @@ -24,10 +23,10 @@ "more": "Továbbiak", "mainNavigation": "Fő navigáció", "mobileNavigation": "Mobil navigáció", - "currentDebate": "Aktuális vita" + "currentDebate": "Aktuális vita", + "tools": "Eszközök" }, "navDesc": { - "home": "Mód választása", "workspaces": "Közös esetírás", "generate": "Érvek építése", "attack": "Gyengeségek keresése", @@ -41,7 +40,8 @@ "flowchart": "Érvek térképezése", "history": "Mentett elemek", "currentDebate": "Tétel, források és eredmények", - "settings": "Nyelv, fiók és adatok" + "settings": "Nyelv, fiók és adatok", + "tools": "Eszköz választása" }, "workspaces": { "title": "Munkaterek", @@ -244,7 +244,8 @@ "copy": "Másolás", "deleted": "Törölve", "discuss": "Megvitatás", - "retry": "Újrapróbálás" + "retry": "Újrapróbálás", + "undo": "Visszavonás" }, "aiClient": { "inputShortened": "A bemenet rövidítve lett", @@ -355,7 +356,8 @@ "flowchart": "Folyamatábra", "flowchartDesc": "Térképezd az állításokat és cáfolatokat.", "history": "Előzmények", - "historyDesc": "Nyisd meg újra a mentett eseteket, chateket és korábbi felkészüléseket." + "historyDesc": "Nyisd meg újra a mentett eseteket, chateket és korábbi felkészüléseket.", + "heading": "Min dolgozol most?" }, "generate": { "emptyTitle": "Az Eseted Vár", @@ -501,7 +503,9 @@ "deletingAccount": "Törlés...", "deleteAccountSuccess": "Fiók törölve", "deleteAccountSuccessDesc": "A fiókodat és minden adatát véglegesen töröltük.", - "deleteAccountFailed": "A fiók nem lett törölve" + "deleteAccountFailed": "A fiók nem lett törölve", + "signingOut": "Kijelentkezés…", + "deletingData": "Törlés…" }, "login": { "title": "Bejelentkezés a DebateLab-ba", @@ -538,7 +542,18 @@ "demoCodeHint": "Add meg az esemény szervezőjétől kapott kódot.", "redirecting": "Átirányítás...", "failed": "Nem sikerült elindítani a bejelentkezést", - "tryAgain": "Próbáld újra." + "tryAgain": "Próbáld újra.", + "backHome": "Vissza a DebateLabhez", + "errors": { + "demoNotFound": "Nincs ilyen demókód.", + "demoUnusable": "Ez a demókód nem használható.", + "demoDisabled": "Ezt a demókódot letiltották.", + "demoExpired": "Ez a demókód lejárt.", + "demoUsedUp": "Ezzel a demókóddal már nem lehet többször belépni.", + "rateLimited": "Túl sok próbálkozás. Próbáld újra később.", + "codeExpired": "Ez a kód lejárt. Kérj újat.", + "network": "A DebateLab nem érhető el. Ellenőrizd a kapcsolatot, és próbáld újra." + } }, "admin": { "demoAccess": "Demóhozzáférés", @@ -670,7 +685,14 @@ "user": "Felhasználó", "admin": "Adminisztrátor", "banned": "Kitiltott" - } + }, + "confirmMakeAdmin": "Adminná teszed: {{email}}?", + "confirmMakeAdminDesc": "Az adminok megnyithatják az adminoldalt, és módosíthatják mások szerepkörét és csomagját.", + "confirmBan": "Letiltod: {{email}}?", + "confirmBanDesc": "Addig nem használhatja a DebateLabet, amíg fel nem oldod a tiltást.", + "confirmUnban": "Feloldod a tiltást: {{email}}?", + "confirmRemoveAdmin": "Elveszed az admin jogot: {{email}}?", + "confirmRoleDesc": "Átlagos felhasználó lesz." }, "promos": { "createTitle": "Promóciós kód létrehozása", @@ -1344,7 +1366,8 @@ "limitReached": "Napi hozzáférés szünetel", "limitReachedDesc": "Ma {{current}} / {{limit}} benne foglalt MI használatot használtál fel. A hozzáférés éjfélkor (UTC) frissül.", "upgrade": "Frissítés Pro-ra - $5/hó", - "upgradeNote": "Több MI felkészülési hozzáférés + magas szintű érvelés" + "upgradeNote": "Több MI felkészülési hozzáférés + magas szintű érvelés", + "notNow": "Most nem" }, "modelModal": { "title": "Pro érvelés", @@ -1620,12 +1643,8 @@ "sortOldest": "Hozzáadás dátuma (legrégebbi)", "sortTitle": "Név (A–Z)", "sortFolder": "Mappa", - "folderCreated": "Mappa létrehozva", - "folderCreatedDesc": "Az új forrásokat már ide is mentheted.", "allFolders": "Minden mappa", "importUrlRequired": "Adj meg egy importálandó URL-t.", - "importReady": "Forrás kész", - "importReadyDesc": "Mentés előtt nézd át a kinyert szöveget.", "importFailed": "Import sikertelen", "importFailedDesc": "Nem sikerült importálni ezt a forrást.", "duplicateSource": "A forrás már a könyvtárban van", @@ -1648,16 +1667,10 @@ "groundingDisabled": "Megalapozás Letiltva", "groundingActiveDesc": "Az MI válaszok használják a forrásaidat", "groundingDisabledDesc": "Az MI válaszok nincsenek megalapozva", - "errorRequired": "A cím és tartalom kötelező", - "sourceAdded": "Forrás Hozzáadva", - "sourceAddedDesc": "A forrásod elérhető megalapozáshoz.", "clearFailed": "Nem sikerült törölni a forrásokat", "clearConfirm": "Biztosan törölni szeretnéd az összes forrást?", "importUrlHelp": "Az automatikus importálás szerkeszthető piszkozatot hoz létre. Nézd át, mielőtt a könyvtárba mented.", "unsupportedFile": "Nem támogatott fájl", - "totalSources": "Összes forrás", - "activeSources": "Aktív források", - "syncedFolders": "Szinkronizált mappák", "manualSource": "Kézi forrás", "searchSources": "Források keresése", "searchPlaceholder": "Keresés forrásokban, mappákban vagy tartalomban...", @@ -1667,14 +1680,8 @@ "deactivateFolder": "Deaktiválás", "deleteFolderDesc": "A mappában lévő források átkerülnek az Általános mappába. Maguk a források nem törlődnek.", "deleteFolderTitle": "Törlöd a mappát?", - "deleteSourceDesc": "Ezzel eltávolítod a forrást a szinkronizált könyvtáradból.", - "deleteSourceTitle": "Törlöd a forrást?", "folderActions": "Mappaműveletek", "folderActionsDesc": "Nevezd át vagy töröld a mappát, illetve állítsd be, hogy a benne lévő forrásokat felhasználja-e az MI a válaszai megalapozásához.", - "folderActivated": "Mappa aktiválva", - "folderActivatedDesc": "A mappa összes forrása aktív.", - "folderDeactivated": "Mappa deaktiválva", - "folderDeactivatedDesc": "A források mentve maradnak, de az MI nem használja őket a válaszai megalapozásához.", "folderDeleted": "Mappa törölve", "folderDeletedDesc": "A források átkerültek az Általános mappába.", "folderMergeConfirm": "Már van ilyen nevű mappa. Áthelyezed bele a forrásokat?", @@ -1684,13 +1691,23 @@ "noMatchingSourcesDesc": "Próbálj más keresést vagy mappát.", "renameFolder": "Mappa átnevezése", "sourceCopied": "A forrás a vágólapra másolva.", - "sourceDeleted": "Forrás eltávolítva.", "sourceUpdated": "Forrás frissítve", "sourceUpdatedDesc": "A forrás módosításai mentve.", "typeLabel": "Típus", "untitledSource": "Névtelen forrás", "useForGrounding": "Felhasználás az MI válaszainak megalapozásához", - "useForGroundingDesc": "Az inaktív források mentve maradnak, de a generálás nem használja őket." + "useForGroundingDesc": "Az inaktív források mentve maradnak, de a generálás nem használja őket.", + "titleRequired": "Adj meg egy címet.", + "contentRequired": "Add meg a forrás szövegét.", + "sourceDeletedTitle": "Törölve: {{title}}", + "restoreFailed": "Nem sikerült visszaállítani a forrást", + "deleteFailed": "Nem sikerült törölni a forrást.", + "readingFile": "Fájl beolvasása…", + "summary": "{{total}} forrás · {{active}} aktív · {{folders}} mappa", + "mergeFolderTitle": "Összevonod ezzel: {{name}}?", + "mergeFolders": "Mappák összevonása", + "mergeFailed": "Nem sikerült összevonni a mappákat. Próbáld újra.", + "openOrigin": "{{title}} megnyitása új lapon" }, "autoSavedSources": { "undo": "Automatikus források visszavonása", @@ -1710,15 +1727,9 @@ "loadFailed": "Nem sikerült betölteni a megosztott tartalmat", "contentNotFound": "Ez a megosztott tartalom már nem elérhető", "goToApp": "Ugrás az Appba", - "importToAccount": "Importálás a Fiókba", "openApp": "App Megnyitása", "sharedOn": "Megosztva", - "views": "megtekintés", - "loginRequired": "Bejelentkezés Szükséges", - "loginRequiredDesc": "Jelentkezz be a tartalom fiókodba importálásához.", - "importNotAvailable": "Az importálás még nem elérhető", - "importNotAvailableDesc": "A megosztott tartalom fiókba mentése hamarosan érkezik.", - "importFailed": "Nem sikerült importálni a tartalmat" + "views": "megtekintés" }, "privacyPage": { "title": "Adatvédelmi Irányelvek", @@ -2075,7 +2086,6 @@ "langSwitcher": { "label": "Nyelv", "title": "Nyelv", - "description": "Válaszd ki az alkalmazás nyelvét és az AI válaszok nyelvét.", "uiLanguage": "Felület nyelve", "outputLanguage": "AI kimenet nyelve" }, @@ -2181,7 +2191,8 @@ }, "routeError": { "heading": "Az oldal nem tölthető be", - "reload": "Újratöltés" + "reload": "Újratöltés", + "home": "Ugrás a kezdőlapra" }, "sourcesPage": { "title": "Források", @@ -2321,7 +2332,10 @@ "offerDesc": "A vita együtt tartja a tételt, a forrásait és az eredményeidet, és minden eszköz ezt használja.", "offerNotNow": "Most nem, csak egyszer használom", "itemCount_one": "{{count}} elem", - "itemCount_other": "{{count}} elem" + "itemCount_other": "{{count}} elem", + "viewAll": "Összes megtekintése ({{total}})", + "switching": "Váltás…", + "deleting": "Törlés…" }, "debateContext": { "using": "Felhasználva: {{list}}", diff --git a/src/pages/AdminPage.tsx b/src/pages/AdminPage.tsx index 88c5f0896..82eddb218 100644 --- a/src/pages/AdminPage.tsx +++ b/src/pages/AdminPage.tsx @@ -450,6 +450,9 @@ export default function AdminPage() { const [demoCodesError, setDemoCodesError] = useState<string | null>(null); const [newPromo, setNewPromo] = useState({ code: '', type: 'extended_trial', configDays: 30, configLimit: 15, maxRedemptions: '' }); const [giftTarget, setGiftTarget] = useState<User | null>(null); + // Role changes are consequential and silent to the affected user, so each one asks first. + const [roleChange, setRoleChange] = useState<{ user: User; role: 'admin' | 'banned' | 'user' } | null>(null); + const [roleSaving, setRoleSaving] = useState(false); const [giftDays, setGiftDays] = useState(30); const [giftNotes, setGiftNotes] = useState(''); const [giftSubmitting, setGiftSubmitting] = useState(false); @@ -598,16 +601,27 @@ export default function AdminPage() { if (isAdmin === true) captureProductEvent('tool_opened', { tool: 'admin' }); }, [isAdmin]); + /** Resolves true once the role is saved; failures are reported here and leave the caller's dialog open. */ const handleSetUserRole = async (userId: string, role: string) => { try { await postAdminData('set-role', { userId, role }); toast({ title: t('admin.success'), description: t('admin.users.roleUpdated', { role: t(`admin.users.roles.${role}`, role) }) }); - setUsers(users.map(u => u.id === userId ? { ...u, role } : u)); + setUsers((current) => current.map(u => u.id === userId ? { ...u, role } : u)); + return true; } catch (err) { toast({ title: t('admin.error'), description: err instanceof Error ? err.message : t('admin.users.roleUpdateFailed'), variant: 'destructive' }); + return false; } }; + const confirmRoleChange = async () => { + if (!roleChange || roleSaving) return; + setRoleSaving(true); + const saved = await handleSetUserRole(roleChange.user.id, roleChange.role); + setRoleSaving(false); + if (saved) setRoleChange(null); + }; + const openGiftDialog = (user: User) => { setGiftTarget(user); setGiftDays(30); @@ -994,22 +1008,22 @@ export default function AdminPage() { </Button> )} {user.role === 'banned' ? ( - <Button size="sm" variant="outline" onClick={() => handleSetUserRole(user.id, 'user')}> + <Button size="sm" variant="outline" onClick={() => setRoleChange({ user, role: 'user' })}> <UserCheck className="h-4 w-4 mr-1" /> {t('admin.users.unban')} </Button> ) : user.role !== 'admin' ? ( <> - <Button size="sm" variant="outline" onClick={() => handleSetUserRole(user.id, 'admin')}> + <Button size="sm" variant="outline" onClick={() => setRoleChange({ user, role: 'admin' })}> {t('admin.users.makeAdmin')} </Button> - <Button size="sm" variant="destructive" onClick={() => handleSetUserRole(user.id, 'banned')}> + <Button size="sm" variant="destructive" onClick={() => setRoleChange({ user, role: 'banned' })}> <Ban className="h-4 w-4 mr-1" /> {t('admin.users.ban')} </Button> </> ) : ( - <Button size="sm" variant="outline" onClick={() => handleSetUserRole(user.id, 'user')}> + <Button size="sm" variant="outline" onClick={() => setRoleChange({ user, role: 'user' })}> {t('admin.users.removeAdmin')} </Button> )} @@ -1242,6 +1256,39 @@ export default function AdminPage() { </Tabs> </div> + <Dialog open={roleChange !== null} onOpenChange={(open) => { if (!open && !roleSaving) setRoleChange(null); }}> + <DialogContent> + <DialogHeader> + <DialogTitle> + {roleChange?.role === 'admin' + ? t('admin.users.confirmMakeAdmin', { email: roleChange.user.email }) + : roleChange?.role === 'banned' + ? t('admin.users.confirmBan', { email: roleChange.user.email }) + : roleChange?.user.role === 'banned' + ? t('admin.users.confirmUnban', { email: roleChange?.user.email }) + : t('admin.users.confirmRemoveAdmin', { email: roleChange?.user.email })} + </DialogTitle> + <DialogDescription> + {roleChange?.role === 'admin' + ? t('admin.users.confirmMakeAdminDesc') + : roleChange?.role === 'banned' + ? t('admin.users.confirmBanDesc') + : t('admin.users.confirmRoleDesc')} + </DialogDescription> + </DialogHeader> + <DialogFooter> + <Button type="button" variant="outline" onClick={() => setRoleChange(null)} disabled={roleSaving}>{t('admin.cancel')}</Button> + <Button type="button" variant={roleChange?.role === 'banned' ? 'destructive' : 'default'} onClick={() => void confirmRoleChange()} disabled={roleSaving}> + {roleSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} + {roleChange?.role === 'admin' + ? t('admin.users.makeAdmin') + : roleChange?.role === 'banned' + ? t('admin.users.ban') + : roleChange?.user.role === 'banned' ? t('admin.users.unban') : t('admin.users.removeAdmin')} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> <Dialog open={giftTarget !== null} onOpenChange={(open) => { if (!open) closeGiftDialog(); }}> <DialogContent> <DialogHeader> diff --git a/src/pages/CurrentDebatePage.tsx b/src/pages/CurrentDebatePage.tsx index 4679f5f8f..6ab391c4e 100644 --- a/src/pages/CurrentDebatePage.tsx +++ b/src/pages/CurrentDebatePage.tsx @@ -1,24 +1,13 @@ import { useState } from 'react'; import { Link } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { - ArrowRight, - BookOpen, - Crosshair, - Database, - Dumbbell, - GitBranch, - Lightbulb, - MessageCircle, - Pencil, - Sparkles, - Swords, - type LucideIcon, -} from 'lucide-react'; +import { Database, Pencil, Sparkles } from 'lucide-react'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { EmptyState } from '@/components/ui/empty-state'; +import { PageHeader } from '@/components/layout/PageHeader'; +import { PRACTICE_TOOLS, PREPARE_TOOLS, ToolLink } from '@/components/features/tools/tool-links'; import { DebateForm } from '@/components/features/debate/DebateForm'; import { sideLabelKey } from '@/components/features/debate/CurrentDebateStrip'; import { useAllDebateItems, useOpenDebateItem, type DebateItem } from '@/components/features/debate/debate-items'; @@ -27,16 +16,17 @@ import { formatEuropeanDateTime, formatRelativeDate } from '@/lib/date-format'; import { useCurrentDebate, useDebateStore } from '@/lib/stores/debate-store'; import { useSessionStore } from '@/lib/stores/session-store'; -const TOOLS: Array<{ key: string; href: string; icon: LucideIcon }> = [ - { key: 'generate', href: '/app/generate', icon: Sparkles }, - { key: 'brainstorm', href: '/app/brainstorm', icon: Lightbulb }, - { key: 'factoids', href: '/app/factoids', icon: BookOpen }, - { key: 'flowchart', href: '/app/flowchart', icon: GitBranch }, - { key: 'attack', href: '/app/attack', icon: Swords }, - { key: 'ask', href: '/app/ask', icon: MessageCircle }, - { key: 'crossfire', href: '/app/crossfire', icon: Crosshair }, - { key: 'sparring', href: '/app/sparring', icon: Dumbbell }, -]; +const TOOLS = [...PREPARE_TOOLS, ...PRACTICE_TOOLS]; + +function ViewAll({ to, count, shown }: { to: string; count: number; shown: number }) { + const { t } = useTranslation(); + if (count <= shown) return null; + return ( + <Button asChild variant="link" size="sm" className="mt-2 h-auto px-0"> + <Link to={to}>{t('debate.viewAll', { total: count, defaultValue: 'View all ({{total}})' })}</Link> + </Button> + ); +} function ItemRow({ item }: { item: DebateItem }) { const { t } = useTranslation(); @@ -65,13 +55,14 @@ function SideSection({ side, items }: { side: 'pro' | 'con'; items: DebateItem[] return ( <Card className="h-full border-border/70 bg-card/60" data-testid={`debate-side-${side}`}> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> - <CardTitle className="text-lg">{t(sideLabelKey(side))}</CardTitle> + <CardTitle>{t(sideLabelKey(side))}</CardTitle> <Badge variant="secondary">{t('debate.itemCount', { count: relevant.length, defaultValue: '{{count}} items' })}</Badge> </CardHeader> <CardContent> {relevant.length === 0 ? <p className="text-sm text-muted-foreground">{t('debate.sideEmpty', 'No work for this side yet.')}</p> : <ul>{relevant.slice(0, 4).map((item) => <ItemRow key={`${item.kind}-${item.id}`} item={item} />)}</ul>} + <ViewAll to="/app/history" count={relevant.length} shown={4} /> </CardContent> </Card> ); @@ -109,22 +100,32 @@ export default function CurrentDebatePage() { return ( <div className="container px-4 py-8"> <div className="mx-auto max-w-5xl space-y-6"> - <header className="space-y-3"> - <p className="text-sm font-medium text-muted-foreground">{t('nav.currentDebate', 'Current debate')}</p> - <h1 className="break-words text-3xl font-bold tracking-tight text-foreground" data-testid="current-debate-motion">{debate.motion}</h1> - <p className="text-sm text-muted-foreground"> + <PageHeader + className="mb-0" + eyebrow={t('nav.currentDebate', 'Current debate')} + title={debate.motion} + titleTestId="current-debate-motion" + meta={<> {t('debate.lastActivity', { when: formatRelativeDate(debate.lastActivityAt), defaultValue: 'Last activity {{when}}' })} {' · '} {t('debate.counts', { sources: debateSources.length, outputs: items.length, defaultValue: '{{sources}} sources · {{outputs}} outputs' })} - </p> - <div className="flex flex-wrap gap-2"> - <Button variant="outline" size="sm" onClick={() => setEditing((value) => !value)}> - <Pencil className="mr-1 h-4 w-4" aria-hidden="true" />{t('debate.editSettings', 'Edit settings')} + </>} + actions={<> + <Button variant="ghost" size="sm" onClick={() => setEditing((value) => !value)}> + <Pencil className="h-4 w-4" aria-hidden="true" />{t('debate.editSettings', 'Edit settings')} </Button> - <Button variant="outline" size="sm" onClick={() => openDialog('switch')}>{t('debate.switch', 'Switch debate')}</Button> - <Button variant="outline" size="sm" onClick={() => openDialog('create')}>{t('debate.startNew', 'Start new debate')}</Button> + <Button variant="ghost" size="sm" onClick={() => openDialog('switch')}>{t('debate.switch', 'Switch debate')}</Button> + <Button variant="ghost" size="sm" onClick={() => openDialog('create')}>{t('debate.startNew', 'Start new debate')}</Button> + </>} + /> + + {/* The tools are the main thing to do here, so they come first. */} + <section aria-labelledby="debate-tools-title"> + <h2 id="debate-tools-title" className="mb-3 text-lg font-semibold tracking-tight">{t('debate.tools', 'Prepare and practice')}</h2> + <div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-4"> + {TOOLS.map((tool) => <ToolLink key={tool.key} tool={tool} variant="tile" />)} </div> - </header> + </section> {editing ? ( <Card> @@ -161,7 +162,7 @@ export default function CurrentDebatePage() { <div className="grid gap-6 md:grid-cols-2"> <Card className="border-border/70 bg-card/60" data-testid="debate-sources"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> - <CardTitle className="text-lg">{t('debate.sources', 'Sources')}</CardTitle> + <CardTitle>{t('debate.sources', 'Sources')}</CardTitle> <Button asChild variant="ghost" size="sm"> <Link to="/app/library"><Database className="mr-1 h-4 w-4" aria-hidden="true" />{t('nav.library', 'Library')}</Link> </Button> @@ -174,36 +175,22 @@ export default function CurrentDebatePage() { {debateSources.slice(0, 6).map((source) => <li key={source.id} className="truncate">{source.title}</li>)} </ul> )} + <ViewAll to="/app/library" count={debateSources.length} shown={6} /> </CardContent> </Card> <Card className="border-border/70 bg-card/60" data-testid="debate-recent"> <CardHeader className="pb-2"> - <CardTitle className="text-lg">{t('debate.recentWork', 'Recent work')}</CardTitle> + <CardTitle>{t('debate.recentWork', 'Recent work')}</CardTitle> </CardHeader> <CardContent> {items.length === 0 ? <p className="text-sm text-muted-foreground">{t('debate.noWork', 'Results from any tool appear here automatically.')}</p> : <ul>{items.slice(0, 6).map((item) => <ItemRow key={`${item.kind}-${item.id}`} item={item} />)}</ul>} + <ViewAll to="/app/history" count={items.length} shown={6} /> </CardContent> </Card> </div> - <section aria-labelledby="debate-tools-title"> - <h2 id="debate-tools-title" className="mb-3 text-lg font-semibold tracking-tight">{t('debate.tools', 'Prepare and practice')}</h2> - <div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-4"> - {TOOLS.map((tool) => ( - <Link - key={tool.key} - to={tool.href} - className="motion-press group flex items-center gap-2 rounded-md border border-border bg-card px-3 py-2.5 text-sm font-medium hover:border-primary/40 hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" - > - <tool.icon className="h-4 w-4 text-primary" aria-hidden="true" /> - <span className="flex-1">{t(`nav.${tool.key}`)}</span> - <ArrowRight className="h-4 w-4 text-muted-foreground" aria-hidden="true" /> - </Link> - ))} - </div> - </section> </div> </div> ); diff --git a/src/pages/HistoryPage.tsx b/src/pages/HistoryPage.tsx index d1638566a..673641bb7 100644 --- a/src/pages/HistoryPage.tsx +++ b/src/pages/HistoryPage.tsx @@ -3,11 +3,10 @@ import { useTranslation } from 'react-i18next'; import { useSessionStore } from '@/lib/stores/session-store'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { Button } from '@/components/ui/button'; +import { Button, buttonVariants } from '@/components/ui/button'; import { Trash2, Eye, MessageCircle, FileText, Zap, Brain, BookOpen, Dumbbell } from 'lucide-react'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, useSearchParams } from 'react-router-dom'; import { Badge } from '@/components/ui/badge'; -import { ScrollArea } from '@/components/ui/scroll-area'; import { formatEuropeanDateTime } from '@/lib/date-format'; import { useToast } from '@/hooks/use-toast'; import { @@ -25,10 +24,18 @@ import { useSparringStore } from '@/lib/stores/sparring-store'; import { useDebateStore } from '@/lib/stores/debate-store'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { UnsortedWork } from '@/components/features/debate/UnsortedWork'; +import { PageHeader } from '@/components/layout/PageHeader'; /** History scope value for work that belongs to no debate. */ const OUTSIDE_DEBATES = 'outside'; +const HISTORY_TABS = ['cases', 'chats', 'attacks', 'brainstorms', 'factoids', 'sparring'] as const; +type HistoryTab = (typeof HISTORY_TABS)[number]; +const isHistoryTab = (value: string | null): value is HistoryTab => HISTORY_TABS.includes(value as HistoryTab); + +// Lists scroll with the page on phones; wide screens get a bounded inner list. +const LIST_CLASS = 'space-y-4 lg:max-h-[500px] lg:overflow-y-auto lg:pr-4'; + export default function HistoryPage() { const { t } = useTranslation(); const navigate = useNavigate(); @@ -51,6 +58,17 @@ export default function HistoryPage() { const loadSparringSession = useSparringStore((state) => state.loadSavedSession); const deleteSparringSession = useSparringStore((state) => state.deleteSavedSession); const { toast } = useToast(); + // The open tab lives in the URL, so Back from a loaded item returns to it. + const [searchParams, setSearchParams] = useSearchParams(); + const tabParam = searchParams.get('tab'); + const tab: HistoryTab = isHistoryTab(tabParam) ? tabParam : 'cases'; + const setTab = (value: string) => { + if (!isHistoryTab(value)) return; + setSearchParams((params) => { + params.set('tab', value); + return params; + }, { replace: true }); + }; const [caseToDelete, setCaseToDelete] = useState<{ id: string; title: string } | null>(null); const [sparringSessionToDelete, setSparringSessionToDelete] = useState<{ id: string; motion: string } | null>(null); @@ -116,7 +134,7 @@ export default function HistoryPage() { <div className="container py-8 px-4"> <div className="max-w-5xl mx-auto"> <div className="mb-8 space-y-4"> - <h1 className="text-3xl font-bold tracking-tight text-foreground">{t('historyPage.title')}</h1> + <PageHeader className="mb-0" title={t('historyPage.title')} /> <Select value={scope} onValueChange={setScopeChoice}> <SelectTrigger className="w-full sm:max-w-xl" aria-label={t('debate.historyScope', 'Show work from')}> <SelectValue /> @@ -133,7 +151,7 @@ export default function HistoryPage() { </div> {scope === OUTSIDE_DEBATES ? <UnsortedWork /> : ( - <Tabs defaultValue="cases" className="space-y-6"> + <Tabs value={tab} onValueChange={setTab} className="space-y-6"> <TabsList className="w-full justify-start overflow-x-auto"> <TabsTrigger value="cases" className="gap-2"><FileText className="h-4 w-4" /> {t('historyPage.tabCases')}</TabsTrigger> <TabsTrigger value="chats" className="gap-2"><MessageCircle className="h-4 w-4" /> {t('historyPage.tabChats')}</TabsTrigger> @@ -152,10 +170,10 @@ export default function HistoryPage() { {savedCases.length === 0 ? ( <EmptyState icon={FileText} title={t('historyPage.emptyCases')} description={t('historyPage.emptyCasesDesc', 'Generated cases you save will appear here.')} actionLabel={t('historyPage.createCase', 'Create a case')} onAction={() => navigate('/app/generate')} /> ) : ( - <ScrollArea className="h-[500px] pr-4"> - <div className="space-y-4 stagger-enter"> + <div> + <div className={LIST_CLASS}> {savedCases.map((item) => ( - <div key={item.id} className="flex items-center justify-between p-4 border rounded-lg bg-card hover:bg-accent/5 transition-colors motion-list-item"> + <div key={item.id} className="flex items-center justify-between p-4 border rounded-lg bg-card hover:bg-accent/5 transition-colors"> <div className="space-y-1"> <h4 className="font-semibold">{item.title}</h4> <div className="flex gap-2 text-xs text-muted-foreground"> @@ -174,7 +192,7 @@ export default function HistoryPage() { </div> ))} </div> - </ScrollArea> + </div> )} </CardContent> </Card> @@ -216,8 +234,8 @@ export default function HistoryPage() { {savedAttacks.length === 0 ? ( <EmptyState icon={Zap} title={t('historyPage.emptyAttacks')} description={t('historyPage.emptyAttacksDesc', 'Saved attack analyses will appear here.')} actionLabel={t('historyPage.attackCase', 'Attack a case')} onAction={() => navigate('/app/attack')} /> ) : ( - <ScrollArea className="h-[500px] pr-4"> - <div className="space-y-4"> + <div> + <div className={LIST_CLASS}> {savedAttacks.map((item) => ( <div key={item.id} className="flex items-center justify-between p-4 border rounded-lg bg-card hover:bg-accent/5 transition-colors"> <div className="space-y-1"> @@ -232,7 +250,7 @@ export default function HistoryPage() { </div> ))} </div> - </ScrollArea> + </div> )} </CardContent> </Card> @@ -246,8 +264,8 @@ export default function HistoryPage() { {savedBrainstorms.length === 0 ? ( <EmptyState icon={Brain} title={t('historyPage.emptyBrainstorms')} description={t('historyPage.emptyBrainstormsDesc', 'Saved ideation sessions will appear here.')} actionLabel={t('historyPage.brainstormIdeas', 'Brainstorm ideas')} onAction={() => navigate('/app/brainstorm')} /> ) : ( - <ScrollArea className="h-[500px] pr-4"> - <div className="space-y-4"> + <div> + <div className={LIST_CLASS}> {savedBrainstorms.map((item) => ( <div key={item.id} className="flex items-center justify-between p-4 border rounded-lg bg-card hover:bg-accent/5 transition-colors"> <div className="space-y-1"> @@ -262,7 +280,7 @@ export default function HistoryPage() { </div> ))} </div> - </ScrollArea> + </div> )} </CardContent> </Card> @@ -276,8 +294,8 @@ export default function HistoryPage() { {savedFactoids.length === 0 ? ( <EmptyState icon={BookOpen} title={t('historyPage.emptyFactoids')} description={t('historyPage.emptyFactoidsDesc', 'Saved evidence research will appear here.')} actionLabel={t('historyPage.findEvidence', 'Find evidence')} onAction={() => navigate('/app/factoids')} /> ) : ( - <ScrollArea className="h-[500px] pr-4"> - <div className="space-y-4"> + <div> + <div className={LIST_CLASS}> {savedFactoids.map((item) => ( <div key={item.id} className="flex items-center justify-between p-4 border rounded-lg bg-card hover:bg-accent/5 transition-colors"> <div className="space-y-1"> @@ -292,7 +310,7 @@ export default function HistoryPage() { </div> ))} </div> - </ScrollArea> + </div> )} </CardContent> </Card> @@ -312,8 +330,8 @@ export default function HistoryPage() { onAction={() => navigate('/app/sparring')} /> ) : ( - <ScrollArea className="h-[500px] pr-4"> - <div className="space-y-4"> + <div> + <div className={LIST_CLASS}> {sparringSessions.map((session) => ( <div key={session.id} className="flex items-center justify-between gap-4 rounded-lg border bg-card p-4 transition-colors hover:bg-accent/5"> <div className="min-w-0 space-y-1"> @@ -342,7 +360,7 @@ export default function HistoryPage() { </div> ))} </div> - </ScrollArea> + </div> )} </CardContent> </Card> @@ -359,7 +377,7 @@ export default function HistoryPage() { </AlertDialogHeader> <AlertDialogFooter> <AlertDialogCancel>{t('common.cancel', 'Cancel')}</AlertDialogCancel> - <AlertDialogAction onClick={confirmDeleteCase}>{t('common.delete', 'Delete')}</AlertDialogAction> + <AlertDialogAction className={buttonVariants({ variant: 'destructive' })} onClick={confirmDeleteCase}>{t('common.delete', 'Delete')}</AlertDialogAction> </AlertDialogFooter> </AlertDialogContent> </AlertDialog> @@ -373,7 +391,7 @@ export default function HistoryPage() { </AlertDialogHeader> <AlertDialogFooter> <AlertDialogCancel>{t('common.cancel', 'Cancel')}</AlertDialogCancel> - <AlertDialogAction onClick={confirmDeleteSparringSession}>{t('common.delete', 'Delete')}</AlertDialogAction> + <AlertDialogAction className={buttonVariants({ variant: 'destructive' })} onClick={confirmDeleteSparringSession}>{t('common.delete', 'Delete')}</AlertDialogAction> </AlertDialogFooter> </AlertDialogContent> </AlertDialog> diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index aaf5d77a2..eae56ef3e 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -1,104 +1,23 @@ import { Link } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { - ArrowRight, - BookOpen, - Clock, - Crosshair, - Database, - Dumbbell, - GitBranch, - Lightbulb, - MessageCircle, - Sparkles, - Swords, - Timer, - type LucideIcon, -} from 'lucide-react'; +import { ArrowRight } from 'lucide-react'; import type { TFunction } from 'i18next'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; -import { useRoutePrefetch } from '@/hooks/use-route-prefetch'; -import { cn } from '@/lib/utils'; +import { PageHeader } from '@/components/layout/PageHeader'; +import { PRACTICE_TOOLS, PREPARE_TOOLS, ToolLink, UTILITY_TOOLS, type Tool } from '@/components/features/tools/tool-links'; import { formatRelativeDate } from '@/lib/date-format'; import { useCurrentDebate, useDebateStore } from '@/lib/stores/debate-store'; import { useDebateCounts } from '@/components/features/debate/CurrentDebateStrip'; -type Tool = { - key: string; - href: string; - icon: LucideIcon; -}; - -const prepareTools = [ - { key: 'generate', href: '/app/generate', icon: Sparkles }, - { key: 'brainstorm', href: '/app/brainstorm', icon: Lightbulb }, - { key: 'factoids', href: '/app/factoids', icon: BookOpen }, - { key: 'flowchart', href: '/app/flowchart', icon: GitBranch }, -] satisfies readonly Tool[]; - -const practiceTools = [ - { key: 'attack', href: '/app/attack', icon: Swords }, - { key: 'ask', href: '/app/ask', icon: MessageCircle }, - { key: 'crossfire', href: '/app/crossfire', icon: Crosshair }, - { key: 'sparring', href: '/app/sparring', icon: Dumbbell }, -] satisfies readonly Tool[]; - -const utilityTools = [ - { key: 'library', href: '/app/library', icon: Database }, - { key: 'timer', href: '/app/timer', icon: Timer }, - { key: 'history', href: '/app/history', icon: Clock }, -] satisfies readonly Tool[]; - -function ToolRow({ tool, t }: { tool: Tool; t: TFunction }) { - const Icon = tool.icon; - const prefetchHandlers = useRoutePrefetch(tool.href); - return ( - <Link - to={tool.href} - {...prefetchHandlers} - data-tour={`mode-${tool.key}`} - className="motion-press group flex items-center gap-3 rounded-md border-b border-border/60 px-3 py-3 last:border-b-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" - > - <span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary transition-colors group-hover:bg-primary/20"> - <Icon className="h-5 w-5" aria-hidden="true" /> - </span> - <span className="min-w-0 flex-1"> - <span className="block font-medium text-foreground">{t(`home.${tool.key}`, tool.key)}</span> - <span className="block text-xs leading-5 text-muted-foreground sm:text-sm">{t(`home.${tool.key}Desc`, '')}</span> - </span> - <ArrowRight className="h-4 w-4 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-1 group-hover:text-primary motion-reduce:group-hover:translate-x-0" aria-hidden="true" /> - </Link> - ); -} - -function UtilityToolLink({ tool, t }: { tool: Tool; t: TFunction }) { - const Icon = tool.icon; - const prefetchHandlers = useRoutePrefetch(tool.href); - return ( - <Link - to={tool.href} - {...prefetchHandlers} - data-tour={`mode-${tool.key}`} - className={cn( - 'motion-press inline-flex min-h-10 items-center gap-2 rounded-md border border-border bg-card px-3 text-sm font-medium text-muted-foreground', - 'hover:border-primary/40 hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2', - )} - > - <Icon className="h-4 w-4" aria-hidden="true" /> - {t(`home.${tool.key}`, tool.key)} - </Link> - ); -} - -function ToolGroup({ title, tools, t }: { title: string; tools: readonly Tool[]; t: TFunction }) { +function ToolGroup({ title, tools }: { title: string; tools: readonly Tool[] }) { return ( <Card className="h-full border-border/70 bg-card/60"> <CardHeader className="pb-3"> - <CardTitle className="text-xl">{title}</CardTitle> + <CardTitle>{title}</CardTitle> </CardHeader> <CardContent className="px-3 pb-3 pt-0"> - {tools.map((tool) => <ToolRow key={tool.key} tool={tool} t={t} />)} + {tools.map((tool) => <ToolLink key={tool.key} tool={tool} variant="row" tourId={`mode-${tool.key}`} />)} </CardContent> </Card> ); @@ -151,21 +70,19 @@ export default function HomePage() { return ( <div className="container px-4 py-8"> <div className="mx-auto min-h-[72vh] max-w-5xl"> - <div className="mb-8 text-center"> - <h1 className="text-4xl font-bold tracking-tight text-foreground">{t('home.title')}</h1> - </div> + <PageHeader title={t('home.heading', 'What are you working on?')} /> <CurrentDebateSummary t={t} /> <div className="grid gap-6 md:grid-cols-2"> - <ToolGroup title={t('home.prepare', 'Prepare')} tools={prepareTools} t={t} /> - <ToolGroup title={t('home.practice', 'Practice')} tools={practiceTools} t={t} /> + <ToolGroup title={t('home.prepare', 'Prepare')} tools={PREPARE_TOOLS} /> + <ToolGroup title={t('home.practice', 'Practice')} tools={PRACTICE_TOOLS} /> </div> <section className="mt-8 border-t border-border pt-6" aria-labelledby="home-utilities-title"> <h2 id="home-utilities-title" className="text-lg font-semibold tracking-tight">{t('home.utilities', 'Utilities')}</h2> <div className="mt-3 flex flex-wrap gap-3"> - {utilityTools.map((tool) => <UtilityToolLink key={tool.key} tool={tool} t={t} />)} + {UTILITY_TOOLS.map((tool) => <ToolLink key={tool.key} tool={tool} variant="tile" tourId={`mode-${tool.key}`} />)} </div> </section> </div> diff --git a/src/pages/LibraryPage.tsx b/src/pages/LibraryPage.tsx index 5389e4b9b..9853279c4 100644 --- a/src/pages/LibraryPage.tsx +++ b/src/pages/LibraryPage.tsx @@ -1,8 +1,8 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useSessionStore, type Source } from '@/lib/stores/session-store'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { Button } from '@/components/ui/button'; +import { Button, buttonVariants } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; @@ -36,8 +36,6 @@ import { SheetTitle, } from '@/components/ui/sheet'; -const SOURCE_LIST_HEIGHT = 600; - interface SourceRowProps { source: Source; onRemove: (source: Source) => void; @@ -66,84 +64,93 @@ function SourceRow({ return ( <div className="px-1 pb-2"> + {/* The row is clickable for pointers; the title button is the keyboard and screen-reader target. */} <div - role="button" - tabIndex={0} onClick={() => onOpen(source)} - onKeyDown={(event) => { - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault(); - onOpen(source); - } - }} - className={`flex h-[84px] cursor-pointer items-center justify-between gap-3 rounded-lg border px-3 py-2.5 text-left transition-colors motion-list-item hover:border-primary/40 hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${source.isActive ? 'bg-card' : 'bg-muted/30 opacity-60'}`} + className={`flex min-h-[84px] cursor-pointer items-center justify-between gap-3 rounded-lg border px-3 py-2.5 text-left transition-colors motion-list-item hover:border-primary/40 hover:bg-accent/40 ${source.isActive ? 'bg-card' : 'bg-muted/30 opacity-60'}`} > - <div className="flex min-w-0 items-center gap-3"> + <div className="flex min-w-0 flex-1 items-center gap-3"> <div className={`shrink-0 rounded-md p-1.5 ${source.isActive ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'}`}> {source.type === 'url' ? <Globe className="h-4 w-4" /> : <FileText className="h-4 w-4" />} </div> <div className="min-w-0"> <div className="flex items-center gap-1.5"> - {source.origin?.startsWith('http') ? ( - <a href={source.origin} target="_blank" rel="noopener noreferrer" onClick={(event) => event.stopPropagation()} className="inline-flex min-w-0 items-center gap-1 truncate text-sm font-medium text-foreground transition-colors hover:text-primary"> - {source.title} - <ExternalLink className="h-3 w-3 shrink-0" /> + <button + type="button" + onClick={(event) => { + event.stopPropagation(); + onOpen(source); + }} + className="min-w-0 truncate rounded-sm text-left text-sm font-medium text-foreground transition-colors hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" + > + {source.title} + </button> + {source.origin?.startsWith('http') && ( + <a + href={source.origin} + target="_blank" + rel="noopener noreferrer" + onClick={(event) => event.stopPropagation()} + className="shrink-0 rounded-sm text-muted-foreground transition-colors hover:text-primary" + aria-label={t('libraryPage.openOrigin', { title: source.title, defaultValue: 'Open {{title}} in a new tab' })} + > + <ExternalLink className="h-3.5 w-3.5" aria-hidden="true" /> </a> - ) : ( - <span className="truncate text-sm font-medium">{source.title}</span> )} - <Badge variant="outline" className="shrink-0 py-0 text-[9px]"> + <Badge variant="outline" className="shrink-0 py-0"> {t(`libraryPage.${source.type}Source`, source.type.toUpperCase())} </Badge> </div> - <p className="mt-0.5 truncate text-[11px] text-muted-foreground">{formatEuropeanDate(source.createdAt)} • {source.origin || manualEntryLabel}</p> - <div className="mt-1 flex min-w-0 items-center gap-1.5"> + <p className="mt-0.5 truncate text-xs text-muted-foreground">{formatEuropeanDate(source.createdAt)} • {source.origin || manualEntryLabel}</p> + {/* Badges wrap on narrow rows; the text preview only fits beside them on wider screens. */} + <div className="mt-1 flex min-w-0 flex-wrap items-center gap-1.5"> <Badge variant="secondary" - className="shrink-0 max-w-[200px] truncate py-0 text-[10px]" + className="max-w-[200px] shrink-0 truncate py-0 font-medium" title={`${folderLabel}: ${sourceFolderLabel}`} > <Folder className="mr-1 h-3 w-3 shrink-0" /> <span className="truncate">{sourceFolderLabel}</span> </Badge> {source.metadata?.semantic?.lastUsedInCase && ( - <Badge variant="outline" className="shrink-0 border-primary/30 bg-primary/10 py-0 text-[10px] text-primary"> + <Badge variant="outline" className="shrink-0 border-primary/30 bg-primary/10 py-0 font-medium text-primary"> {t('sourceQuality.usedInCase')} </Badge> )} {source.metadata?.semantic && ( - <Badge variant="outline" className="shrink-0 py-0 text-[10px]"> + <Badge variant="outline" className="shrink-0 py-0 font-medium"> {t('sourceQuality.importance', { level: t(`sourceQuality.levels.${source.metadata.semantic.importance}`) })} </Badge> )} {source.metadata?.semantic && ( - <Badge variant="outline" className="shrink-0 py-0 text-[10px]"> + <Badge variant="outline" className="shrink-0 py-0 font-medium"> {t('sourceQuality.strength', { level: t(`sourceQuality.levels.${source.metadata.semantic.strength}`) })} </Badge> )} - <span className="truncate text-[11px] text-muted-foreground">{source.content}</span> + <span className="hidden min-w-0 flex-1 truncate text-xs text-muted-foreground sm:inline">{source.content}</span> </div> </div> </div> - <div className="flex shrink-0 items-center gap-2"> + <div className="flex shrink-0 items-center gap-1"> <Button variant="ghost" size="icon" - className="h-7 w-7 text-destructive" + className="h-9 w-9 text-destructive" onClick={(event) => { event.stopPropagation(); onRemove(source); }} aria-label={t('libraryPage.deleteSourceLabel', { title: source.title })} > - <Trash2 className="h-3.5 w-3.5" /> + <Trash2 className="h-4 w-4" /> </Button> - <Switch - checked={source.isActive} - onClick={(event) => event.stopPropagation()} - onCheckedChange={() => onToggle(source.id)} - aria-label={t('libraryPage.toggleSourceLabel', { title: source.title })} - /> + <div className="flex items-center justify-center pointer-coarse:min-h-11 pointer-coarse:min-w-11" onClick={(event) => event.stopPropagation()}> + <Switch + checked={source.isActive} + onCheckedChange={() => onToggle(source.id)} + aria-label={t('libraryPage.toggleSourceLabel', { title: source.title })} + /> + </div> </div> </div> </div> @@ -178,6 +185,16 @@ export default function LibraryPage() { const [isAdding, setIsAdding] = useState(false); const [isClearing, setIsClearing] = useState(false); + const [isImportingFile, setIsImportingFile] = useState(false); + const [addErrors, setAddErrors] = useState<{ title?: string; content?: string }>({}); + const [draftErrors, setDraftErrors] = useState<{ title?: string; content?: string }>({}); + const [mergeTarget, setMergeTarget] = useState<string | null>(null); + const [merging, setMerging] = useState(false); + const [mergeError, setMergeError] = useState(false); + const addTitleRef = useRef<HTMLInputElement>(null); + const addContentRef = useRef<HTMLTextAreaElement>(null); + const draftTitleRef = useRef<HTMLInputElement>(null); + const draftContentRef = useRef<HTMLTextAreaElement>(null); const [newSource, setNewSource] = useState({ title: '', content: '', @@ -214,16 +231,21 @@ export default function LibraryPage() { warnings?: string[]; } | null>(null); + // Required fields are checked inline: the message sits under the field and focus moves to it. + const requiredErrors = (title: string, content: string) => ({ + title: title.trim() ? undefined : t('libraryPage.titleRequired', 'Add a title.'), + content: content.trim() ? undefined : t('libraryPage.contentRequired', 'Add the source text.'), + }); + const handleAdd = async () => { - if (!newSource.title || !newSource.content) { - toast({ title: t('common.error'), description: t('libraryPage.errorRequired'), variant: "destructive" }); - return; - } + const errors = requiredErrors(newSource.title, newSource.content); + setAddErrors(errors); + if (errors.title) return addTitleRef.current?.focus(); + if (errors.content) return addContentRef.current?.focus(); // The Library always shows the Current Debate, even while tools run one-off. await addSource({ ...newSource, debateId: currentDebate?.id ?? null }); setNewSource({ title: '', content: '', type: 'text', origin: '', folder: newSource.folder }); setIsAdding(false); - toast({ title: t('libraryPage.sourceAdded'), description: t('libraryPage.sourceAddedDesc') }); }; const handleImportUrl = async () => { @@ -245,7 +267,6 @@ export default function LibraryPage() { domain: imported.domain, excerpt: imported.excerpt, }); - toast({ title: t('libraryPage.importReady'), description: t('libraryPage.importReadyDesc') }); } catch (error) { toast({ title: t('libraryPage.importFailed'), @@ -257,8 +278,10 @@ export default function LibraryPage() { } }; - const handleFileImport = async (file: File | undefined) => { + const handleFileImport = async (input: HTMLInputElement) => { + const file = input.files?.[0]; if (!file) return; + setIsImportingFile(true); try { const imported = await importSourceFile(file); setImportPreview({ @@ -279,6 +302,10 @@ export default function LibraryPage() { description: isSourceError ? error.message : t('libraryPage.fileReadFailed'), variant: 'destructive', }); + } finally { + setIsImportingFile(false); + // Reset so picking the same file again fires a change. + input.value = ''; } }; @@ -304,7 +331,6 @@ export default function LibraryPage() { setImportPreview(null); setImportUrl(''); setIsAdding(false); - toast({ title: t('libraryPage.sourceAdded'), description: t('libraryPage.sourceAddedDesc') }); }; const handleClearLibrary = async () => { @@ -381,6 +407,7 @@ export default function LibraryPage() { useEffect(() => { if (!selectedSource) return; + setDraftErrors({}); setSourceDraft({ title: selectedSource.title, content: selectedSource.content, @@ -401,27 +428,50 @@ export default function LibraryPage() { setSelectedFolder(folder); setNewFolderName(''); setNewFolderOpen(false); - toast({ - title: t('libraryPage.folderCreated', 'Folder created'), - description: t('libraryPage.folderCreatedDesc', 'New sources can now be saved there.'), - }); }; + // A source can be put back exactly, so deleting acts at once and offers Undo instead of asking first. const handleRemoveSource = (source: Source) => { + if (selectedSourceId === source.id) setSelectedSourceId(null); void Promise.resolve(removeSource(source.id)).then(() => { - if (selectedSourceId === source.id) { - setSelectedSourceId(null); - } - toast({ title: t('common.deleted', 'Deleted'), description: t('libraryPage.sourceDeleted', 'Source removed.') }); + toast({ + title: t('libraryPage.sourceDeletedTitle', { title: source.title, defaultValue: 'Deleted {{title}}' }), + action: { + label: t('common.undo', 'Undo'), + onClick: () => { + void addSource({ + id: source.id, + createdAt: source.createdAt, + title: source.title, + content: source.content, + type: source.type, + origin: source.origin, + folder: source.folder, + isActive: source.isActive, + metadata: source.metadata, + debateId: source.debateId ?? null, + }).catch(() => toast({ title: t('libraryPage.restoreFailed', 'Could not restore the source'), variant: 'destructive' })); + }, + }, + }); + }).catch(() => { + toast({ title: t('common.error', 'Error'), description: t('libraryPage.deleteFailed', 'Could not delete the source.'), variant: 'destructive' }); }); }; + const renameFolderTo = async (from: string, nextName: string) => { + await renameSourceFolder(from, nextName); + setSelectedFolder(nextName); + toast({ title: t('libraryPage.folderRenamed', 'Folder renamed'), description: t('libraryPage.folderRenamedDesc', 'Sources in this folder were moved to the new name.') }); + }; const handleRenameFolder = async () => { if (selectedFolder === 'all' || selectedFolder === 'General') return; const nextName = folderRenameValue.trim(); if (!nextName || nextName === selectedFolder) return; - if (folders.includes(nextName) && !window.confirm(t('libraryPage.folderMergeConfirm', 'A folder with this name already exists. Merge sources into it?'))) return; - await renameSourceFolder(selectedFolder, nextName); - setSelectedFolder(nextName); - toast({ title: t('libraryPage.folderRenamed', 'Folder renamed'), description: t('libraryPage.folderRenamedDesc', 'Sources in this folder were moved to the new name.') }); + // Merging into an existing folder can't be split again, so it asks first. + if (folders.includes(nextName)) { + setMergeTarget(nextName); + return; + } + await renameFolderTo(selectedFolder, nextName); }; const handleDeleteSelectedFolder = async () => { if (selectedFolder === 'all' || selectedFolder === 'General') return; @@ -432,17 +482,13 @@ export default function LibraryPage() { const handleSetSelectedFolderActive = async (isActive: boolean) => { if (selectedFolder === 'all') return; await setFolderActive(selectedFolder, isActive); - toast({ - title: isActive ? t('libraryPage.folderActivated', 'Folder activated') : t('libraryPage.folderDeactivated', 'Folder deactivated'), - description: isActive ? t('libraryPage.folderActivatedDesc', 'All sources in this folder are active.') : t('libraryPage.folderDeactivatedDesc', 'Sources stay saved but will not ground AI outputs.'), - }); }; const handleSaveSourceDraft = async () => { if (!selectedSource) return; - if (!sourceDraft.title.trim() || !sourceDraft.content.trim()) { - toast({ title: t('common.error'), description: t('libraryPage.errorRequired'), variant: "destructive" }); - return; - } + const errors = requiredErrors(sourceDraft.title, sourceDraft.content); + setDraftErrors(errors); + if (errors.title) return draftTitleRef.current?.focus(); + if (errors.content) return draftContentRef.current?.focus(); await updateSource(selectedSource.id, { title: sourceDraft.title, @@ -473,13 +519,18 @@ export default function LibraryPage() { <div className="max-w-5xl mx-auto"> <div className="mb-6 flex flex-col justify-between gap-4 lg:flex-row lg:items-start"> <div> - <h1 className="text-3xl font-bold tracking-tight text-foreground">{t('libraryPage.title')}</h1> + <h1 className="text-3xl font-bold leading-tight tracking-tight text-foreground">{t('libraryPage.title')}</h1> {currentDebate ? ( - <p className="mt-2 text-sm text-muted-foreground" data-testid="library-debate"> - {t('debate.librarySources', 'Sources for')}{' '} - <span className="break-words font-medium text-foreground">{currentDebate.motion}</span>{' '} - <Button variant="link" size="sm" className="h-auto p-0" onClick={() => openDebateDialog('switch')}>{t('debate.change', 'Change')}</Button> - </p> + <> + <p className="mt-2 text-sm text-muted-foreground" data-testid="library-debate"> + {t('debate.librarySources', 'Sources for')}{' '} + <span className="break-words font-medium text-foreground">{currentDebate.motion}</span>{' '} + <Button variant="link" size="sm" className="h-auto p-0" onClick={() => openDebateDialog('switch')}>{t('debate.change', 'Change')}</Button> + </p> + <p className="mt-1 text-sm text-muted-foreground"> + {t('libraryPage.summary', { total: sourceLibrary.length, active: activeSourceCount, folders: folders.length, defaultValue: '{{total}} sources · {{active}} active · {{folders}} folders' })} + </p> + </> ) : ( <div className="mt-3 flex flex-wrap items-center gap-2 rounded-lg border border-dashed p-3 text-sm" data-testid="library-debate"> <span className="text-muted-foreground">{t('debate.libraryNeedsDebate', 'Select or create a debate before adding or importing sources.')}</span> @@ -492,18 +543,6 @@ export default function LibraryPage() { <Button variant="outline" size="sm" disabled={!currentDebate} onClick={() => setIsAdding(!isAdding)}> <Plus className="h-4 w-4 mr-1" /> {isAdding ? t('libraryPage.cancel') : t('libraryPage.addSource')} </Button> - <Select value={selectedFolder} onValueChange={setSelectedFolder}> - <SelectTrigger className="h-9 w-[160px]"> - <Folder className="h-4 w-4 mr-1" /> - <SelectValue /> - </SelectTrigger> - <SelectContent> - <SelectItem value="all">{t('libraryPage.allFolders', 'All')}</SelectItem> - {folders.map((f) => ( - <SelectItem key={f} value={f}>{getFolderName(f)}</SelectItem> - ))} - </SelectContent> - </Select> <Popover open={newFolderOpen} onOpenChange={setNewFolderOpen}> <PopoverTrigger asChild> <Button @@ -544,7 +583,7 @@ export default function LibraryPage() { </PopoverContent> </Popover> <Select value={sortBy} onValueChange={(value) => setSortBy(value as typeof sortBy)}> - <SelectTrigger className="h-9 w-[150px]" aria-label={t('libraryPage.sortBy', 'Sort by')}> + <SelectTrigger className="h-9 w-[180px]" aria-label={t('libraryPage.sortBy', 'Sort by')}> <SelectValue /> </SelectTrigger> <SelectContent> @@ -560,7 +599,7 @@ export default function LibraryPage() { </Select> <AlertDialog> <AlertDialogTrigger asChild> - <Button variant="ghost" className="text-destructive" disabled={isClearing}> + <Button variant="ghost" size="sm" className="text-destructive" disabled={isClearing}> <Trash2 className="h-4 w-4 mr-2" /> {isClearing ? t('libraryPage.clearing') : t('libraryPage.clearAll')} </Button> </AlertDialogTrigger> @@ -571,7 +610,7 @@ export default function LibraryPage() { </AlertDialogHeader> <AlertDialogFooter> <AlertDialogCancel>{t('common.cancel', 'Cancel')}</AlertDialogCancel> - <AlertDialogAction onClick={() => void handleClearLibrary()}> + <AlertDialogAction className={buttonVariants({ variant: 'destructive' })} onClick={() => void handleClearLibrary()}> {t('libraryPage.clearAll')} </AlertDialogAction> </AlertDialogFooter> @@ -580,21 +619,6 @@ export default function LibraryPage() { </div> </div> - <div className="mb-6 grid gap-3 md:grid-cols-3"> - <div className="rounded-lg border bg-card px-4 py-3"> - <p className="text-xs font-medium uppercase text-muted-foreground">{t('libraryPage.totalSources', 'Total sources')}</p> - <p className="mt-1 text-2xl font-semibold">{sourceLibrary.length}</p> - </div> - <div className="rounded-lg border bg-card px-4 py-3"> - <p className="text-xs font-medium uppercase text-muted-foreground">{t('libraryPage.activeSources', 'Active sources')}</p> - <p className="mt-1 text-2xl font-semibold">{activeSourceCount}</p> - </div> - <div className="rounded-lg border bg-card px-4 py-3"> - <p className="text-xs font-medium uppercase text-muted-foreground">{t('libraryPage.syncedFolders', 'Synced folders')}</p> - <p className="mt-1 text-2xl font-semibold">{folders.length}</p> - </div> - </div> - {isAdding && currentDebate && ( <Card className="mb-8 border-primary/20 bg-primary/5"> <CardHeader> @@ -604,6 +628,21 @@ export default function LibraryPage() { </CardTitle> </CardHeader> <CardContent className="space-y-4"> + {/* One folder for whatever this form saves, imported or typed. */} + <div className="space-y-2"> + <Label htmlFor="new-source-folder">{t('libraryPage.folder', 'Folder')}</Label> + <Select value={newSource.folder} onValueChange={(f) => setNewSource({ ...newSource, folder: f })}> + <SelectTrigger id="new-source-folder"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + {['General', ...folders.filter(f => f !== 'General')].map((f) => ( + <SelectItem key={f} value={f}>{getFolderName(f)}</SelectItem> + ))} + </SelectContent> + </Select> + <p className="text-xs text-muted-foreground">{t('libraryPage.newFolderHint', 'Need a new folder? Use the "New folder" button up top.')}</p> + </div> <div className="space-y-2"> <Label htmlFor="source-url">{t('libraryPage.importUrlLabel')}</Label> <div className="flex gap-2"> @@ -624,16 +663,18 @@ export default function LibraryPage() { </div> <div className="space-y-2"> <Label htmlFor="source-file">{t('libraryPage.importFileLabel')}</Label> - <Button asChild type="button" variant="outline" className="relative w-full justify-start overflow-hidden"> - <label htmlFor="source-file" className="cursor-pointer"> - <Upload className="h-4 w-4 mr-2" /> - {t('libraryPage.chooseFile')} + {/* The real input is invisible, so the label shows its focus ring. */} + <Button asChild type="button" variant="outline" className="relative w-full justify-start overflow-hidden has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring has-[:focus-visible]:ring-offset-2"> + <label htmlFor="source-file" className="cursor-pointer" aria-busy={isImportingFile}> + {isImportingFile ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Upload className="h-4 w-4 mr-2" />} + {isImportingFile ? t('libraryPage.readingFile', 'Reading file…') : t('libraryPage.chooseFile')} <input id="source-file" type="file" + disabled={isImportingFile} accept=".txt,.md,.markdown,.csv,.json,.pdf,.docx,text/plain,text/markdown,text/csv,application/json,application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document" className="absolute inset-0 cursor-pointer opacity-0" - onChange={(event) => void handleFileImport(event.target.files?.[0])} + onChange={(event) => void handleFileImport(event.currentTarget)} /> </label> </Button> @@ -670,19 +711,6 @@ export default function LibraryPage() { onChange={(e) => setImportPreview({ ...importPreview, title: e.target.value })} /> </div> - <div className="space-y-2"> - <Label>{t('libraryPage.folder', 'Folder')}</Label> - <Select value={newSource.folder} onValueChange={(f) => setNewSource({ ...newSource, folder: f })}> - <SelectTrigger> - <SelectValue /> - </SelectTrigger> - <SelectContent> - {['General', ...folders.filter(f => f !== 'General')].map((f) => ( - <SelectItem key={f} value={f}>{getFolderName(f)}</SelectItem> - ))} - </SelectContent> - </Select> - </div> <div className="space-y-2"> <Label htmlFor="import-content">{t('libraryPage.contentLabel')}</Label> <Textarea @@ -702,35 +730,37 @@ export default function LibraryPage() { </div> <div className="space-y-2"> - <Label>{t('libraryPage.titleLabel')}</Label> + <Label htmlFor="new-source-title">{t('libraryPage.titleLabel')}</Label> <Input + id="new-source-title" + ref={addTitleRef} placeholder={t('libraryPage.titlePlaceholder')} value={newSource.title} - onChange={(e) => setNewSource({ ...newSource, title: e.target.value })} + aria-invalid={Boolean(addErrors.title)} + aria-describedby={addErrors.title ? 'new-source-title-error' : undefined} + onChange={(e) => { + setNewSource({ ...newSource, title: e.target.value }); + if (addErrors.title) setAddErrors({ ...addErrors, title: undefined }); + }} /> + {addErrors.title && <p id="new-source-title-error" role="alert" className="text-sm text-destructive">{addErrors.title}</p>} </div> <div className="space-y-2"> - <Label>{t('libraryPage.contentLabel')}</Label> + <Label htmlFor="new-source-content">{t('libraryPage.contentLabel')}</Label> <Textarea + id="new-source-content" + ref={addContentRef} placeholder={t('libraryPage.contentPlaceholder')} className="min-h-[120px]" value={newSource.content} - onChange={(e) => setNewSource({ ...newSource, content: e.target.value })} + aria-invalid={Boolean(addErrors.content)} + aria-describedby={addErrors.content ? 'new-source-content-error' : undefined} + onChange={(e) => { + setNewSource({ ...newSource, content: e.target.value }); + if (addErrors.content) setAddErrors({ ...addErrors, content: undefined }); + }} /> - </div> - <div className="space-y-2"> - <Label>{t('libraryPage.folder', 'Folder')}</Label> - <Select value={newSource.folder} onValueChange={(f) => setNewSource({ ...newSource, folder: f })}> - <SelectTrigger> - <SelectValue /> - </SelectTrigger> - <SelectContent> - {['General', ...folders.filter(f => f !== 'General')].map((f) => ( - <SelectItem key={f} value={f}>{getFolderName(f)}</SelectItem> - ))} - </SelectContent> - </Select> - <p className="text-xs text-muted-foreground">{t('libraryPage.newFolderHint', 'Need a new folder? Use the "New folder" button up top.')}</p> + {addErrors.content && <p id="new-source-content-error" role="alert" className="text-sm text-destructive">{addErrors.content}</p>} </div> <Button onClick={() => void handleAdd()} className="w-full">{t('libraryPage.saveToLibrary')}</Button> </CardContent> @@ -742,7 +772,7 @@ export default function LibraryPage() { <CardHeader className="space-y-4"> <div className="flex flex-col justify-between gap-3 sm:flex-row sm:items-center"> <div> - <CardTitle className="text-xl">{t('libraryPage.yourKBTitle')}</CardTitle> + <CardTitle>{t('libraryPage.yourKBTitle')}</CardTitle> </div> <div className="flex items-center gap-2"> <Badge variant="secondary" className="bg-primary/10 text-primary border-primary/20"> @@ -774,13 +804,14 @@ export default function LibraryPage() { </Button> )} </div> - <div className="flex gap-1 overflow-x-auto pb-1"> + <div className="flex gap-1 overflow-x-auto pb-1" role="group" aria-label={t('libraryPage.folder', 'Folder')}> {['all', ...folders].map((folder) => ( <Button key={folder} type="button" variant={selectedFolder === folder ? 'default' : 'outline'} size="sm" + aria-pressed={selectedFolder === folder} className="shrink-0" onClick={() => setSelectedFolder(folder)} > @@ -877,7 +908,8 @@ export default function LibraryPage() { </p> </div> ) : ( - <div className="scrollbar-thin overflow-y-auto pr-1 stagger-enter" style={{ height: SOURCE_LIST_HEIGHT }}> + // Phones scroll the page; only wide screens get a bounded inner list. + <div className="lg:max-h-[600px] lg:overflow-y-auto lg:pr-1"> {visibleSources.map((source) => ( <SourceRow key={source.id} @@ -914,6 +946,37 @@ export default function LibraryPage() { </Card> </div> </div> + <AlertDialog open={mergeTarget !== null} onOpenChange={(open) => { if (!open && !merging) { setMergeTarget(null); setMergeError(false); } }}> + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle>{t('libraryPage.mergeFolderTitle', { name: mergeTarget ?? '', defaultValue: 'Merge into {{name}}?' })}</AlertDialogTitle> + <AlertDialogDescription>{t('libraryPage.folderMergeConfirm', 'A folder with this name already exists. Merge sources into it?')}</AlertDialogDescription> + </AlertDialogHeader> + {mergeError && ( + <p role="alert" className="text-sm text-destructive">{t('libraryPage.mergeFailed', 'Could not merge the folders. Try again.')}</p> + )} + <AlertDialogFooter> + <AlertDialogCancel disabled={merging}>{t('common.cancel', 'Cancel')}</AlertDialogCancel> + <AlertDialogAction + disabled={merging} + onClick={(event) => { + // Stays open while the write is pending and closes only once it lands. + event.preventDefault(); + if (!mergeTarget) return; + setMerging(true); + setMergeError(false); + renameFolderTo(selectedFolder, mergeTarget) + .then(() => setMergeTarget(null)) + .catch(() => setMergeError(true)) + .finally(() => setMerging(false)); + }} + > + {merging && <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />} + {t('libraryPage.mergeFolders', 'Merge folders')} + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> <Sheet open={Boolean(selectedSource)} onOpenChange={(open) => !open && setSelectedSourceId(null)}> <SheetContent side="right" className="flex w-full flex-col overflow-hidden p-0 sm:max-w-xl"> {selectedSource && ( @@ -953,15 +1016,22 @@ export default function LibraryPage() { <Label htmlFor="source-detail-title">{t('libraryPage.titleLabel')}</Label> <Input id="source-detail-title" + ref={draftTitleRef} value={sourceDraft.title} - onChange={(event) => setSourceDraft({ ...sourceDraft, title: event.target.value })} + aria-invalid={Boolean(draftErrors.title)} + aria-describedby={draftErrors.title ? 'source-detail-title-error' : undefined} + onChange={(event) => { + setSourceDraft({ ...sourceDraft, title: event.target.value }); + if (draftErrors.title) setDraftErrors({ ...draftErrors, title: undefined }); + }} /> + {draftErrors.title && <p id="source-detail-title-error" role="alert" className="text-sm text-destructive">{draftErrors.title}</p>} </div> <div className="grid gap-2 sm:grid-cols-2"> <div className="space-y-2"> - <Label>{t('libraryPage.folder', 'Folder')}</Label> + <Label htmlFor="source-detail-folder">{t('libraryPage.folder', 'Folder')}</Label> <Select value={sourceDraft.folder || 'General'} onValueChange={(folder) => setSourceDraft({ ...sourceDraft, folder })}> - <SelectTrigger> + <SelectTrigger id="source-detail-folder"> <SelectValue /> </SelectTrigger> <SelectContent> @@ -972,9 +1042,9 @@ export default function LibraryPage() { </Select> </div> <div className="space-y-2"> - <Label>{t('libraryPage.typeLabel', 'Type')}</Label> + <Label htmlFor="source-detail-type">{t('libraryPage.typeLabel', 'Type')}</Label> <Select value={sourceDraft.type} onValueChange={(type) => setSourceDraft({ ...sourceDraft, type: type as Source['type'] })}> - <SelectTrigger> + <SelectTrigger id="source-detail-type"> <SelectValue /> </SelectTrigger> <SelectContent> @@ -1009,10 +1079,17 @@ export default function LibraryPage() { <Label htmlFor="source-detail-content">{t('libraryPage.contentLabel')}</Label> <Textarea id="source-detail-content" + ref={draftContentRef} className="min-h-[320px] resize-y" value={sourceDraft.content} - onChange={(event) => setSourceDraft({ ...sourceDraft, content: event.target.value })} + aria-invalid={Boolean(draftErrors.content)} + aria-describedby={draftErrors.content ? 'source-detail-content-error' : undefined} + onChange={(event) => { + setSourceDraft({ ...sourceDraft, content: event.target.value }); + if (draftErrors.content) setDraftErrors({ ...draftErrors, content: undefined }); + }} /> + {draftErrors.content && <p id="source-detail-content-error" role="alert" className="text-sm text-destructive">{draftErrors.content}</p>} </div> </div> </div> @@ -1022,26 +1099,10 @@ export default function LibraryPage() { <Copy className="mr-2 h-4 w-4" /> {t('common.copy', 'Copy')} </Button> - <AlertDialog> - <AlertDialogTrigger asChild> - <Button type="button" variant="ghost" className="text-destructive"> - <Trash2 className="mr-2 h-4 w-4" /> - {t('common.delete', 'Delete')} - </Button> - </AlertDialogTrigger> - <AlertDialogContent> - <AlertDialogHeader> - <AlertDialogTitle>{t('libraryPage.deleteSourceTitle', 'Delete source?')}</AlertDialogTitle> - <AlertDialogDescription>{t('libraryPage.deleteSourceDesc', 'This removes the source from your synced library.')}</AlertDialogDescription> - </AlertDialogHeader> - <AlertDialogFooter> - <AlertDialogCancel>{t('common.cancel', 'Cancel')}</AlertDialogCancel> - <AlertDialogAction onClick={() => handleRemoveSource(selectedSource)}> - {t('common.delete', 'Delete')} - </AlertDialogAction> - </AlertDialogFooter> - </AlertDialogContent> - </AlertDialog> + <Button type="button" variant="ghost" className="text-destructive" onClick={() => handleRemoveSource(selectedSource)}> + <Trash2 className="mr-2 h-4 w-4" /> + {t('common.delete', 'Delete')} + </Button> </div> <Button type="button" onClick={() => void handleSaveSourceDraft()}> <Save className="mr-2 h-4 w-4" /> diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx index 827b969f1..cf2931dab 100644 --- a/src/pages/LoginPage.tsx +++ b/src/pages/LoginPage.tsx @@ -1,15 +1,16 @@ import { useEffect, useRef, useState } from 'react'; -import { useLocation, useNavigate } from 'react-router-dom'; +import { Link, useLocation, useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { Sparkles } from 'lucide-react'; +import { ArrowLeft } from 'lucide-react'; import { useAuth } from '@/lib/supabase/AuthProvider'; import { useToast } from '@/hooks/use-toast'; import { consumeWorkspaceInvite } from '@/lib/workspace/invite-handoff'; +import { localizeAuthError } from '@/lib/auth-errors'; type AuthMode = 'email' | 'demo'; type AuthStatus = 'idle' | 'submitting' | 'code-sent' | 'verifying' | 'success' | 'error'; @@ -32,6 +33,8 @@ export default function LoginPage() { const [demoCode, setDemoCode] = useState(''); const [errorMessage, setErrorMessage] = useState(''); const otpInputRef = useRef<HTMLInputElement>(null); + const emailInputRef = useRef<HTMLInputElement>(null); + const demoInputRef = useRef<HTMLInputElement>(null); const navigate = useNavigate(); const location = useLocation(); const { t } = useTranslation(); @@ -46,6 +49,13 @@ export default function LoginPage() { if (authStatus === 'code-sent') otpInputRef.current?.focus(); }, [authStatus]); + // An error is shown under the field it is about, and focus moves there. + useEffect(() => { + if (authStatus !== 'error') return; + const field = authMode === 'demo' ? demoInputRef : emailSent ? otpInputRef : emailInputRef; + field.current?.focus(); + }, [authStatus, authMode, emailSent]); + const handleGoogleSignIn = async () => { setIsRedirecting(true); try { @@ -54,7 +64,7 @@ export default function LoginPage() { setIsRedirecting(false); toast({ title: t('login.failed', 'Could not start sign in'), - description: error instanceof Error ? error.message : t('login.tryAgain', 'Please try again.'), + description: localizeAuthError(error, t, 'login.tryAgain', 'Please try again.'), variant: 'destructive', }); } @@ -86,7 +96,7 @@ export default function LoginPage() { setAuthStatus('code-sent'); } catch (error) { setAuthStatus('error'); - setErrorMessage(error instanceof Error ? error.message : t('login.emailOtpError', 'Could not send code.')); + setErrorMessage(localizeAuthError(error, t, 'login.emailOtpError', 'Could not send code.')); } }; @@ -107,7 +117,7 @@ export default function LoginPage() { setAuthStatus('success'); } catch (error) { setAuthStatus('error'); - setErrorMessage(error instanceof Error ? error.message : t('login.verifyOtpError', 'Could not verify code.')); + setErrorMessage(localizeAuthError(error, t, 'login.verifyOtpError', 'Could not verify code.')); } }; @@ -120,7 +130,7 @@ export default function LoginPage() { setAuthStatus('code-sent'); } catch (error) { setAuthStatus('error'); - setErrorMessage(error instanceof Error ? error.message : t('login.emailOtpError', 'Could not send code.')); + setErrorMessage(localizeAuthError(error, t, 'login.emailOtpError', 'Could not send code.')); } }; @@ -140,18 +150,29 @@ export default function LoginPage() { setAuthStatus('success'); } catch (error) { setAuthStatus('error'); - setErrorMessage(error instanceof Error ? error.message : t('login.demoError', 'Could not sign in with demo code.')); + setErrorMessage(localizeAuthError(error, t, 'login.demoError', 'Could not sign in with demo code.')); } }; const isSubmitting = authStatus === 'submitting' || authStatus === 'verifying'; + const hasError = authStatus === 'error' && Boolean(errorMessage); + const fieldError = (id: string) => hasError && ( + <p id={id} className="text-sm text-destructive" role="alert">{errorMessage}</p> + ); + const describedBy = (hintId: string, errorId: string) => hasError ? `${hintId} ${errorId}` : hintId; return ( - <div className="min-h-screen flex items-center justify-center bg-background px-4"> + <div className="min-h-screen flex flex-col items-center justify-center gap-4 bg-background px-4 py-8"> + <div className="w-full max-w-md"> + <Link to="/" className="inline-flex items-center gap-1.5 rounded-sm text-sm text-muted-foreground transition-colors hover:text-foreground pointer-coarse:min-h-11"> + <ArrowLeft className="h-4 w-4" aria-hidden="true" /> + {t('login.backHome', 'Back to DebateLab')} + </Link> + </div> <Card className="w-full max-w-md"> <CardHeader className="text-center"> - <div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-lg bg-primary"> - <Sparkles className="h-6 w-6 text-primary-foreground" /> + <div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center overflow-hidden rounded-lg bg-white dark:bg-slate-800"> + <img src="/favicon-72.png" alt="" width={48} height={48} className="h-full w-full object-cover" /> </div> {/* The page heading, styled like CardTitle (which renders an h3). */} <h1 className="text-2xl font-semibold leading-none tracking-tight">{t('login.title')}</h1> @@ -194,10 +215,11 @@ export default function LoginPage() { maxLength={6} value={otp} onChange={(event) => setOtp(event.target.value)} - aria-describedby="login-otp-hint" + aria-describedby={describedBy('login-otp-hint', 'login-otp-error')} aria-invalid={authStatus === 'error'} disabled={isSubmitting || authStatus === 'success'} /> + {fieldError('login-otp-error')} <p id="login-otp-hint" className="text-xs text-muted-foreground"> {t('login.codeSentDesc', 'Check your email for the six-digit code.')} </p> @@ -217,6 +239,7 @@ export default function LoginPage() { <div className="space-y-2"> <Label htmlFor="login-email">{t('login.emailLabel', 'Email address')}</Label> <Input + ref={emailInputRef} id="login-email" name="email" type="email" @@ -225,9 +248,10 @@ export default function LoginPage() { placeholder={t('login.emailPlaceholder', 'you@example.com')} value={email} onChange={(event) => setEmail(event.target.value)} - aria-describedby="login-email-hint" + aria-describedby={describedBy('login-email-hint', 'login-email-error')} aria-invalid={authStatus === 'error'} /> + {fieldError('login-email-error')} <p id="login-email-hint" className="text-xs text-muted-foreground"> {t('login.emailHint', 'We\'ll send a six-digit code.')} </p> @@ -243,6 +267,7 @@ export default function LoginPage() { <div className="space-y-2"> <Label htmlFor="login-demo-code">{t('login.demoCodeLabel', 'Demo code')}</Label> <Input + ref={demoInputRef} id="login-demo-code" name="demoCode" type="text" @@ -251,9 +276,10 @@ export default function LoginPage() { maxLength={14} value={demoCode} onChange={(event) => setDemoCode(event.target.value)} - aria-describedby="login-demo-code-hint" + aria-describedby={describedBy('login-demo-code-hint', 'login-demo-code-error')} aria-invalid={authStatus === 'error'} /> + {fieldError('login-demo-code-error')} <p id="login-demo-code-hint" className="text-xs text-muted-foreground"> {t('login.demoCodeHint', 'Enter the code provided by your event host.')} </p> @@ -265,20 +291,15 @@ export default function LoginPage() { </TabsContent> </Tabs> {authStatus === 'code-sent' && ( - <p className="mt-4 text-sm text-green-600" role="status" aria-live="polite"> + <p className="mt-4 text-sm text-success" role="status" aria-live="polite"> {t('login.codeSent', 'Code sent')} </p> )} {authStatus === 'success' && ( - <p className="mt-4 text-sm text-green-600" role="status" aria-live="polite"> + <p className="mt-4 text-sm text-success" role="status" aria-live="polite"> {t('login.signedIn', 'Signed in')} </p> )} - {authStatus === 'error' && errorMessage && ( - <p className="mt-4 text-sm text-destructive" role="alert"> - {errorMessage} - </p> - )} </CardContent> </Card> </div> diff --git a/src/pages/NotFound.tsx b/src/pages/NotFound.tsx index 89d46bd1e..8c5b1e8a8 100644 --- a/src/pages/NotFound.tsx +++ b/src/pages/NotFound.tsx @@ -1,18 +1,21 @@ +import { Link } from "react-router-dom"; import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { PublicPageShell, useHomePath } from "@/components/layout/PublicPageShell"; const NotFound = () => { const { t } = useTranslation(); + const home = useHomePath(); return ( - <div className="flex min-h-screen items-center justify-center bg-muted"> - <div className="text-center"> - <h1 className="mb-4 text-4xl font-bold">{t('notFound.title')}</h1> - <p className="mb-4 text-xl text-muted-foreground">{t('notFound.message')}</p> - <a href="/" className="text-primary underline hover:text-primary/90"> - {t('notFound.link')} - </a> - </div> - </div> + <PublicPageShell className="flex min-h-[60vh] max-w-[65ch] flex-col items-center justify-center text-center"> + <h1 className="mb-4 text-4xl font-bold tracking-tight">{t('notFound.title')}</h1> + <p className="mb-6 text-xl text-muted-foreground">{t('notFound.message')}</p> + {/* An in-app link: no full reload through the startup shell, and signed-in users stay in the app. */} + <Button asChild> + <Link to={home}>{t('notFound.link')}</Link> + </Button> + </PublicPageShell> ); }; diff --git a/src/pages/PrivacyPage.tsx b/src/pages/PrivacyPage.tsx index 717e4337c..20fac1465 100644 --- a/src/pages/PrivacyPage.tsx +++ b/src/pages/PrivacyPage.tsx @@ -1,4 +1,5 @@ import { useTranslation } from 'react-i18next'; +import { PublicPageShell } from '@/components/layout/PublicPageShell'; export default function PrivacyPage() { const { t } = useTranslation(); @@ -6,12 +7,11 @@ export default function PrivacyPage() { const sections = Array.from({ length: 10 }, (_, i) => i + 1); return ( - <div className="min-h-screen bg-background"> - <main className="container mx-auto max-w-3xl px-4 py-10"> + <PublicPageShell className="max-w-[65ch]"> <h1 className="text-3xl font-bold tracking-tight text-foreground">{t('privacyPage.title')}</h1> <p className="mt-2 text-sm text-muted-foreground">{t('privacyPage.lastUpdated')}: {t('privacyPage.date')}</p> - <div className="mt-8 space-y-6 text-sm leading-6 text-foreground"> + <div className="mt-8 space-y-6 text-base leading-7 text-foreground"> {sections.map((n) => ( <section key={n}> <h2 className="text-lg font-semibold">{n}. {t(`privacyPage.s${n}`)}</h2> @@ -19,7 +19,6 @@ export default function PrivacyPage() { </section> ))} </div> - </main> - </div> + </PublicPageShell> ); } diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index e020fbb15..0cc2e101e 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -31,14 +31,16 @@ import { EnvironmentSwitcher } from '@/components/features/settings/EnvironmentS import { formatEuropeanDate } from '@/lib/date-format'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; +import { PageHeader } from '@/components/layout/PageHeader'; import { APP_LANGUAGES, isAppLanguage } from '@/lib/languages'; +// Tinted badges keep their label readable (white on amber or green was about 2:1). const tierLabels: Record<string, { labelKey: string; color: string }> = { - trial: { labelKey: 'settings.tierTrial', color: 'bg-blue-500' }, - pro: { labelKey: 'settings.tierPro', color: 'bg-amber-500' }, - legacy_free: { labelKey: 'settings.tierLegacyFree', color: 'bg-green-500' }, - expired: { labelKey: 'settings.tierExpired', color: 'bg-red-500' }, + trial: { labelKey: 'settings.tierTrial', color: 'border-blue-500/30 bg-blue-500/15 text-blue-800 dark:text-blue-300' }, + pro: { labelKey: 'settings.tierPro', color: 'border-amber-500/30 bg-amber-500/15 text-amber-800 dark:text-amber-300' }, + legacy_free: { labelKey: 'settings.tierLegacyFree', color: 'border-success/30 bg-success/15 text-success' }, + expired: { labelKey: 'settings.tierExpired', color: 'border-destructive/30 bg-destructive/10 text-destructive' }, }; export default function SettingsPage() { @@ -57,6 +59,8 @@ export default function SettingsPage() { const [deleteAccountOpen, setDeleteAccountOpen] = useState(false); const [accountConfirmation, setAccountConfirmation] = useState(''); const [deletingAccount, setDeletingAccount] = useState(false); + const [deletingData, setDeletingData] = useState(false); + const [signingOut, setSigningOut] = useState(false); const navigate = useNavigate(); // Must match deletionConfirmation in supabase/functions/account/index.ts. const expectedAccountConfirmation = user?.email || 'DELETE'; @@ -92,6 +96,8 @@ export default function SettingsPage() { return; } + if (deletingData) return; + setDeletingData(true); void clearAllData().then(() => { clearLocalData(); setDeleteDialogOpen(false); @@ -100,7 +106,7 @@ export default function SettingsPage() { }).catch((error) => { console.error('Failed to delete data:', error); toast({ title: t('settings.deleteFailed'), description: t('settings.deleteFailedDesc'), variant: "destructive" }); - }); + }).finally(() => setDeletingData(false)); }; const handleDownloadData = async () => { @@ -141,10 +147,13 @@ export default function SettingsPage() { }; const handleSignOut = () => { + if (signingOut) return; + setSigningOut(true); void signOut() .then(() => resetSparringLocalData()) .catch((error) => { console.error('Failed to sign out:', error); + setSigningOut(false); toast({ title: t('common.error', 'Error'), description: t('settings.signOutFailed', 'Could not sign out. Please try again.'), variant: 'destructive' }); }); }; @@ -179,14 +188,7 @@ export default function SettingsPage() { return ( <div className="container py-8 px-4"> <div className="max-w-4xl mx-auto"> - <div className="mb-8"> - <div className="flex items-center gap-3 mb-2"> - <div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10"> - <Settings className="h-5 w-5 text-primary" /> - </div> - <h1 className="text-3xl font-bold tracking-tight text-foreground">{t('settings.title')}</h1> - </div> - </div> + <PageHeader className="mb-8" icon={<Settings />} title={t('settings.title')} /> {/* Subscription Status */} <Card className="mb-8 border-primary/20"> @@ -203,7 +205,7 @@ export default function SettingsPage() { <> <div className="flex items-center justify-between"> <div className="flex items-center gap-3"> - <Badge className={`${tierInfo.color} text-white`}>{t(tierInfo.labelKey)}</Badge> + <Badge variant="outline" className={tierInfo.color}>{t(tierInfo.labelKey)}</Badge> {subscription.tier === 'trial' && subscription.trialEndsAt && ( <span className="text-sm text-muted-foreground"> {t('settings.ends')} {formatEuropeanDate(subscription.trialEndsAt)} @@ -304,8 +306,8 @@ export default function SettingsPage() { <p className="text-sm text-muted-foreground">{t('settings.signedInAs')}</p> <p className="font-medium break-all">{user?.email || t('settings.unknownUser')}</p> </div> - <Button variant="outline" className="shrink-0" onClick={handleSignOut}> - {t('settings.signOut')} + <Button variant="outline" className="shrink-0" onClick={handleSignOut} disabled={signingOut}> + {signingOut ? t('settings.signingOut', 'Signing out…') : t('settings.signOut')} </Button> </div> <div className="flex flex-col gap-3 border-t border-primary/10 pt-4 sm:flex-row sm:items-center sm:justify-between"> @@ -321,9 +323,7 @@ export default function SettingsPage() { </CardContent> </Card> - {user && adminRole.isPending && ( - <div role="status" aria-label={t('settings.loading')} className="my-8 h-40 animate-pulse rounded-lg bg-muted motion-reduce:animate-none" /> - )} + {/* Nothing renders while the admin check runs: almost nobody is an admin. */} {user && adminRole.isError && ( <div role="alert" className="my-8 flex items-center gap-3 text-sm"> <p>{t('settings.adminAccessFailed', 'Could not check admin access.')}</p> @@ -383,7 +383,7 @@ export default function SettingsPage() { onOpenChange={setPricingOpen} onPromoRedeemed={() => void subscription.refresh()} /> - <Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}> + <Dialog open={deleteDialogOpen} onOpenChange={(open) => { if (!deletingData) setDeleteDialogOpen(open); }}> <DialogContent> <DialogHeader> <DialogTitle>{t('settings.deleteDialogTitle')}</DialogTitle> @@ -399,12 +399,13 @@ export default function SettingsPage() { onChange={(event) => setDeleteEmail(event.target.value)} onKeyDown={(e) => { if (e.key === 'Enter' && deleteEmail === user?.email) handleDeleteAllData(); }} placeholder={user?.email || ''} + disabled={deletingData} /> </div> <DialogFooter> - <Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>{t('common.cancel', 'Cancel')}</Button> - <Button variant="destructive" onClick={handleDeleteAllData} disabled={deleteEmail !== user?.email}> - {t('settings.deleteAllData')} + <Button variant="outline" onClick={() => setDeleteDialogOpen(false)} disabled={deletingData}>{t('common.cancel', 'Cancel')}</Button> + <Button variant="destructive" onClick={handleDeleteAllData} disabled={deleteEmail !== user?.email || deletingData}> + {deletingData ? t('settings.deletingData', 'Deleting…') : t('settings.deleteAllData')} </Button> </DialogFooter> </DialogContent> diff --git a/src/pages/SharedPage.tsx b/src/pages/SharedPage.tsx index ca5f83f8b..955e5bdfa 100644 --- a/src/pages/SharedPage.tsx +++ b/src/pages/SharedPage.tsx @@ -1,13 +1,20 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useState, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; -import { useParams, useNavigate } from 'react-router-dom'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Link, useParams } from 'react-router-dom'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; -import { useToast } from '@/hooks/use-toast'; import { supabase } from '@/lib/supabase/client'; -import { Loader2, Download, ExternalLink, Share2 } from 'lucide-react'; +import { Loader2, Share2 } from 'lucide-react'; import { formatEuropeanDate } from '@/lib/date-format'; +import { PublicPageShell, useHomePath } from '@/components/layout/PublicPageShell'; +import { GenerateOutputSchema } from '@/lib/ai/schemas/generate'; +import { AttackOutputSchema } from '@/lib/ai/schemas/attack'; +import { BrainstormOutputSchema } from '@/lib/ai/schemas/brainstorm'; +import { FactoidsOutputSchema } from '@/lib/ai/schemas/factoids'; +import { GenerateResults } from '@/components/features/generate/GenerateResults'; +import { AttackResults } from '@/components/features/attack/AttackResults'; +import { BrainstormResults } from '@/components/features/brainstorm/BrainstormResults'; +import { FactoidsResults } from '@/components/features/factoids/FactoidsResults'; interface SharedItem { id: string; @@ -19,15 +26,69 @@ interface SharedItem { view_count: number; } +/** + * Shared data is public input, so it is checked against the tool's schema before the + * tool's own result view renders it. Anything else falls back to plain readable text. + */ +function renderSharedResult(item: SharedItem): ReactNode | null { + switch (item.item_type) { + case 'case': + case 'generate': { + const parsed = GenerateOutputSchema.safeParse(item.item_data); + return parsed.success ? <GenerateResults result={parsed.data} /> : null; + } + case 'attack': { + const parsed = AttackOutputSchema.safeParse(item.item_data); + return parsed.success ? <AttackResults result={parsed.data} /> : null; + } + case 'brainstorm': { + const parsed = BrainstormOutputSchema.safeParse(item.item_data); + return parsed.success ? <BrainstormResults result={parsed.data} /> : null; + } + case 'factoids': { + const parsed = FactoidsOutputSchema.safeParse(item.item_data); + return parsed.success ? <FactoidsResults result={parsed.data} /> : null; + } + default: + return null; + } +} + +/** Plain text for any shape we don't have a view for: labels and values, never raw JSON. */ +function PlainValue({ value }: { value: unknown }): ReactNode { + if (value === null || value === undefined || value === '') return null; + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return <p className="whitespace-pre-wrap break-words">{String(value)}</p>; + } + if (Array.isArray(value)) { + return ( + <ul className="list-disc space-y-2 pl-5"> + {value.map((entry, index) => <li key={index}><PlainValue value={entry} /></li>)} + </ul> + ); + } + if (typeof value === 'object') { + return ( + <dl className="space-y-3"> + {Object.entries(value as Record<string, unknown>).map(([key, entry]) => ( + <div key={key}> + <dt className="text-sm font-semibold capitalize text-muted-foreground">{key.replace(/([a-z])([A-Z])/g, '$1 $2')}</dt> + <dd><PlainValue value={entry} /></dd> + </div> + ))} + </dl> + ); + } + return null; +} + export default function SharedPage() { const { t } = useTranslation(); const { code } = useParams<{ code: string }>(); - const navigate = useNavigate(); + const home = useHomePath(); const [loading, setLoading] = useState(true); const [item, setItem] = useState<SharedItem | null>(null); const [error, setError] = useState<string | null>(null); - const { toast } = useToast(); - useEffect(() => { const loadSharedItem = async () => { @@ -50,7 +111,7 @@ export default function SharedPage() { } setItem(data as SharedItem); - } catch (err) { + } catch { setError(t('sharedPage.loadFailed')); } finally { setLoading(false); @@ -60,103 +121,49 @@ export default function SharedPage() { void loadSharedItem(); }, [code, t]); - const handleImport = async () => { - if (!item) return; - - try { - const { data: { user } } = await supabase.auth.getUser(); - - if (!user) { - toast({ - title: t('sharedPage.loginRequired'), - description: t('sharedPage.loginRequiredDesc'), - variant: 'default', - }); - navigate('/login'); - return; - } - - toast({ - title: t('sharedPage.importNotAvailable'), - description: t('sharedPage.importNotAvailableDesc'), - }); - } catch (err) { - toast({ - title: t('common.error'), - description: t('sharedPage.importFailed'), - variant: 'destructive', - }); - } - }; - if (loading) { return ( - <div className="min-h-screen flex items-center justify-center bg-background"> - <Loader2 className="h-8 w-8 animate-spin text-primary" /> - </div> + <PublicPageShell className="flex min-h-[50vh] items-center justify-center"> + <Loader2 className="h-8 w-8 animate-spin text-primary" aria-label={t('common.loading', 'Loading')} /> + </PublicPageShell> ); } if (error || !item) { return ( - <div className="min-h-screen flex items-center justify-center bg-background p-4"> - <Card className="max-w-md w-full"> - <CardHeader> - <CardTitle className="flex items-center gap-2"> - <Share2 className="h-5 w-5" /> - {t('sharedPage.title')} - </CardTitle> - <CardDescription>{error || t('sharedPage.notFound')}</CardDescription> - </CardHeader> - <CardContent> - <Button onClick={() => navigate('/app')} className="w-full"> - {t('sharedPage.goToApp')} - </Button> - </CardContent> - </Card> - </div> + <PublicPageShell className="max-w-[65ch] text-center"> + <Share2 className="mx-auto mb-4 h-8 w-8 text-muted-foreground" aria-hidden="true" /> + <h1 className="text-2xl font-bold tracking-tight">{t('sharedPage.title')}</h1> + <p className="mt-2 text-muted-foreground">{error || t('sharedPage.notFound')}</p> + <Button asChild className="mt-6"> + <Link to={home}>{t('sharedPage.goToApp')}</Link> + </Button> + </PublicPageShell> ); } - return ( - <div className="min-h-screen bg-background p-4"> - <div className="max-w-4xl mx-auto"> - <Card> - <CardHeader> - <div className="flex items-center justify-between"> - <div> - <CardTitle className="flex items-center gap-2"> - <Share2 className="h-5 w-5 text-primary" /> - {item.title || t('sharedPage.title')} - </CardTitle> - <CardDescription className="mt-1"> - <Badge variant="secondary" className="mr-2">{item.item_type}</Badge> - {t('sharedPage.sharedOn')} {formatEuropeanDate(item.created_at)} - <span className="text-muted-foreground ml-2">• {item.view_count} {t('sharedPage.views')}</span> - </CardDescription> - </div> - <Button onClick={handleImport} className="gap-2"> - <Download className="h-4 w-4" /> - {t('sharedPage.importToAccount')} - </Button> - </div> - </CardHeader> - <CardContent> - <div className="bg-muted/50 rounded-lg p-4 overflow-auto max-h-[600px]"> - <pre className="text-sm whitespace-pre-wrap"> - {JSON.stringify(item.item_data, null, 2)} - </pre> - </div> - </CardContent> - </Card> + const result = renderSharedResult(item); - <div className="mt-4 text-center"> - <Button variant="outline" onClick={() => navigate('/app')} className="gap-2"> - <ExternalLink className="h-4 w-4" /> - {t('sharedPage.openApp')} - </Button> + return ( + <PublicPageShell className="max-w-4xl"> + <header className="mb-6 space-y-2"> + <h1 className="break-words text-3xl font-bold leading-tight tracking-tight">{item.title || t('sharedPage.title')}</h1> + <p className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground"> + <Badge variant="secondary">{item.item_type}</Badge> + {t('sharedPage.sharedOn')} {formatEuropeanDate(item.created_at)} + <span>• {item.view_count} {t('sharedPage.views')}</span> + </p> + </header> + {result ?? ( + <div className="max-w-[65ch] text-base leading-7"> + <PlainValue value={item.item_data} /> </div> + )} + <div className="mt-8"> + <Button asChild variant="outline"> + <Link to={home}>{t('sharedPage.openApp')}</Link> + </Button> </div> - </div> + </PublicPageShell> ); } diff --git a/src/pages/SourcesPage.tsx b/src/pages/SourcesPage.tsx index 32df6075f..a2f81fc4b 100644 --- a/src/pages/SourcesPage.tsx +++ b/src/pages/SourcesPage.tsx @@ -1,4 +1,5 @@ import { useTranslation } from 'react-i18next'; +import { PublicPageShell } from '@/components/layout/PublicPageShell'; export default function SourcesPage() { const { t } = useTranslation(); @@ -126,11 +127,10 @@ export default function SourcesPage() { ]; return ( - <div className="min-h-screen bg-background"> - <main className="container mx-auto max-w-3xl px-4 py-10"> + <PublicPageShell className="max-w-[65ch]"> <h1 className="text-3xl font-bold tracking-tight text-foreground">{t('sourcesPage.title')}</h1> - <div className="mt-8 space-y-6 text-sm leading-6 text-foreground"> + <div className="mt-8 space-y-6 text-base leading-7 text-foreground"> <section> <p className="text-muted-foreground"> {t('sourcesPage.intro')} @@ -158,7 +158,6 @@ export default function SourcesPage() { </ol> </section> </div> - </main> - </div> + </PublicPageShell> ); } diff --git a/src/pages/TermsPage.tsx b/src/pages/TermsPage.tsx index d3227b6a3..462f0f2b6 100644 --- a/src/pages/TermsPage.tsx +++ b/src/pages/TermsPage.tsx @@ -1,4 +1,5 @@ import { useTranslation } from 'react-i18next'; +import { PublicPageShell } from '@/components/layout/PublicPageShell'; export default function TermsPage() { const { t } = useTranslation(); @@ -6,12 +7,11 @@ export default function TermsPage() { const sections = Array.from({ length: 10 }, (_, i) => i + 1); return ( - <div className="min-h-screen bg-background"> - <main className="container mx-auto max-w-3xl px-4 py-10"> + <PublicPageShell className="max-w-[65ch]"> <h1 className="text-3xl font-bold tracking-tight text-foreground">{t('termsPage.title')}</h1> <p className="mt-2 text-sm text-muted-foreground">{t('termsPage.lastUpdated')}: {t('termsPage.date')}</p> - <div className="mt-8 space-y-6 text-sm leading-6 text-foreground"> + <div className="mt-8 space-y-6 text-base leading-7 text-foreground"> {sections.map((n) => ( <section key={n}> <h2 className="text-lg font-semibold">{n}. {t(`termsPage.s${n}`)}</h2> @@ -19,7 +19,6 @@ export default function TermsPage() { </section> ))} </div> - </main> - </div> + </PublicPageShell> ); } diff --git a/src/test/debate/debate-gates.test.tsx b/src/test/debate/debate-gates.test.tsx index 923172e5f..f15d25beb 100644 --- a/src/test/debate/debate-gates.test.tsx +++ b/src/test/debate/debate-gates.test.tsx @@ -30,9 +30,14 @@ describe('five-day Current Debate gate', () => { expect(dialog).toHaveTextContent('This House would ban homework'); const buttons = Array.from(dialog.querySelectorAll('button')).map((button) => button.textContent); expect(buttons).toEqual(['Switch debate', 'Start new debate', 'Continue']); - // Escape does not dismiss it. - fireEvent.keyDown(dialog, { key: 'Escape' }); - expect(screen.getByRole('alertdialog')).toBeInTheDocument(); + }); + + it('treats Escape as Continue, so the gate never traps the user', async () => { + seedCurrentDebate({ lastActivityAt: Date.now() - 6 * DAY }); + renderGate(); + fireEvent.keyDown(screen.getByRole('alertdialog'), { key: 'Escape' }); + await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull()); + expect(Date.now() - useDebateStore.getState().debates[0].lastActivityAt).toBeLessThan(1000); }); it('stays out of the way before five days and before the server state loads', () => { diff --git a/tailwind.config.ts b/tailwind.config.ts index 4101b9325..edcd2fa97 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -59,7 +59,8 @@ export default { } }, transitionDuration: { - '120': '120ms' + '120': '120ms', + '180': '180ms' }, transitionTimingFunction: { 'out-strong': 'cubic-bezier(0.23, 1, 0.32, 1)',