diff --git a/apps/pwa/src/components/EventDetailPopover.test.tsx b/apps/pwa/src/components/EventDetailPopover.test.tsx
new file mode 100644
index 0000000..428aa52
--- /dev/null
+++ b/apps/pwa/src/components/EventDetailPopover.test.tsx
@@ -0,0 +1,191 @@
+/**
+ * EventDetailPopover tests — Task 1 (TDD RED → GREEN)
+ *
+ * Guards:
+ * - T-02e-01: Title containing HTML-looking strings renders as escaped text (XSS guard)
+ * - Escape key closes the popover and clears openEventId
+ * - All event fields (title, location, description, calendar name) render as text
+ * - Close button has aria-label="Close"
+ * - Backdrop click closes the popover
+ */
+
+import 'temporal-polyfill/global'
+import React from 'react'
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { render, screen, fireEvent } from '@testing-library/react'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+
+// window.matchMedia polyfilled in test-setup.ts
+
+// ── Module mocks ──────────────────────────────────────────────────────────────
+
+const mockSetOpenEventId = vi.fn()
+let mockOpenEventId: string | null = null
+
+vi.mock('../store/calendarStore.js', () => ({
+ useCalendarStore: vi.fn(() => ({
+ openEventId: mockOpenEventId,
+ setOpenEventId: mockSetOpenEventId,
+ })),
+}))
+
+// ── Fixtures ───────────────────────────────────────────────────────────────────
+
+const TIMED_OCCURRENCE = {
+ id: 'test-uid::2026-06-15T10:00:00',
+ uid: 'test-uid',
+ calendarId: 1,
+ calendarName: 'My Calendar',
+ ownerUserId: 1,
+ color: '#4A90D9',
+ isShared: false,
+ title: 'Team Standup',
+ start: '2026-06-15T10:00:00-04:00[America/New_York]',
+ end: '2026-06-15T10:30:00-04:00[America/New_York]',
+ allDay: false,
+ location: 'Conference Room B',
+ description: 'Daily team sync meeting',
+}
+
+const OCCURRENCE_WITH_HTML = {
+ ...TIMED_OCCURRENCE,
+ id: 'xss-uid::2026-06-15T10:00:00',
+ uid: 'xss-uid',
+ title: 'Team Meeting',
+ description: 'Bold description',
+ location: '
Room',
+}
+
+const ALLDAY_OCCURRENCE = {
+ id: 'allday-uid::2026-06-20',
+ uid: 'allday-uid',
+ calendarId: 2,
+ calendarName: 'Shared Calendar',
+ ownerUserId: 1,
+ color: '#F25C7A',
+ isShared: true,
+ title: 'Birthday Party',
+ start: '2026-06-20',
+ end: '2026-06-20',
+ allDay: true,
+ location: null,
+ description: null,
+}
+
+// ── Import component (after mocks are declared) ───────────────────────────────
+
+import { EventDetailPopover } from './EventDetailPopover.js'
+import { useCalendarStore } from '../store/calendarStore.js'
+
+// ── Helpers ───────────────────────────────────────────────────────────────────
+
+function renderPopover(occurrence = TIMED_OCCURRENCE) {
+ mockOpenEventId = occurrence.id
+ ;(useCalendarStore as ReturnType).mockImplementation(() => ({
+ openEventId: mockOpenEventId,
+ setOpenEventId: mockSetOpenEventId,
+ }))
+
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false, staleTime: Infinity } },
+ })
+ // Pre-populate the events cache so EventDetailPopover can resolve by id
+ client.setQueryData(['events'], { occurrences: [occurrence] })
+
+ return render(
+
+
+ ,
+ )
+}
+
+// ── Tests ─────────────────────────────────────────────────────────────────────
+
+describe('EventDetailPopover', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockOpenEventId = null
+ })
+
+ it('renders the event title as a heading', () => {
+ renderPopover(TIMED_OCCURRENCE)
+ expect(screen.getByRole('heading')).toHaveTextContent('Team Standup')
+ })
+
+ it('renders location when present', () => {
+ renderPopover(TIMED_OCCURRENCE)
+ expect(screen.getByText(/Conference Room B/)).toBeDefined()
+ })
+
+ it('renders description when present', () => {
+ renderPopover(TIMED_OCCURRENCE)
+ expect(screen.getByText(/Daily team sync meeting/)).toBeDefined()
+ })
+
+ it('renders calendar name in footer', () => {
+ renderPopover(TIMED_OCCURRENCE)
+ expect(screen.getByText(/My Calendar/)).toBeDefined()
+ })
+
+ it('renders an all-day event without crashing', () => {
+ renderPopover(ALLDAY_OCCURRENCE)
+ expect(screen.getByRole('heading')).toHaveTextContent('Birthday Party')
+ })
+
+ it('close button has aria-label="Close"', () => {
+ renderPopover(TIMED_OCCURRENCE)
+ expect(screen.getByLabelText('Close')).toBeDefined()
+ })
+
+ it('pressing Escape calls setOpenEventId(null)', () => {
+ renderPopover(TIMED_OCCURRENCE)
+ fireEvent.keyDown(document, { key: 'Escape' })
+ expect(mockSetOpenEventId).toHaveBeenCalledWith(null)
+ })
+
+ it('clicking the close button calls setOpenEventId(null)', () => {
+ renderPopover(TIMED_OCCURRENCE)
+ fireEvent.click(screen.getByLabelText('Close'))
+ expect(mockSetOpenEventId).toHaveBeenCalledWith(null)
+ })
+
+ it('clicking the backdrop calls setOpenEventId(null)', () => {
+ renderPopover(TIMED_OCCURRENCE)
+ fireEvent.click(screen.getByTestId('popover-backdrop'))
+ expect(mockSetOpenEventId).toHaveBeenCalledWith(null)
+ })
+
+ it('renders nothing when openEventId is null', () => {
+ mockOpenEventId = null
+ ;(useCalendarStore as ReturnType).mockImplementation(() => ({
+ openEventId: null,
+ setOpenEventId: mockSetOpenEventId,
+ }))
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false, staleTime: Infinity } },
+ })
+ const { container } = render(
+
+
+ ,
+ )
+ expect(container.firstChild).toBeNull()
+ })
+
+ it('XSS guard: HTML in title renders as escaped text, not as DOM elements', () => {
+ renderPopover(OCCURRENCE_WITH_HTML)
+ const heading = screen.getByRole('heading')
+ // Team Meeting')
+ })
+
+ it('XSS guard: HTML in description renders as escaped text', () => {
+ renderPopover(OCCURRENCE_WITH_HTML)
+ const descEl = screen.getByTestId('event-description')
+ // must NOT be rendered as a bold element
+ expect(descEl.innerHTML).not.toContain('')
+ expect(descEl.textContent).toContain('Bold description')
+ })
+})