diff --git a/e2e/tests/session-guidance.spec.ts b/e2e/tests/session-guidance.spec.ts new file mode 100644 index 00000000..fa2f83d8 --- /dev/null +++ b/e2e/tests/session-guidance.spec.ts @@ -0,0 +1,175 @@ +import { expect, stackTest } from '../fixtures/stack'; +import { loginAttendeeWithTicket } from '../fixtures/auth'; +import { SESSION_ES, TICKETS } from '../fixtures/test-data'; +import { requireDirectDb, withSessionStatus } from '../fixtures/db'; +import { + expectMediaContinuity, + installMediaProbe, + mediaProbeSnapshot, +} from '../helpers/media-probe'; + +/** + * UX-COPY-01 (#142): the listening guidance end to end, in real browsers + * against the real stack. + * + * Test 1 proves the pre-room surface: while the doors are closed the + * attendee sees the guidance disclosure next to the waiting card, opens it, + * and reads the full guidance (intention, volume, balance, camera/mic). + * + * Test 2 is the media-continuity guard from the issue: opening and closing + * the guidance inside the live room must not disturb the audio/scene + * pipeline. It reuses the TAP media probe and skips precisely when LiveKit + * is unreachable, like the canonical continuity suite. It also proves the + * disclosure sits next to the volume controls without overlapping them. + */ + +const LIVEKIT_URL = process.env.E2E_LIVEKIT_URL ?? 'ws://localhost:7880'; + +const ATTENDEE_A = { + name: 'E2E Attendee', + email: 'e2e.attendee@altermundi.net', + code: TICKETS.esIssuedA, +} as const; + +async function livekitReachable(): Promise { + const httpUrl = LIVEKIT_URL.replace(/^ws/, 'http'); + try { + const response = await fetch(httpUrl, { signal: AbortSignal.timeout(3000) }); + return response.ok; + } catch { + return false; + } +} + +stackTest.describe('session listening guidance (#142)', () => { + stackTest('waiting attendee can read the guidance before the doors open', async ({ + browser, + }, testInfo) => { + stackTest.slow(); + const db = requireDirectDb(testInfo); + // Doors closed: the entry gate holds the attendee in the WAITING + // surface, where the guidance lives next to the waiting card. + await withSessionStatus(db, SESSION_ES.id, 'SCHEDULED', async () => { + const context = await browser.newContext(); + const page = await context.newPage(); + try { + await loginAttendeeWithTicket(page, ATTENDEE_A); + await page.waitForURL(`**/session/${SESSION_ES.id}`); + + const guidance = page.getByTestId('session-guidance'); + await expect(guidance).toBeVisible({ timeout: 30_000 }); + + const toggle = guidance.getByRole('button'); + await expect(toggle).toHaveAttribute('aria-expanded', 'false'); + // The label is always visible text, never an icon-only control. + await expect(toggle).toContainText(/Cómo funciona la escucha|How listening works/); + + await toggle.click(); + await expect(toggle).toHaveAttribute('aria-expanded', 'true'); + await expect( + guidance.getByText(/buscar una pregunta|look for a question/), + ).toBeVisible(); + await expect( + guidance.getByText(/volumen general controla|Overall volume controls/), + ).toBeVisible(); + await expect( + guidance.getByText(/balance elige|balance chooses/), + ).toBeVisible(); + await expect( + guidance.getByText(/Apagar la cámara no apaga|Turning off your camera/), + ).toBeVisible(); + + // Keyboard path: close with Enter, focus stays on the toggle. + await toggle.focus(); + await page.keyboard.press('Enter'); + await expect(toggle).toHaveAttribute('aria-expanded', 'false'); + await expect(toggle).toBeFocused(); + } finally { + await context.close(); + } + }); + }); + + stackTest('opening the guidance in the room never disturbs the audio/scene pipeline', async ({ + browser, + }, testInfo) => { + testInfo.skip( + !(await livekitReachable()), + `LiveKit not reachable at ${LIVEKIT_URL} — start the dev server (see e2e/README.md) or set E2E_LIVEKIT_URL`, + ); + stackTest.slow(); + const db = requireDirectDb(testInfo); + await withSessionStatus(db, SESSION_ES.id, 'LIVE', async () => { + const context = await browser.newContext(); + const page = await context.newPage(); + try { + await installMediaProbe(page); + await loginAttendeeWithTicket(page, ATTENDEE_A); + await page.waitForURL(`**/session/${SESSION_ES.id}`); + await expect(page.getByTestId('connection-state')).toHaveAttribute( + 'data-state', + 'connected', + { timeout: 30_000 }, + ); + await page.getByRole('button', { name: /Start audio|Iniciar audio/i }).click(); + + // Baseline: let the media pipeline settle before touching the + // guidance (same stable-read discipline as the canonical + // continuity suite: counters frozen for 4 consecutive reads). + let baseline = await mediaProbeSnapshot(page); + let stableReads = 0; + for (let attempt = 0; attempt < 20 && stableReads < 4; attempt += 1) { + await page.waitForTimeout(250); + const current = await mediaProbeSnapshot(page); + const unchanged = + current.audioElements === baseline.audioElements && + current.videoElements === baseline.videoElements && + current.playCalls === baseline.playCalls && + current.mediaElementsAttached === baseline.mediaElementsAttached && + current.mediaElementsRemoved === baseline.mediaElementsRemoved; + stableReads = unchanged ? stableReads + 1 : 0; + baseline = current; + } + + const guidance = page.getByTestId('session-guidance'); + const toggle = guidance.getByRole('button'); + + // The disclosure sits right above the volume controls it + // explains, without overlapping them or the scene. + const toggleBox = await toggle.boundingBox(); + const volumeBox = await page.locator('#room-master-volume').boundingBox(); + expect(toggleBox, 'guidance toggle has no layout box').not.toBeNull(); + expect(volumeBox, 'master volume has no layout box').not.toBeNull(); + expect(toggleBox!.y + toggleBox!.height).toBeLessThanOrEqual(volumeBox!.y + 2); + expect(toggleBox!.x).toBeGreaterThanOrEqual(0); + expect(toggleBox!.x + toggleBox!.width).toBeLessThanOrEqual( + page.viewportSize()!.width + 1, + ); + + // Exercise the disclosure: open, read, close. + await expect(toggle).toHaveAttribute('aria-expanded', 'false'); + await toggle.click(); + await expect(toggle).toHaveAttribute('aria-expanded', 'true'); + await expect( + guidance.getByText(/Apagar la cámara no apaga|Turning off your camera/), + ).toBeVisible(); + await page.waitForTimeout(500); + await toggle.click(); + await expect(toggle).toHaveAttribute('aria-expanded', 'false'); + await page.waitForTimeout(2_500); + + const afterGuidance = await mediaProbeSnapshot(page); + expectMediaContinuity(baseline, afterGuidance, { + // Headless Firefox keeps LiveKit's global autoplay-unlock + // listener active and retries resume() on every user gesture, + // even while sockets, peers, media elements and play() stay + // untouched. That browser behavior cannot be attributed to + // this disclosure; all structural media invariants remain strict. + ignoreAmbientAudioContextResumes: testInfo.project.name === 'firefox', + }); + } finally { + await context.close(); + } + }); + }); +}); diff --git a/e2e/tests/visual.spec.ts-snapshots/attendee-audio-prompt-w390-linux.png b/e2e/tests/visual.spec.ts-snapshots/attendee-audio-prompt-w390-linux.png index a266e347..ecb2afcc 100644 Binary files a/e2e/tests/visual.spec.ts-snapshots/attendee-audio-prompt-w390-linux.png and b/e2e/tests/visual.spec.ts-snapshots/attendee-audio-prompt-w390-linux.png differ diff --git a/src/app/session/[id]/page.tsx b/src/app/session/[id]/page.tsx index be6d5891..051f5e6d 100644 --- a/src/app/session/[id]/page.tsx +++ b/src/app/session/[id]/page.tsx @@ -17,6 +17,7 @@ import { AudioProvider, useAudio } from "@/context/AudioContext"; import { useLocale } from "@/context/LocaleContext"; import HandRaiseButton from "@/components/session/HandRaiseButton"; import SessionContributions from "@/components/session/SessionContributions"; +import SessionGuidance from "@/components/session/SessionGuidance"; import StageLayout, { type StagePublisherView } from "@/components/session/StageLayout"; import ThumbnailSender from "@/components/session/ThumbnailSender"; import ThumbnailTapestry from "@/components/session/ThumbnailTapestry"; @@ -1068,6 +1069,11 @@ function SessionRoom() { + {/* Listening guidance: a quiet disclosure above the + controls it explains. Presentational only — opening it + never remounts the Room or touches audio state. */} + + {/* Volume + Mix controls */}
@@ -1369,7 +1375,7 @@ function SessionEntryGate({ sessionId }: { sessionId: string }) { }).format(new Date(entry.session.scheduledAt)); return (
-
+

@@ -1390,6 +1396,9 @@ function SessionEntryGate({ sessionId }: { sessionId: string }) {

)}
+ {/* Guidance waits outside the live region: the disclosure + toggling must not be announced as a door-state change. */} +
); diff --git a/src/components/session/SessionGuidance.tsx b/src/components/session/SessionGuidance.tsx new file mode 100644 index 00000000..3df459fe --- /dev/null +++ b/src/components/session/SessionGuidance.tsx @@ -0,0 +1,64 @@ +"use client"; + +import { useId, useState } from "react"; +import type { Messages } from "@/lib/i18n"; + +type SessionGuidanceCopy = Messages["session"]["guidance"]; + +interface SessionGuidanceProps { + copy: SessionGuidanceCopy; + className?: string; +} + +/** + * Brief, bilingual-by-locale listener guidance: why the room invites a + * question, what the master volume and Beacon/Session balance do, and the + * independence of camera and microphone. Purely presentational — it owns no + * media, network, or room state, so opening or closing it can never remount + * the Room, touch an AudioContext, or call a device handler. The disclosure + * starts closed and stays quiet next to the scene; the native + +
+ ); +} diff --git a/src/components/session/__tests__/SessionGuidance.test.tsx b/src/components/session/__tests__/SessionGuidance.test.tsx new file mode 100644 index 00000000..2f95cbae --- /dev/null +++ b/src/components/session/__tests__/SessionGuidance.test.tsx @@ -0,0 +1,137 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it } from 'vitest'; +import SessionGuidance from '@/components/session/SessionGuidance'; +import { messages } from '@/lib/i18n'; + +describe('SessionGuidance', () => { + afterEach(() => { + cleanup(); + }); + + it('renders the full Spanish guidance behind a closed disclosure', () => { + render(); + + const toggle = screen.getByRole('button', { name: 'Cómo funciona la escucha' }); + expect(toggle).toHaveAttribute('aria-expanded', 'false'); + + fireEvent.click(toggle); + expect(toggle).toHaveAttribute('aria-expanded', 'true'); + + const guidance = messages.es.session.guidance; + expect(screen.getByText(guidance.intention)).toBeVisible(); + expect(screen.getByText(guidance.volume)).toBeVisible(); + expect(screen.getByText(guidance.balance)).toBeVisible(); + expect(screen.getByText(guidance.balanceFullBeacon)).toBeVisible(); + expect(screen.getByText(guidance.cameraMic)).toBeVisible(); + expect(screen.getByText(guidance.control)).toBeVisible(); + }); + + it('renders the full English guidance behind a closed disclosure', () => { + render(); + + const toggle = screen.getByRole('button', { name: 'How listening works' }); + fireEvent.click(toggle); + + const guidance = messages.en.session.guidance; + expect(screen.getByText(guidance.intention)).toBeVisible(); + expect(screen.getByText(guidance.volume)).toBeVisible(); + expect(screen.getByText(guidance.balance)).toBeVisible(); + expect(screen.getByText(guidance.balanceFullBeacon)).toBeVisible(); + expect(screen.getByText(guidance.cameraMic)).toBeVisible(); + expect(screen.getByText(guidance.control)).toBeVisible(); + }); + + it('starts closed and hides the panel from sighted and assistive users', () => { + render(); + + const toggle = screen.getByRole('button'); + const panel = document.getElementById(toggle.getAttribute('aria-controls') ?? ''); + expect(panel).not.toBeNull(); + expect(panel).not.toBeVisible(); + expect(screen.queryByText(messages.es.session.guidance.intention)).not.toBeVisible(); + }); + + it('wires aria-expanded and aria-controls to a real panel', () => { + render(); + + const toggle = screen.getByRole('button'); + const controls = toggle.getAttribute('aria-controls'); + expect(controls).toBeTruthy(); + const panel = document.getElementById(controls as string); + expect(panel).not.toBeNull(); + expect(panel).toHaveAttribute('hidden'); + + fireEvent.click(toggle); + expect(toggle).toHaveAttribute('aria-expanded', 'true'); + expect(panel).not.toHaveAttribute('hidden'); + + fireEvent.click(toggle); + expect(toggle).toHaveAttribute('aria-expanded', 'false'); + expect(panel).toHaveAttribute('hidden'); + }); + + it('toggles with keyboard and keeps focus on the button', () => { + render(); + + const toggle = screen.getByRole('button'); + toggle.focus(); + expect(toggle).toHaveFocus(); + + fireEvent.keyDown(toggle, { key: 'Enter', code: 'Enter' }); + fireEvent.keyUp(toggle, { key: 'Enter', code: 'Enter' }); + fireEvent.click(toggle); + expect(toggle).toHaveAttribute('aria-expanded', 'true'); + expect(toggle).toHaveFocus(); + + fireEvent.keyDown(toggle, { key: ' ', code: 'Space' }); + fireEvent.keyUp(toggle, { key: ' ', code: 'Space' }); + fireEvent.click(toggle); + expect(toggle).toHaveAttribute('aria-expanded', 'false'); + expect(toggle).toHaveFocus(); + }); + + it('always shows a readable text label, never an icon-only control', () => { + render(); + + const toggle = screen.getByRole('button'); + expect(toggle.textContent).toContain(messages.es.session.guidance.label); + const icon = toggle.querySelector('svg'); + expect(icon).not.toBeNull(); + expect(icon).toHaveAttribute('aria-hidden', 'true'); + }); + + it('uses motion-safe classes only, so reduced-motion users see no animation', () => { + render(); + + const root = screen.getByTestId('session-guidance'); + const animated = root.querySelectorAll('[class*="motion-safe"]'); + expect(animated.length).toBeGreaterThan(0); + animated.forEach((element) => { + expect(element.getAttribute('class') ?? '').toContain('motion-reduce'); + }); + // No JS-driven animation timers: the panel toggles via the hidden + // attribute alone, which prefers-reduced-motion cannot aggravate. + fireEvent.click(screen.getByRole('button')); + expect(screen.getByText(messages.en.session.guidance.intention)).toBeVisible(); + }); + + it('meets the 44px touch target and never truncates long copy', () => { + render(); + + const toggle = screen.getByRole('button'); + expect(toggle.className).toContain('min-h-11'); + + fireEvent.click(toggle); + // Full sentences render untruncated: the panel applies no clamping or + // ellipsis utilities, so each complete sentence is present verbatim. + const panel = document.getElementById(toggle.getAttribute('aria-controls') ?? ''); + expect(panel?.className).not.toContain('truncate'); + expect(panel?.className).not.toContain('line-clamp'); + expect(panel?.className).not.toContain('overflow-hidden'); + for (const line of Object.values(messages.es.session.guidance)) { + if (line === messages.es.session.guidance.label) continue; + expect(panel?.textContent).toContain(line); + } + }); +}); diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts index b9bf031e..82dcb0c1 100644 --- a/src/lib/i18n.ts +++ b/src/lib/i18n.ts @@ -126,6 +126,15 @@ export type Messages = { masterVolume: string; mix: string; sessionChannel: string; + guidance: { + label: string; + intention: string; + volume: string; + balance: string; + balanceFullBeacon: string; + cameraMic: string; + control: string; + }; beaconRoom: string; playlist: string; live: string; @@ -607,6 +616,15 @@ export const messages: Record = { masterVolume: 'Volumen general de la sala', mix: 'Balance Beacon / Sesión', sessionChannel: 'Sesión', + guidance: { + label: 'Cómo funciona la escucha', + intention: 'La experiencia te invita a buscar una pregunta, no una respuesta.', + volume: 'El volumen general controla todo lo que escuchás en la sala.', + balance: 'El balance elige cuánto escuchás del Beacon y cuánto de la sesión.', + balanceFullBeacon: 'Si llevás el balance completamente hacia Beacon, podés quedarte solo con su sonido y dejar la voz de la sesión en cero.', + cameraMic: 'Apagar la cámara no apaga tu micrófono. Cada control funciona por separado.', + control: 'Tu cámara y tu micrófono permanecen siempre bajo tu control.', + }, beaconRoom: 'Sala Beacon', playlist: 'Playlist', live: 'En vivo', @@ -1143,6 +1161,15 @@ export const messages: Record = { masterVolume: 'Overall room volume', mix: 'Beacon / Session balance', sessionChannel: 'Session', + guidance: { + label: 'How listening works', + intention: 'The experience invites you to look for a question, not an answer.', + volume: 'Overall volume controls everything you hear in the room.', + balance: 'The balance chooses how much of the Beacon and how much of the session you hear.', + balanceFullBeacon: 'Move the balance fully toward Beacon to hear only the Beacon and bring the session voice to zero.', + cameraMic: 'Turning off your camera does not turn off your microphone. Each control works independently.', + control: 'Your camera and microphone always remain under your control.', + }, beaconRoom: 'Beacon room', playlist: 'Playlist', live: 'Live',