Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
227 changes: 227 additions & 0 deletions Terranes/rules/completion-log.md

Large diffs are not rendered by default.

38 changes: 12 additions & 26 deletions Terranes/rules/milestones.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,37 +83,23 @@ Vue frontend cleanup, reusable components, and 49 Vitest component tests. Old Bl

## Phase 13 — UX & UI Polish (AI-Driven)

> **AI Agent Rule:** Before implementing any chunk in this phase, read `rules/ux-rules.md` for
> component conventions, design principles, and implementation patterns.

**Goal:** Transform the functional Vue 3 frontend into a polished, production-quality UI with
smooth interactions, responsive layouts, accessible controls, and user-friendly feedback.
Each chunk is independently deployable and testable.

| Chunk | Scope | Status |
|-------|-------|--------|
| 050 | **Toast Notifications & Action Feedback** — Create `ToastContainer.vue` + `useToast()` composable. Add success/error toasts to Journey actions (advance stage, request quote, complete). Add loading-disabled state to all async buttons. 6+ Vitest tests. | `done` |
| 051 | **Skeleton Loaders & Smooth Transitions** — Create `SkeletonCard.vue` and `SkeletonTable.vue` components. Replace `<LoadingSpinner>` with skeleton placeholders in all 5 data views. Add Vue `<Transition>` fade on route changes and list enter/leave in card grids. 8+ Vitest tests. | `done` |
| 052 | **Responsive Layout Overhaul** — Migrate sidebar breakpoint from 641px to Bootstrap md (768px). Add collapsible sidebar with slide animation. Make all card grids stack to 1-column on mobile. Add responsive table scrolling. Fix top-bar on mobile. Verify on 320px/768px/1200px. 4+ Vitest tests. | `done` |
| 053 | **Accessibility & Keyboard Navigation** — Add `aria-label` to all buttons and interactive elements. Add Escape-to-close on all modals. Add focus trap inside modals. Add skip-to-content link. Add `aria-live` region for toast announcements. Audit with axe-core rules. 6+ Vitest tests. | `done` |
| 054 | **Dark Mode Support** — Add `useTheme()` composable with system-preference detection + manual toggle. Add theme toggle button in sidebar. Migrate `style.css` hardcoded colours to Bootstrap CSS variables. Sidebar gradient adapts to dark mode. Persist preference in localStorage. 4+ Vitest tests. | `done` |
| 055 | **Enhanced Home Landing Page** — Add hero section with animated gradient background. Add "How it works" 4-step visual flow. Add testimonial carousel (static data). Add footer with links. Add smooth scroll to sections. Mobile-optimised layout. 4+ Vitest tests. | `done` |
| 056 | **Search & Filter UX Improvements** — Add debounced search inputs (300ms) across Villages, Home Models, Land, Marketplace. Add filter chips showing active filters with ×-remove. Add result count badge. Add empty-state illustrations (SVG). Add URL query-string sync for shareable filter URLs. 6+ Vitest tests. | `not-started` |
| 057 | **Card & List Interaction Polish** — Add hover lift effect on all cards (transform + shadow). Add click-ripple feedback. Add image placeholder gradients on model/village cards. Add pagination component for lists > 12 items. Add sort-by dropdown on Marketplace and Land views. 6+ Vitest tests. | `not-started` |
| 058 | **Journey UX Enhancement** — Add animated step indicator (horizontal stepper with connecting lines). Add confirmation dialogs before irreversible actions (complete journey). Add journey timeline sidebar showing all past actions with timestamps. Add confetti animation on journey completion. 5+ Vitest tests. | `not-started` |
| 059 | **Dashboard Widgets & Charts** — Add `StatCard.vue` with animated count-up numbers. Add mini sparkline chart for analytics trends (pure SVG, no chart library). Add notification bell icon in top-bar with unread count badge. Add quick-action buttons (New Journey, Browse Designs). 6+ Vitest tests. | `not-started` |
| 060 | **Breadcrumbs, Page Titles & Navigation** — Add `BreadcrumbBar.vue` component with auto-generated breadcrumbs from route meta. Add `<title>` updates per route. Add active-page icon highlighting in sidebar. Add "Back to" links in detail modals. Add 404 page. 5+ Vitest tests. | `not-started` |
| 061 | **Form Validation & Input UX** — Add real-time validation on all filter inputs (number ranges, required fields). Add input masking for price fields (AUD format). Add clear-all-filters button. Add auto-focus on first input when views mount. Standardise form-group spacing. 5+ Vitest tests. | `not-started` |
| 062 | **Performance & Bundle Optimisation** — Add route-based code splitting verification. Add image lazy loading for card thumbnails. Add virtual scrolling for large lists (> 50 items). Add web font preloading. Measure and log Lighthouse scores. 3+ Vitest tests. | `not-started` |
| 063 | **Playwright Multi-Browser E2E Tests** — Add Playwright with Chromium, Firefox, WebKit + mobile + tablet viewports. 29 E2E tests across 5 spec files: navigation, home page, responsive layout, views smoke, UX feedback/accessibility. AI agent rule in `rules/playwright-rules.md`. | `done` |
✅ Phase 13 complete — see `rules/completion-log.md` for full history.

