Files
zeroclock/docs/plans/2026-02-18-motion-system-implementation.md
Your Name a3a6ab2fdf 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

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">&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:

<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 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):

<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:

  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:

<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 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:

<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: ... }":

<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"