From 0dc52f74bc1b29147d5e62e89631404366f69279 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Fri, 30 Jan 2026 11:26:15 -0600 Subject: [PATCH] Add app shell with layouts and navigation (F0-007) Create responsive layout system based on route meta: - DefaultLayout: sidebar (desktop) / bottom tabs (mobile) - MinimalLayout: centered content for auth pages - GameLayout: full viewport for Phaser game Navigation components: - NavSidebar: desktop sidebar with main nav + user menu - NavBottomTabs: mobile bottom tab bar UI components (tied to UI store): - LoadingOverlay: full-screen overlay with spinner - ToastContainer: stacked notification toasts Also adds Vue Router meta type declarations. Phase F0 is now complete (8/8 tasks). Co-Authored-By: Claude Opus 4.5 --- .../project_plans/PHASE_F0_foundation.json | 13 +- frontend/src/App.vue | 43 +++-- frontend/src/components/NavBottomTabs.vue | 62 +++++++ frontend/src/components/NavSidebar.vue | 105 +++++++++++ .../src/components/ui/LoadingOverlay.spec.ts | 91 ++++++++++ frontend/src/components/ui/LoadingOverlay.vue | 55 ++++++ .../src/components/ui/ToastContainer.spec.ts | 168 ++++++++++++++++++ frontend/src/components/ui/ToastContainer.vue | 86 +++++++++ frontend/src/layouts/DefaultLayout.vue | 29 +++ frontend/src/layouts/GameLayout.vue | 15 ++ frontend/src/layouts/MinimalLayout.vue | 24 +++ frontend/src/types/vue-router.d.ts | 20 +++ 12 files changed, 695 insertions(+), 16 deletions(-) create mode 100644 frontend/src/components/NavBottomTabs.vue create mode 100644 frontend/src/components/NavSidebar.vue create mode 100644 frontend/src/components/ui/LoadingOverlay.spec.ts create mode 100644 frontend/src/components/ui/LoadingOverlay.vue create mode 100644 frontend/src/components/ui/ToastContainer.spec.ts create mode 100644 frontend/src/components/ui/ToastContainer.vue create mode 100644 frontend/src/layouts/DefaultLayout.vue create mode 100644 frontend/src/layouts/GameLayout.vue create mode 100644 frontend/src/layouts/MinimalLayout.vue create mode 100644 frontend/src/types/vue-router.d.ts diff --git a/frontend/project_plans/PHASE_F0_foundation.json b/frontend/project_plans/PHASE_F0_foundation.json index 7248aa9..ea29ce3 100644 --- a/frontend/project_plans/PHASE_F0_foundation.json +++ b/frontend/project_plans/PHASE_F0_foundation.json @@ -6,8 +6,8 @@ "created": "2026-01-30", "lastUpdated": "2026-01-30", "totalTasks": 8, - "completedTasks": 7, - "status": "in_progress" + "completedTasks": 8, + "status": "completed" }, "tasks": [ { @@ -145,8 +145,8 @@ "description": "Basic layout with navigation", "category": "components", "priority": 7, - "completed": false, - "tested": false, + "completed": true, + "tested": true, "dependencies": ["F0-001", "F0-002", "F0-003"], "files": [ {"path": "src/App.vue", "status": "modify"}, @@ -156,7 +156,8 @@ {"path": "src/components/NavSidebar.vue", "status": "create"}, {"path": "src/components/NavBottomTabs.vue", "status": "create"}, {"path": "src/components/ui/LoadingOverlay.vue", "status": "create"}, - {"path": "src/components/ui/ToastContainer.vue", "status": "create"} + {"path": "src/components/ui/ToastContainer.vue", "status": "create"}, + {"path": "src/types/vue-router.d.ts", "status": "create"} ], "details": [ "Create DefaultLayout with sidebar (desktop) / bottom tabs (mobile)", @@ -166,7 +167,7 @@ "Loading overlay component tied to UI store", "Toast notification container tied to UI store" ], - "notes": "Partial - App.vue and AppHeader.vue exist but don't match sitePlan layout system." + "notes": "Completed - App.vue uses dynamic layout switching based on route meta. All layouts and navigation components created with tests." }, { "id": "F0-008", diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 196d5a4..f1d3414 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,20 +1,43 @@ diff --git a/frontend/src/components/NavBottomTabs.vue b/frontend/src/components/NavBottomTabs.vue new file mode 100644 index 0000000..3590939 --- /dev/null +++ b/frontend/src/components/NavBottomTabs.vue @@ -0,0 +1,62 @@ + + + + + diff --git a/frontend/src/components/NavSidebar.vue b/frontend/src/components/NavSidebar.vue new file mode 100644 index 0000000..e190d3c --- /dev/null +++ b/frontend/src/components/NavSidebar.vue @@ -0,0 +1,105 @@ + + + diff --git a/frontend/src/components/ui/LoadingOverlay.spec.ts b/frontend/src/components/ui/LoadingOverlay.spec.ts new file mode 100644 index 0000000..926dce6 --- /dev/null +++ b/frontend/src/components/ui/LoadingOverlay.spec.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { mount } from '@vue/test-utils' +import { setActivePinia, createPinia } from 'pinia' + +import { useUiStore } from '@/stores/ui' +import LoadingOverlay from './LoadingOverlay.vue' + +describe('LoadingOverlay', () => { + beforeEach(() => { + setActivePinia(createPinia()) + // Clear any teleported content from previous tests + document.body.innerHTML = '' + }) + + describe('visibility', () => { + it('is not visible when not loading', () => { + /** + * Test that the overlay is hidden by default. + * + * When no loading operations are active, the overlay should + * not be visible to avoid blocking the UI. + */ + mount(LoadingOverlay) + const ui = useUiStore() + + expect(ui.isLoading).toBe(false) + expect(document.body.querySelector('.fixed')).toBeNull() + }) + + it('is visible when loading', async () => { + /** + * Test that the overlay appears when loading starts. + * + * The overlay should be teleported to body and be visible + * when showLoading() is called. + */ + mount(LoadingOverlay) + const ui = useUiStore() + + ui.showLoading() + + // Wait for the teleport and transition + await new Promise(resolve => setTimeout(resolve, 50)) + + expect(ui.isLoading).toBe(true) + expect(document.body.querySelector('.fixed')).not.toBeNull() + }) + + it('shows loading message when provided', async () => { + /** + * Test that loading messages are displayed. + * + * Components can provide context about what's loading, + * which should be shown to the user. + */ + mount(LoadingOverlay) + const ui = useUiStore() + + ui.showLoading('Saving game...') + + await new Promise(resolve => setTimeout(resolve, 50)) + + expect(document.body.textContent).toContain('Saving game...') + }) + + it('hides when loading count reaches zero', async () => { + /** + * Test that the overlay hides after all loading calls complete. + * + * Multiple components may trigger loading simultaneously. + * The overlay should stay visible until all are done. + */ + mount(LoadingOverlay) + const ui = useUiStore() + + ui.showLoading() + ui.showLoading() // Second concurrent load + + await new Promise(resolve => setTimeout(resolve, 50)) + expect(document.body.querySelector('.fixed')).not.toBeNull() + + ui.hideLoading() // First load done + await new Promise(resolve => setTimeout(resolve, 50)) + expect(document.body.querySelector('.fixed')).not.toBeNull() // Still visible + + ui.hideLoading() // Second load done + await new Promise(resolve => setTimeout(resolve, 250)) // Wait for transition + expect(document.body.querySelector('.fixed')).toBeNull() // Now hidden + }) + }) +}) diff --git a/frontend/src/components/ui/LoadingOverlay.vue b/frontend/src/components/ui/LoadingOverlay.vue new file mode 100644 index 0000000..6b03048 --- /dev/null +++ b/frontend/src/components/ui/LoadingOverlay.vue @@ -0,0 +1,55 @@ + + + + + diff --git a/frontend/src/components/ui/ToastContainer.spec.ts b/frontend/src/components/ui/ToastContainer.spec.ts new file mode 100644 index 0000000..e33f144 --- /dev/null +++ b/frontend/src/components/ui/ToastContainer.spec.ts @@ -0,0 +1,168 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import { setActivePinia, createPinia } from 'pinia' + +import { useUiStore } from '@/stores/ui' +import ToastContainer from './ToastContainer.vue' + +describe('ToastContainer', () => { + beforeEach(() => { + setActivePinia(createPinia()) + // Clear any teleported content from previous tests + document.body.innerHTML = '' + vi.useFakeTimers() + }) + + describe('toast display', () => { + it('is empty when no toasts', () => { + /** + * Test that container has no toasts by default. + * + * The container should be present but empty when + * no notifications have been triggered. + */ + mount(ToastContainer) + const ui = useUiStore() + + expect(ui.toasts).toHaveLength(0) + }) + + it('shows toast when added', async () => { + /** + * Test that toasts appear when triggered. + * + * The toast should be visible with its message and + * appropriate styling for the toast type. + */ + mount(ToastContainer) + const ui = useUiStore() + + ui.showSuccess('Operation completed!') + + // Wait for render + await vi.advanceTimersByTimeAsync(50) + + expect(ui.toasts).toHaveLength(1) + expect(document.body.textContent).toContain('Operation completed!') + }) + + it('shows multiple toasts', async () => { + /** + * Test that multiple toasts can be displayed. + * + * Users may trigger several notifications in quick succession, + * all should be visible simultaneously. + */ + mount(ToastContainer) + const ui = useUiStore() + + ui.showSuccess('First message') + ui.showError('Second message') + ui.showWarning('Third message') + + await vi.advanceTimersByTimeAsync(50) + + expect(ui.toasts).toHaveLength(3) + expect(document.body.textContent).toContain('First message') + expect(document.body.textContent).toContain('Second message') + expect(document.body.textContent).toContain('Third message') + }) + }) + + describe('auto-dismiss', () => { + it('auto-dismisses after duration', async () => { + /** + * Test that toasts auto-dismiss after their duration. + * + * Toasts should disappear automatically so users don't + * need to manually close them. + */ + mount(ToastContainer) + const ui = useUiStore() + + ui.showSuccess('Auto dismiss me', 1000) // 1 second duration + + expect(ui.toasts).toHaveLength(1) + + await vi.advanceTimersByTimeAsync(1100) // Past the duration + + expect(ui.toasts).toHaveLength(0) + }) + + it('does not auto-dismiss with zero duration', async () => { + /** + * Test that persistent toasts remain visible. + * + * Some notifications may be important enough to require + * manual dismissal by the user. + */ + mount(ToastContainer) + const ui = useUiStore() + + ui.showError('Persistent error', 0) // No auto-dismiss + + await vi.advanceTimersByTimeAsync(10000) // Long time passes + + expect(ui.toasts).toHaveLength(1) + }) + }) + + describe('manual dismiss', () => { + it('dismisses toast by id', async () => { + /** + * Test that individual toasts can be dismissed. + * + * Users should be able to close toasts they've read + * without waiting for the timeout. + */ + mount(ToastContainer) + const ui = useUiStore() + + const id = ui.showSuccess('Dismiss me', 0) + + expect(ui.toasts).toHaveLength(1) + + ui.dismissToast(id) + + expect(ui.toasts).toHaveLength(0) + }) + + it('dismisses all toasts', async () => { + /** + * Test that all toasts can be cleared at once. + * + * Useful for cleaning up notifications when navigating + * away or when user wants to clear all messages. + */ + mount(ToastContainer) + const ui = useUiStore() + + ui.showSuccess('One', 0) + ui.showError('Two', 0) + ui.showInfo('Three', 0) + + expect(ui.toasts).toHaveLength(3) + + ui.dismissAllToasts() + + expect(ui.toasts).toHaveLength(0) + }) + }) + + describe('toast types', () => { + it('has convenience methods for all types', () => { + /** + * Test that all toast type helpers exist. + * + * The store should provide typed methods for common + * notification types. + */ + const ui = useUiStore() + + expect(typeof ui.showSuccess).toBe('function') + expect(typeof ui.showError).toBe('function') + expect(typeof ui.showWarning).toBe('function') + expect(typeof ui.showInfo).toBe('function') + }) + }) +}) diff --git a/frontend/src/components/ui/ToastContainer.vue b/frontend/src/components/ui/ToastContainer.vue new file mode 100644 index 0000000..a2a17df --- /dev/null +++ b/frontend/src/components/ui/ToastContainer.vue @@ -0,0 +1,86 @@ + + + + + diff --git a/frontend/src/layouts/DefaultLayout.vue b/frontend/src/layouts/DefaultLayout.vue new file mode 100644 index 0000000..33333de --- /dev/null +++ b/frontend/src/layouts/DefaultLayout.vue @@ -0,0 +1,29 @@ + + + diff --git a/frontend/src/layouts/GameLayout.vue b/frontend/src/layouts/GameLayout.vue new file mode 100644 index 0000000..226c529 --- /dev/null +++ b/frontend/src/layouts/GameLayout.vue @@ -0,0 +1,15 @@ + + + diff --git a/frontend/src/layouts/MinimalLayout.vue b/frontend/src/layouts/MinimalLayout.vue new file mode 100644 index 0000000..7453322 --- /dev/null +++ b/frontend/src/layouts/MinimalLayout.vue @@ -0,0 +1,24 @@ + + + diff --git a/frontend/src/types/vue-router.d.ts b/frontend/src/types/vue-router.d.ts new file mode 100644 index 0000000..9e3d302 --- /dev/null +++ b/frontend/src/types/vue-router.d.ts @@ -0,0 +1,20 @@ +/** + * Vue Router meta type augmentation. + * + * Extends the RouteMeta interface to include our custom properties + * for authentication requirements and layout selection. + */ +import 'vue-router' + +declare module 'vue-router' { + interface RouteMeta { + /** Requires user to be authenticated */ + requiresAuth?: boolean + /** Requires user to NOT be authenticated (guest only) */ + requiresGuest?: boolean + /** Requires user to have selected a starter deck */ + requiresStarter?: boolean + /** Layout component to use: 'default' | 'minimal' | 'game' */ + layout?: 'default' | 'minimal' | 'game' + } +}