20 Vue components, 9 composables, 9 views. 214 Vitest tests (38 files) + 29 Playwright E2E tests. 446 NUnit backend tests. All chunks 050–063 implemented.

---

## Next Chunk
## Phase 14 — Extended Views & API Coverage (AI-Driven)

✅ Phase 14 complete — see `rules/completion-log.md` for full history.

21 Vue components, 8 composables, 15 views. 243 Vitest tests (46 files) + 29 Playwright E2E tests. 446 NUnit backend tests. All chunks 064–066 implemented.

**Chunk 056** — Search & Filter UX Improvements.
---

## Next Chunk

Read `rules/ux-rules.md` before implementing.
All Phase 14 chunks are complete. Next: Phase 15 or new feature phase.

---

Expand Down
2 changes: 2 additions & 0 deletions Terranes/src/Web.Vue/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Terranes</title>
<link rel="preload" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" as="style" crossorigin="anonymous" />
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet"
integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous" />
<link rel="dns-prefetch" href="https://cdn.jsdelivr.net" />
</head>
<body>
<div id="app"></div>
Expand Down
28 changes: 28 additions & 0 deletions Terranes/src/Web.Vue/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { ref } from 'vue';
import { RouterLink, RouterView } from 'vue-router';
import ToastContainer from './components/ToastContainer.vue';
import BreadcrumbBar from './components/BreadcrumbBar.vue';
import { useTheme } from './composables/useTheme';

const { theme, toggleTheme } = useTheme();
Expand Down Expand Up @@ -62,6 +63,31 @@ function toggleSidebar() {
<span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span> Dashboard
</RouterLink>
</div>
<div class="nav-item px-3">
<RouterLink class="nav-link" to="/search" active-class="active">
<span class="bi bi-list-nested-nav-menu" aria-hidden="true"></span> Search
</RouterLink>
</div>
<div class="nav-item px-3">
<RouterLink class="nav-link" to="/walkthroughs" active-class="active">
<span class="bi bi-plus-square-fill-nav-menu" aria-hidden="true"></span> Walkthroughs
</RouterLink>
</div>
<div class="nav-item px-3">
<RouterLink class="nav-link" to="/design-editor" active-class="active">
<span class="bi bi-plus-square-fill-nav-menu" aria-hidden="true"></span> Design Editor
</RouterLink>
</div>
<div class="nav-item px-3">
<RouterLink class="nav-link" to="/partners" active-class="active">
<span class="bi bi-list-nested-nav-menu" aria-hidden="true"></span> Partners
</RouterLink>
</div>
<div class="nav-item px-3">
<RouterLink class="nav-link" to="/reports" active-class="active">
<span class="bi bi-list-nested-nav-menu" aria-hidden="true"></span> Reports
</RouterLink>
</div>
<div class="nav-item px-3 mt-auto">
<button
class="nav-link theme-toggle-btn w-100 text-start"
Expand All @@ -78,9 +104,11 @@ function toggleSidebar() {

<main id="main-content">
<div class="top-row px-4">
<RouterLink to="/login" class="btn btn-sm btn-outline-primary me-2">Login</RouterLink>
<a href="https://learn.microsoft.com/aspnet/core/" target="_blank">About</a>
</div>
<article class="content px-4">
<BreadcrumbBar />
<RouterView v-slot="{ Component }">
<Transition name="fade" mode="out-in">
<component :is="Component" />
Expand Down
37 changes: 37 additions & 0 deletions Terranes/src/Web.Vue/src/__tests__/DesignEditorView.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import { createRouter, createMemoryHistory } from 'vue-router';
import DesignEditorView from '../views/DesignEditorView.vue';

async function mountDesignEditorView() {
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: { template: '<div />' } },
{ path: '/design-editor', component: DesignEditorView },
],
});
await router.push('/design-editor');
await router.isReady();
return mount(DesignEditorView, { global: { plugins: [router] } });
}

