From a3a6ab2fdf2575c46933bddfab65237317f6ffb3 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 18 Feb 2026 11:33:58 +0200 Subject: [PATCH] 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-motion-system-implementation.md | 1139 +++++++++++++++++ src/App.vue | 6 +- src/components/AppColorPicker.vue | 423 ++++++ src/components/AppDatePicker.vue | 293 +++-- src/components/AppSelect.vue | 23 +- src/components/AppTagInput.vue | 9 +- src/components/NavRail.vue | 23 +- src/components/RunningAppsPicker.vue | 212 +++ src/views/CalendarView.vue | 4 +- src/views/Clients.vue | 37 +- src/views/Dashboard.vue | 12 +- src/views/Entries.vue | 17 +- src/views/Projects.vue | 21 +- src/views/Reports.vue | 2 + src/views/Timer.vue | 34 +- src/views/TimesheetView.vue | 4 +- 16 files changed, 2115 insertions(+), 144 deletions(-) create mode 100644 docs/plans/2026-02-18-motion-system-implementation.md create mode 100644 src/components/AppColorPicker.vue create mode 100644 src/components/RunningAppsPicker.vue diff --git a/docs/plans/2026-02-18-motion-system-implementation.md b/docs/plans/2026-02-18-motion-system-implementation.md new file mode 100644 index 0000000..3162c80 --- /dev/null +++ b/docs/plans/2026-02-18-motion-system-implementation.md @@ -0,0 +1,1139 @@ +# 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 `` and `` 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 `` at line 82 with: + +```html + + + + + +``` + +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 `
` 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 + +``` + +**Step 2: Add activeIndex computed** + +In the ` + + diff --git a/src/components/AppDatePicker.vue b/src/components/AppDatePicker.vue index 53dad42..68629c4 100644 --- a/src/components/AppDatePicker.vue +++ b/src/components/AppDatePicker.vue @@ -51,28 +51,105 @@ const displayText = computed(() => { return datePart }) -// ── Time helpers ────────────────────────────────────────────────────── +// ── Time wheel ────────────────────────────────────────────────────── +const WHEEL_ITEM_H = 36 +const WHEEL_VISIBLE = 5 +const WHEEL_HEIGHT = WHEEL_ITEM_H * WHEEL_VISIBLE // 180px +const WHEEL_PAD = WHEEL_ITEM_H * 2 // 72px spacer (2 items above/below center) + const internalHour = ref(props.hour) const internalMinute = ref(props.minute) +const hourWheelRef = ref(null) +const minuteWheelRef = ref(null) watch(() => props.hour, (v) => { internalHour.value = v }) watch(() => props.minute, (v) => { internalMinute.value = v }) -function onHourInput(e: Event) { - const val = parseInt((e.target as HTMLInputElement).value) - if (!isNaN(val)) { - const clamped = Math.min(23, Math.max(0, val)) - internalHour.value = clamped - emit('update:hour', clamped) - } +// Debounced scroll handler to read the current value +let hourScrollTimer: ReturnType | null = null +let minuteScrollTimer: ReturnType | null = null + +function onHourScroll() { + if (hourScrollTimer) clearTimeout(hourScrollTimer) + hourScrollTimer = setTimeout(() => { + if (!hourWheelRef.value) return + const index = Math.round(hourWheelRef.value.scrollTop / WHEEL_ITEM_H) + const clamped = Math.min(23, Math.max(0, index)) + if (internalHour.value !== clamped) { + internalHour.value = clamped + emit('update:hour', clamped) + } + }, 60) } -function onMinuteInput(e: Event) { - const val = parseInt((e.target as HTMLInputElement).value) - if (!isNaN(val)) { - const clamped = Math.min(59, Math.max(0, val)) - internalMinute.value = clamped - emit('update:minute', clamped) +function onMinuteScroll() { + if (minuteScrollTimer) clearTimeout(minuteScrollTimer) + minuteScrollTimer = setTimeout(() => { + if (!minuteWheelRef.value) return + const index = Math.round(minuteWheelRef.value.scrollTop / WHEEL_ITEM_H) + const clamped = Math.min(59, Math.max(0, index)) + if (internalMinute.value !== clamped) { + internalMinute.value = clamped + emit('update:minute', clamped) + } + }, 60) +} + +// Mouse wheel: one item per tick +function onHourWheel(e: WheelEvent) { + e.preventDefault() + if (!hourWheelRef.value) return + const dir = e.deltaY > 0 ? 1 : -1 + const cur = Math.round(hourWheelRef.value.scrollTop / WHEEL_ITEM_H) + const next = Math.min(23, Math.max(0, cur + dir)) + hourWheelRef.value.scrollTo({ top: next * WHEEL_ITEM_H, behavior: 'smooth' }) +} + +function onMinuteWheel(e: WheelEvent) { + e.preventDefault() + if (!minuteWheelRef.value) return + const dir = e.deltaY > 0 ? 1 : -1 + const cur = Math.round(minuteWheelRef.value.scrollTop / WHEEL_ITEM_H) + const next = Math.min(59, Math.max(0, cur + dir)) + minuteWheelRef.value.scrollTo({ top: next * WHEEL_ITEM_H, behavior: 'smooth' }) +} + +// Click-and-drag support +let dragEl: HTMLElement | null = null +let dragStartY = 0 +let dragStartScrollTop = 0 + +function onWheelPointerDown(e: PointerEvent) { + const el = e.currentTarget as HTMLElement + dragEl = el + dragStartY = e.clientY + dragStartScrollTop = el.scrollTop + el.setPointerCapture(e.pointerId) +} + +function onWheelPointerMove(e: PointerEvent) { + if (!dragEl) return + e.preventDefault() + const delta = dragStartY - e.clientY + dragEl.scrollTop = dragStartScrollTop + delta +} + +function onWheelPointerUp(e: PointerEvent) { + if (!dragEl) return + const el = dragEl + dragEl = null + el.releasePointerCapture(e.pointerId) + // Snap to nearest item + const index = Math.round(el.scrollTop / WHEEL_ITEM_H) + el.scrollTo({ top: index * WHEEL_ITEM_H, behavior: 'smooth' }) +} + +function scrollWheelsToTime() { + if (hourWheelRef.value) { + hourWheelRef.value.scrollTop = internalHour.value * WHEEL_ITEM_H + } + if (minuteWheelRef.value) { + minuteWheelRef.value.scrollTop = internalMinute.value * WHEEL_ITEM_H } } @@ -106,9 +183,7 @@ const dayCells = computed(() => { const y = viewYear.value const m = viewMonth.value - // First day of the month (0=Sun, 1=Mon, ...) const firstDayOfWeek = new Date(y, m, 1).getDay() - // Shift so Monday=0 const startOffset = (firstDayOfWeek + 6) % 7 const daysInMonth = new Date(y, m + 1, 0).getDate() @@ -116,7 +191,6 @@ const dayCells = computed(() => { const cells: DayCell[] = [] - // Previous month padding const prevMonth = m === 0 ? 11 : m - 1 const prevYear = m === 0 ? y - 1 : y for (let i = startOffset - 1; i >= 0; i--) { @@ -130,7 +204,6 @@ const dayCells = computed(() => { }) } - // Current month days for (let d = 1; d <= daysInMonth; d++) { cells.push({ date: d, @@ -141,7 +214,6 @@ const dayCells = computed(() => { }) } - // Next month padding (fill up to 42 cells = 6 rows) const nextMonth = m === 11 ? 0 : m + 1 const nextYear = m === 11 ? y + 1 : y let nextDay = 1 @@ -173,10 +245,9 @@ function updatePosition() { if (!triggerRef.value) return const rect = triggerRef.value.getBoundingClientRect() const zoom = getZoomFactor() - const panelWidth = 280 + const panelWidth = props.showTime ? 390 : 280 const renderedWidth = panelWidth * zoom - // Compute left in viewport space, then convert to panel's zoomed coordinate space let leftViewport = rect.left if (leftViewport + renderedWidth > window.innerWidth) { leftViewport = window.innerWidth - renderedWidth - 8 @@ -203,7 +274,6 @@ function toggle() { } function open() { - // Sync view to modelValue or today if (props.modelValue) { const [y, m] = props.modelValue.split('-').map(Number) viewYear.value = y @@ -221,6 +291,10 @@ function open() { document.addEventListener('click', onClickOutside, true) document.addEventListener('scroll', onScrollOrResize, true) window.addEventListener('resize', onScrollOrResize) + + if (props.showTime) { + scrollWheelsToTime() + } }) } @@ -254,12 +328,16 @@ function nextMonthNav() { function selectDay(cell: DayCell) { if (!cell.isCurrentMonth) return emit('update:modelValue', cell.dateString) - close() + if (!props.showTime) { + close() + } } function selectToday() { emit('update:modelValue', todayString()) - close() + if (!props.showTime) { + close() + } } // ── Event handlers ────────────────────────────────────────────────── @@ -332,11 +410,12 @@ onBeforeUnmount(() => { +
@@ -359,60 +438,121 @@ onBeforeUnmount(() => {
- -
-
- {{ header }} + +
+ +
+ +
+
+ {{ header }} +
+
+ + +
+ +
-
- -
- -
+ +
+
+ +
+ +
+ +
+
+
+ {{ String(h - 1).padStart(2, '0') }} +
+
+
+
- -
- Time - - : - + : + + +
+ +
+ +
+
+
+ {{ String(m - 1).padStart(2, '0') }} +
+
+
+
+
+
@@ -426,6 +566,7 @@ onBeforeUnmount(() => {
+
diff --git a/src/components/AppSelect.vue b/src/components/AppSelect.vue index ed94b08..04f6034 100644 --- a/src/components/AppSelect.vue +++ b/src/components/AppSelect.vue @@ -1,6 +1,7 @@ + + diff --git a/src/views/CalendarView.vue b/src/views/CalendarView.vue index 4d01e24..e611772 100644 --- a/src/views/CalendarView.vue +++ b/src/views/CalendarView.vue @@ -34,7 +34,8 @@
-
+ +
@@ -119,6 +120,7 @@
+
diff --git a/src/views/Clients.vue b/src/views/Clients.vue index 6011bf0..805aa39 100644 --- a/src/views/Clients.vue +++ b/src/views/Clients.vue @@ -12,11 +12,12 @@
-
+
@@ -49,11 +50,11 @@
-
+
- +

No clients yet

Clients let you organize projects and generate invoices with billing details.