- 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
1140 lines
31 KiB
Markdown
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">· {{ 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"
|
|
```
|