diff --git a/web/src/components/UndoToast.test.ts b/web/src/components/UndoToast.test.ts
new file mode 100644
index 0000000..3378504
--- /dev/null
+++ b/web/src/components/UndoToast.test.ts
@@ -0,0 +1,74 @@
+import { mount } from '@vue/test-utils'
+import UndoToast from './UndoToast.vue'
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+
+// Mock requestAnimationFrame for jsdom
+beforeEach(() => {
+ vi.stubGlobal('requestAnimationFrame', (fn: FrameRequestCallback) => {
+ // Call with a fake timestamp to simulate one frame
+ setTimeout(() => fn(16), 0)
+ return 1
+ })
+ vi.stubGlobal('cancelAnimationFrame', vi.fn())
+})
+
+afterEach(() => {
+ vi.unstubAllGlobals()
+})
+
+const labelAction = {
+ type: 'label' as const,
+ item: { id: 'abc', subject: 'Interview at Acme', body: '...', from: 'hr@acme.com', date: '2026-03-01', source: 'imap:test' },
+ label: 'interview_scheduled',
+}
+
+const skipAction = {
+ type: 'skip' as const,
+ item: { id: 'xyz', subject: 'Cold Outreach', body: '...', from: 'recruiter@x.com', date: '2026-03-01', source: 'imap:test' },
+}
+
+const discardAction = {
+ type: 'discard' as const,
+ item: { id: 'def', subject: 'Spam Email', body: '...', from: 'spam@spam.com', date: '2026-03-01', source: 'imap:test' },
+}
+
+describe('UndoToast', () => {
+ it('renders subject for a label action', () => {
+ const w = mount(UndoToast, { props: { action: labelAction } })
+ expect(w.text()).toContain('Interview at Acme')
+ expect(w.text()).toContain('interview_scheduled')
+ })
+
+ it('renders subject for a skip action', () => {
+ const w = mount(UndoToast, { props: { action: skipAction } })
+ expect(w.text()).toContain('Cold Outreach')
+ expect(w.text()).toContain('Skipped')
+ })
+
+ it('renders subject for a discard action', () => {
+ const w = mount(UndoToast, { props: { action: discardAction } })
+ expect(w.text()).toContain('Spam Email')
+ expect(w.text()).toContain('Discarded')
+ })
+
+ it('has undo button', () => {
+ const w = mount(UndoToast, { props: { action: labelAction } })
+ expect(w.find('.undo-btn').exists()).toBe(true)
+ })
+
+ it('emits undo when button clicked', async () => {
+ const w = mount(UndoToast, { props: { action: labelAction } })
+ await w.find('.undo-btn').trigger('click')
+ expect(w.emitted('undo')).toBeTruthy()
+ })
+
+ it('has timer bar element', () => {
+ const w = mount(UndoToast, { props: { action: labelAction } })
+ expect(w.find('.timer-bar').exists()).toBe(true)
+ })
+
+ it('has accessible role=status', () => {
+ const w = mount(UndoToast, { props: { action: labelAction } })
+ expect(w.find('[role="status"]').exists()).toBe(true)
+ })
+})
diff --git a/web/src/components/UndoToast.vue b/web/src/components/UndoToast.vue
new file mode 100644
index 0000000..18e7f57
--- /dev/null
+++ b/web/src/components/UndoToast.vue
@@ -0,0 +1,105 @@
+
+
+
{{ label }}
+
+
+
+
+
+
+
+