describe('DesignEditorView', () => {
it('renders editor heading', async () => {
const wrapper = await mountDesignEditorView();
expect(wrapper.text()).toContain('Design Editor');
});

it('has placement ID input', async () => {
const wrapper = await mountDesignEditorView();
expect(wrapper.find('#placement-id').exists()).toBe(true);
});

it('has operation select', async () => {
const wrapper = await mountDesignEditorView();
// Before loading, the form is not shown — only after placement ID
// But the form is always there once history is loaded. Initially editHistory is null
// so form is not shown.
expect(wrapper.find('#placement-id').exists()).toBe(true);
});
});
43 changes: 43 additions & 0 deletions Terranes/src/Web.Vue/src/__tests__/LoginView.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import { createRouter, createMemoryHistory } from 'vue-router';
import LoginView from '../views/LoginView.vue';

async function mountLoginView() {
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: { template: '<div />' } },
{ path: '/login', component: LoginView },
{ path: '/register', component: { template: '<div />' } },
{ path: '/dashboard', component: { template: '<div />' } },
],
});
await router.push('/login');
await router.isReady();
return mount(LoginView, { global: { plugins: [router] } });
}

describe('LoginView', () => {
it('renders login heading', async () => {
const wrapper = await mountLoginView();
expect(wrapper.text()).toContain('Login');
});

it('has email and password inputs', async () => {
const wrapper = await mountLoginView();
expect(wrapper.find('#login-email').exists()).toBe(true);
expect(wrapper.find('#login-password').exists()).toBe(true);
});

it('has a sign in button', async () => {
const wrapper = await mountLoginView();
expect(wrapper.text()).toContain('Sign In');
});

it('has a link to register', async () => {
const wrapper = await mountLoginView();
expect(wrapper.text()).toContain('Register here');
expect(wrapper.find('a[href="/register"]').exists()).toBe(true);
});
});
41 changes: 41 additions & 0 deletions Terranes/src/Web.Vue/src/__tests__/NotFoundView.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import { createRouter, createMemoryHistory } from 'vue-router';
import NotFoundView from '../views/NotFoundView.vue';

async function mountNotFound() {
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: { template: '<div />' } },
{ path: '/:pathMatch(.*)*', name: 'not-found', component: NotFoundView },
],
});
await router.push('/some-invalid-path');
await router.isReady();
return mount(NotFoundView, { global: { plugins: [router] } });
}

describe('NotFoundView', () => {
it('displays 404 heading', async () => {
const wrapper = await mountNotFound();
expect(wrapper.text()).toContain('404');
});

it('displays "Page Not Found" message', async () => {
const wrapper = await mountNotFound();
expect(wrapper.text()).toContain('Page Not Found');
});

it('has a link back to home', async () => {
const wrapper = await mountNotFound();
const link = wrapper.find('a[href="/"]');
expect(link.exists()).toBe(true);
expect(link.text()).toContain('Home');
});

it('displays helpful description text', async () => {
const wrapper = await mountNotFound();
expect(wrapper.text()).toContain("doesn't exist");
});
});
36 changes: 36 additions & 0 deletions Terranes/src/Web.Vue/src/__tests__/PartnersView.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import { createRouter, createMemoryHistory } from 'vue-router';
import PartnersView from '../views/PartnersView.vue';

async function mountPartnersView() {
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: { template: '<div />' } },
{ path: '/partners', component: PartnersView },
],
});
await router.push('/partners');
await router.isReady();
return mount(PartnersView, { global: { plugins: [router] } });
}

