- 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
31 KiB
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:
import { MotionPlugin } from '@vueuse/motion'
// After app.use(router), before app.mount:
app.use(MotionPlugin)
Final src/main.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:
// 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
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
/* 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
/* 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
/* 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
/* 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
/* 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
/* 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
/* 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
/* 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
/* Toggle switch overshoot easing */
.toggle-thumb {
transition: transform 200ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
Step 10: Add progress bar animation
/* 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:
@keyframes toast-enter {
from {
opacity: 0;
transform: translateY(-20px) translateX(10px);
}
to {
opacity: 1;
transform: translateY(0) translateX(0);
}
}
Replace @keyframes toast-exit with:
@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:
/* 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
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:
<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
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:
<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:
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
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:
<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>:
<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:
<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
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>:
<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>:
<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">· {{ 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:
<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
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 usingv-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):
<div v-if="showDialog" class="fixed inset-0 bg-black/70 ...">
<div class="... animate-modal-enter">
...
</div>
</div>
After (with transitions):
<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:
<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:
<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:
<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):
.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:
<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:
- Wrap in
<Transition name="modal">(just before the<div v-if="...">) - Remove
animate-modal-enterfrom 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:
<div v-if="showDialog" class="fixed inset-0 bg-black/70 ...">
<div class="... animate-modal-enter">
To:
<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
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:
<Teleport to="body">
<div
v-if="isOpen"
ref="panelRef"
...
class="... animate-dropdown-enter"
>
With:
<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
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 withcard-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:
<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.vueline 6:<Clock class="w-12 h-12 text-text-tertiary animate-float" ...Entries.vueline 146:<ListIcon class="w-12 h-12 text-text-tertiary animate-float" ...Projects.vueline 70:<FolderKanban class="w-12 h-12 text-text-tertiary animate-float" ...Clients.vueline 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: ... }":
<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:
<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
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
/* 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>:
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):
<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
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>:
<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:
<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
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">:
<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
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:
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
git add -A
git commit -m "chore: remove unused animation keyframes"