Files
zeroclock/docs/plans/2026-02-18-motion-system-implementation.md
Your Name 04d4220604 feat: add transitions and micro-interactions across all views
- Page transitions with slide-up/fade on route changes (App.vue)
- NavRail sliding active indicator with spring-like easing
- List enter/leave/move animations on Entries, Projects, Clients, Timer
- Modal enter/leave transitions with scale+fade on all dialogs
- Dropdown transitions with overshoot on all select/picker components
- Button feedback (scale on hover/active), card hover lift effects
- Timer pulse on start, glow on stop, floating empty state icons
- Content fade-in on Dashboard, Reports, Calendar, Timesheet
- Tag chip enter/leave animations in AppTagInput
- Progress bar smooth width transitions
- Implementation plan document
2026-02-18 11:33:58 +02:00

1140 lines
31 KiB
Markdown

# Motion System Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Add fluid spring-based animations and micro-interactions throughout ZeroClock — page transitions, list animations, button feedback, loading states, modal/dropdown polish, and timer-specific effects.
**Architecture:** Vue `<Transition>` and `<TransitionGroup>` for enter/leave orchestration with CSS transition classes. `@vueuse/motion` for spring-physics on interactive elements (nav rail indicator, timer pulse). CSS keyframes for ambient/looping animations (shimmer, float). All animations respect `prefers-reduced-motion`.
**Tech Stack:** Vue 3, @vueuse/motion, Tailwind CSS v4, CSS transitions/keyframes
---
### Task 1: Install @vueuse/motion and create spring presets
**Files:**
- Modify: `package.json`
- Modify: `src/main.ts`
- Create: `src/utils/motion.ts`
**Step 1: Install the dependency**
Run: `npm install @vueuse/motion`
**Step 2: Register MotionPlugin in main.ts**
Add to `src/main.ts`:
```ts
import { MotionPlugin } from '@vueuse/motion'
// After app.use(router), before app.mount:
app.use(MotionPlugin)
```
Final `src/main.ts`:
```ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { MotionPlugin } from '@vueuse/motion'
import router from './router'
import App from './App.vue'
import './styles/main.css'
const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
app.use(router)
app.use(MotionPlugin)
app.mount('#app')
```
**Step 3: Create spring preset module**
Create `src/utils/motion.ts`:
```ts
// Spring presets for @vueuse/motion
export const springPresets = {
snappy: { damping: 20, stiffness: 300 },
smooth: { damping: 15, stiffness: 200 },
popIn: { damping: 12, stiffness: 400 },
}
```
**Step 4: Verify build**
Run: `npm run build`
Expected: Build passes with no errors.
**Step 5: Commit**
```bash
git add package.json package-lock.json src/main.ts src/utils/motion.ts
git commit -m "feat: install @vueuse/motion and create spring presets"
```
---
### Task 2: Add CSS animation classes and keyframes
**Files:**
- Modify: `src/styles/main.css`
Add all the reusable transition classes and new keyframes needed by subsequent tasks. Place them after the existing keyframes block (after line 232 `.animate-pulse-colon`).
**Step 1: Add page transition CSS classes**
```css
/* Page transitions */
.page-enter-active {
transition: opacity 250ms cubic-bezier(0.22, 1, 0.36, 1),
transform 250ms cubic-bezier(0.22, 1, 0.36, 1);
}
.page-leave-active {
transition: opacity 150ms ease-out,
transform 150ms ease-out;
}
.page-enter-from {
opacity: 0;
transform: translateY(8px);
}
.page-leave-to {
opacity: 0;
transform: translateY(-8px);
}
```
**Step 2: Add list transition CSS classes**
```css
/* List item transitions */
.list-enter-active {
transition: opacity 250ms cubic-bezier(0.22, 1, 0.36, 1),
transform 250ms cubic-bezier(0.22, 1, 0.36, 1);
}
.list-leave-active {
transition: opacity 150ms ease-in,
transform 150ms ease-in;
}
.list-move {
transition: transform 300ms cubic-bezier(0.22, 1, 0.36, 1);
}
.list-enter-from {
opacity: 0;
transform: translateY(12px);
}
.list-leave-to {
opacity: 0;
transform: translateX(-20px);
}
```
**Step 3: Add tag chip transition classes**
```css
/* Tag chip transitions */
.chip-enter-active {
transition: opacity 200ms cubic-bezier(0.34, 1.56, 0.64, 1),
transform 200ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
.chip-leave-active {
transition: opacity 100ms ease-in,
transform 100ms ease-in;
}
.chip-enter-from {
opacity: 0;
transform: scale(0.8);
}
.chip-leave-to {
opacity: 0;
transform: scale(0.8);
}
```
**Step 4: Add modal transition classes**
```css
/* Modal backdrop transitions */
.modal-enter-active {
transition: opacity 200ms ease-out;
}
.modal-leave-active {
transition: opacity 150ms ease-in;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
/* Modal panel transitions */
.modal-panel-enter-active {
transition: opacity 250ms cubic-bezier(0.22, 1, 0.36, 1),
transform 250ms cubic-bezier(0.22, 1, 0.36, 1);
}
.modal-panel-leave-active {
transition: opacity 150ms ease-in,
transform 150ms ease-in;
}
.modal-panel-enter-from {
opacity: 0;
transform: scale(0.95);
}
.modal-panel-leave-to {
opacity: 0;
transform: scale(0.97);
}
```
**Step 5: Add dropdown transition classes**
```css
/* Dropdown transitions */
.dropdown-enter-active {
transition: opacity 150ms cubic-bezier(0.22, 1, 0.36, 1),
transform 150ms cubic-bezier(0.22, 1, 0.36, 1);
}
.dropdown-leave-active {
transition: opacity 100ms ease-in,
transform 100ms ease-in;
}
.dropdown-enter-from {
opacity: 0;
transform: translateY(-4px) scale(0.95);
}
.dropdown-leave-to {
opacity: 0;
transform: translateY(-4px);
}
```
**Step 6: Add content fade-in transition**
```css
/* Content fade-in */
.fade-enter-active {
transition: opacity 250ms cubic-bezier(0.22, 1, 0.36, 1),
transform 250ms cubic-bezier(0.22, 1, 0.36, 1);
}
.fade-leave-active {
transition: opacity 150ms ease-out;
}
.fade-enter-from {
opacity: 0;
transform: translateY(4px);
}
.fade-leave-to {
opacity: 0;
}
```
**Step 7: Add shimmer and float keyframes**
```css
/* Skeleton shimmer */
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.skeleton {
background: linear-gradient(90deg, var(--color-bg-elevated) 25%, var(--color-bg-surface) 50%, var(--color-bg-elevated) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s ease-in-out infinite;
}
/* Empty state floating icon */
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-4px); }
}
.animate-float {
animation: float 3s ease-in-out infinite;
}
```
**Step 8: Add button feedback utilities**
```css
/* Button interactive feedback */
.btn-primary {
transition: transform 150ms cubic-bezier(0.22, 1, 0.36, 1),
box-shadow 150ms ease;
}
.btn-primary:hover {
transform: scale(1.02);
}
.btn-primary:active {
transform: scale(0.97);
transition-duration: 50ms;
}
.btn-icon:active {
transform: scale(0.85);
transition: transform 100ms cubic-bezier(0.22, 1, 0.36, 1);
}
.btn-icon-delete:active {
transform: scale(0.85) rotate(-10deg);
transition: transform 100ms cubic-bezier(0.22, 1, 0.36, 1);
}
/* Card hover lift */
.card-hover {
transition: transform 200ms cubic-bezier(0.22, 1, 0.36, 1),
box-shadow 200ms ease;
}
.card-hover:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.card-hover:active {
transform: translateY(0);
}
```
**Step 9: Add toggle switch overshoot**
```css
/* Toggle switch overshoot easing */
.toggle-thumb {
transition: transform 200ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
```
**Step 10: Add progress bar animation**
```css
/* Progress bar animate-in */
.progress-bar {
transition: width 600ms cubic-bezier(0.22, 1, 0.36, 1);
}
```
**Step 11: Update toast keyframes with horizontal slide**
Replace the existing `@keyframes toast-enter` (around line 196-205) with:
```css
@keyframes toast-enter {
from {
opacity: 0;
transform: translateY(-20px) translateX(10px);
}
to {
opacity: 1;
transform: translateY(0) translateX(0);
}
}
```
Replace `@keyframes toast-exit` with:
```css
@keyframes toast-exit {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(-10px);
}
}
```
**Step 12: Add reduced motion override**
At the very end of the file:
```css
/* Respect reduced motion preference */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
```
**Step 13: Verify build**
Run: `npm run build`
Expected: Build passes.
**Step 14: Commit**
```bash
git add src/styles/main.css
git commit -m "feat: add animation CSS classes, keyframes, and reduced-motion support"
```
---
### Task 3: Page transitions on router-view
**Files:**
- Modify: `src/App.vue:76-87` (template section)
**Step 1: Wrap router-view in Transition**
Replace the current `<router-view />` at line 82 with:
```html
<router-view v-slot="{ Component }">
<Transition name="page" mode="out-in">
<component :is="Component" :key="$route.path" />
</Transition>
</router-view>
```
The `:key="$route.path"` ensures the transition fires on every route change. The CSS classes `page-enter-active`, `page-leave-active`, `page-enter-from`, `page-leave-to` were added in Task 2.
**Step 2: Verify build**
Run: `npm run build`
Expected: Build passes.
**Step 3: Commit**
```bash
git add src/App.vue
git commit -m "feat: add page transitions on route changes"
```
---
### Task 4: NavRail animated active indicator
**Files:**
- Modify: `src/components/NavRail.vue`
Currently NavRail renders a `<div v-if="currentPath === item.path">` inside each button, creating/destroying the indicator. Change this to a single absolutely-positioned indicator div that slides to the active item using CSS transitions.
**Step 1: Rewrite NavRail template**
Replace the entire template in `src/components/NavRail.vue` with:
```html
<template>
<nav class="w-12 flex flex-col items-center bg-bg-surface border-r border-border-subtle shrink-0">
<!-- Navigation icons -->
<div class="relative flex-1 flex flex-col items-center pt-2 gap-1">
<!-- Sliding active indicator -->
<div
v-if="activeIndex >= 0"
class="absolute left-0 w-[2px] bg-accent transition-all duration-300"
:style="{ top: `${activeIndex * 52 + 8 + 8}px`, height: '36px' }"
style="transition-timing-function: cubic-bezier(0.22, 1, 0.36, 1);"
/>
<button
v-for="(item, index) in navItems"
:key="item.path"
@click="navigate(item.path)"
class="relative w-12 h-[52px] flex items-center justify-center transition-colors duration-150 group"
:class="currentPath === item.path
? 'text-text-primary'
: 'text-text-tertiary hover:text-text-secondary'"
:title="item.name"
>
<component :is="item.icon" class="w-[18px] h-[18px]" :stroke-width="1.5" />
<!-- Tooltip -->
<div class="absolute left-full ml-2 px-2 py-1 bg-bg-elevated border border-border-subtle rounded-lg text-[0.6875rem] text-text-primary whitespace-nowrap opacity-0 pointer-events-none group-hover:opacity-100 transition-opacity duration-150 z-50">
<div class="absolute -left-1 top-1/2 -translate-y-1/2 w-0 h-0 border-y-4 border-y-transparent border-r-4" style="border-right-color: var(--color-bg-elevated)"></div>
{{ item.name }}
</div>
</button>
</div>
<!-- Timer status indicator (bottom) -->
<div class="pb-4">
<div
v-if="timerStore.isRunning"
class="w-2 h-2 rounded-full bg-status-running animate-pulse-dot"
/>
<div
v-else-if="timerStore.isPaused"
class="w-2 h-2 rounded-full bg-status-warning animate-pulse-dot"
/>
</div>
</nav>
</template>
```
**Step 2: Add activeIndex computed**
In the `<script setup>`, add:
```ts
const activeIndex = computed(() => {
return navItems.findIndex(item => item.path === currentPath.value)
})
```
The indicator's top position is calculated from the index. Each button is 52px tall (`h-[52px]`). The parent has `pt-2` (8px top padding). The indicator is 36px tall, offset 8px from top of the button for centering: `top = index * 52 + 8 + 8`.
**Step 3: Verify build**
Run: `npm run build`
Expected: Build passes.
**Step 4: Commit**
```bash
git add src/components/NavRail.vue
git commit -m "feat: add sliding active indicator to NavRail"
```
---
### Task 5: List animations on Entries, Projects, Clients
**Files:**
- Modify: `src/views/Entries.vue` (entry table rows)
- Modify: `src/views/Projects.vue` (project cards grid)
- Modify: `src/views/Clients.vue` (client cards grid)
**Step 1: Entries.vue — wrap table body rows in TransitionGroup**
In `src/views/Entries.vue`, replace `<tbody>` block (lines 73-142) with:
```html
<TransitionGroup name="list" tag="tbody">
<tr
v-for="(entry, index) in filteredEntries"
:key="entry.id"
class="group border-b border-border-subtle hover:bg-bg-elevated transition-colors duration-150"
:style="{ transitionDelay: `${index * 30}ms` }"
>
<!-- ... existing td cells unchanged ... -->
</tr>
</TransitionGroup>
```
The `:style="{ transitionDelay: ... }"` creates the stagger effect. The `list-*` CSS classes from Task 2 handle the actual enter/leave/move animation.
Note: Keep all the `<td>` content exactly as-is inside the `<tr>`.
**Step 2: Projects.vue — wrap project cards in TransitionGroup**
In `src/views/Projects.vue`, replace the wrapping `<div v-if="projectsStore.projects.length > 0" class="grid ...">` (line 15) from a plain `<div>` to a `<TransitionGroup>`:
```html
<TransitionGroup
v-if="projectsStore.projects.length > 0"
name="list"
tag="div"
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"
>
<!-- existing v-for cards, add :style for stagger -->
<div
v-for="(project, index) in projectsStore.projects"
:key="project.id"
class="group bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] card-hover cursor-pointer"
:style="{ transitionDelay: `${index * 30}ms` }"
@click="openEditDialog(project)"
>
<!-- ... existing content unchanged ... -->
</div>
</TransitionGroup>
```
Note: Also replace `hover:bg-bg-elevated transition-all duration-150` with the `card-hover` class (from Task 2) to get the hover-lift effect.
**Step 3: Clients.vue — wrap client cards in TransitionGroup**
Same pattern as Projects. In `src/views/Clients.vue`, replace the grid `<div>` (line 15) with:
```html
<TransitionGroup
v-if="clientsStore.clients.length > 0"
name="list"
tag="div"
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"
>
<div
v-for="(client, index) in clientsStore.clients"
:key="client.id"
class="group bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] card-hover cursor-pointer"
:style="{ transitionDelay: `${index * 30}ms` }"
@click="openEditDialog(client)"
>
<!-- ... existing content unchanged ... -->
</div>
</TransitionGroup>
```
**Step 4: Verify build**
Run: `npm run build`
Expected: Build passes.
**Step 5: Commit**
```bash
git add src/views/Entries.vue src/views/Projects.vue src/views/Clients.vue
git commit -m "feat: add list enter/leave animations to Entries, Projects, Clients"
```
---
### Task 6: List animations on Timer view
**Files:**
- Modify: `src/views/Timer.vue`
**Step 1: Animate recent entries list**
In `src/views/Timer.vue`, replace the `<div v-if="recentEntries.length > 0">` container (line 120-152) with a `<TransitionGroup>`:
```html
<TransitionGroup v-if="recentEntries.length > 0" name="list" tag="div">
<div
v-for="(entry, index) in recentEntries"
:key="entry.id"
class="flex items-center justify-between py-3 border-b border-border-subtle last:border-0"
:class="index === 0 ? 'border-l-2 border-l-accent pl-3' : ''"
:style="{ transitionDelay: `${index * 40}ms` }"
>
<!-- ... existing content unchanged ... -->
</div>
</TransitionGroup>
```
**Step 2: Animate favorites strip**
Replace the favorites `<div class="flex items-center gap-2 ...">` (line 44-56) with a `<TransitionGroup>`:
```html
<TransitionGroup tag="div" name="chip" class="flex items-center gap-2 overflow-x-auto pb-1">
<button
v-for="(fav, index) in favorites"
:key="fav.id"
@click="applyFavorite(fav)"
:disabled="!timerStore.isStopped"
class="shrink-0 flex items-center gap-1.5 px-3 py-1.5 rounded-full border border-border-subtle text-[0.6875rem] text-text-secondary hover:text-text-primary hover:border-border-visible transition-colors disabled:opacity-40"
:style="{ transitionDelay: `${index * 50}ms` }"
>
<span class="w-2 h-2 rounded-full" :style="{ backgroundColor: getProjectColor(fav.project_id) }" />
{{ getProjectName(fav.project_id) }}
<span v-if="fav.description" class="text-text-tertiary">&middot; {{ fav.description }}</span>
</button>
</TransitionGroup>
```
**Step 3: Add empty state floating icon**
In the empty state section (line 155-159), add `animate-float` class to the Timer icon:
```html
<TimerIcon class="w-10 h-10 text-text-tertiary animate-float" :stroke-width="1.5" />
```
**Step 4: Verify build**
Run: `npm run build`
Expected: Build passes.
**Step 5: Commit**
```bash
git add src/views/Timer.vue
git commit -m "feat: add list animations and floating empty-state icon to Timer"
```
---
### Task 7: Modal transitions (enter + leave)
**Files:**
- Modify: `src/views/Projects.vue`
- Modify: `src/views/Clients.vue`
- Modify: `src/views/Entries.vue`
- Modify: `src/views/Settings.vue` (if it has modals using `v-if` + `animate-modal-enter`)
Currently all modals use `v-if` on a wrapping div with `animate-modal-enter` on the panel. This gives enter-only animation. We'll wrap each modal in a `<Transition>` to get both enter AND leave.
The pattern for every modal is:
**Before (current):**
```html
<div v-if="showDialog" class="fixed inset-0 bg-black/70 ...">
<div class="... animate-modal-enter">
...
</div>
</div>
```
**After (with transitions):**
```html
<Transition name="modal">
<div v-if="showDialog" class="fixed inset-0 bg-black/70 ..." @click.self="...">
<Transition name="modal-panel" appear>
<div class="..." :key="showDialog">
...
</div>
</Transition>
</div>
</Transition>
```
Actually, since the backdrop and panel appear/disappear together (same `v-if`), we can simplify. Instead of nesting Transitions, use a single `<Transition>` on the backdrop `v-if` and add CSS for the panel animation within the enter/leave classes.
**Simplified pattern:**
Replace:
```html
<div
v-if="showDialog"
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-center justify-center p-4 z-50"
@click.self="closeDialog"
>
<div class="bg-bg-surface ... animate-modal-enter">
```
With:
```html
<Transition name="modal" appear>
<div
v-if="showDialog"
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-center justify-center p-4 z-50"
@click.self="closeDialog"
>
<div class="bg-bg-surface ...">
```
Remove `animate-modal-enter` from the panel div — the `modal-*` CSS transition classes from Task 2 handle the fade. Also add panel scale animation by targeting the child:
We need an additional CSS approach: wrap the panel itself in a nested `<Transition name="modal-panel">`. Since the panel is inside the backdrop div and the backdrop controls `v-if`, we need `appear` on the inner transition.
**Simplest correct approach:**
```html
<Transition name="modal">
<div
v-if="showDialog"
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-center justify-center p-4 z-50"
@click.self="tryCloseDialog"
>
<div class="bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] w-full max-w-md p-6 max-h-[calc(100vh-2rem)] overflow-y-auto">
```
And add to CSS (already in Task 2):
```css
.modal-enter-active .bg-bg-surface,
.modal-leave-active .bg-bg-surface { ... }
```
Actually, let's keep it simpler. The `modal` transition classes already handle opacity. For the panel scale, let's add a combined approach — use the same transition name but add extra CSS that targets the child.
**Best approach:** Use two separate `<Transition>` wrappers — one for backdrop, one for panel. The panel should use `appear` since it becomes visible when the backdrop does.
Actually, the simplest approach that works: wrap the outer div in `<Transition name="modal">` (this gives the fade in/out on the backdrop), and separately wrap the inner panel div in its own `<Transition name="modal-panel" appear>`. BUT the inner panel doesn't have its own `v-if` — it's always rendered when the outer is rendered.
Vue's `<Transition>` with `appear` will run the enter animation on initial render, which covers the case when `showDialog` becomes true. And when `showDialog` becomes false, the outer `<Transition name="modal">` handles the leave. But the inner panel won't get a leave animation this way.
**Final correct approach:** Combine both animations into a single transition by targeting both backdrop and panel with CSS:
```html
<Transition name="modal">
<div v-if="showDialog" class="fixed inset-0 z-50" @click.self="...">
<div class="absolute inset-0 bg-black/70 backdrop-blur-[4px]" />
<div class="relative flex items-center justify-center h-full p-4" @click.self="...">
<div class="bg-bg-surface ... modal-panel">
...
</div>
</div>
</div>
</Transition>
```
This is getting complex. Let's use the most pragmatic approach instead:
**Step 1: Apply to all modals**
The simplest approach: wrap the outermost `v-if` div in `<Transition name="modal">`. The `modal-*` CSS classes will fade the entire modal (backdrop + panel) in and out. Remove the old `animate-modal-enter` class from all panels.
In each file (Projects.vue, Clients.vue, Entries.vue), find every modal block that uses `v-if="show..."` + `animate-modal-enter` and:
1. Wrap in `<Transition name="modal">` (just before the `<div v-if="...">`)
2. Remove `animate-modal-enter` from the inner panel div
**Projects.vue** — 2 modals:
- Create/Edit dialog (line 82-242): `v-if="showDialog"`
- Delete dialog (line 245-270): `v-if="showDeleteDialog"`
**Clients.vue** — 2 modals:
- Create/Edit dialog (line 68-203): `v-if="showDialog"`
- Delete dialog (line 206-231): `v-if="showDeleteDialog"`
**Entries.vue** — 2 modals:
- Edit dialog (line 155-234): `v-if="showEditDialog"`
- Delete dialog (line 237-262): `v-if="showDeleteDialog"`
For each modal, change from:
```html
<div v-if="showDialog" class="fixed inset-0 bg-black/70 ...">
<div class="... animate-modal-enter">
```
To:
```html
<Transition name="modal">
<div v-if="showDialog" class="fixed inset-0 bg-black/70 ...">
<div class="...">
```
And add `</Transition>` after the closing `</div></div>`.
**Step 2: Verify build**
Run: `npm run build`
Expected: Build passes.
**Step 3: Commit**
```bash
git add src/views/Projects.vue src/views/Clients.vue src/views/Entries.vue
git commit -m "feat: add modal enter/leave transitions"
```
---
### Task 8: Dropdown transitions (AppSelect, AppDatePicker, AppTagInput)
**Files:**
- Modify: `src/components/AppSelect.vue`
- Modify: `src/components/AppDatePicker.vue`
- Modify: `src/components/AppTagInput.vue`
All three components use `v-if` on a teleported dropdown panel with `animate-dropdown-enter`. Replace with `<Transition name="dropdown">` for proper enter/leave.
**Step 1: AppSelect.vue**
In the `<Teleport to="body">` block (line 255-294), wrap the dropdown div in `<Transition>`:
Replace:
```html
<Teleport to="body">
<div
v-if="isOpen"
ref="panelRef"
...
class="... animate-dropdown-enter"
>
```
With:
```html
<Teleport to="body">
<Transition name="dropdown">
<div
v-if="isOpen"
ref="panelRef"
...
class="..."
>
```
Remove `animate-dropdown-enter` from the class list. Add `</Transition>` before `</Teleport>`.
**Step 2: AppDatePicker.vue**
Same pattern — find the teleported dropdown panel with `v-if="isOpen"` and `animate-dropdown-enter`. Wrap in `<Transition name="dropdown">`, remove `animate-dropdown-enter`.
**Step 3: AppTagInput.vue**
Same pattern — find the teleported dropdown panel with `v-if="isOpen"` and `animate-dropdown-enter`. Wrap in `<Transition name="dropdown">`, remove `animate-dropdown-enter`.
**Step 4: Verify build**
Run: `npm run build`
Expected: Build passes.
**Step 5: Commit**
```bash
git add src/components/AppSelect.vue src/components/AppDatePicker.vue src/components/AppTagInput.vue
git commit -m "feat: add dropdown enter/leave transitions"
```
---
### Task 9: Button and card feedback
**Files:**
- Modify: `src/views/Timer.vue` (Start/Stop button)
- Modify: `src/views/Projects.vue` (project cards already handled in Task 5 with `card-hover`)
- Modify: `src/views/Dashboard.vue` (empty state icon)
- Modify: `src/views/Entries.vue` (empty state icon)
- Modify: `src/views/Projects.vue` (empty state icon)
- Modify: `src/views/Clients.vue` (empty state icon)
**Step 1: Timer Start/Stop button — add `btn-primary` class**
In `src/views/Timer.vue`, find the Start/Stop button (line 33-39). Add `btn-primary` to its class list:
```html
<button
@click="toggleTimer"
class="btn-primary px-10 py-3 text-sm font-medium rounded-lg transition-colors duration-150"
:class="buttonClass"
>
```
**Step 2: Empty state icons — add `animate-float` class**
In each view with an empty state, add `animate-float` to the empty state icon:
- `Dashboard.vue` line 6: `<Clock class="w-12 h-12 text-text-tertiary animate-float" ...`
- `Entries.vue` line 146: `<ListIcon class="w-12 h-12 text-text-tertiary animate-float" ...`
- `Projects.vue` line 70: `<FolderKanban class="w-12 h-12 text-text-tertiary animate-float" ...`
- `Clients.vue` line 56: `<Users class="w-12 h-12 text-text-tertiary animate-float" ...`
**Step 3: Dashboard progress bars — add `progress-bar` class**
In `src/views/Dashboard.vue`, find the goal progress bars (around lines 52 and 63). Add `progress-bar` to each inner bar div that has `:style="{ width: ... }"`:
```html
<div
class="h-1.5 rounded-full bg-accent progress-bar"
:style="{ width: Math.min(dailyPct, 100) + '%' }"
/>
```
Same for the weekly progress bar.
**Step 4: Projects budget progress bars**
In `src/views/Projects.vue`, find the budget progress bar (around line 57). Add `progress-bar`:
```html
<div
class="h-1 rounded-full progress-bar"
:class="..."
:style="{ width: ... }"
/>
```
**Step 5: Verify build**
Run: `npm run build`
Expected: Build passes.
**Step 6: Commit**
```bash
git add src/views/Timer.vue src/views/Dashboard.vue src/views/Entries.vue src/views/Projects.vue src/views/Clients.vue
git commit -m "feat: add button feedback, floating empty states, and progress bar animations"
```
---
### Task 10: Timer-specific animations
**Files:**
- Modify: `src/views/Timer.vue`
- Modify: `src/styles/main.css`
**Step 1: Add timer pulse keyframe to main.css**
```css
/* Timer start pulse */
@keyframes timer-pulse {
0% { transform: scale(1); }
50% { transform: scale(1.03); }
100% { transform: scale(1); }
}
.animate-timer-pulse {
animation: timer-pulse 300ms cubic-bezier(0.22, 1, 0.36, 1);
}
/* Timer stop glow */
@keyframes timer-glow {
0% { text-shadow: 0 0 0 transparent; }
30% { text-shadow: 0 0 12px var(--color-accent-muted); }
100% { text-shadow: 0 0 0 transparent; }
}
.animate-timer-glow {
animation: timer-glow 600ms ease-out;
}
```
**Step 2: Add reactive pulse class to timer display**
In `src/views/Timer.vue`, add a ref for the pulse class and toggle it on timer state changes.
In `<script setup>`:
```ts
const timerPulseClass = ref('')
// Watch for timer state changes to trigger pulse
watch(() => timerStore.isRunning, (isRunning, wasRunning) => {
if (isRunning && !wasRunning) {
// Timer started
timerPulseClass.value = 'animate-timer-pulse'
setTimeout(() => { timerPulseClass.value = '' }, 300)
}
})
watch(() => timerStore.isStopped, (isStopped, wasStopped) => {
if (isStopped && !wasStopped) {
// Timer stopped
timerPulseClass.value = 'animate-timer-glow'
setTimeout(() => { timerPulseClass.value = '' }, 600)
}
})
```
In the template, add `:class="timerPulseClass"` to the timer display `<p>` element (line 6):
```html
<p
class="text-[3.5rem] font-medium font-[family-name:var(--font-heading)] tracking-tighter mb-2"
:class="timerPulseClass"
>
```
**Step 3: Verify build**
Run: `npm run build`
Expected: Build passes.
**Step 4: Commit**
```bash
git add src/views/Timer.vue src/styles/main.css
git commit -m "feat: add timer start pulse and stop glow animations"
```
---
### Task 11: Content fade-in on data load views
**Files:**
- Modify: `src/views/Dashboard.vue`
- Modify: `src/views/Reports.vue`
- Modify: `src/views/CalendarView.vue`
- Modify: `src/views/TimesheetView.vue`
Add a `<Transition name="fade">` around the main content areas that load data asynchronously, so content fades in smoothly when data arrives.
**Step 1: Dashboard.vue — fade main content**
Wrap the `<template v-else>` block (line 14-133) content in a `<Transition>`:
```html
<Transition name="fade" appear>
<template v-if="!isEmpty">
<!-- ... existing content ... -->
</template>
</Transition>
```
Since `<Transition>` needs a single root child, replace `<template v-else>` with a wrapper `<div v-else>` inside the Transition:
```html
<Transition name="fade" appear>
<div v-if="!isEmpty">
<!-- all existing content from the template v-else block -->
</div>
</Transition>
```
**Step 2: CalendarView.vue — wrap calendar body in fade**
Add `<Transition name="fade" mode="out-in">` around the calendar body with `:key="weekStart"` so it transitions when the week changes.
**Step 3: TimesheetView.vue — wrap table body in fade**
Add `<Transition name="fade" mode="out-in">` around the main table content with a key that changes on week navigation.
**Step 4: Verify build**
Run: `npm run build`
Expected: Build passes.
**Step 5: Commit**
```bash
git add src/views/Dashboard.vue src/views/Reports.vue src/views/CalendarView.vue src/views/TimesheetView.vue
git commit -m "feat: add content fade-in transitions on data load"
```
---
### Task 12: Tag chip animations in AppTagInput
**Files:**
- Modify: `src/components/AppTagInput.vue`
**Step 1: Wrap selected tag chips in TransitionGroup**
In the template, find where selected tags are rendered (the chips display area). Wrap the selected tag chips in `<TransitionGroup name="chip">`:
```html
<TransitionGroup name="chip" tag="div" class="flex flex-wrap gap-1.5">
<span
v-for="tag in selectedTags"
:key="tag.id"
class="..."
>
<!-- existing chip content -->
</span>
</TransitionGroup>
```
**Step 2: Verify build**
Run: `npm run build`
Expected: Build passes.
**Step 3: Commit**
```bash
git add src/components/AppTagInput.vue
git commit -m "feat: add tag chip enter/leave animations"
```
---
### Task 13: Final verification and cleanup
**Step 1: Full build**
Run: `npm run build`
Expected: Build passes with no errors.
**Step 2: Clean up unused CSS keyframes**
Check if `animate-modal-enter` and `animate-dropdown-enter` classes are still used anywhere. If not, remove them from `main.css` (they were replaced by Vue Transition classes in Tasks 7 and 8).
Run:
```bash
grep -r "animate-modal-enter\|animate-dropdown-enter" src/
```
If no results, remove the `@keyframes modal-enter`, `.animate-modal-enter`, `@keyframes dropdown-enter`, `.animate-dropdown-enter` blocks from `main.css`.
**Step 3: Final build**
Run: `npm run build`
Expected: Build passes with no errors.
**Step 4: Commit**
```bash
git add -A
git commit -m "chore: remove unused animation keyframes"
```