describe('PartnersView', () => {
it('renders partners heading', async () => {
const wrapper = await mountPartnersView();
expect(wrapper.text()).toContain('Partners');
});

it('shows 6 partner type tabs', async () => {
const wrapper = await mountPartnersView();
const tabs = wrapper.findAll('[role="tab"]');
expect(tabs.length).toBe(6);
});

it('builder tab is active by default', async () => {
const wrapper = await mountPartnersView();
const activeTab = wrapper.find('[role="tab"][aria-selected="true"]');
expect(activeTab.text()).toContain('Builders');
});
});
45 changes: 45 additions & 0 deletions Terranes/src/Web.Vue/src/__tests__/RegisterView.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import { createRouter, createMemoryHistory } from 'vue-router';
import RegisterView from '../views/RegisterView.vue';

async function mountRegisterView() {
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: { template: '<div />' } },
{ path: '/register', component: RegisterView },
{ path: '/login', component: { template: '<div />' } },
{ path: '/dashboard', component: { template: '<div />' } },
],
});
await router.push('/register');
await router.isReady();
return mount(RegisterView, { global: { plugins: [router] } });
}

describe('RegisterView', () => {
it('renders register heading', async () => {
const wrapper = await mountRegisterView();
expect(wrapper.text()).toContain('Register');
});

it('has all four registration fields', async () => {
const wrapper = await mountRegisterView();
expect(wrapper.find('#reg-name').exists()).toBe(true);
expect(wrapper.find('#reg-email').exists()).toBe(true);
expect(wrapper.find('#reg-password').exists()).toBe(true);
expect(wrapper.find('#reg-confirm').exists()).toBe(true);
});

it('has a create account button', async () => {
const wrapper = await mountRegisterView();
expect(wrapper.text()).toContain('Create Account');
});

it('has a link to login', async () => {
const wrapper = await mountRegisterView();
expect(wrapper.text()).toContain('Sign in');
expect(wrapper.find('a[href="/login"]').exists()).toBe(true);
});
});
34 changes: 34 additions & 0 deletions Terranes/src/Web.Vue/src/__tests__/ReportsView.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import { createRouter, createMemoryHistory } from 'vue-router';
import ReportsView from '../views/ReportsView.vue';

async function mountReportsView() {
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: { template: '<div />' } },
{ path: '/reports', component: ReportsView },
],
});
await router.push('/reports');
await router.isReady();
return mount(ReportsView, { global: { plugins: [router] } });
}

describe('ReportsView', () => {
it('renders reports heading', async () => {
const wrapper = await mountReportsView();
expect(wrapper.text()).toContain('Reports');
});

it('has report type select', async () => {
const wrapper = await mountReportsView();
expect(wrapper.find('select[aria-label="Report type"]').exists()).toBe(true);
});

it('has generate button', async () => {
const wrapper = await mountReportsView();
expect(wrapper.text()).toContain('Generate');
});
});
42 changes: 42 additions & 0 deletions Terranes/src/Web.Vue/src/__tests__/SearchView.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import { createRouter, createMemoryHistory } from 'vue-router';
import SearchView from '../views/SearchView.vue';

async function mountSearchView() {
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: { template: '<div />' } },
{ path: '/search', component: SearchView },
{ path: '/home-models', component: { template: '<div />' } },
],
});
await router.push('/search');
await router.isReady();
return mount(SearchView, { global: { plugins: [router] } });
}

describe('SearchView', () => {
it('renders search heading', async () => {
const wrapper = await mountSearchView();
expect(wrapper.text()).toContain('Search');
});

it('has a search input', async () => {
const wrapper = await mountSearchView();
expect(wrapper.find('input[type="search"], input[placeholder*="Search"]').exists() || wrapper.findComponent({ name: 'SearchBar' }).exists()).toBe(true);
});

it('has an entity type filter dropdown', async () => {
const wrapper = await mountSearchView();
expect(wrapper.find('select[aria-label="Filter by entity type"]').exists()).toBe(true);
});

it('displays entity type options', async () => {
const wrapper = await mountSearchView();
const options = wrapper.findAll('option');
expect(options.length).toBeGreaterThanOrEqual(4);
expect(options.some(o => o.text().includes('All Types'))).toBe(true);
});
});
Loading
Loading