diff --git a/frontend/taskdeck-web/src/tests/components/ActivityResults.spec.ts b/frontend/taskdeck-web/src/tests/components/ActivityResults.spec.ts
new file mode 100644
index 000000000..08a7ed78e
--- /dev/null
+++ b/frontend/taskdeck-web/src/tests/components/ActivityResults.spec.ts
@@ -0,0 +1,139 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { mount } from '@vue/test-utils'
+import { reactive, ref, computed } from 'vue'
+import ActivityResults from '../../components/activity/ActivityResults.vue'
+
+const mockAuditStore = reactive({
+ loading: false,
+ entries: [] as Array<{
+ action: string | number
+ timestamp: string
+ entityType: string
+ entityId: string
+ userName: string | null
+ changes: string | null
+ }>,
+})
+
+vi.mock('../../store/auditStore', () => ({
+ useAuditStore: () => mockAuditStore,
+}))
+
+// Mock the virtual list composable to render items directly
+vi.mock('../../composables/useVirtualList', () => ({
+ useVirtualList: (options: { count: { value: number } }) => ({
+ parentRef: ref(null),
+ virtualItemEls: ref([]),
+ virtualRows: computed(() =>
+ Array.from({ length: options.count.value }, (_, i) => ({
+ index: i,
+ key: i,
+ start: i * 100,
+ end: (i + 1) * 100,
+ size: 100,
+ })),
+ ),
+ totalSize: computed(() => options.count.value * 100),
+ translateY: computed(() => 0),
+ }),
+}))
+
+vi.mock('../../composables/useActivityQuery', () => ({
+ formatAction: (action: string | number) => (typeof action === 'string' ? action : `Action${action}`),
+ formatTimestamp: (ts: string) => ts,
+}))
+
+describe('ActivityResults', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockAuditStore.loading = false
+ mockAuditStore.entries = []
+ })
+
+ it('shows loading state', () => {
+ mockAuditStore.loading = true
+ const wrapper = mount(ActivityResults, {
+ props: { emptyStateTitle: 'No Activity', emptyStateBody: 'Nothing here.' },
+ })
+ expect(wrapper.text()).toContain('Loading activity...')
+ })
+
+ it('shows empty state with custom title and body', () => {
+ const wrapper = mount(ActivityResults, {
+ props: { emptyStateTitle: 'No Activity', emptyStateBody: 'Nothing here yet.' },
+ })
+ expect(wrapper.text()).toContain('No Activity')
+ expect(wrapper.text()).toContain('Nothing here yet.')
+ })
+
+ it('shows action buttons in empty state', () => {
+ const wrapper = mount(ActivityResults, {
+ props: { emptyStateTitle: 'No Activity', emptyStateBody: 'Nothing.' },
+ })
+ expect(wrapper.text()).toContain('Open Review')
+ expect(wrapper.text()).toContain('Open Boards')
+ })
+
+ it('emits navigate when empty state action buttons are clicked', async () => {
+ const wrapper = mount(ActivityResults, {
+ props: { emptyStateTitle: 'No Activity', emptyStateBody: 'Nothing.' },
+ })
+ const reviewBtn = wrapper.findAll('button').find((b) => b.text() === 'Open Review')
+ expect(reviewBtn).toBeTruthy()
+ await reviewBtn?.trigger('click')
+ expect(wrapper.emitted('navigate')?.[0]).toEqual(['/workspace/review'])
+
+ const boardsBtn = wrapper.findAll('button').find((b) => b.text() === 'Open Boards')
+ expect(boardsBtn).toBeTruthy()
+ await boardsBtn?.trigger('click')
+ expect(wrapper.emitted('navigate')?.[1]).toEqual(['/workspace/boards'])
+ })
+
+ it('renders timeline entries', () => {
+ mockAuditStore.entries = [
+ {
+ action: 'Created',
+ timestamp: '2026-03-15T10:00:00Z',
+ entityType: 'Card',
+ entityId: 'card-1',
+ userName: 'alice',
+ changes: null,
+ },
+ {
+ action: 1,
+ timestamp: '2026-03-15T11:00:00Z',
+ entityType: 'Board',
+ entityId: 'board-1',
+ userName: 'bob',
+ changes: 'Updated name',
+ },
+ ]
+ const wrapper = mount(ActivityResults, {
+ props: { emptyStateTitle: 'No Activity', emptyStateBody: 'Nothing.' },
+ })
+ expect(wrapper.text()).toContain('Created')
+ expect(wrapper.text()).toContain('Card - card-1')
+ expect(wrapper.text()).toContain('by alice')
+ expect(wrapper.text()).toContain('Action1')
+ expect(wrapper.text()).toContain('Board - board-1')
+ expect(wrapper.text()).toContain('by bob')
+ expect(wrapper.text()).toContain('Updated name')
+ })
+
+ it('does not show actor when userName is null', () => {
+ mockAuditStore.entries = [
+ {
+ action: 'Archived',
+ timestamp: '2026-03-15T10:00:00Z',
+ entityType: 'Card',
+ entityId: 'card-2',
+ userName: null,
+ changes: null,
+ },
+ ]
+ const wrapper = mount(ActivityResults, {
+ props: { emptyStateTitle: 'No Activity', emptyStateBody: 'Nothing.' },
+ })
+ expect(wrapper.text()).not.toContain('by')
+ })
+})
diff --git a/frontend/taskdeck-web/src/tests/components/ActivitySelector.spec.ts b/frontend/taskdeck-web/src/tests/components/ActivitySelector.spec.ts
new file mode 100644
index 000000000..dfbad59ca
--- /dev/null
+++ b/frontend/taskdeck-web/src/tests/components/ActivitySelector.spec.ts
@@ -0,0 +1,153 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { mount } from '@vue/test-utils'
+import { reactive } from 'vue'
+import ActivitySelector from '../../components/activity/ActivitySelector.vue'
+
+const mockSessionStore = reactive({
+ username: 'alice',
+})
+
+vi.mock('../../store/sessionStore', () => ({
+ useSessionStore: () => mockSessionStore,
+}))
+
+const defaultProps = {
+ viewMode: 'board' as const,
+ selectedBoardId: '',
+ selectedEntityType: '' as const,
+ selectedEntityBoardId: '',
+ selectedEntityId: '',
+ limit: 50,
+ loadingEntitySource: false,
+ boardOptions: [
+ { id: 'b1', label: 'Board One' },
+ { id: 'b2', label: 'Board Two' },
+ ],
+ requiresEntityBoardContext: false,
+ entityOptions: [],
+ canFetch: true,
+ selectedIdForCopy: '',
+ selectedIdLabel: '',
+}
+
+describe('ActivitySelector', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockSessionStore.username = 'alice'
+ })
+
+ it('renders view mode selector with all options', () => {
+ const wrapper = mount(ActivitySelector, { props: defaultProps })
+ const modeSelect = wrapper.find('#activity-view-mode')
+ expect(modeSelect.exists()).toBe(true)
+ const options = modeSelect.findAll('option')
+ expect(options).toHaveLength(3)
+ expect(options[0].text()).toBe('Board History')
+ expect(options[1].text()).toBe('Entity History')
+ expect(options[2].text()).toBe('User History')
+ })
+
+ it('emits update:viewMode when view mode changes', async () => {
+ const wrapper = mount(ActivitySelector, { props: defaultProps })
+ await wrapper.find('#activity-view-mode').setValue('entity')
+ expect(wrapper.emitted('update:viewMode')?.[0]).toEqual(['entity'])
+ })
+
+ it('shows board selector when viewMode is "board"', () => {
+ const wrapper = mount(ActivitySelector, { props: defaultProps })
+ const boardSelect = wrapper.find('#activity-board-select')
+ expect(boardSelect.exists()).toBe(true)
+ const options = boardSelect.findAll('option')
+ // 1 disabled placeholder + 2 board options
+ expect(options).toHaveLength(3)
+ expect(options[1].text()).toBe('Board One')
+ expect(options[2].text()).toBe('Board Two')
+ })
+
+ it('emits update:selectedBoardId when board is changed', async () => {
+ const wrapper = mount(ActivitySelector, { props: defaultProps })
+ await wrapper.find('#activity-board-select').setValue('b2')
+ expect(wrapper.emitted('update:selectedBoardId')?.[0]).toEqual(['b2'])
+ })
+
+ it('shows current user info when viewMode is "user"', () => {
+ const wrapper = mount(ActivitySelector, {
+ props: { ...defaultProps, viewMode: 'user' as const },
+ })
+ expect(wrapper.text()).toContain('Current user:')
+ expect(wrapper.text()).toContain('alice')
+ // Board selector should NOT be shown in user mode
+ expect(wrapper.find('#activity-board-select').exists()).toBe(false)
+ })
+
+ it('shows entity type selector when viewMode is "entity"', () => {
+ const wrapper = mount(ActivitySelector, {
+ props: { ...defaultProps, viewMode: 'entity' as const },
+ })
+ const entityTypeSelect = wrapper.find('#activity-entity-type')
+ expect(entityTypeSelect.exists()).toBe(true)
+ const options = entityTypeSelect.findAll('option')
+ // placeholder + Board, Column, Card, Label
+ expect(options).toHaveLength(5)
+ })
+
+ it('emits update:selectedEntityType when entity type changes', async () => {
+ const wrapper = mount(ActivitySelector, {
+ props: { ...defaultProps, viewMode: 'entity' as const },
+ })
+ await wrapper.find('#activity-entity-type').setValue('Card')
+ expect(wrapper.emitted('update:selectedEntityType')?.[0]).toEqual(['Card'])
+ })
+
+ it('shows limit selector with all options', () => {
+ const wrapper = mount(ActivitySelector, { props: defaultProps })
+ const limitSelect = wrapper.find('[aria-label="Activity limit"]')
+ expect(limitSelect.exists()).toBe(true)
+ const options = limitSelect.findAll('option')
+ expect(options).toHaveLength(3)
+ expect(options.map((o) => o.text())).toEqual(['25', '50', '100'])
+ })
+
+ it('emits fetch when Fetch button is clicked', async () => {
+ const wrapper = mount(ActivitySelector, { props: defaultProps })
+ const fetchBtn = wrapper.findAll('button').find((b) => b.text() === 'Fetch')
+ expect(fetchBtn).toBeTruthy()
+ await fetchBtn?.trigger('click')
+ expect(wrapper.emitted('fetch')).toHaveLength(1)
+ })
+
+ it('disables Fetch button when canFetch is false', () => {
+ const wrapper = mount(ActivitySelector, {
+ props: { ...defaultProps, canFetch: false },
+ })
+ const fetchBtn = wrapper.findAll('button').find((b) => b.text() === 'Fetch')
+ expect(fetchBtn).toBeTruthy()
+ expect(fetchBtn?.attributes('disabled')).toBeDefined()
+ })
+
+ it('shows loading helper text when loadingEntitySource is true', () => {
+ const wrapper = mount(ActivitySelector, {
+ props: { ...defaultProps, viewMode: 'entity' as const, loadingEntitySource: true },
+ })
+ expect(wrapper.text()).toContain('Loading entities for selected board...')
+ })
+
+ it('shows copy ID affordance when selectedIdForCopy is set', () => {
+ const wrapper = mount(ActivitySelector, {
+ props: { ...defaultProps, selectedIdForCopy: 'abc-123', selectedIdLabel: 'Board ID' },
+ })
+ expect(wrapper.text()).toContain('Board ID')
+ expect(wrapper.text()).toContain('abc-123')
+ expect(wrapper.text()).toContain('Copy Raw ID')
+ })
+
+ it('emits copyId when Copy Raw ID button is clicked', async () => {
+ const wrapper = mount(ActivitySelector, {
+ props: { ...defaultProps, selectedIdForCopy: 'abc-123', selectedIdLabel: 'Board ID' },
+ })
+ const copyBtn = wrapper.findAll('button').find((b) => b.text().includes('Copy Raw ID'))
+ expect(copyBtn).toBeTruthy()
+ await copyBtn?.trigger('click')
+ expect(wrapper.emitted('copyId')).toHaveLength(1)
+ })
+})
diff --git a/frontend/taskdeck-web/src/tests/components/KeyboardShortcutsHelp.spec.ts b/frontend/taskdeck-web/src/tests/components/KeyboardShortcutsHelp.spec.ts
new file mode 100644
index 000000000..de4ce4dcf
--- /dev/null
+++ b/frontend/taskdeck-web/src/tests/components/KeyboardShortcutsHelp.spec.ts
@@ -0,0 +1,109 @@
+import { describe, expect, it, vi } from 'vitest'
+import { mount } from '@vue/test-utils'
+import KeyboardShortcutsHelp from '../../components/KeyboardShortcutsHelp.vue'
+
+vi.mock('../../composables/useEscapeToClose', () => ({
+ useEscapeToClose: vi.fn(),
+}))
+
+function mountHelp(isOpen: boolean) {
+ return mount(KeyboardShortcutsHelp, {
+ props: { isOpen },
+ attachTo: document.body,
+ })
+}
+
+function bodyText() {
+ return document.body.textContent ?? ''
+}
+
+describe('KeyboardShortcutsHelp', () => {
+ it('renders nothing when isOpen is false', () => {
+ const wrapper = mountHelp(false)
+ expect(document.body.querySelector('[role="dialog"]')).toBeNull()
+ wrapper.unmount()
+ })
+
+ it('renders dialog when isOpen is true', () => {
+ const wrapper = mountHelp(true)
+ const dialog = document.body.querySelector('[role="dialog"]') as HTMLElement
+ expect(dialog).not.toBeNull()
+ expect(dialog.getAttribute('aria-label')).toBe('Keyboard Shortcuts')
+ expect(dialog.getAttribute('aria-modal')).toBe('true')
+ wrapper.unmount()
+ })
+
+ it('displays all four shortcut categories', () => {
+ const wrapper = mountHelp(true)
+ const text = bodyText()
+ expect(text).toContain('Navigation')
+ expect(text).toContain('Card Movement')
+ expect(text).toContain('Actions')
+ expect(text).toContain('General')
+ wrapper.unmount()
+ })
+
+ it('displays navigation shortcuts', () => {
+ const wrapper = mountHelp(true)
+ const text = bodyText()
+ expect(text).toContain('Select next card')
+ expect(text).toContain('Select previous card')
+ expect(text).toContain('Move to previous column')
+ expect(text).toContain('Move to next column')
+ wrapper.unmount()
+ })
+
+ it('displays card movement shortcuts', () => {
+ const wrapper = mountHelp(true)
+ const text = bodyText()
+ expect(text).toContain('Alt + ArrowRight')
+ expect(text).toContain('Move card to next column')
+ expect(text).toContain('Alt + ArrowUp')
+ expect(text).toContain('Move card up in column')
+ wrapper.unmount()
+ })
+
+ it('displays action shortcuts with Enter and n keys', () => {
+ const wrapper = mountHelp(true)
+ const text = bodyText()
+ expect(text).toContain('Open selected card')
+ expect(text).toContain('Create new card in current column')
+ wrapper.unmount()
+ })
+
+ it('displays general shortcuts including ? key', () => {
+ const wrapper = mountHelp(true)
+ const text = bodyText()
+ expect(text).toContain('Toggle this help dialog')
+ expect(text).toContain('Toggle filter panel')
+ wrapper.unmount()
+ })
+
+ it('emits close when close button is clicked', async () => {
+ const wrapper = mountHelp(true)
+ const closeBtn = document.body.querySelector('button[aria-label="Close"]') as HTMLElement
+ expect(closeBtn).not.toBeNull()
+ closeBtn.click()
+ await wrapper.vm.$nextTick()
+ expect(wrapper.emitted('close')).toHaveLength(1)
+ wrapper.unmount()
+ })
+
+ it('emits close when "Got it!" button is clicked', async () => {
+ const wrapper = mountHelp(true)
+ const buttons = document.body.querySelectorAll('button')
+ const gotItBtn = Array.from(buttons).find((b) => b.textContent?.includes('Got it!'))
+ expect(gotItBtn).toBeTruthy()
+ gotItBtn?.click()
+ await wrapper.vm.$nextTick()
+ expect(wrapper.emitted('close')).toHaveLength(1)
+ wrapper.unmount()
+ })
+
+ it('shows footer hint about the ? key', () => {
+ const wrapper = mountHelp(true)
+ const text = bodyText()
+ expect(text).toContain('anytime to show or hide this help')
+ wrapper.unmount()
+ })
+})
diff --git a/frontend/taskdeck-web/src/tests/components/ShellKeyboardHelp.spec.ts b/frontend/taskdeck-web/src/tests/components/ShellKeyboardHelp.spec.ts
new file mode 100644
index 000000000..270d3c702
--- /dev/null
+++ b/frontend/taskdeck-web/src/tests/components/ShellKeyboardHelp.spec.ts
@@ -0,0 +1,82 @@
+import { describe, expect, it } from 'vitest'
+import { mount } from '@vue/test-utils'
+import ShellKeyboardHelp from '../../components/shell/ShellKeyboardHelp.vue'
+
+function mountHelp(visible: boolean) {
+ return mount(ShellKeyboardHelp, {
+ props: { visible },
+ attachTo: document.body,
+ })
+}
+
+function bodyText() {
+ return document.body.textContent ?? ''
+}
+
+describe('ShellKeyboardHelp', () => {
+ it('renders nothing when visible is false', () => {
+ const wrapper = mountHelp(false)
+ expect(document.body.querySelector('.td-keyboard-help')).toBeNull()
+ wrapper.unmount()
+ })
+
+ it('renders keyboard shortcuts dialog when visible is true', () => {
+ const wrapper = mountHelp(true)
+ expect(document.body.querySelector('.td-keyboard-help')).not.toBeNull()
+ expect(bodyText()).toContain('Keyboard Shortcuts')
+ wrapper.unmount()
+ })
+
+ it('displays all shortcut sections (Global, Board Navigation, Editor)', () => {
+ const wrapper = mountHelp(true)
+ const text = bodyText()
+ expect(text).toContain('Global')
+ expect(text).toContain('Board Navigation')
+ expect(text).toContain('Editor')
+ wrapper.unmount()
+ })
+
+ it('displays key global shortcuts', () => {
+ const wrapper = mountHelp(true)
+ const text = bodyText()
+ expect(text).toContain('Ctrl+K')
+ expect(text).toContain('Command palette')
+ expect(text).toContain('Ctrl+Shift+C')
+ expect(text).toContain('Quick capture modal')
+ expect(text).toContain('Escape')
+ expect(text).toContain('Close top surface')
+ wrapper.unmount()
+ })
+
+ it('displays board navigation shortcuts', () => {
+ const wrapper = mountHelp(true)
+ const text = bodyText()
+ expect(text).toContain('h / Left')
+ expect(text).toContain('Previous column')
+ expect(text).toContain('j / Down')
+ expect(text).toContain('Next card')
+ expect(text).toContain('Enter')
+ expect(text).toContain('Open card')
+ wrapper.unmount()
+ })
+
+ it('emits close when close button is clicked', async () => {
+ const wrapper = mountHelp(true)
+ const closeBtn = document.body.querySelector('.td-keyboard-help__header button') as HTMLElement
+ expect(closeBtn).not.toBeNull()
+ closeBtn.click()
+ await wrapper.vm.$nextTick()
+ expect(wrapper.emitted('close')).toHaveLength(1)
+ wrapper.unmount()
+ })
+
+ it('has dialog role and aria-label for accessibility', () => {
+ const wrapper = mountHelp(true)
+ const overlay = document.body.querySelector('.td-overlay') as HTMLElement
+ expect(overlay).not.toBeNull()
+ expect(overlay.getAttribute('role')).toBe('dialog')
+ expect(overlay.getAttribute('aria-label')).toBe('Keyboard shortcuts')
+ expect(overlay.getAttribute('aria-modal')).toBe('true')
+ wrapper.unmount()
+ })
+})
diff --git a/frontend/taskdeck-web/src/tests/components/ShellSidebar.spec.ts b/frontend/taskdeck-web/src/tests/components/ShellSidebar.spec.ts
new file mode 100644
index 000000000..329a31a9f
--- /dev/null
+++ b/frontend/taskdeck-web/src/tests/components/ShellSidebar.spec.ts
@@ -0,0 +1,189 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { mount } from '@vue/test-utils'
+import { reactive } from 'vue'
+import ShellSidebar from '../../components/shell/ShellSidebar.vue'
+
+const mockFeatureFlags = reactive({
+ isEnabled: vi.fn((_flag: string) => true),
+})
+
+const mockWorkspaceStore = reactive({
+ mode: 'guided' as string,
+ inboxBadgeCount: 0,
+ reviewBadgeCount: 0,
+})
+
+vi.mock('../../store/featureFlagStore', () => ({
+ useFeatureFlagStore: () => mockFeatureFlags,
+}))
+
+vi.mock('../../store/workspaceStore', () => ({
+ useWorkspaceStore: () => mockWorkspaceStore,
+}))
+
+vi.mock('../../composables/useEscapeStack', () => ({
+ registerEscapeHandler: vi.fn(() => vi.fn()),
+}))
+
+const routeMock = reactive({
+ path: '/workspace/home',
+})
+
+vi.mock('vue-router', () => ({
+ useRoute: () => routeMock,
+}))
+
+const routerLinkStub = { template: '', props: ['to'] }
+
+function mountSidebar(overrides?: { isAuthenticated?: boolean; stub?: Record }) {
+ return mount(ShellSidebar, {
+ props: { isAuthenticated: overrides?.isAuthenticated ?? true },
+ global: { stubs: { 'router-link': overrides?.stub ?? routerLinkStub } },
+ })
+}
+
+describe('ShellSidebar', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockWorkspaceStore.mode = 'guided'
+ mockWorkspaceStore.inboxBadgeCount = 0
+ mockWorkspaceStore.reviewBadgeCount = 0
+ mockFeatureFlags.isEnabled = vi.fn(() => true)
+ routeMock.path = '/workspace/home'
+ })
+
+ it('renders the Taskdeck brand title', () => {
+ const wrapper = mountSidebar()
+ expect(wrapper.text()).toContain('Taskdeck')
+ expect(wrapper.text()).toContain('Precision Mode Active')
+ })
+
+ it('renders primary nav items for guided mode', () => {
+ const wrapper = mountSidebar()
+ expect(wrapper.text()).toContain('Home')
+ expect(wrapper.text()).toContain('Today')
+ expect(wrapper.text()).toContain('Review')
+ expect(wrapper.text()).toContain('Boards')
+ expect(wrapper.text()).toContain('Inbox')
+ })
+
+ it('shows secondary nav items with Workbench Tools section label in guided mode', () => {
+ mockWorkspaceStore.mode = 'guided'
+ const wrapper = mountSidebar()
+ // In guided mode, items with secondaryModes including 'guided' appear as secondary
+ expect(wrapper.text()).toContain('Workbench Tools')
+ expect(wrapper.text()).toContain('Views')
+ expect(wrapper.text()).toContain('Notifications')
+ })
+
+ it('promotes all nav items to primary in workbench mode', () => {
+ mockWorkspaceStore.mode = 'workbench'
+ const wrapper = mountSidebar()
+ // In workbench mode, items with primaryModes=['workbench'] are primary, not secondary
+ expect(wrapper.text()).toContain('Metrics')
+ expect(wrapper.text()).toContain('Activity')
+ expect(wrapper.text()).toContain('Ops')
+ })
+
+ it('shows badge count on inbox when inboxBadgeCount > 0', () => {
+ mockWorkspaceStore.inboxBadgeCount = 5
+ const wrapper = mountSidebar()
+ const badges = wrapper.findAll('.td-nav-badge')
+ const inboxBadge = badges.find((b) => b.text() === '5')
+ expect(inboxBadge).toBeDefined()
+ })
+
+ it('shows badge count on review when reviewBadgeCount > 0', () => {
+ mockWorkspaceStore.reviewBadgeCount = 3
+ const wrapper = mountSidebar()
+ const badges = wrapper.findAll('.td-nav-badge')
+ const reviewBadge = badges.find((b) => b.text() === '3')
+ expect(reviewBadge).toBeDefined()
+ })
+
+ it('does not show badges when counts are zero', () => {
+ const wrapper = mountSidebar()
+ expect(wrapper.findAll('.td-nav-badge')).toHaveLength(0)
+ })
+
+ it('shows shortcuts button and emits show-keyboard-help on click', async () => {
+ const wrapper = mountSidebar()
+ const shortcutsBtn = wrapper.find('.td-nav-item--help')
+ expect(shortcutsBtn.exists()).toBe(true)
+ expect(shortcutsBtn.text()).toContain('Shortcuts')
+ await shortcutsBtn.trigger('click')
+ expect(wrapper.emitted('show-keyboard-help')).toHaveLength(1)
+ })
+
+ it('shows logout button when authenticated and emits logout on click', async () => {
+ const wrapper = mountSidebar()
+ const logoutBtn = wrapper.find('.td-nav-item--logout')
+ expect(logoutBtn.exists()).toBe(true)
+ expect(logoutBtn.attributes('aria-label')).toBe('Log out')
+ await logoutBtn.trigger('click')
+ expect(wrapper.emitted('logout')).toHaveLength(1)
+ })
+
+ it('hides logout button when not authenticated', () => {
+ const wrapper = mountSidebar({ isAuthenticated: false })
+ expect(wrapper.find('.td-nav-item--logout').exists()).toBe(false)
+ })
+
+ it('toggles collapsed state when toggle button is clicked', async () => {
+ const wrapper = mountSidebar()
+ expect(wrapper.find('.td-sidebar--collapsed').exists()).toBe(false)
+
+ const toggleBtn = wrapper.find('.td-sidebar__toggle')
+ await toggleBtn.trigger('click')
+ expect(wrapper.find('.td-sidebar--collapsed').exists()).toBe(true)
+
+ // Labels should be hidden when collapsed
+ expect(wrapper.find('.td-nav-item__label').exists()).toBe(false)
+ })
+
+ it('hides feature-flagged items when flag is disabled', () => {
+ mockFeatureFlags.isEnabled = vi.fn((flag: string) => {
+ if (flag === 'newAutomation') return false
+ return true
+ })
+ const wrapper = mountSidebar()
+ expect(wrapper.text()).not.toContain('Review')
+ })
+
+ it('shows feature-flagged items in workbench mode even when flag is disabled (workbenchBypassesFlag)', () => {
+ mockWorkspaceStore.mode = 'workbench'
+ mockFeatureFlags.isEnabled = vi.fn(() => false)
+ const wrapper = mountSidebar()
+ // Review has workbenchBypassesFlag=true so it should appear even with flag disabled
+ expect(wrapper.text()).toContain('Review')
+ })
+
+ it('has navigation landmark role', () => {
+ const wrapper = mountSidebar()
+ expect(wrapper.find('aside').attributes('role')).toBe('navigation')
+ expect(wrapper.find('aside').attributes('aria-label')).toBe('Main navigation')
+ })
+
+ it('highlights the active route with aria-current', () => {
+ routeMock.path = '/workspace/inbox'
+ const wrapper = mountSidebar({
+ stub: {
+ template: '',
+ props: ['to'],
+ },
+ })
+ // The Inbox nav item should have td-nav-item--active class applied by the component
+ const navItems = wrapper.findAll('.td-nav-item')
+ const inboxItem = navItems.find((el) => el.text().includes('Inbox'))
+ expect(inboxItem).toBeTruthy()
+ expect(inboxItem!.classes()).toContain('td-nav-item--active')
+ })
+
+ it('exposes availableNavItems via defineExpose for command palette', () => {
+ const wrapper = mountSidebar()
+ const exposed = (wrapper.vm as unknown as { availableNavItems: Array<{ id: string }> }).availableNavItems
+ expect(Array.isArray(exposed)).toBe(true)
+ expect(exposed.length).toBeGreaterThan(0)
+ expect(exposed.some((item) => item.id === 'home')).toBe(true)
+ })
+})
diff --git a/frontend/taskdeck-web/src/tests/components/ShellTopbar.spec.ts b/frontend/taskdeck-web/src/tests/components/ShellTopbar.spec.ts
new file mode 100644
index 000000000..616e01702
--- /dev/null
+++ b/frontend/taskdeck-web/src/tests/components/ShellTopbar.spec.ts
@@ -0,0 +1,97 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { mount } from '@vue/test-utils'
+import { reactive } from 'vue'
+import ShellTopbar from '../../components/shell/ShellTopbar.vue'
+
+const mockSessionStore = reactive({
+ isAuthenticated: true,
+ username: 'alice',
+})
+
+const mockWorkspaceStore = reactive({
+ mode: 'guided' as string,
+ updateMode: vi.fn(),
+})
+
+vi.mock('../../store/sessionStore', () => ({
+ useSessionStore: () => mockSessionStore,
+}))
+
+vi.mock('../../store/workspaceStore', () => ({
+ useWorkspaceStore: () => mockWorkspaceStore,
+}))
+
+describe('ShellTopbar', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockSessionStore.isAuthenticated = true
+ mockSessionStore.username = 'alice'
+ mockWorkspaceStore.mode = 'guided'
+ })
+
+ it('renders workspace mode selector with all three options', () => {
+ const wrapper = mount(ShellTopbar)
+ const select = wrapper.find('#workspace-mode-select')
+ expect(select.exists()).toBe(true)
+ const options = select.findAll('option')
+ expect(options).toHaveLength(3)
+ expect(options[0].text()).toBe('Guided')
+ expect(options[1].text()).toBe('Workbench')
+ expect(options[2].text()).toBe('Agent')
+ })
+
+ it('shows correct description for guided mode', () => {
+ const wrapper = mount(ShellTopbar)
+ expect(wrapper.text()).toContain('Keep Home, Review, and board work front and center.')
+ })
+
+ it('shows correct description for workbench mode', () => {
+ mockWorkspaceStore.mode = 'workbench'
+ const wrapper = mount(ShellTopbar)
+ expect(wrapper.text()).toContain('Show the full shipped workspace')
+ })
+
+ it('calls updateMode when workspace mode is changed', async () => {
+ const wrapper = mount(ShellTopbar)
+ const select = wrapper.find('#workspace-mode-select')
+ await select.setValue('workbench')
+ expect(mockWorkspaceStore.updateMode).toHaveBeenCalledWith('workbench')
+ })
+
+ it('renders command palette trigger button', () => {
+ const wrapper = mount(ShellTopbar)
+ const paletteBtn = wrapper.find('.td-topbar__palette-trigger')
+ expect(paletteBtn.exists()).toBe(true)
+ expect(paletteBtn.text()).toContain('Go anywhere... (Ctrl+K)')
+ })
+
+ it('emits open-command-palette when palette trigger is clicked', async () => {
+ const wrapper = mount(ShellTopbar)
+ const paletteBtn = wrapper.find('.td-topbar__palette-trigger')
+ await paletteBtn.trigger('click')
+ expect(wrapper.emitted('open-command-palette')).toHaveLength(1)
+ })
+
+ it('shows username when authenticated', () => {
+ const wrapper = mount(ShellTopbar)
+ expect(wrapper.find('.td-topbar__user').text()).toBe('alice')
+ })
+
+ it('does not show username when not authenticated', () => {
+ mockSessionStore.isAuthenticated = false
+ const wrapper = mount(ShellTopbar)
+ expect(wrapper.find('.td-topbar__user').exists()).toBe(false)
+ })
+
+ it('shows system status indicator', () => {
+ const wrapper = mount(ShellTopbar)
+ expect(wrapper.find('.td-topbar__status-dot').exists()).toBe(true)
+ expect(wrapper.find('.td-topbar__status-label').text()).toBe('System Live')
+ })
+
+ it('has accessible label on workspace mode select', () => {
+ const wrapper = mount(ShellTopbar)
+ const select = wrapper.find('#workspace-mode-select')
+ expect(select.attributes('aria-label')).toBe('Workspace mode')
+ })
+})
diff --git a/frontend/taskdeck-web/src/tests/components/SwUpdatePrompt.spec.ts b/frontend/taskdeck-web/src/tests/components/SwUpdatePrompt.spec.ts
new file mode 100644
index 000000000..948f4267f
--- /dev/null
+++ b/frontend/taskdeck-web/src/tests/components/SwUpdatePrompt.spec.ts
@@ -0,0 +1,85 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { mount, flushPromises } from '@vue/test-utils'
+import SwUpdatePrompt from '../../components/shell/SwUpdatePrompt.vue'
+
+// Capture the onNeedRefresh callback
+let capturedOnNeedRefresh: (() => void) | undefined
+const mockUpdateSW = vi.fn(async () => {})
+
+vi.mock('virtual:pwa-register', () => ({
+ registerSW: (options?: { onNeedRefresh?: () => void }) => {
+ capturedOnNeedRefresh = options?.onNeedRefresh
+ return mockUpdateSW
+ },
+}))
+
+describe('SwUpdatePrompt', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ capturedOnNeedRefresh = undefined
+ })
+
+ it('does not show update prompt initially', () => {
+ const wrapper = mount(SwUpdatePrompt)
+ expect(wrapper.find('.td-sw-update').exists()).toBe(false)
+ })
+
+ it('shows update prompt when onNeedRefresh is called', async () => {
+ const wrapper = mount(SwUpdatePrompt)
+ expect(capturedOnNeedRefresh).toBeDefined()
+
+ capturedOnNeedRefresh!()
+ await wrapper.vm.$nextTick()
+
+ expect(wrapper.find('.td-sw-update').exists()).toBe(true)
+ expect(wrapper.text()).toContain('A new version of Taskdeck is available.')
+ })
+
+ it('shows Update now and dismiss buttons', async () => {
+ const wrapper = mount(SwUpdatePrompt)
+ capturedOnNeedRefresh!()
+ await wrapper.vm.$nextTick()
+
+ const updateBtn = wrapper.find('.td-sw-update__btn--primary')
+ expect(updateBtn.exists()).toBe(true)
+ expect(updateBtn.text()).toBe('Update now')
+
+ const dismissBtn = wrapper.find('.td-sw-update__btn--dismiss')
+ expect(dismissBtn.exists()).toBe(true)
+ expect(dismissBtn.attributes('aria-label')).toBe('Dismiss update notification')
+ })
+
+ it('calls updateSW and hides prompt when Update now is clicked', async () => {
+ const wrapper = mount(SwUpdatePrompt)
+ capturedOnNeedRefresh!()
+ await wrapper.vm.$nextTick()
+
+ await wrapper.find('.td-sw-update__btn--primary').trigger('click')
+ await flushPromises()
+
+ expect(mockUpdateSW).toHaveBeenCalled()
+ expect(wrapper.find('.td-sw-update').exists()).toBe(false)
+ })
+
+ it('hides prompt without updating when dismiss is clicked', async () => {
+ const wrapper = mount(SwUpdatePrompt)
+ capturedOnNeedRefresh!()
+ await wrapper.vm.$nextTick()
+
+ await wrapper.find('.td-sw-update__btn--dismiss').trigger('click')
+ await wrapper.vm.$nextTick()
+
+ expect(wrapper.find('.td-sw-update').exists()).toBe(false)
+ expect(mockUpdateSW).not.toHaveBeenCalled()
+ })
+
+ it('has status role and aria-live polite for accessibility', async () => {
+ const wrapper = mount(SwUpdatePrompt)
+ capturedOnNeedRefresh!()
+ await wrapper.vm.$nextTick()
+
+ const updateEl = wrapper.find('.td-sw-update')
+ expect(updateEl.attributes('role')).toBe('status')
+ expect(updateEl.attributes('aria-live')).toBe('polite')
+ })
+})
diff --git a/frontend/taskdeck-web/src/tests/components/ToastContainer.spec.ts b/frontend/taskdeck-web/src/tests/components/ToastContainer.spec.ts
new file mode 100644
index 000000000..7c3d96350
--- /dev/null
+++ b/frontend/taskdeck-web/src/tests/components/ToastContainer.spec.ts
@@ -0,0 +1,91 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { mount } from '@vue/test-utils'
+import { reactive } from 'vue'
+import ToastContainer from '../../components/common/ToastContainer.vue'
+import type { Toast } from '../../store/toastStore'
+
+const mockToastStore = reactive({
+ toasts: [] as Toast[],
+ remove: vi.fn(),
+})
+
+vi.mock('../../store/toastStore', () => ({
+ useToastStore: () => mockToastStore,
+}))
+
+describe('ToastContainer', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockToastStore.toasts = []
+ })
+
+ it('renders nothing when there are no toasts', () => {
+ const wrapper = mount(ToastContainer)
+ // Container exists but no toast items
+ expect(wrapper.findAll('[role="alert"]')).toHaveLength(0)
+ expect(wrapper.text()).toBe('')
+ })
+
+ it('renders toast messages', () => {
+ mockToastStore.toasts = [
+ { id: 't1', message: 'Board created', type: 'success', duration: 3000 },
+ { id: 't2', message: 'Network error', type: 'error', duration: 5000 },
+ ]
+ const wrapper = mount(ToastContainer)
+ expect(wrapper.text()).toContain('Board created')
+ expect(wrapper.text()).toContain('Network error')
+ })
+
+ it('applies error role="alert" for error toasts', () => {
+ mockToastStore.toasts = [
+ { id: 't1', message: 'Failed', type: 'error', duration: 5000 },
+ ]
+ const wrapper = mount(ToastContainer)
+ const errorToast = wrapper.find('[role="alert"]')
+ expect(errorToast.exists()).toBe(true)
+ expect(errorToast.text()).toContain('Failed')
+ })
+
+ it('does not apply role="alert" for non-error toasts', () => {
+ mockToastStore.toasts = [
+ { id: 't1', message: 'Saved', type: 'success', duration: 3000 },
+ ]
+ const wrapper = mount(ToastContainer)
+ expect(wrapper.find('[role="alert"]').exists()).toBe(false)
+ })
+
+ it('calls remove when close button is clicked', async () => {
+ mockToastStore.toasts = [
+ { id: 't1', message: 'Dismiss me', type: 'info', duration: 3000 },
+ ]
+ const wrapper = mount(ToastContainer)
+ const closeBtn = wrapper.find('button[aria-label="Close"]')
+ expect(closeBtn.exists()).toBe(true)
+ await closeBtn.trigger('click')
+ expect(mockToastStore.remove).toHaveBeenCalledWith('t1')
+ })
+
+ it('renders success toast with success styling', () => {
+ mockToastStore.toasts = [
+ { id: 't1', message: 'Done', type: 'success', duration: 3000 },
+ ]
+ const wrapper = mount(ToastContainer)
+ // Success toast should have green-themed classes
+ const toastEl = wrapper.find('.bg-green-50')
+ expect(toastEl.exists()).toBe(true)
+ })
+
+ it('renders warning toast with warning styling', () => {
+ mockToastStore.toasts = [
+ { id: 't1', message: 'Warning', type: 'warning', duration: 4000 },
+ ]
+ const wrapper = mount(ToastContainer)
+ const toastEl = wrapper.find('.bg-yellow-50')
+ expect(toastEl.exists()).toBe(true)
+ })
+
+ it('has aria-live="polite" on the container', () => {
+ const wrapper = mount(ToastContainer)
+ expect(wrapper.find('[aria-live="polite"]').exists()).toBe(true)
+ })
+})
diff --git a/frontend/taskdeck-web/src/tests/components/WorkspaceHelpCallout.spec.ts b/frontend/taskdeck-web/src/tests/components/WorkspaceHelpCallout.spec.ts
new file mode 100644
index 000000000..fe72207c3
--- /dev/null
+++ b/frontend/taskdeck-web/src/tests/components/WorkspaceHelpCallout.spec.ts
@@ -0,0 +1,144 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { mount } from '@vue/test-utils'
+import { computed, ref } from 'vue'
+import WorkspaceHelpCallout from '../../components/workspace/WorkspaceHelpCallout.vue'
+
+const visibleRef = ref(true)
+const mockDismiss = vi.fn()
+const mockReplay = vi.fn()
+
+vi.mock('../../composables/useWorkspaceHelp', () => ({
+ useWorkspaceHelp: () => ({
+ isVisible: computed(() => visibleRef.value),
+ isDismissed: computed(() => !visibleRef.value),
+ dismiss: mockDismiss,
+ replay: mockReplay,
+ }),
+}))
+
+describe('WorkspaceHelpCallout', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ visibleRef.value = true
+ })
+
+ it('renders title, description, and eyebrow when visible', () => {
+ const wrapper = mount(WorkspaceHelpCallout, {
+ props: {
+ topic: 'home',
+ title: 'Welcome to Home',
+ description: 'This is your starting point.',
+ },
+ })
+ expect(wrapper.text()).toContain('What is this?')
+ expect(wrapper.text()).toContain('Welcome to Home')
+ expect(wrapper.text()).toContain('This is your starting point.')
+ })
+
+ it('uses custom eyebrow text', () => {
+ const wrapper = mount(WorkspaceHelpCallout, {
+ props: {
+ topic: 'today',
+ title: 'Today',
+ description: 'desc',
+ eyebrow: 'Quick start',
+ },
+ })
+ expect(wrapper.find('.td-help-callout__eyebrow').text()).toBe('Quick start')
+ })
+
+ it('calls dismiss when dismiss button is clicked', async () => {
+ const wrapper = mount(WorkspaceHelpCallout, {
+ props: {
+ topic: 'home',
+ title: 'Title',
+ description: 'Desc',
+ },
+ })
+ const dismissBtn = wrapper.findAll('button').find((b) => b.text().includes('Hide this guide'))
+ expect(dismissBtn).toBeTruthy()
+ await dismissBtn?.trigger('click')
+ expect(mockDismiss).toHaveBeenCalledTimes(1)
+ })
+
+ it('shows dismissed state with replay button when not visible', () => {
+ visibleRef.value = false
+ const wrapper = mount(WorkspaceHelpCallout, {
+ props: {
+ topic: 'inbox',
+ title: 'Inbox Guide',
+ description: 'Learn about inbox.',
+ },
+ })
+ expect(wrapper.text()).toContain('This page guide is hidden.')
+ expect(wrapper.text()).toContain('Show page guide')
+ })
+
+ it('calls replay when replay button is clicked in dismissed state', async () => {
+ visibleRef.value = false
+ const wrapper = mount(WorkspaceHelpCallout, {
+ props: {
+ topic: 'review',
+ title: 'Review Guide',
+ description: 'Learn about review.',
+ },
+ })
+ const replayBtn = wrapper.findAll('button').find((b) => b.text().includes('Show page guide'))
+ expect(replayBtn).toBeTruthy()
+ await replayBtn?.trigger('click')
+ expect(mockReplay).toHaveBeenCalledTimes(1)
+ })
+
+ it('renders default slot content when visible', () => {
+ const wrapper = mount(WorkspaceHelpCallout, {
+ props: { topic: 'home', title: 'Title', description: 'Desc' },
+ slots: { default: 'Extra help content
' },
+ })
+ expect(wrapper.find('.td-help-callout__body').exists()).toBe(true)
+ expect(wrapper.text()).toContain('Extra help content')
+ })
+
+ it('renders actions slot when visible', () => {
+ const wrapper = mount(WorkspaceHelpCallout, {
+ props: { topic: 'home', title: 'Title', description: 'Desc' },
+ slots: { actions: '' },
+ })
+ expect(wrapper.find('.td-help-callout__actions').exists()).toBe(true)
+ expect(wrapper.text()).toContain('Get Started')
+ })
+
+ it('uses custom dismiss label when visible', () => {
+ visibleRef.value = true
+ const wrapper = mount(WorkspaceHelpCallout, {
+ props: {
+ topic: 'board',
+ title: 'Board',
+ description: 'Desc',
+ dismissLabel: 'Got it',
+ },
+ })
+ const dismissBtn = wrapper.findAll('button').find((b) => b.text().includes('Got it'))
+ expect(dismissBtn).toBeTruthy()
+ })
+
+ it('uses custom replay label when dismissed', () => {
+ visibleRef.value = false
+ const wrapper = mount(WorkspaceHelpCallout, {
+ props: {
+ topic: 'board',
+ title: 'Board',
+ description: 'Desc',
+ replayLabel: 'Bring it back',
+ },
+ })
+ const replayBtn = wrapper.findAll('button').find((b) => b.text().includes('Bring it back'))
+ expect(replayBtn).toBeTruthy()
+ })
+
+ it('sets data-help-topic attribute on root element', () => {
+ const wrapper = mount(WorkspaceHelpCallout, {
+ props: { topic: 'today', title: 'Today', description: 'Desc' },
+ })
+ expect(wrapper.find('[data-help-topic="today"]').exists()).toBe(true)
+ })
+})
diff --git a/frontend/taskdeck-web/src/tests/components/ui/TdBadge.spec.ts b/frontend/taskdeck-web/src/tests/components/ui/TdBadge.spec.ts
new file mode 100644
index 000000000..32697aa73
--- /dev/null
+++ b/frontend/taskdeck-web/src/tests/components/ui/TdBadge.spec.ts
@@ -0,0 +1,34 @@
+import { describe, expect, it } from 'vitest'
+import { mount } from '@vue/test-utils'
+import TdBadge from '../../../components/ui/TdBadge.vue'
+
+describe('TdBadge', () => {
+ it('renders slot content', () => {
+ const wrapper = mount(TdBadge, { slots: { default: '3 items' } })
+ expect(wrapper.text()).toBe('3 items')
+ })
+
+ it('applies default variant and md size classes by default', () => {
+ const wrapper = mount(TdBadge)
+ expect(wrapper.classes()).toContain('td-badge--default')
+ expect(wrapper.classes()).toContain('td-badge--md')
+ })
+
+ it.each(['default', 'primary', 'success', 'warning', 'error', 'info'] as const)(
+ 'applies %s variant class',
+ (variant) => {
+ const wrapper = mount(TdBadge, { props: { variant } })
+ expect(wrapper.classes()).toContain(`td-badge--${variant}`)
+ },
+ )
+
+ it.each(['sm', 'md'] as const)('applies %s size class', (size) => {
+ const wrapper = mount(TdBadge, { props: { size } })
+ expect(wrapper.classes()).toContain(`td-badge--${size}`)
+ })
+
+ it('renders as a span element', () => {
+ const wrapper = mount(TdBadge)
+ expect(wrapper.element.tagName).toBe('SPAN')
+ })
+})
diff --git a/frontend/taskdeck-web/src/tests/components/ui/TdEmptyState.spec.ts b/frontend/taskdeck-web/src/tests/components/ui/TdEmptyState.spec.ts
new file mode 100644
index 000000000..3246a9eec
--- /dev/null
+++ b/frontend/taskdeck-web/src/tests/components/ui/TdEmptyState.spec.ts
@@ -0,0 +1,53 @@
+import { describe, expect, it } from 'vitest'
+import { mount } from '@vue/test-utils'
+import TdEmptyState from '../../../components/ui/TdEmptyState.vue'
+
+describe('TdEmptyState', () => {
+ it('renders the title', () => {
+ const wrapper = mount(TdEmptyState, { props: { title: 'No boards yet' } })
+ expect(wrapper.text()).toContain('No boards yet')
+ expect(wrapper.find('.td-empty-state__title').text()).toBe('No boards yet')
+ })
+
+ it('renders description when provided', () => {
+ const wrapper = mount(TdEmptyState, {
+ props: { title: 'Nothing here', description: 'Create your first board to get started.' },
+ })
+ expect(wrapper.find('.td-empty-state__description').text()).toBe(
+ 'Create your first board to get started.',
+ )
+ })
+
+ it('does not render description element when description is empty', () => {
+ const wrapper = mount(TdEmptyState, { props: { title: 'Empty' } })
+ expect(wrapper.find('.td-empty-state__description').exists()).toBe(false)
+ })
+
+ it('renders icon slot when provided', () => {
+ const wrapper = mount(TdEmptyState, {
+ props: { title: 'Empty' },
+ slots: { icon: 'ICON' },
+ })
+ expect(wrapper.find('.td-empty-state__icon').exists()).toBe(true)
+ expect(wrapper.find('[data-testid="custom-icon"]').text()).toBe('ICON')
+ })
+
+ it('does not render icon wrapper when no icon slot is provided', () => {
+ const wrapper = mount(TdEmptyState, { props: { title: 'Empty' } })
+ expect(wrapper.find('.td-empty-state__icon').exists()).toBe(false)
+ })
+
+ it('renders action slot when provided', () => {
+ const wrapper = mount(TdEmptyState, {
+ props: { title: 'No data' },
+ slots: { action: '' },
+ })
+ expect(wrapper.find('.td-empty-state__action').exists()).toBe(true)
+ expect(wrapper.find('.td-empty-state__action button').text()).toBe('Create Board')
+ })
+
+ it('does not render action wrapper when no action slot is provided', () => {
+ const wrapper = mount(TdEmptyState, { props: { title: 'Empty' } })
+ expect(wrapper.find('.td-empty-state__action').exists()).toBe(false)
+ })
+})
diff --git a/frontend/taskdeck-web/src/tests/components/ui/TdInlineAlert.spec.ts b/frontend/taskdeck-web/src/tests/components/ui/TdInlineAlert.spec.ts
new file mode 100644
index 000000000..cc611d666
--- /dev/null
+++ b/frontend/taskdeck-web/src/tests/components/ui/TdInlineAlert.spec.ts
@@ -0,0 +1,53 @@
+import { describe, expect, it } from 'vitest'
+import { mount } from '@vue/test-utils'
+import TdInlineAlert from '../../../components/ui/TdInlineAlert.vue'
+
+describe('TdInlineAlert', () => {
+ it('renders slot content as alert message', () => {
+ const wrapper = mount(TdInlineAlert, {
+ slots: { default: 'Something went wrong.' },
+ })
+ expect(wrapper.text()).toContain('Something went wrong.')
+ })
+
+ it('applies info variant class by default', () => {
+ const wrapper = mount(TdInlineAlert)
+ expect(wrapper.classes()).toContain('td-inline-alert--info')
+ })
+
+ it.each(['info', 'success', 'warning', 'error'] as const)(
+ 'applies %s variant class',
+ (variant) => {
+ const wrapper = mount(TdInlineAlert, { props: { variant } })
+ expect(wrapper.classes()).toContain(`td-inline-alert--${variant}`)
+ },
+ )
+
+ it('has role="alert" for accessibility', () => {
+ const wrapper = mount(TdInlineAlert)
+ expect(wrapper.attributes('role')).toBe('alert')
+ })
+
+ it('does not show dismiss button by default', () => {
+ const wrapper = mount(TdInlineAlert)
+ expect(wrapper.find('.td-inline-alert__dismiss').exists()).toBe(false)
+ })
+
+ it('shows dismiss button when dismissible is true', () => {
+ const wrapper = mount(TdInlineAlert, { props: { dismissible: true } })
+ const dismissBtn = wrapper.find('.td-inline-alert__dismiss')
+ expect(dismissBtn.exists()).toBe(true)
+ expect(dismissBtn.attributes('aria-label')).toBe('Dismiss alert')
+ })
+
+ it('emits dismiss event when dismiss button is clicked', async () => {
+ const wrapper = mount(TdInlineAlert, { props: { dismissible: true } })
+ await wrapper.find('.td-inline-alert__dismiss').trigger('click')
+ expect(wrapper.emitted('dismiss')).toHaveLength(1)
+ })
+
+ it('does not emit dismiss when not dismissible', () => {
+ const wrapper = mount(TdInlineAlert)
+ expect(wrapper.emitted('dismiss')).toBeUndefined()
+ })
+})
diff --git a/frontend/taskdeck-web/src/tests/components/ui/TdSkeleton.spec.ts b/frontend/taskdeck-web/src/tests/components/ui/TdSkeleton.spec.ts
new file mode 100644
index 000000000..d3821a5d6
--- /dev/null
+++ b/frontend/taskdeck-web/src/tests/components/ui/TdSkeleton.spec.ts
@@ -0,0 +1,48 @@
+import { describe, expect, it } from 'vitest'
+import { mount } from '@vue/test-utils'
+import TdSkeleton from '../../../components/ui/TdSkeleton.vue'
+
+describe('TdSkeleton', () => {
+ it('renders with default dimensions', () => {
+ const wrapper = mount(TdSkeleton)
+ const style = wrapper.attributes('style')
+ expect(style).toContain('width: 100%')
+ expect(style).toContain('height: 1rem')
+ })
+
+ it('applies custom width and height', () => {
+ const wrapper = mount(TdSkeleton, { props: { width: '200px', height: '3rem' } })
+ const style = wrapper.attributes('style')
+ expect(style).toContain('width: 200px')
+ expect(style).toContain('height: 3rem')
+ })
+
+ it('applies rounded class by default', () => {
+ const wrapper = mount(TdSkeleton)
+ expect(wrapper.classes()).toContain('td-skeleton--rounded')
+ expect(wrapper.classes()).not.toContain('td-skeleton--circle')
+ })
+
+ it('applies circle class when circle prop is true', () => {
+ const wrapper = mount(TdSkeleton, { props: { circle: true } })
+ expect(wrapper.classes()).toContain('td-skeleton--circle')
+ expect(wrapper.classes()).not.toContain('td-skeleton--rounded')
+ })
+
+ it('does not apply rounded class when circle is true, even with rounded default', () => {
+ const wrapper = mount(TdSkeleton, { props: { circle: true, rounded: true } })
+ expect(wrapper.classes()).toContain('td-skeleton--circle')
+ expect(wrapper.classes()).not.toContain('td-skeleton--rounded')
+ })
+
+ it('does not apply rounded class when rounded is false', () => {
+ const wrapper = mount(TdSkeleton, { props: { rounded: false } })
+ expect(wrapper.classes()).not.toContain('td-skeleton--rounded')
+ expect(wrapper.classes()).not.toContain('td-skeleton--circle')
+ })
+
+ it('sets aria-hidden="true" to hide from screen readers', () => {
+ const wrapper = mount(TdSkeleton)
+ expect(wrapper.attributes('aria-hidden')).toBe('true')
+ })
+})
diff --git a/frontend/taskdeck-web/src/tests/components/ui/TdSpinner.spec.ts b/frontend/taskdeck-web/src/tests/components/ui/TdSpinner.spec.ts
new file mode 100644
index 000000000..a9aa13361
--- /dev/null
+++ b/frontend/taskdeck-web/src/tests/components/ui/TdSpinner.spec.ts
@@ -0,0 +1,43 @@
+import { describe, expect, it } from 'vitest'
+import { mount } from '@vue/test-utils'
+import TdSpinner from '../../../components/ui/TdSpinner.vue'
+
+describe('TdSpinner', () => {
+ it('renders with default label "Loading"', () => {
+ const wrapper = mount(TdSpinner)
+ expect(wrapper.find('.td-spinner__label').text()).toBe('Loading')
+ })
+
+ it('renders with custom label', () => {
+ const wrapper = mount(TdSpinner, { props: { label: 'Saving...' } })
+ expect(wrapper.find('.td-spinner__label').text()).toBe('Saving...')
+ })
+
+ it('applies md size class by default', () => {
+ const wrapper = mount(TdSpinner)
+ expect(wrapper.classes()).toContain('td-spinner--md')
+ })
+
+ it.each(['sm', 'md', 'lg'] as const)('applies %s size class', (size) => {
+ const wrapper = mount(TdSpinner, { props: { size } })
+ expect(wrapper.classes()).toContain(`td-spinner--${size}`)
+ })
+
+ it('has role="status" for accessibility', () => {
+ const wrapper = mount(TdSpinner)
+ expect(wrapper.attributes('role')).toBe('status')
+ })
+
+ it('renders an SVG spinner element', () => {
+ const wrapper = mount(TdSpinner)
+ const svg = wrapper.find('.td-spinner__svg')
+ expect(svg.exists()).toBe(true)
+ expect(svg.attributes('aria-hidden')).toBe('true')
+ })
+
+ it('contains track and arc path elements', () => {
+ const wrapper = mount(TdSpinner)
+ expect(wrapper.find('.td-spinner__track').exists()).toBe(true)
+ expect(wrapper.find('.td-spinner__arc').exists()).toBe(true)
+ })
+})
diff --git a/frontend/taskdeck-web/src/tests/components/ui/TdTag.spec.ts b/frontend/taskdeck-web/src/tests/components/ui/TdTag.spec.ts
new file mode 100644
index 000000000..abd036c8a
--- /dev/null
+++ b/frontend/taskdeck-web/src/tests/components/ui/TdTag.spec.ts
@@ -0,0 +1,54 @@
+import { describe, expect, it } from 'vitest'
+import { mount } from '@vue/test-utils'
+import TdTag from '../../../components/ui/TdTag.vue'
+
+describe('TdTag', () => {
+ it('renders slot content as the tag label', () => {
+ const wrapper = mount(TdTag, { slots: { default: 'Bug' } })
+ expect(wrapper.find('.td-tag__label').text()).toBe('Bug')
+ })
+
+ it('does not show remove button by default', () => {
+ const wrapper = mount(TdTag, { slots: { default: 'Feature' } })
+ expect(wrapper.find('.td-tag__remove').exists()).toBe(false)
+ })
+
+ it('shows remove button when removable is true', () => {
+ const wrapper = mount(TdTag, {
+ props: { removable: true },
+ slots: { default: 'Feature' },
+ })
+ const removeBtn = wrapper.find('.td-tag__remove')
+ expect(removeBtn.exists()).toBe(true)
+ expect(removeBtn.attributes('aria-label')).toBe('Remove tag')
+ })
+
+ it('emits remove event when remove button is clicked', async () => {
+ const wrapper = mount(TdTag, {
+ props: { removable: true },
+ slots: { default: 'Label' },
+ })
+ await wrapper.find('.td-tag__remove').trigger('click')
+ expect(wrapper.emitted('remove')).toHaveLength(1)
+ })
+
+ it('applies custom color CSS variable when color prop is set', () => {
+ const wrapper = mount(TdTag, {
+ props: { color: '#ff6600' },
+ slots: { default: 'Orange' },
+ })
+ expect(wrapper.classes()).toContain('td-tag--custom')
+ expect(wrapper.attributes('style')).toContain('--td-tag-color: #ff6600')
+ })
+
+ it('does not apply custom class or style when no color is set', () => {
+ const wrapper = mount(TdTag, { slots: { default: 'Plain' } })
+ expect(wrapper.classes()).not.toContain('td-tag--custom')
+ expect(wrapper.attributes('style')).toBeUndefined()
+ })
+
+ it('renders as a span element', () => {
+ const wrapper = mount(TdTag, { slots: { default: 'Tag' } })
+ expect(wrapper.element.tagName).toBe('SPAN')
+ })
+})
diff --git a/frontend/taskdeck-web/src/tests/components/ui/TdToast.spec.ts b/frontend/taskdeck-web/src/tests/components/ui/TdToast.spec.ts
new file mode 100644
index 000000000..3213312c6
--- /dev/null
+++ b/frontend/taskdeck-web/src/tests/components/ui/TdToast.spec.ts
@@ -0,0 +1,47 @@
+import { describe, expect, it } from 'vitest'
+import { mount } from '@vue/test-utils'
+import TdToast from '../../../components/ui/TdToast.vue'
+
+describe('TdToast', () => {
+ it('renders the message text', () => {
+ const wrapper = mount(TdToast, { props: { message: 'Board created' } })
+ expect(wrapper.find('.td-toast__message').text()).toBe('Board created')
+ })
+
+ it('applies info variant class by default', () => {
+ const wrapper = mount(TdToast, { props: { message: 'Info message' } })
+ expect(wrapper.classes()).toContain('td-toast--info')
+ })
+
+ it.each(['info', 'success', 'warning', 'error'] as const)(
+ 'applies %s variant class',
+ (variant) => {
+ const wrapper = mount(TdToast, { props: { message: 'msg', variant } })
+ expect(wrapper.classes()).toContain(`td-toast--${variant}`)
+ },
+ )
+
+ it('has role="status" and aria-live="polite" for accessibility', () => {
+ const wrapper = mount(TdToast, { props: { message: 'test' } })
+ expect(wrapper.attributes('role')).toBe('status')
+ expect(wrapper.attributes('aria-live')).toBe('polite')
+ })
+
+ it('shows dismiss button by default (dismissible=true)', () => {
+ const wrapper = mount(TdToast, { props: { message: 'test' } })
+ const dismissBtn = wrapper.find('.td-toast__dismiss')
+ expect(dismissBtn.exists()).toBe(true)
+ expect(dismissBtn.attributes('aria-label')).toBe('Dismiss')
+ })
+
+ it('hides dismiss button when dismissible is false', () => {
+ const wrapper = mount(TdToast, { props: { message: 'test', dismissible: false } })
+ expect(wrapper.find('.td-toast__dismiss').exists()).toBe(false)
+ })
+
+ it('emits dismiss event when dismiss button is clicked', async () => {
+ const wrapper = mount(TdToast, { props: { message: 'test' } })
+ await wrapper.find('.td-toast__dismiss').trigger('click')
+ expect(wrapper.emitted('dismiss')).toHaveLength(1)
+ })
+})