From 96ba2b4a46e0db5c224ce8b9d7a2096bcca95d84 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 13:44:59 +0000 Subject: [PATCH 1/3] Add two-phase exit motion: completion, move-to-Today, swipe (WP1/2/4/6) - Motion tokens + useRowExit two-phase exit-commit hook (D52) - Custom drawn-tick checkbox + satisfying completion choreography - Row-level hover move control and touch swipe gestures via useSwipeAction - task__surface wrapper so a swipe translates row content over a colour cue - Self-labelling, destination-coloured editor move button; fixed action order - Generalised arrival flash (created/completed/moved) across all three columns Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0138ccb6URP1H46oAjNs3Toz --- src/ui/App.tsx | 92 ++++++++-- src/ui/DoneColumn.tsx | 50 +++--- src/ui/MasterColumn.tsx | 84 +++++++-- src/ui/TaskEditPanel.tsx | 39 +++-- src/ui/TodayColumn.tsx | 321 ++++++++++++++++++++++------------ src/ui/styles.css | 363 ++++++++++++++++++++++++++++++++++++--- src/ui/useRowExit.ts | 108 ++++++++++++ src/ui/useSwipeAction.ts | 215 +++++++++++++++++++++++ 8 files changed, 1073 insertions(+), 199 deletions(-) create mode 100644 src/ui/useRowExit.ts create mode 100644 src/ui/useSwipeAction.ts diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 14394b1..4a713ce 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -49,8 +49,19 @@ function isTypingTarget(target: EventTarget | null): boolean { return tag === 'INPUT' || tag === 'TEXTAREA' || target.isContentEditable; } -/** How long a newly created task stays highlighted, in ms. */ -const FLASH_MS = 1600; +/** How long a freshly landed task stays highlighted, in ms (D52). */ +const FLASH_MS = 2600; + +/** + * What kind of arrival a flash marks, so each column highlights only the rows + * it owns and only 'created' scrolls itself into view (a created task sorts + * into place and may be offscreen; completed/moved rows land where you looked). + */ +type FlashKind = 'created' | 'completed' | 'moved'; +interface FlashState { + id: string; + kind: FlashKind; +} /** How long the undo toast stays on screen after a destructive action, in ms. */ const TOAST_MS = 5000; @@ -106,9 +117,10 @@ export function App() { today: false, done: false, }); - // Id of the task created most recently, highlighted briefly so it can be - // spotted without scanning the list (it sorts into place, not to the bottom). - const [flashId, setFlashId] = useState(null); + // The task that most recently arrived in a column, highlighted briefly so it + // can be spotted without scanning the list (D52). Carries the arrival kind so + // the right column flashes and only creations scroll into view. + const [flash, setFlash] = useState(null); // A destructive action (delete / Clear / New Day) captures the prior state // here so the undo toast can restore it — those reducers have no inverse. const [toast, setToast] = useState(null); @@ -137,12 +149,29 @@ export function App() { save(state); }, [state]); - // Drop the new-task highlight once it has served its purpose. + // Drop the arrival highlight once it has served its purpose. useEffect(() => { - if (flashId === null) return; - const id = window.setTimeout(() => setFlashId(null), FLASH_MS); + if (flash === null) return; + const id = window.setTimeout(() => setFlash(null), FLASH_MS); return () => window.clearTimeout(id); - }, [flashId]); + }, [flash]); + + // Bring a newly *created* task into view: createTask appends but sortMaster + // slots it anywhere, so it can be offscreen. Only 'created' scrolls — a + // completed row lands in Done and a moved row where the user was already + // looking. scroll-behavior isn't covered by the reduced-motion kill-switch, + // so honour the preference explicitly here. + useEffect(() => { + if (flash?.kind !== 'created') return; + const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + // Wait a frame so the flashed row is in the DOM before we look for it. + const raf = window.requestAnimationFrame(() => { + document + .querySelector('.task--flash') + ?.scrollIntoView({ block: 'nearest', behavior: reduced ? 'auto' : 'smooth' }); + }); + return () => window.cancelAnimationFrame(raf); + }, [flash]); // Auto-dismiss the undo toast after a few seconds (mirrors the flash timeout). useEffect(() => { @@ -256,11 +285,44 @@ export function App() { const handleCreateTask = (input: CreateTaskInput) => { const next = createTask(state, input); setState(next); - setFlashId(next.tasks[next.tasks.length - 1].id); + setFlash({ id: next.tasks[next.tasks.length - 1].id, kind: 'created' }); // A task added while Master is collapsed would otherwise vanish silently. setCollapsed((c) => (c.master ? { ...c, master: false } : c)); }; + // Moving a Master task to Today is the target of both the hover arrow and the + // touch swipe-right (D52). Computed eagerly so the landed Today row can be + // flashed: a plain master keeps its id; a recurring one gets a fresh day-copy + // (sourceTaskId === id), and there is only ever one live copy (D43). + const handleAddToday = (id: string) => { + const next = moveToToday(state, id); + setState(next); + const landed = next.tasks.find( + (t) => t.column === 'today' && (t.id === id || t.sourceTaskId === id), + ); + if (landed) setFlash({ id: landed.id, kind: 'moved' }); + }; + + // Completion routes through the exit animation in TodayColumn, so by the time + // this runs the task may already have changed underneath us (a sync pull, a + // subtask reopened). Guard defensively — completeTask throws on open subtasks + // (D11) — and flash the row as it lands in Done. + const handleComplete = (id: string) => { + setState((s) => { + const t = s.tasks.find((t) => t.id === id); + if (!t || t.column !== 'today' || t.subtasks.some((st) => !st.isCompleted)) return s; + return completeTask(s, id); + }); + setFlash({ id, kind: 'completed' }); + }; + + // Uncomplete gets no exit choreography — just the entrance wash on the Today + // side (D52). + const handleUncomplete = (id: string) => { + setState((s) => uncompleteTask(s, id)); + setFlash({ id, kind: 'moved' }); + }; + const subtaskHandlers: SubtaskHandlers = { onAddSubtask: (taskId, title) => setState((s) => addSubtask(s, taskId, title)), onUpdateSubtask: (taskId, subtaskId, patch) => @@ -451,11 +513,11 @@ export function App() { tasks={state.tasks} today={today} addInputRef={addInputRef} - flashId={flashId} + flashId={flash?.kind === 'created' ? flash.id : null} onCreate={handleCreateTask} onUpdate={(id, patch) => setState((s) => updateTask(s, id, patch))} onDelete={(id) => runWithUndo('Task deleted', deleteTask(state, id))} - onAddToday={(id) => setState((s) => moveToToday(s, id))} + onAddToday={handleAddToday} subtaskHandlers={subtaskHandlers} /> @@ -470,9 +532,10 @@ export function App() { setState((s) => reorderToday(s, id, targetIndex))} onRemove={(id) => setState((s) => removeFromToday(s, id))} - onComplete={(id) => setState((s) => completeTask(s, id))} + onComplete={handleComplete} onSetActive={(id) => setState((s) => setActive(s, id))} onUpdate={(id, patch) => setState((s) => updateTask(s, id, patch))} onDelete={(id) => runWithUndo('Task deleted', deleteTask(state, id))} @@ -501,7 +564,8 @@ export function App() { setState((s) => uncompleteTask(s, id))} + flashId={flash?.kind === 'completed' ? flash.id : null} + onUncomplete={handleUncomplete} /> diff --git a/src/ui/DoneColumn.tsx b/src/ui/DoneColumn.tsx index 25d5ae1..966b5d5 100644 --- a/src/ui/DoneColumn.tsx +++ b/src/ui/DoneColumn.tsx @@ -16,10 +16,12 @@ const noopSubtaskHandlers = { interface Props { tasks: Task[]; today: string; + /** Id of the task that just landed in Done, briefly washed sage (D52). */ + flashId?: string | null; onUncomplete: (id: string) => void; } -export function DoneColumn({ tasks, today, onUncomplete }: Props) { +export function DoneColumn({ tasks, today, flashId, onUncomplete }: Props) { const doneTasks = tasks.filter((t) => t.column === 'done'); return ( @@ -31,7 +33,7 @@ export function DoneColumn({ tasks, today, onUncomplete }: Props) { {doneTasks.map((task) => (
  • { if (handleArrowNav(e)) return; @@ -42,28 +44,30 @@ export function DoneColumn({ tasks, today, onUncomplete }: Props) { } }} > -
  • ))} diff --git a/src/ui/MasterColumn.tsx b/src/ui/MasterColumn.tsx index 617adb4..019960e 100644 --- a/src/ui/MasterColumn.tsx +++ b/src/ui/MasterColumn.tsx @@ -9,7 +9,10 @@ import { SubtaskList, type SubtaskHandlers } from './SubtaskList'; import { TaskEditPanel } from './TaskEditPanel'; import { DueDate } from './DueDate'; import { TaskTitle } from './TaskTitle'; +import { Icon } from './Icon'; import { handleArrowNav, isCardTarget, isDeleteKey, isEditTarget } from './cardKeys'; +import { useRowExit } from './useRowExit'; +import { useSwipeAction } from './useSwipeAction'; interface Props { tasks: Task[]; @@ -180,6 +183,21 @@ function MasterTask({ subtaskHandlers: SubtaskHandlers; }) { const [editing, setEditing] = useState(false); + const { exitClassFor, beginExit, onRowAnimationEnd } = useRowExit(); + // Touch swipe-right → Today, mirroring the hover arrow. Disabled (damped, + // never triggers) for a recurring master with a live day-copy (D43). + const swipe = useSwipeAction({ + direction: 'right', + enabled: !alreadyInToday, + onTrigger: () => onAddToday(task.id), + }); + + // Play the departing slide, then commit the move — one path for the arrow + // click and the keyboard shortcut so both feel the same. + function moveToToday() { + if (alreadyInToday) return; + beginExit(task.id, 'task--departing-right', () => onAddToday(task.id)); + } if (editing) { return ( @@ -193,7 +211,9 @@ function MasterTask({ subtaskHandlers={subtaskHandlers} move={{ label: alreadyInToday ? 'Already in Today' : 'Move to Today', + shortLabel: 'Today', icon: 'arrow-right', + destination: 'today', disabled: alreadyInToday, onMove: () => { onAddToday(task.id); @@ -210,7 +230,7 @@ function MasterTask({ if (!isCardTarget(e)) return; if (e.key === 'Enter' || e.key === 'ArrowRight') { e.preventDefault(); - if (!alreadyInToday) onAddToday(task.id); + moveToToday(); } else if (e.key === 'e') { e.preventDefault(); setEditing(true); @@ -225,30 +245,62 @@ function MasterTask({ className={ 'task' + (task.isRecurring ? ' task--recurring' : '') + - (flash ? ' task--flash' : '') + (flash ? ' task--flash' : '') + + (swipe.swiping ? ' task--swiping' : '') + + (swipe.flinging ? ' task--flinging' : '') + + (exitClassFor(task.id) ? ' ' + exitClassFor(task.id) : '') } tabIndex={0} onKeyDown={onKeyDown} + onPointerDown={swipe.handlers.onPointerDown} + onPointerMove={swipe.handlers.onPointerMove} + onPointerUp={swipe.handlers.onPointerUp} + onPointerCancel={swipe.handlers.onPointerCancel} + onAnimationEnd={(e) => onRowAnimationEnd(task.id, e)} onClick={(e) => { if (isEditTarget(e)) setEditing(true); }} > -