From 8b475e4df50ebbb04f4bb89f29e81eb250dde0a6 Mon Sep 17 00:00:00 2001 From: bboysoul Date: Fri, 11 Sep 2026 08:56:51 +0800 Subject: [PATCH] feat: animate completed items out --- frontend/src/App.vue | 26 +++++++++++++----- frontend/src/MvpPanel.vue | 38 +++++++++++++++++++++------ frontend/src/lib/completion-motion.ts | 19 ++++++++++++++ frontend/src/lib/mvp-utils.ts | 14 +++++++--- frontend/src/lib/performance.test.ts | 25 ++++++++++++++++++ frontend/src/style.css | 2 +- frontend/src/style.test.ts | 19 ++++++++++++-- 7 files changed, 121 insertions(+), 22 deletions(-) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 8111e53..92adf71 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -8,7 +8,7 @@ import { import { buildTaskRecurrencePayload, defaultTaskDueAt, filterTasks, fromDateTimeLocal, groupTaskTree, isSameTaskSortTier, moveItemWithinScope, parseTaskRecurrence, parseTaskRrule, renderMarkdown, toDateTimeLocal, type TaskRepeatConfig, type TaskRepeatOption } from './lib/task-utils' import { beginLatestRequest, createMutationReconciler, formatApiErrorDetail, isLatestRequest, isTaskView, loadCountdownCache, nextTotalAfterLocalTaskAdd, nextTotalAfterLocalTaskRemoval, normalizeRequiredName, performTrashMutation, readStoredBoolean, readStoredNavigation, runLatestRequest, shouldToggleRowSwipe, startPrimaryWithBackground, writeStoredBoolean, writeStoredNavigation } from './lib/mvp-utils' import { csrfHeader } from './lib/csrf' -import { createCompletionPulse } from './lib/completion-motion' +import { createCompletionPulse, shouldAnimateCompletionExit, waitForCompletionExit } from './lib/completion-motion' import { captureListDragPointer, getAdjacentListMove, hasExceededLongPressMovement, moveListToScope, snapshotListDragPointer, type ListDragPointer } from './lib/list-drag' import { clampSearchPullDistance, isAtSearchPullOrigin, isSearchShortcut, shouldHideSearchAfterSwipe, shouldRevealSearchAfterPull } from './lib/mobile-search' import { nextDialogFocusIndex } from './lib/list-purge' @@ -100,6 +100,12 @@ const totalPages = computed(() => Math.max(1, Math.ceil(totalTasks.value / pageS const expandedFolders = ref(new Set()) const collapsedTaskIds = ref(new Set()) const justCompletedTaskIds = ref(new Set()) +const completionExitingTaskIds = ref(new Set()) +function setTaskCompletionExiting(id: string, active: boolean) { + const next = new Set(completionExitingTaskIds.value) + active ? next.add(id) : next.delete(id) + completionExitingTaskIds.value = next +} const markTaskJustCompleted = createCompletionPulse( (id) => { justCompletedTaskIds.value = new Set(justCompletedTaskIds.value).add(id) }, (id) => { const next = new Set(justCompletedTaskIds.value); next.delete(id); justCompletedTaskIds.value = next }, @@ -606,7 +612,7 @@ function applyTaskUpdate(task: Task, updated: Task) { if (index >= 0) tasks.value[index] = { ...tasks.value[index], ...updated } const overdueIndex = overdueTasks.value.findIndex((item) => item.id === task.id) if (overdueIndex >= 0) { - if (updated.completed) overdueTasks.value.splice(overdueIndex, 1) + if (updated.completed && !completionExitingTaskIds.value.has(task.id)) overdueTasks.value.splice(overdueIndex, 1) else overdueTasks.value[overdueIndex] = { ...overdueTasks.value[overdueIndex], ...updated } } if (selectedTask.value?.id === task.id) selectedTask.value = { ...selectedTask.value, ...updated } @@ -624,13 +630,19 @@ const taskMutationReconciler = createMutationReconciler( ) async function toggle(task: Task) { const completing = !task.completed + const animateExit = shouldAnimateCompletionExit({ completing, showCompleted: showCompleted.value }) await taskMutationReconciler.run( () => api(`/tasks/${task.id}`, { method: 'PATCH', body: JSON.stringify({ completed: completing, version: task.version }) }) as Promise, () => toast(completing ? '完成啦' : '已重新打开'), fail, - (updated) => { + async (updated) => { + if (animateExit) setTaskCompletionExiting(task.id, true) applyTaskUpdate(task, updated) if (completing) markTaskJustCompleted(task.id) + if (animateExit) { + await waitForCompletionExit() + setTaskCompletionExiting(task.id, false) + } }, ) } @@ -1326,8 +1338,8 @@ onUnmounted(() => {

已过期 {{overdueTaskTree.length}}

@@ -1337,13 +1349,13 @@ onUnmounted(() => {
{{page}} / {{totalPages}}
{{ query ? '没有匹配的任务' : hiddenCompletedTaskCount > 0 ? '已完成任务已隐藏' : '这里还很安静' }}{{query?'换个关键词试试':hiddenCompletedTaskCount > 0 ? '开启“显示已完成”即可查看。':'写下第一件想完成的小事吧'}}
diff --git a/frontend/src/MvpPanel.vue b/frontend/src/MvpPanel.vue index 6ff7eb3..24b976d 100644 --- a/frontend/src/MvpPanel.vue +++ b/frontend/src/MvpPanel.vue @@ -4,7 +4,7 @@ import { Activity, ArchiveRestore, Check, ChevronRight, Download, FileJson, Grip import { moveItemWithinScope } from './lib/task-utils' import { archivePanelFlags, changedHabitFields, dateKey, formatArchivedAt, formatAuditAction, formatAuditEntity, formatHabitApiError, formatLocalShortDateTime, formatUserAgent, habitActionState, habitButtonNotice, habitButtonValue, invalidateHabitGridCache, isHabitComplete, isHabitScheduledToday, mergePage, nextHabitSwipeValue, performHabitRestore, previousHabitSwipeValue, readHabitGridCache, shouldToggleRowSwipe, validateHabitForm, writeHabitGridCache, type ArchivePanelState, type HabitFormErrors, type HabitFormValues } from './lib/mvp-utils' import { csrfHeader } from './lib/csrf' -import { createCompletionPulse } from './lib/completion-motion' +import { createCompletionPulse, shouldAnimateCompletionExit, waitForCompletionExit } from './lib/completion-motion' type View = 'habits' | 'today-habits' | 'settings' type HabitCell = { day: string; scheduled?: boolean; paused?: boolean; value: number | boolean } @@ -61,13 +61,26 @@ const habitSwipeOffsets = ref>({}) const habitReorder = ref<{ id: string; startY: number; offsetY: number } | null>(null) const habitReorderTarget = ref('') const justCompletedHabitIds = ref(new Set()) +const completionExitingHabitIds = ref(new Set()) +function setHabitCompletionExiting(id: string, active: boolean) { + const next = new Set(completionExitingHabitIds.value) + active ? next.add(id) : next.delete(id) + completionExitingHabitIds.value = next +} +async function finishHabitCompletion(h: Habit, wasDone: boolean, next: number | boolean, animateExit: boolean) { + if (wasDone || !isHabitComplete(h.kind, next, h.target ?? 1)) return + markHabitJustCompleted(h.id) + if (!animateExit) return + await waitForCompletionExit() + setHabitCompletionExiting(h.id, false) +} const markHabitJustCompleted = createCompletionPulse( (id) => { justCompletedHabitIds.value = new Set(justCompletedHabitIds.value).add(id) }, (id) => { const next = new Set(justCompletedHabitIds.value); next.delete(id); justCompletedHabitIds.value = next }, ) const todayHabits = computed(() => habits.value.filter((item) => isHabitScheduledToday(item, todayKey.value))) -const visibleTodayHabits = computed(() => props.showCompleted ? todayHabits.value : todayHabits.value.filter((item) => !isDone(item, todayKey.value))) -const visibleHabits = computed(() => props.showCompleted ? habits.value : habits.value.filter((item) => !isDone(item, todayKey.value))) +const visibleTodayHabits = computed(() => props.showCompleted ? todayHabits.value : todayHabits.value.filter((item) => !isDone(item, todayKey.value) || completionExitingHabitIds.value.has(item.id))) +const visibleHabits = computed(() => props.showCompleted ? habits.value : habits.value.filter((item) => !isDone(item, todayKey.value) || completionExitingHabitIds.value.has(item.id))) const todayHabitSummary = computed(() => ({ total: todayHabits.value.length, completed: todayHabits.value.filter((item) => isDone(item, todayKey.value)).length, @@ -222,12 +235,18 @@ async function applyHabitSwipe(h: Habit, deltaX: number) { ? nextHabitSwipeValue(h.kind, current, h.target ?? 1) : previousHabitSwipeValue(h.kind, current) const previous = current ?? 0 - setLocalHabitValue(h, next) + const animateExit = !wasDone && isHabitComplete(h.kind, next, h.target ?? 1) && shouldAnimateCompletionExit({ completing: true, showCompleted: props.showCompleted }) + if (!animateExit) setLocalHabitValue(h, next) try { await request(`/habits/${h.id}/logs/${todayKey.value}`, { method: 'PUT', body: JSON.stringify({ value: next }) }) - if (!wasDone && isHabitComplete(h.kind, next, h.target ?? 1)) markHabitJustCompleted(h.id) + if (animateExit) { + setHabitCompletionExiting(h.id, true) + setLocalHabitValue(h, next) + } + await finishHabitCompletion(h, wasDone, next, animateExit) emit('notice', next > Number(previous) ? '已记录一次 🎉' : '已减少一次') } catch (e) { + setHabitCompletionExiting(h.id, false) setLocalHabitValue(h, previous) error.value = e instanceof Error ? e.message : '请求失败' } @@ -284,12 +303,15 @@ async function toggleHabitFromButton(h: Habit) { const wasDone = isHabitComplete(h.kind, current, h.target ?? 1) const previous = current ?? 0 const next = habitButtonValue(h.kind, current, h.target ?? 1) + const animateExit = !wasDone && isHabitComplete(h.kind, next, h.target ?? 1) && shouldAnimateCompletionExit({ completing: true, showCompleted: props.showCompleted }) + if (animateExit) setHabitCompletionExiting(h.id, true) setLocalHabitValue(h, next) try { await request(`/habits/${h.id}/logs/${todayKey.value}`, { method: 'PUT', body: JSON.stringify({ value: next }) }) - if (!wasDone && isHabitComplete(h.kind, next, h.target ?? 1)) markHabitJustCompleted(h.id) + await finishHabitCompletion(h, wasDone, next, animateExit) emit('notice', habitButtonNotice(h.kind, previous, next)) } catch (e) { + setHabitCompletionExiting(h.id, false) setLocalHabitValue(h, previous) error.value = e instanceof Error ? e.message : '请求失败' } @@ -567,7 +589,7 @@ onBeforeUnmount(() => {
-
+
{{ h.name }} @@ -601,7 +623,7 @@ onBeforeUnmount(() => {
-
+
diff --git a/frontend/src/lib/completion-motion.ts b/frontend/src/lib/completion-motion.ts index 73350b2..6feec0f 100644 --- a/frontend/src/lib/completion-motion.ts +++ b/frontend/src/lib/completion-motion.ts @@ -3,6 +3,25 @@ type CompletionId = string type CompletionTimer = number type CompletionFrame = number +export const COMPLETION_EXIT_DURATION = 320 + +export function shouldAnimateCompletionExit({ completing, showCompleted }: { completing: boolean; showCompleted: boolean }) { + return completing && !showCompleted +} + +export function completionExitDelay(reducedMotion: boolean) { + return reducedMotion ? 0 : COMPLETION_EXIT_DURATION +} + +export function prefersReducedMotion() { + return window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false +} + +export function waitForCompletionExit(reducedMotion = prefersReducedMotion()) { + const delay = completionExitDelay(reducedMotion) + return delay ? new Promise((resolve) => window.setTimeout(resolve, delay)) : Promise.resolve() +} + export function createCompletionPulse( activate: (id: CompletionId) => void, deactivate: (id: CompletionId) => void, diff --git a/frontend/src/lib/mvp-utils.ts b/frontend/src/lib/mvp-utils.ts index cc76a0e..5c0ffc1 100644 --- a/frontend/src/lib/mvp-utils.ts +++ b/frontend/src/lib/mvp-utils.ts @@ -227,13 +227,15 @@ export async function runLatestRequest( return committed } +type MutationSuccessCallback = (value: T) => void | Promise + export type MutationReconciler = { run( mutation: () => Promise, onSuccess: (value: T) => void, onError?: (reason: unknown) => void, - onCurrentSuccess?: (value: T) => void, - ): Promise + onCurrentSuccess?: MutationSuccessCallback, + ): Promise } export function createMutationReconciler( @@ -269,12 +271,16 @@ export function createMutationReconciler( value = await mutation() } catch (reason) { onError?.(reason) - return + return false } onSuccess(value) - if (sameContext(context, currentContext())) onCurrentSuccess?.(value) + if (sameContext(context, currentContext())) { + const currentSuccess = onCurrentSuccess?.(value) + if (currentSuccess instanceof Promise) await currentSuccess + } await reconcile(context) if (sameContext(context, currentContext()) && reconciled < dirty) await reconcile(context) + return true }, } } diff --git a/frontend/src/lib/performance.test.ts b/frontend/src/lib/performance.test.ts index 93cbd55..7ad92c8 100644 --- a/frontend/src/lib/performance.test.ts +++ b/frontend/src/lib/performance.test.ts @@ -78,6 +78,31 @@ describe('request generation protection', () => { await Promise.all([first, second]) }) + it('waits for current-context success work before refreshing', async () => { + const events: string[] = [] + let releaseExit!: () => void + const reconciler = createMutationReconciler( + () => ({ view: 'today' }), + (left, right) => left.view === right.view, + async () => { events.push('refresh') }, + ) + const mutation = reconciler.run( + async () => 'done', + () => events.push('success'), + undefined, + async () => { + events.push('exit:start') + await new Promise((resolve) => { releaseExit = resolve }) + events.push('exit:end') + }, + ) + await Promise.resolve() + expect(events).toEqual(['success', 'exit:start']) + releaseExit() + await mutation + expect(events).toEqual(['success', 'exit:start', 'exit:end', 'refresh']) + }) + it('refreshes again when the first refresh finishes before the second mutation commits', async () => { const events: string[] = [] const context = { view: 'today' } diff --git a/frontend/src/style.css b/frontend/src/style.css index 4f8d6dc..fe5230d 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -45,7 +45,7 @@ main{container-type:inline-size;min-width:0;padding:27px 34px 50px;overflow:auto .overdue-section{margin-top:18px}.overdue-heading{color:var(--danger);margin-bottom:6px}.overdue-heading span{min-width:22px;height:22px;padding:0 7px;display:inline-grid;place-items:center;border-radius:999px;background:#fde2dc;color:var(--danger);font-size:11px}.overdue-list{margin-bottom:18px}.overdue-task{background:#fff8f6}.task-tail{flex:0 1 auto;min-width:0;max-width:42%;display:flex;justify-content:flex-end;text-align:right;white-space:nowrap}.task-actions{display:flex;align-items:center;flex:0 0 auto}.task-row>.drag-handle,.task-row>.task-check,.task-actions>*{flex-shrink:0}.task-due{min-width:0;display:inline-flex;justify-content:flex-end;align-items:center}.task-due__text{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-align:right}.task-due--overdue{color:var(--danger)}.task-due--neutral,.task-due--completed{color:var(--muted)}.task-due--completed{opacity:.8}.pager{display:flex;align-items:center;justify-content:flex-end;gap:10px;margin:0 0 14px}.pager button:disabled{opacity:.4;cursor:not-allowed}.pager span{color:var(--muted);font-size:13px}.section-heading{display:flex;align-items:center;gap:7px;margin:16px 0 10px;font-size:16px;line-height:1.35;color:#514a40}.section-heading svg{width:17px;height:17px;color:var(--accent)}.sr-only{position:absolute!important;width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important}.more-settings{border:1px solid var(--line);border-radius:10px;background:#fff}.more-settings summary{cursor:pointer;padding:11px 13px;color:#756d61;font-size:12px;font-weight:700}.more-settings-body{display:grid;gap:12px;padding:0 13px 13px}.more-settings-body>label{display:grid;grid-template-columns:80px 1fr;align-items:center;gap:7px;color:#756d61;font-size:12px;font-weight:650}.modal-mask{position:fixed;inset:0;z-index:80;background:rgba(45,38,31,.4);display:grid;place-items:center;padding:24px}.modal-box{width:min(360px,92vw);background:#fffdf8;border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow);padding:22px;display:grid;gap:14px}.modal-box h3{margin:0;font-size:17px}.modal-box label{display:grid;gap:7px;font-size:12px;font-weight:650;color:#756d61}.modal-input{width:100%;border:1px solid var(--line);background:#fff;padding:11px;border-radius:9px;outline:none;font-size:16px}.modal-actions{display:flex;justify-content:flex-end;gap:8px}.purge-list-dialog{width:min(430px,92vw)}.purge-list-dialog p{margin:0;color:var(--muted);line-height:1.65}.purge-list-dialog button{min-height:44px;min-width:88px}.purge-list-dialog button:disabled{cursor:not-allowed;opacity:.62}.purge-list-error{padding:10px 12px;border-radius:10px;background:#fde7e2!important;color:var(--danger)!important;font-weight:650}.countdown-view{display:grid;gap:16px;padding-bottom:36px}.countdown-content{display:grid;gap:16px}.countdown-hero{display:flex;align-items:end;justify-content:space-between;border-bottom:1px solid var(--line);padding-bottom:10px}.countdown-hero small{display:block;color:var(--muted);font-size:12px}.countdown-layout{display:grid;gap:22px;align-items:start}.countdown-timeline{display:grid;gap:20px;max-width:820px;width:100%;margin:0 auto}.countdown-group{display:grid;gap:2px}.countdown-group>h3{margin:0 0 7px;padding-left:12px;color:#8a8175;font-size:11px;font-weight:750;letter-spacing:.08em}.countdown-row{--countdown-tone:#d9a17c;position:relative;width:100%;min-height:66px;display:grid;grid-template-columns:minmax(0,1fr) 86px;align-items:center;gap:14px;border:0;border-bottom:1px solid var(--line);border-radius:0;background:transparent;padding:9px 10px 9px 17px;text-align:left;color:inherit;transition:background .15s ease,transform .15s ease}.countdown-row:before{content:'';position:absolute;left:3px;top:50%;width:4px;height:4px;border-radius:50%;background:var(--countdown-tone);transform:translateY(-50%)}.countdown-row:first-of-type{border-top:1px solid var(--line)}.countdown-row:hover{background:#fffaf3}.countdown-row:active{transform:scale(.995)}.countdown-row.kind-anniversary{--countdown-tone:#b69b7d}.countdown-row.kind-birthday{--countdown-tone:#9cab8d}.countdown-row.today{--countdown-tone:#71856b}.countdown-row.past{--countdown-tone:#aaa094;opacity:.66}.countdown-row .pinned-icon{position:absolute;right:4px;top:5px}.countdown-icon{width:38px;height:38px;border-radius:12px;background:#fff2e8;display:grid;place-items:center;font-size:19px}.countdown-main{min-width:0;display:grid;gap:4px}.countdown-main>b{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:15px;color:#3f3932}.countdown-main>small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--muted);font-size:11px}.countdown-badges{display:flex;gap:5px}.countdown-badges em{font-style:normal;color:#8a7f72;font-size:10px}.countdown-badges em+em:before{content:'·';margin-right:5px;color:#c1b7aa}.countdown-state{display:grid;justify-items:end;gap:3px}.countdown-state strong{font-variant-numeric:tabular-nums;font-size:26px;line-height:1;color:#62594e}.countdown-state small{color:var(--muted);font-size:10px}.countdown-row.today .countdown-state strong,.countdown-row.today .countdown-state small{color:#71856b}.countdown-row.past .countdown-state strong{color:#887d70}.countdown-focus{--countdown-tone:#d9a17c;width:min(720px,100%);min-height:255px;margin:0 auto;display:grid;place-content:center;justify-items:center;gap:7px;border:1px solid color-mix(in srgb,var(--countdown-tone) 34%,var(--line));border-radius:16px;background:color-mix(in srgb,var(--countdown-tone) 7%,#fffdf8);padding:28px 24px;text-align:center;color:inherit;box-shadow:none;transition:border-color .15s ease,background .15s ease}.countdown-focus:hover{border-color:color-mix(in srgb,var(--countdown-tone) 54%,var(--line));background:color-mix(in srgb,var(--countdown-tone) 10%,#fffdf8)}.countdown-focus.kind-anniversary{--countdown-tone:#b69b7d}.countdown-focus.kind-birthday{--countdown-tone:#9cab8d}.countdown-focus>small{color:#8b8276;font-size:10px;font-weight:750;letter-spacing:.1em}.countdown-focus h3{margin:7px 0 0;font-size:20px;font-weight:680}.countdown-focus p{margin:1px 0 0;color:var(--muted);font-size:11px}.countdown-number{display:flex;align-items:end;gap:6px;margin:4px 0 3px}.countdown-number strong{font-variant-numeric:tabular-nums;font-size:76px;line-height:.9;font-weight:720;letter-spacing:-.05em;color:#3f3932}.countdown-number span{font-size:13px;color:#7a7165;padding-bottom:6px}.countdown-copy{font-size:12px;color:#71685e;font-weight:650}.countdown-empty{min-height:260px;background:#fffdf8;border:1px dashed var(--line);border-radius:16px;padding:26px;color:#766d60;text-align:center;display:grid;gap:9px;place-content:center;justify-items:center}.countdown-empty svg{color:var(--accent);width:30px;height:30px}.countdown-empty span{font-size:12px}.countdown-empty button{margin-top:5px}.archived-toggle,.archived-countdowns button{display:inline-flex;align-items:center;gap:5px;border:1px solid var(--line);background:#fff;border-radius:10px;padding:8px 11px;font-size:12px}.archived-toggle{justify-self:start}.archived-countdowns{display:grid;gap:10px}.archived-countdowns article{display:flex;align-items:center;gap:10px;background:#fff;border:1px solid var(--line);border-radius:14px;padding:12px 14px}.archived-countdowns article b{flex:1}.archived-countdowns article small{color:var(--muted)}.countdown-detail-mask,.countdown-modal-mask{position:fixed;inset:0;background:rgba(45,38,31,.36);display:grid;padding:20px}.countdown-detail-mask{z-index:85;place-items:end center}.countdown-detail-sheet{width:min(500px,100%);background:#fffdf8;border:1px solid var(--line);border-radius:20px;box-shadow:0 12px 36px rgba(56,40,24,.18);padding:18px;display:grid;gap:16px}.countdown-detail-sheet header{display:grid;grid-template-columns:42px 1fr 44px;align-items:center;gap:10px}.countdown-detail-sheet header small{color:var(--muted);font-size:11px}.countdown-detail-sheet header h3{margin:2px 0 0;font-size:19px}.countdown-detail-sheet header button{min-width:44px;min-height:44px;border:0;background:transparent;color:#766d60;border-radius:8px;width:44px;height:44px;display:grid;place-items:center}.countdown-modal header button{min-width:44px;min-height:44px;border:0;background:transparent;color:#766d60;border-radius:8px;width:44px;height:44px;display:grid;place-items:center}.countdown-detail-days{display:flex;align-items:baseline;gap:6px;padding:8px 0}.countdown-detail-days strong{font-variant-numeric:tabular-nums;font-size:46px;line-height:1}.countdown-detail-days span{color:var(--muted)}.countdown-detail-days b{margin-left:auto;color:var(--accent);font-size:13px}.countdown-detail-sheet dl{margin:0;display:grid;gap:1px;background:var(--line)}.countdown-detail-sheet dl div{display:grid;grid-template-columns:62px 1fr;gap:10px;background:#fffdf8;padding:10px 0}.countdown-detail-sheet dt{color:var(--muted);font-size:12px}.countdown-detail-sheet dd{margin:0;font-size:13px}.countdown-detail-sheet footer{display:flex;justify-content:flex-end;gap:8px}.countdown-detail-sheet footer button{min-height:44px;display:inline-flex;align-items:center;gap:6px;border:1px solid var(--line);background:#fff;border-radius:10px;padding:9px 13px}.countdown-modal-mask{z-index:90;place-items:center}.countdown-modal{width:min(420px,96vw);background:#fffdf8;border:1px solid var(--line);border-radius:18px;box-shadow:var(--shadow);padding:18px;display:grid;gap:12px}.countdown-modal header{display:flex;justify-content:space-between;align-items:start}.countdown-modal header small{color:var(--muted)}.countdown-modal header h3{margin:2px 0 0}.countdown-modal label{display:grid;gap:6px;color:#675f54;font-size:12px;font-weight:650}.countdown-modal input,.countdown-modal select{border:1px solid var(--line);background:#fff;border-radius:10px;padding:11px 12px;outline:none}.countdown-modal input:focus,.countdown-modal select:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(241,90,41,.1)}.countdown-title-fields{display:grid;grid-template-columns:72px 1fr;gap:8px}.countdown-modal footer{display:flex;justify-content:flex-end;gap:8px;padding-top:6px}.countdown-advanced{border:1px solid var(--line);border-radius:11px;background:#fffaf4}.countdown-advanced summary{min-height:44px;display:flex;align-items:center;justify-content:space-between;padding:0 12px;cursor:pointer;color:#756d61;font-size:12px;font-weight:700;list-style:none}.countdown-advanced summary::-webkit-details-marker{display:none}.countdown-advanced summary svg{width:16px;height:16px;transition:transform .16s ease}.countdown-advanced[open] summary svg{transform:rotate(180deg)}.countdown-advanced>label,.countdown-advanced>.countdown-title-fields{margin:0 12px 12px}.countdown-detail-enter-active,.countdown-detail-leave-active{transition:background .2s ease}.countdown-detail-enter-active .countdown-detail-sheet,.countdown-detail-leave-active .countdown-detail-sheet{transition:transform .24s ease,opacity .2s ease}.countdown-detail-enter-from .countdown-detail-sheet,.countdown-detail-leave-to .countdown-detail-sheet{transform:translateY(100%);opacity:.6}@media(max-width:930px){.countdown-layout{grid-template-columns:1fr;gap:16px}.countdown-focus{display:grid;min-height:205px;padding:22px 16px}.countdown-number strong{font-size:64px}.countdown-timeline{gap:16px}}@media(max-width:800px){.countdown-hero{align-items:center}.countdown-hero h2{font-size:19px}.countdown-row{grid-template-columns:minmax(0,1fr) 72px;padding:9px 4px;gap:9px}.countdown-state strong{font-size:25px}.countdown-detail-mask,.countdown-modal-mask{place-items:end;padding:0}.countdown-detail-sheet,.countdown-modal{width:100%;border-radius:22px 22px 0 0;padding:18px 16px calc(18px + env(safe-area-inset-bottom));max-height:min(88dvh,760px);overflow:auto}.countdown-view{position:relative}} @media(max-width:930px){.task-tail{flex:0 1 auto;max-width:132px;align-self:stretch;align-items:center;overflow:hidden}.task-tail:has(.task-due--timed){flex:0 1 76px;max-width:76px}.task-row:has(>.task-tail .task-due--timed){height:66px;max-height:68px;}.task-row:has(>.task-tail .task-due--timed) .task-main{padding:4px 0}.task-due{max-width:100%;overflow:hidden}.task-due--timed .task-due__text{display:grid;justify-items:end;overflow:hidden;text-overflow:ellipsis;max-width:100%;line-height:1.2;white-space:normal}.task-due--timed .task-due__absolute,.task-due--timed .task-due__relative{max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.task-due--timed .task-due__separator{display:none}.countdown-row,.countdown-row:first-of-type{min-height:62px;grid-template-columns:minmax(0,1fr) 72px;gap:8px;padding:8px 9px 8px 17px;background:transparent;border:0;border-bottom:1px solid var(--line);border-radius:0;box-shadow:none}.countdown-row:first-of-type{border-top:1px solid var(--line)}.countdown-row.pinned,.countdown-row.today{background:transparent}.countdown-state strong{font-size:24px}} -.task-row.just-completed,.habit-row.just-completed{animation:completion-row-settle .34s cubic-bezier(.2,.85,.3,1)}.task-row.just-completed .task-check-mark,.habit-row.just-completed .task-check-mark{animation:completion-check-pop .38s cubic-bezier(.2,1.4,.35,1)}@keyframes completion-row-settle{0%{background:rgba(113,133,107,.16);transform:translate(var(--swipe-x),var(--reorder-y,0px)) scale(1)}45%{background:rgba(113,133,107,.1);transform:translate(var(--swipe-x),var(--reorder-y,0px)) scale(.992)}100%{transform:translate(var(--swipe-x),var(--reorder-y,0px)) scale(1)}}@keyframes completion-check-pop{0%{transform:scale(.72);box-shadow:0 0 0 0 rgba(113,133,107,.28)}58%{transform:scale(1.18);box-shadow:0 0 0 7px rgba(113,133,107,0)}100%{transform:scale(1);box-shadow:none}}@media(prefers-reduced-motion:reduce){.task-row.just-completed,.habit-row.just-completed,.task-row.just-completed .task-check-mark,.habit-row.just-completed .task-check-mark{animation:none}} +.task-row.just-completed,.habit-row.just-completed{animation:completion-row-settle .34s cubic-bezier(.2,.85,.3,1)}.task-row.just-completed .task-check-mark,.habit-row.just-completed .task-check-mark{animation:completion-check-pop .38s cubic-bezier(.2,1.4,.35,1)}.task-row.completion-exiting,.habit-row.completion-exiting{pointer-events:none;overflow:hidden;animation:completion-slide-out .32s cubic-bezier(.4,0,.8,.2) forwards}@keyframes completion-slide-out{0%{opacity:1;transform:translate(var(--swipe-x),var(--reorder-y,0px));max-height:180px}68%{opacity:0;transform:translate(calc(100% + 24px),var(--reorder-y,0px));max-height:180px}100%{opacity:0;transform:translate(calc(100% + 24px),var(--reorder-y,0px));max-height:0;min-height:0;padding-top:0;padding-bottom:0;border-width:0}}@keyframes completion-row-settle{0%{background:rgba(113,133,107,.16);transform:translate(var(--swipe-x),var(--reorder-y,0px)) scale(1)}45%{background:rgba(113,133,107,.1);transform:translate(var(--swipe-x),var(--reorder-y,0px)) scale(.992)}100%{transform:translate(var(--swipe-x),var(--reorder-y,0px)) scale(1)}}@keyframes completion-check-pop{0%{transform:scale(.72);box-shadow:0 0 0 0 rgba(113,133,107,.28)}58%{transform:scale(1.18);box-shadow:0 0 0 7px rgba(113,133,107,0)}100%{transform:scale(1);box-shadow:none}}@media(prefers-reduced-motion:reduce){.task-row.just-completed,.habit-row.just-completed,.task-row.just-completed .task-check-mark,.habit-row.just-completed .task-check-mark{animation:none}.task-row.completion-exiting,.habit-row.completion-exiting{animation:none}} /* Solid cream material system: opaque surfaces, warm edges, quiet depth. */ :root{--surface-canvas:#f6f0e5;--surface-base:#fff9ef;--surface-raised:#fffdf8;--border-cream:#e5d7c3;--highlight-inner:inset 0 1px 0 #fff;--shadow-soft:0 4px 14px rgba(88,67,42,.08);--shadow-raised:0 12px 30px rgba(88,67,42,.12);--focus-ring:rgba(180,66,30,.34);--success:#667f5e;--scrim:rgba(45,38,31,.38);--radius-control:11px;--radius-card:14px;--radius-panel:20px;--paper:var(--surface-raised);--sidebar:var(--surface-canvas);--line:var(--border-cream);--shadow:var(--shadow-raised);--sheet-radius:20px;--sheet-scrim:var(--scrim)} diff --git a/frontend/src/style.test.ts b/frontend/src/style.test.ts index 483f7f9..144f8b0 100644 --- a/frontend/src/style.test.ts +++ b/frontend/src/style.test.ts @@ -1,6 +1,6 @@ import { readFileSync } from 'node:fs' import { describe, expect, it, vi } from 'vitest' -import { createCompletionPulse } from './lib/completion-motion' +import { createCompletionPulse, completionExitDelay, shouldAnimateCompletionExit } from './lib/completion-motion' const css = readFileSync('src/style.css', 'utf8') const app = readFileSync('src/App.vue', 'utf8') @@ -200,13 +200,28 @@ describe('solid cream material system', () => { }) describe('completion feedback motion', () => { + it('delays removal only when a completed row will become hidden', () => { + expect(shouldAnimateCompletionExit({ completing: true, showCompleted: false })).toBe(true) + expect(shouldAnimateCompletionExit({ completing: true, showCompleted: true })).toBe(false) + expect(shouldAnimateCompletionExit({ completing: false, showCompleted: false })).toBe(false) + expect(completionExitDelay(false)).toBe(320) + expect(completionExitDelay(true)).toBe(0) + }) + + it('keeps completed task and habit rows mounted for the exit motion', () => { + expect(app).toContain('completion-exiting') + expect(mvpPanel).toContain('completion-exiting') + expect(css).toContain('.task-row.completion-exiting,.habit-row.completion-exiting{') + expect(css).toContain('@keyframes completion-slide-out') + }) + it('animates only transient just-completed rows and respects reduced motion', () => { expect(css).toContain('.task-row.just-completed,.habit-row.just-completed{animation:completion-row-settle .34s cubic-bezier(.2,.85,.3,1)}') expect(css).toContain('.task-row.just-completed .task-check-mark,.habit-row.just-completed .task-check-mark{animation:completion-check-pop .38s cubic-bezier(.2,1.4,.35,1)}') expect(css).not.toContain('.task-row.done,.habit-row.done{animation:') expect(css).toContain('@keyframes completion-row-settle') expect(css).toContain('@keyframes completion-check-pop') - expect(css).toContain('@media(prefers-reduced-motion:reduce){.task-row.just-completed,.habit-row.just-completed,.task-row.just-completed .task-check-mark,.habit-row.just-completed .task-check-mark{animation:none}}') + expect(css).toContain('@media(prefers-reduced-motion:reduce){.task-row.just-completed,.habit-row.just-completed,.task-row.just-completed .task-check-mark,.habit-row.just-completed .task-check-mark{animation:none}.task-row.completion-exiting,.habit-row.completion-exiting{animation:none}}') }) it('syncs the browser IANA timezone before loading user task data', () => {