diff --git a/.claude/skills/verify/SKILL.md b/.claude/skills/verify/SKILL.md
index d3cc84e..75c763c 100644
--- a/.claude/skills/verify/SKILL.md
+++ b/.claude/skills/verify/SKILL.md
@@ -29,10 +29,24 @@ copy changes more often than the structure:
- Add form: `.add__input` (+ `.add__submit`, `.add__recur` for the Repeat toggle,
`.add__date`). The placeholder is `New task… “fri”, “+3d”` with curly quotes,
so an exact `input[placeholder="New task…"]` match finds nothing.
-- Task rows: `.col--master .task`, `.col--today .task`, `.col--done .task`
-- Row actions: `button[aria-label="…"]` within a row — `Move to Today`,
- `Complete`, `Remove from Today`, `Delete`, `Edit`, `Add subtask`, `Undo (back
- to Today)`. **These need a `.hover()` on the row first** — see Gotchas.
+- Task rows: `.col--master .task`, `.col--today .task`, `.col--done .task`.
+ Each row's visible content is wrapped in `.task__surface` (D53) — the swipe
+ translates that, not the `
`.
+- Row interactions (D51/D52/D53):
+ - **Complete** a Today task: click `.task__check input[type=checkbox]` — it
+ plays a ~0.4s exit animation, so `waitForTimeout(~600ms)` before asserting
+ it landed in Done. Done rows uncomplete via the same checkbox.
+ - **Set active** (Today): the colour mark dot `button.task__mark`.
+ - **Edit**: click the card *body* (a spot that isn't a control) to open
+ `.edit-panel`; inside it the move button is `.edit-panel__move`
+ (`--today`/`--master` variants), delete is `.edit-panel__delete`, Done is
+ `.edit-panel__done`, reorder chevrons are `.icon-btn`.
+ - **Move to Today** (Master, desktop): `button.task__move` (`aria-label="Move
+ to Today"`) — **hover-gated**, see Gotchas. Disabled for a recurring master
+ with a live day-copy (D43).
+ - **Move by swipe** (touch): see the touch-context recipe in Gotchas.
+- Add-form token pill: `.add__token` (the ochre highlight under a recognised
+ date token); the resolved-date echo is `.add__hint-due`.
- Overflow menu: `.app__menu button[aria-label="More actions"]`, items in
`.app__menu-list` (Export / Import / Sync…). There is no `.app__menu-btn`.
- Theme toggle: `button[aria-label*="theme"]` — cycles system → light → dark,
@@ -66,19 +80,36 @@ simulation by bumping the stored envelope's `modifiedAt`, offline via
## Gotchas
-- **Row actions are hover-gated.** On hover-capable devices (which is what
- Playwright reports) `.task__actions` is `opacity: 0; pointer-events: none`
- until the row is hovered (D40). Clicking one directly times out with
- "`` intercepts pointer events" even though
- the button reports visible and enabled. Hover the row first:
+- **The Master "Move to Today" control is hover-gated** (D53). `.task__move` is
+ `opacity: 0; pointer-events: none` until the row is hovered (and only inside
+ `@media (hover: hover)`, which is what Playwright reports). Hover the row
+ first:
```js
- const clickAction = async (row, label) => {
+ const move = async (row) => {
await row.hover();
- await row.locator(`button[aria-label="${label}"]`).click();
+ await row.locator('button.task__move').click();
};
```
+- **Swipe gestures are touch-only** (D53). Use a `hasTouch: true` context and
+ dispatch synthetic `PointerEvent`s with `pointerType: 'touch'` (a real
+ horizontal drag on the row). Declare horizontal intent by moving `>12px`
+ horizontally and past `max(72px, 30%)` of the row width to trigger; a shorter
+ swipe springs back. Swipe-right on Master → Today, swipe-left on Today →
+ Master. A near-vertical drag must *not* move the row (it scrolls). Allow
+ ~600ms after release for the commit. Mouse HTML5 drag-reorder in Today is
+ unaffected — verify it still works after any change to `.task__surface`.
+
+- **Animations gate commits, so wait.** Completion (~0.4s) and the Master move
+ (~0.25s) animate before `setState`. Under `page.emulateMedia({ reducedMotion:
+ 'reduce' })` they still *function* (the `animationend`/timeout still fire), so
+ assert behaviour there too, just without waiting on visible motion.
+
+- **Click-away closes the editor** (D54): a click on empty chrome (header, board
+ gaps) closes `.edit-panel` and persists via the fields' blur-commits. A click
+ inside any `.task` row (including another card) does not force-close it.
+
- Focus/visibility reconciles are throttled to one per 30s.
- A recurring master's "Move to Today" is **disabled while its day-copy is in
Today** (D43), so a second click is a no-op by design, not a broken test.
diff --git a/CLAUDE.md b/CLAUDE.md
index 9118429..56803b0 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -54,6 +54,8 @@ src/
useGistSync.ts # Sync hook (push-on-change, pull-on-load/focus)
useTheme.ts # Light/dark/system toggle — sets documentElement.dataset.theme (D45)
useLongPress.ts # Click-on-mouse / hold-on-touch gesture — History rows (D50)
+ useRowExit.ts # Two-phase exit-commit: animate the row, then setState (D52)
+ useSwipeAction.ts # Hand-rolled touch swipe-to-move for Master/Today rows (D53)
useFocusTrap.ts # Tab containment for the three modals (D46)
cardKeys.ts # Card keyboard nav + isEditTarget (tap-to-edit hit test, D51)
styles.css # All styles — "warm Bauhaus" visual system (D34)
@@ -80,7 +82,7 @@ src/
- `save()` returns a boolean and never throws; all localStorage goes through `core/safeStorage.ts` (D41). Don't call `localStorage` directly.
- History `day` = manual `currentDay`, not wall-clock date (D3). Due-date labels use wall-clock day (D25).
- `startNewDay`: collapses Done → History (old day), discards unfinished recurring day-copies, returns remaining Today → Master, clears active, advances `currentDay` (D15).
-- **Interaction model (D51):** tapping a Master/Today card body opens `TaskEditPanel` — the one place to retitle, re-date, edit/add/delete subtasks (blank a subtask input = delete), delete, move columns, and reorder Today. Completion is a **checkbox** (Today complete / Done uncomplete; Master has none). The Today "active" toggle is the **colour mark dot**, not the title. Delete / Clear / New Day apply immediately and raise the undo `Toast` (5s), which restores a full pre-action `AppState` snapshot (`runWithUndo` in `App.tsx`) — the only faithful undo for those inverse-less reducers. Add form height is always constant — no `:focus-within` expansion (D35).
+- **Interaction model (D51, extended D52–D57):** tapping a Master/Today card body opens `TaskEditPanel` — the one place to retitle, re-date, edit/add/delete subtasks (blank a subtask input = delete), delete, move columns, and reorder Today. **Click-away closes and persists the editor** (D54 — it listens for `click`, never `pointerdown`, so blur-commits run first). Completion is a **checkbox** (Today complete / Done uncomplete; Master has none) and plays a two-phase exit animation before the state commit (D52). The Today "active" toggle is the **colour mark dot**, not the title. **Moving Master → Today** has three paths that share the same exit motion and D43 disable: a hover/focus `→` control on the row (desktop), a touch **swipe-right** (swipe-left on Today returns to Master, D53), and the editor's destination-labelled move button (D55). Delete / Clear / New Day apply immediately and raise the undo `Toast` (5s), which restores a full pre-action `AppState` snapshot (`runWithUndo` in `App.tsx`) — the only faithful undo for those inverse-less reducers. A created/moved/completed row flashes on arrival (`flash: {id, kind}`, `FLASH_MS` 2600, D56); the add field highlights the live date token and sweeps on submit. All motion is CSS on the `--dur-*`/`--ease-*` tokens and is neutralised by the `prefers-reduced-motion` kill-switch (D52). Add form height is always constant — no `:focus-within` expansion (D35).
## Constraints
- **Approved deps only** (D1, amended by D47): `react`, `react-dom`, `typescript`, `vite`, `@vitejs/plugin-react`, `vitest`, plus dev-only `eslint`, `@eslint/js`, `typescript-eslint`, `eslint-plugin-react-hooks`. Anything else needs explicit approval.
diff --git a/DECISIONS.md b/DECISIONS.md
index e482ec0..a8d52f1 100644
--- a/DECISIONS.md
+++ b/DECISIONS.md
@@ -51,3 +51,9 @@
- **D49** A `SessionStart` hook (`.claude/hooks/session-start.sh`, registered in `.claude/settings.json`) installs dependencies for Claude Code on the web sessions. Those start from a clone with no `node_modules`, so the first `npm test` failed with `vitest: not found` and the session had to stop and install before it could verify anything. Guarded on `$CLAUDE_CODE_REMOTE` so local checkouts are untouched, and uses `npm install` rather than `npm ci` — the container image is cached after the hook completes, and `install` reuses an existing `node_modules` where `ci` always deletes and refetches. Left synchronous rather than async: it takes ~3s from cold, which is not worth the race where the agent runs the test suite while the install is still in flight. — 2026-07-25 (user request)
- **D50** History entries are editable and deletable in place (`updateHistoryEntry`, `deleteHistoryEntry`, `historyDeletionScope`; `HistoryPanel` rows, `ui/useLongPress.ts`) — the panel was read-only (SPEC §10 amended), so a task completed under a typo'd or wrong title was logged that way for 30 days with no way to fix it. **Title only:** `day`, `completedAt`, `occurrenceType` and the task-id links are the record of *when and what kind of* occurrence happened; offering those as fields would be rewriting the log rather than correcting it, and the title is the only field the panel shows. **Delete cascades** from a task entry to the subtask entries logged under it (matched on `parentTaskId` + `day`, so it cannot reach across days), because a subtask occurrence is never independent of its parent (SPEC §6.4) and orphaned children would sit indented under a parent that no longer exists — the same "delete means delete" position as D42. Delete confirms and names the collateral, since a History entry is the only remaining record of that occurrence; edit does not, being reversible by editing again. **Gesture:** a `` per row so keyboard and screen readers get activation for free, with click on a mouse and a 500ms hold on touch (a tap is how you scroll past a row, so firing on tap would make the list unreadable; >10px of travel cancels the press so a scroll stays a scroll). Neither action restores a task to a column — this fixes the log, it does not undo a completion. — 2026-07-25 (user request)
- **D51** The board switched to a tap-first interaction model (`TaskEditPanel`, checkbox completion, undo toasts), replacing the floating action toolbar of D40 and re-adding the undo toasts D39 removed. **Inline edit:** tapping a Master/Today card body (`isEditTarget` in `cardKeys.ts` — a click that missed the card's own controls) opens one inline editor holding every per-card action: retitle (blank reverts), re-date, edit/add/delete subtasks (blanking a subtask input deletes it), delete, move between columns, and Today reorder. This retires the hover/`:focus-within` corner overlay (D40) and the title-only `TaskEditForm`; the panel keeps the mounted-only-while-editing convention so each field seeds fresh (bug C11). **Checkboxes, not buttons:** task completion is a checkbox in Today (checked ⇒ `completeTask`, disabled while subtasks are open so the D11 throw is never reached) and Done (unchecked ⇒ `uncompleteTask`); Master has none, since a template task cannot be completed (D18-adjacent — `completeTask` no-ops outside Today). Setting the Today "active" item moved from the title tap to the colour mark dot (now a `` with an enlarged invisible hit area), freeing the body for tap-to-edit. **Undo toasts:** delete, Clear, and Start New Day now apply immediately and raise a bottom-centre one-tap undo bar (`Toast`, 5s). Those three reducers drop or collapse data with no inverse (unlike complete↔uncomplete, see `undoComposition.test.ts`), so undo restores a full pre-action `AppState` snapshot captured in `App` — which the existing save/sync effect re-persists for free. This replaces Start New Day's modal confirm; delete and Clear gain their first safety net. Touch reorder, which the D39 `@media (hover:none)` block had left to the now-removed toolbar buttons, moves into the Today edit panel. — 2026-07-27 (user request)
+- **D52** Motion foundation — motion tokens (`--dur-quick`/`--dur-move`/`--ease-brisk`/`--ease-settle`, theme-independent) plus a two-phase exit-commit hook (`ui/useRowExit.ts`). The core reducers are instant and inverse-less: `completeTask`/`moveToToday`/`removeFromToday` move a task between columns in one commit, so the row unmounts and remounts on the next render with no window to animate. `beginExit(id, className, commit)` opens that window — it marks the still-mounted row with an animation class, waits for the row's own `animationend` (guarded by `e.target === e.currentTarget` so child animations like the tick-draw don't fire it early), then runs the real `setState`. The commit **must not** depend on a visible duration: `prefers-reduced-motion` clamps every animation to ~0ms (`styles.css` kill-switch), so `animationend` still fires near-instantly and the row still commits; a fallback timeout covers a dropped event. Repeat `beginExit` for an already-exiting id is ignored, so a double-clicked checkbox commits exactly once, and the completion commit is a defensive functional `setState` (task still exists, still in Today, no open subtasks — `completeTask` throws otherwise, D11) so a sync pull landing mid-animation can't cause a throw. — 2026-07-28 (user request)
+- **D53** Row-level move control + touch swipe (`ui/useSwipeAction.ts`, `MasterColumn`, `TodayColumn`). Moving a Master task to Today no longer requires opening the editor: hover (or keyboard-focus) a Master row and a destination-coloured `→` button appears in the empty check slot (`.task__move`), revealed only inside `@media (hover: hover)` and `pointer-events:none` while hidden so it never intercepts tap-to-edit; on touch it stays hidden and the gesture is a swipe instead. `useSwipeAction` is a hand-rolled pointer hook (no gesture library, D1/D47; modelled on `useLongPress`): touch-only, it waits to declare horizontal intent (`|dx|>12 && |dx|>1.5·|dy|`) before claiming the gesture so vertical scrolling is untouched, then tracks the finger and commits past a `max(72px, 30%)` threshold — swipe-right on Master → Today, swipe-left on Today → Master. Both the button and the swipe respect D43 (a recurring master with a live day-copy is disabled; the swipe still gives damped rubber-band travel so the resistance itself reads as "blocked"). Rows carry `touch-action: pan-y` so the browser keeps vertical scroll and hands us horizontal. The one DOM restructure: each row's grid content moved into an inner `.task__surface` that the swipe translates over a column-coloured `.task__swipe-cue` underlay, without moving the ``, its border, or its row-state backgrounds (which stay on the ` ` and show through the normally-transparent surface). Mouse HTML5 drag-reorder in Today is unaffected — the swipe hook ignores non-touch pointers. — 2026-07-28 (user request)
+- **D54** Click-away closes and persists the editor (`TaskEditPanel`). The inline editor's fields already commit on `blur` (title, subtasks) or `change` (date/recurring); the missing piece was that clicking outside did nothing, so the user had to find "Done". A document listener now closes the panel on an outside click — and because the fields blur-commit first, click-away therefore *persists*. It **must** listen for `click`, never `pointerdown`: on an outside pointerdown the focused input fires `blur` (its commit runs) → then `click` → then we close; closing on pointerdown would unmount the inputs before React's `onBlur` runs and drop the pending edit (an explicit code comment guards this). Clicks inside any `.task` row are ignored (the panel lives inside a `.task--editing` row, and another card manages its own open — in Today the shared editing state closes this one for us), as are `.confirm-dialog`/`.toast` surfaces; a target already detached by a re-render is treated as inside. Only a click on genuinely empty chrome closes-and-persists. — 2026-07-28 (user request)
+- **D55** Editor action rows are consistent across columns (`TaskEditPanel`, `MoveAction`). Both editors previously showed a bare arrow in the same slot, meaning "→ Today" in Master and "← Master" in Today — the same glyph flipping meaning between sections. The move control is now self-labelling and destination-coloured: an icon **plus visible text** ("Today"/"Master") tinted with the destination column's own colour (`--c-today-text`/`--c-master`, one meaningful accent per D34), full description on `title`/`aria-label`. The action row is a fixed order in both editors — **[move] · [reorder chevrons — Today only] · spacer · [delete] · [Done]** — with move always leftmost and delete pushed to the trailing edge next to Done (auto margin), so no control that exists in both editors shifts position between them. — 2026-07-28 (user request)
+- **D56** Live parse highlight + richer add feedback (`MasterColumn` AddTaskForm). The typed trailing date token (D36) is now highlighted *as it is typed*: a mirror-overlay technique (no deps) renders a `position:absolute` `.add__mirror` behind the input with identical metrics and all text transparent, so only an ochre `.add__token` pill behind the trailing word shows through the input's own glyphs — zero layout shift (D35), the input background stays transparent, and `mirror.scrollLeft` tracks the input for long titles. The pill only lights when the raw trailing token is exactly the one the parser resolved to a date. On submit the field sweeps as it clears (`.add--committed`), the arrival flash is longer and richer (`FLASH_MS` 1600→2600, keyframe holds then decays, an entrance `translateY` so the row lands rather than pops), and the flashed **created** row scrolls into view (`scrollIntoView`, honouring `prefers-reduced-motion` explicitly since `scroll-behavior` isn't covered by the CSS kill-switch). The flash state generalised to `{id, kind}` (`created`/`completed`/`moved`) so each column highlights only its own arrivals and only creations scroll. — 2026-07-28 (user request)
+- **D57** Hover wash + physical micro-states (`styles.css`, WP3/WP8). Rows get a barely-there `--hover-wash` on hover, inside `@media (hover: hover)` so touch never sticks it on and `:not(.task--active)` so the active vermilion wash and a running flash animation still win. Every interactive atom gained a rest → hover (grow/brighten) → press (compress) language with real transitions on the WP1 tokens: the drag handle brightens and scales on hover (grabbing cursor while dragging), the active-mark dot scales up on hover / down on press and transitions its vermilion toggle, the custom `appearance:none` checkbox (task + subtask + Done, with a drawn-in tick that carries the completion choreography) grows on hover / compresses on press, and buttons share a uniform 1px press. All hover rules sit in `@media (hover: hover)`; nothing adds layout shift. Three new custom properties (`--flash-sage`, `--hover-wash`, `--token-wash`) were added to all three theme blocks per the D45 sync rule. — 2026-07-28 (user request)
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) {
}
}}
>
-
-
-
- {task.sourceTaskId && copy }
-
- {task.dueDate ? (
-
- ) : (
-
- —
+
+
+
+
+ {task.sourceTaskId && copy }
+
+ {task.dueDate ? (
+
+ ) : (
+
+ —
+
+ )}
+
+ onUncomplete(task.id)}
+ />
- )}
-
- onUncomplete(task.id)}
- />
-
-
+
+
))}
diff --git a/src/ui/MasterColumn.tsx b/src/ui/MasterColumn.tsx
index 617adb4..1572249 100644
--- a/src/ui/MasterColumn.tsx
+++ b/src/ui/MasterColumn.tsx
@@ -1,4 +1,4 @@
-import { useState } from 'react';
+import { useEffect, useRef, useState } from 'react';
import type { RefObject } from 'react';
import type { Task } from '../core/types';
import type { CreateTaskInput, UpdateTaskPatch } from '../core/state';
@@ -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[];
@@ -91,6 +94,10 @@ function AddTaskForm({
const [raw, setRaw] = useState('');
const [pickedDate, setPickedDate] = useState('');
const [isRecurring, setIsRecurring] = useState(false);
+ // Briefly true after a submit, to sweep the field as it clears (D52/WP7b).
+ const [committed, setCommitted] = useState(false);
+ const localInput = useRef(null);
+ const mirrorRef = useRef(null);
const parsed = parseTaskInput(raw, today);
// A typed token wins over the picker, so the last thing you expressed is the
@@ -98,6 +105,39 @@ function AddTaskForm({
const dueDate = parsed.dueDate ?? (pickedDate || null);
const canSubmit = parsed.title.trim() !== '';
+ // Locate the trailing token in the *raw* string (trailing spaces and all) and
+ // only highlight it when it is exactly the token the parser resolved to a
+ // date — so the ochre pill under the word means precisely "this is a date".
+ const rawToken = /(\S+)\s*$/.exec(raw);
+ const highlight =
+ parsed.dueDate !== null && parsed.token !== null && rawToken?.[1] === parsed.token
+ ? {
+ before: raw.slice(0, rawToken.index),
+ token: rawToken[1],
+ after: raw.slice(rawToken.index + rawToken[1].length),
+ }
+ : null;
+
+ // Merge the forwarded focus ref with a local one used for scroll syncing.
+ function setInputRef(el: HTMLInputElement | null) {
+ localInput.current = el;
+ if (inputRef) (inputRef as React.MutableRefObject).current = el;
+ }
+
+ // Long titles scroll the input horizontally; keep the mirror in lockstep so
+ // the pill tracks its word off-screen and back.
+ function syncScroll() {
+ if (mirrorRef.current && localInput.current) {
+ mirrorRef.current.scrollLeft = localInput.current.scrollLeft;
+ }
+ }
+
+ useEffect(() => {
+ if (!committed) return;
+ const id = window.setTimeout(() => setCommitted(false), 300);
+ return () => window.clearTimeout(id);
+ }, [committed]);
+
function submit(e: React.FormEvent) {
e.preventDefault();
if (!canSubmit) return;
@@ -105,23 +145,42 @@ function AddTaskForm({
setRaw('');
setPickedDate('');
setIsRecurring(false);
+ setCommitted(true);
// Keep focus in the field so several tasks can be added in a row.
inputRef?.current?.focus();
}
return (
-
);
}
diff --git a/src/ui/TaskEditPanel.tsx b/src/ui/TaskEditPanel.tsx
index fa9323d..3c6ae4f 100644
--- a/src/ui/TaskEditPanel.tsx
+++ b/src/ui/TaskEditPanel.tsx
@@ -1,4 +1,4 @@
-import { useState } from 'react';
+import { useEffect, useRef, useState } from 'react';
import type { Task } from '../core/types';
import type { UpdateTaskPatch } from '../core/state';
import type { SubtaskHandlers } from './SubtaskList';
@@ -6,8 +6,13 @@ import { AddSubtaskForm } from './SubtaskList';
import { Icon, type IconName } from './Icon';
interface MoveAction {
+ /** Full description for title/aria (e.g. "Move to Today"). */
label: string;
+ /** The destination, shown as visible button text so the arrow isn't decoded. */
+ shortLabel: 'Today' | 'Master';
icon: IconName;
+ /** Tints the button with the destination column's colour (D34/D52). */
+ destination: 'today' | 'master';
disabled?: boolean;
onMove: () => void;
}
@@ -56,6 +61,7 @@ export function TaskEditPanel({
}: Props) {
const [title, setTitle] = useState(task.title);
const [adding, setAdding] = useState(false);
+ const panelRef = useRef(null);
// A blank title reverts to the old one (D-inline); any real change is saved.
function commitTitle() {
@@ -66,8 +72,39 @@ export function TaskEditPanel({
if (title !== task.title) onUpdate({ title });
}
+ // Click-away closes the panel; the fields' own blur-commits mean click-away
+ // therefore *persists* the edit (D52/WP5).
+ //
+ // This MUST listen for `click`, never `pointerdown`. Ordering: an outside
+ // pointerdown makes the focused input fire `blur` (its commit runs) → then the
+ // `click` fires → we close. Closing on pointerdown would unmount the inputs
+ // before React's onBlur runs and lose the pending edit. Do not change this to
+ // pointerdown.
+ useEffect(() => {
+ function onDocClick(e: MouseEvent) {
+ const target = e.target as Node;
+ const panel = panelRef.current;
+ if (!panel) return;
+ // A target detached by a re-render before we look (a subtask row that a
+ // blur just deleted) originated inside the panel — treat it as inside.
+ if (!document.contains(target)) return;
+ // Any click within a task row is handled by that row: clicks inside this
+ // panel keep it open; clicks on another card let that card manage the
+ // switch (in Today the shared editing state closes this one for us). Only
+ // a click on genuinely empty chrome closes-and-persists here. Modal
+ // surfaces are left alone too.
+ if (target instanceof Element && target.closest('.task, .confirm-dialog, .toast')) {
+ return;
+ }
+ onClose();
+ }
+ document.addEventListener('click', onDocClick);
+ return () => document.removeEventListener('click', onDocClick);
+ }, [onClose]);
+
return (
{
if (e.key === 'Escape') {
@@ -138,7 +175,27 @@ export function TaskEditPanel({
)}
+ {/*
+ Fixed slot order in both editors: [move] · [reorder — Today only] ·
+ spacer · [delete] · [Done]. Move is always leftmost and reads its
+ destination as text, so the arrow never flips meaning between sections
+ (D52/WP6); delete sits apart next to Done via the delete button's
+ auto left margin, so nothing shifts position between the two editors.
+ */}
+ {move && (
+
+
+ {move.shortLabel}
+
+ )}
{reorder && (
<>
>
)}
- {move && (
-
-
-
- )}
void;
onRemove: (id: string) => void;
onComplete: (id: string) => void;
@@ -35,6 +40,7 @@ function slotForCard(e: DragEvent, index: number): number {
export function TodayColumn({
tasks,
today,
+ flashId,
onReorder,
onRemove,
onComplete,
@@ -66,127 +72,62 @@ export function TodayColumn({
Add tasks from Master with “→”.
)}
- {todayTasks.map((task, index) => {
- if (editingId === task.id) {
- return (
-
- onUpdate(task.id, patch)}
- onDelete={() => onDelete(task.id)}
- onClose={() => setEditingId(null)}
- subtaskHandlers={subtaskHandlers}
- move={{
- label: 'Move to Master',
- icon: 'arrow-left',
- onMove: () => {
- onRemove(task.id);
- setEditingId(null);
- },
- }}
- reorder={{
- onUp: () => onReorder(task.id, index - 1),
- onDown: () => onReorder(task.id, index + 1),
- canUp: index > 0,
- canDown: index < todayTasks.length - 1,
- }}
- />
-
- );
- }
- const openSubtasks = task.subtasks.some((s) => !s.isCompleted);
- return (
-
+ editingId === task.id ? (
+
+ onUpdate(task.id, patch)}
+ onDelete={() => onDelete(task.id)}
+ onClose={() => setEditingId(null)}
+ subtaskHandlers={subtaskHandlers}
+ move={{
+ label: 'Move to Master',
+ shortLabel: 'Master',
+ icon: 'arrow-left',
+ destination: 'master',
+ onMove: () => {
+ onRemove(task.id);
+ setEditingId(null);
+ },
+ }}
+ reorder={{
+ onUp: () => onReorder(task.id, index - 1),
+ onDown: () => onReorder(task.id, index + 1),
+ canUp: index > 0,
+ canDown: index < todayTasks.length - 1,
+ }}
+ />
+
+ ) : (
+ {
- if (isEditTarget(e)) setEditingId(task.id);
- }}
- onKeyDown={(e) => {
- if (handleArrowNav(e)) return;
- if (!isCardTarget(e)) return;
- if (e.key === ' ') {
- e.preventDefault();
- onSetActive(task.id);
- } else if (e.key === 'c') {
- if (openSubtasks) return;
- e.preventDefault();
- onComplete(task.id);
- } else if (e.key === 'e') {
- e.preventDefault();
- setEditingId(task.id);
- } else if (e.key === 'r') {
- e.preventDefault();
- onRemove(task.id);
- } else if (isDeleteKey(e.key)) {
- e.preventDefault();
- onDelete(task.id);
- }
- }}
+ task={task}
+ index={index}
+ today={today}
+ flash={task.id === flashId}
+ isDragging={draggingId === task.id}
+ showInsertBefore={insertAt === index}
+ onStartEdit={() => setEditingId(task.id)}
onDragStart={(e) => {
setDraggingId(task.id);
e.dataTransfer.setData('text/plain', task.id);
e.dataTransfer.effectAllowed = 'move';
}}
- onDragOver={(e) => {
- e.preventDefault();
- setInsertAt(slotForCard(e, index));
- }}
- onDrop={(e) => {
- e.preventDefault();
- handleDrop(slotForCard(e, index));
- }}
+ onDragOver={(slot) => setInsertAt(slot)}
+ onDrop={handleDrop}
onDragEnd={() => {
setDraggingId(null);
setInsertAt(null);
}}
- >
- {
- e.stopPropagation();
- onSetActive(task.id);
- }}
- />
-
-
- ⠿
-
-
- {task.sourceTaskId && copy }
-
- {task.dueDate ? (
-
- ) : (
-
- —
-
- )}
-
- e.stopPropagation()}
- onChange={() => onComplete(task.id)}
- />
-
-
-
- );
- })}
+ onRemove={onRemove}
+ onComplete={onComplete}
+ onSetActive={onSetActive}
+ onDelete={onDelete}
+ subtaskHandlers={subtaskHandlers}
+ />
+ ),
+ )}
{draggingId !== null && (
);
}
+
+function TodayTask({
+ task,
+ index,
+ today,
+ flash,
+ isDragging,
+ showInsertBefore,
+ onStartEdit,
+ onDragStart,
+ onDragOver,
+ onDrop,
+ onDragEnd,
+ onRemove,
+ onComplete,
+ onSetActive,
+ onDelete,
+ subtaskHandlers,
+}: {
+ task: Task;
+ index: number;
+ today: string;
+ flash: boolean;
+ isDragging: boolean;
+ showInsertBefore: boolean;
+ onStartEdit: () => void;
+ onDragStart: (e: DragEvent) => void;
+ onDragOver: (slot: number) => void;
+ onDrop: (slot: number) => void;
+ onDragEnd: () => void;
+ onRemove: (id: string) => void;
+ onComplete: (id: string) => void;
+ onSetActive: (id: string) => void;
+ onDelete: (id: string) => void;
+ subtaskHandlers: SubtaskHandlers;
+}) {
+ const { exitClassFor, beginExit, onRowAnimationEnd } = useRowExit();
+ // Touch swipe-left returns the task to Master, mirroring the edit-panel arrow.
+ const swipe = useSwipeAction({
+ direction: 'left',
+ enabled: true,
+ onTrigger: () => onRemove(task.id),
+ });
+ const openSubtasks = task.subtasks.some((s) => !s.isCompleted);
+
+ // Play the completing choreography, then commit — the checkbox and the `c`
+ // key both route here so they feel identical.
+ function complete() {
+ if (openSubtasks) return;
+ beginExit(task.id, 'task--completing', () => onComplete(task.id));
+ }
+
+ const exitClass = exitClassFor(task.id);
+
+ return (
+ onRowAnimationEnd(task.id, e)}
+ onClick={(e) => {
+ if (isEditTarget(e)) onStartEdit();
+ }}
+ onKeyDown={(e) => {
+ if (handleArrowNav(e)) return;
+ if (!isCardTarget(e)) return;
+ if (e.key === ' ') {
+ e.preventDefault();
+ onSetActive(task.id);
+ } else if (e.key === 'c') {
+ if (openSubtasks) return;
+ e.preventDefault();
+ complete();
+ } else if (e.key === 'e') {
+ e.preventDefault();
+ onStartEdit();
+ } else if (e.key === 'r') {
+ e.preventDefault();
+ onRemove(task.id);
+ } else if (isDeleteKey(e.key)) {
+ e.preventDefault();
+ onDelete(task.id);
+ }
+ }}
+ onDragStart={onDragStart}
+ onDragOver={(e) => {
+ e.preventDefault();
+ onDragOver(slotForCard(e, index));
+ }}
+ onDrop={(e) => {
+ e.preventDefault();
+ onDrop(slotForCard(e, index));
+ }}
+ onDragEnd={onDragEnd}
+ >
+ {swipe.dx !== 0 && (
+
+
+
+ )}
+
+
+ );
+}
diff --git a/src/ui/styles.css b/src/ui/styles.css
index fb1d70c..039e672 100644
--- a/src/ui/styles.css
+++ b/src/ui/styles.css
@@ -34,6 +34,9 @@
--overlay: rgba(30, 28, 25, 0.42);
--raised: #fbf8f1;
--flash: rgba(224, 163, 46, 0.42);
+ --flash-sage: rgba(110, 139, 106, 0.34);
+ --hover-wash: rgba(30, 28, 25, 0.04);
+ --token-wash: rgba(224, 163, 46, 0.30);
--active-wash: rgba(192, 73, 46, 0.07);
--c-master: var(--blue);
@@ -45,6 +48,15 @@
--focus: var(--blue);
+ /* Motion tokens (D52). "Warm Bauhaus" wants crisp and physical, not bouncy:
+ --ease-settle is a restrained overshoot, not a spring. Theme-independent,
+ so they live only here. The prefers-reduced-motion block still clamps every
+ duration to ~0, so these are advisory. */
+ --dur-quick: 0.15s;
+ --dur-move: 0.3s;
+ --ease-brisk: cubic-bezier(0.2, 0, 0, 1);
+ --ease-settle: cubic-bezier(0.34, 1.3, 0.64, 1);
+
/* Let native controls (date picker, checkbox) follow the theme. */
color-scheme: light dark;
}
@@ -61,6 +73,9 @@
--overlay: rgba(0, 0, 0, 0.62);
--raised: #1f1c16;
--flash: rgba(232, 178, 76, 0.3);
+ --flash-sage: rgba(143, 174, 138, 0.26);
+ --hover-wash: rgba(237, 230, 214, 0.05);
+ --token-wash: rgba(232, 178, 76, 0.26);
--active-wash: rgba(224, 103, 74, 0.14);
--c-master: #6e93c9;
@@ -86,6 +101,9 @@
--overlay: rgba(0, 0, 0, 0.62);
--raised: #1f1c16;
--flash: rgba(232, 178, 76, 0.3);
+ --flash-sage: rgba(143, 174, 138, 0.26);
+ --hover-wash: rgba(237, 230, 214, 0.05);
+ --token-wash: rgba(232, 178, 76, 0.26);
--active-wash: rgba(224, 103, 74, 0.14);
--c-master: #6e93c9;
@@ -109,6 +127,9 @@
--overlay: rgba(30, 28, 25, 0.42);
--raised: #fbf8f1;
--flash: rgba(224, 163, 46, 0.42);
+ --flash-sage: rgba(110, 139, 106, 0.34);
+ --hover-wash: rgba(30, 28, 25, 0.04);
+ --token-wash: rgba(224, 163, 46, 0.30);
--active-wash: rgba(192, 73, 46, 0.07);
--c-master: var(--blue);
@@ -589,9 +610,19 @@ body {
border-bottom: 1px solid var(--rule);
}
-.add__input {
+/* The input and its mirror overlay share this box (D52/WP7a). */
+.add__field {
+ position: relative;
flex: 1 1 8rem;
min-width: 0;
+ display: flex;
+}
+
+.add__input {
+ flex: 1 1 auto;
+ min-width: 0;
+ position: relative;
+ z-index: 1;
padding: 0.35rem 0.1rem;
background: transparent;
border: 0;
@@ -599,6 +630,64 @@ body {
font-size: 0.95rem;
}
+/* Behind the input, same metrics, text transparent — only the token pill's
+ background shows through the input's (transparent-background) glyphs. */
+.add__mirror {
+ position: absolute;
+ inset: 0;
+ z-index: 0;
+ padding: 0.35rem 0.1rem;
+ font-family: var(--font-body);
+ font-size: 0.95rem;
+ line-height: normal;
+ white-space: pre;
+ overflow: hidden;
+ color: transparent;
+ pointer-events: none;
+}
+
+/* The ochre pill under a recognised date token. Negative margins offset the
+ padding so the surrounding glyph positions never shift (the token is trailing
+ anyway) and the mirror stays glyph-aligned with the input. */
+.add__token {
+ display: inline-block;
+ padding: 0.08rem 0.22rem;
+ margin: 0 -0.22rem;
+ border-radius: 3px;
+ background: var(--token-wash);
+ color: transparent;
+ animation: token-pop 0.15s var(--ease-settle);
+}
+
+@keyframes token-pop {
+ from {
+ opacity: 0;
+ transform: scale(0.9);
+ }
+ to {
+ opacity: 1;
+ transform: scale(1);
+ }
+}
+
+/* Add feedback (D52/WP7b): a quick wash sweeps the field as it clears. The
+ field is already empty by then, so no pill is in the way. */
+.add--committed .add__input {
+ background-image: linear-gradient(90deg, transparent, var(--flash), transparent);
+ background-repeat: no-repeat;
+ background-size: 55% 100%;
+ animation: add-sweep 0.25s var(--ease-brisk);
+}
+
+@keyframes add-sweep {
+ from {
+ background-position: -60% 0;
+ }
+ to {
+ background-position: 160% 0;
+ }
+}
+
.add__input::placeholder {
color: var(--faint);
}
@@ -687,6 +776,33 @@ body {
}
.task {
+ position: relative;
+ margin-left: -0.5rem;
+ border-bottom: 1px solid var(--rule);
+ transition:
+ background var(--dur-quick) var(--ease-brisk),
+ box-shadow var(--dur-quick) var(--ease-brisk),
+ transform var(--dur-quick) var(--ease-brisk);
+}
+
+/* Barely-there hover wash on ruled-paper rows (D52/WP3). Hover-capable media
+ only, so touch never sticks it on. Active rows keep their vermilion wash
+ (:not guard) and a running flash animation outranks it in the cascade. */
+@media (hover: hover) {
+ .task:not(.task--active):hover {
+ background: var(--hover-wash);
+ }
+}
+
+/* The row's visible content lives on an inner surface so a touch swipe can
+ translate it — revealing the colour cue beneath — without moving the li, its
+ border, or the swipe backdrop (D52). Row-state backgrounds (hover, flash,
+ active) stay on the li and show through the normally-transparent surface;
+ the surface only turns opaque while swiping, to occlude the cue everywhere it
+ hasn't slid away from. Editing rows render the panel directly, no surface. */
+.task__surface {
+ position: relative;
+ z-index: 1;
display: grid;
grid-template-columns: 14px minmax(0, 1fr) auto auto;
grid-template-areas:
@@ -694,9 +810,45 @@ body {
'. subs subs subs';
align-items: center;
column-gap: 0.55rem;
- margin-left: -0.5rem;
- padding: 0.5rem 0.5rem 0.5rem 0.5rem;
- border-bottom: 1px solid var(--rule);
+ padding: 0.5rem;
+ /* Browser owns vertical scroll; horizontal is ours for the swipe gesture. */
+ touch-action: pan-y;
+ transition: transform var(--dur-move) var(--ease-settle);
+}
+
+/* While the finger is tracking, the surface follows it with no easing. */
+.task--swiping .task__surface {
+ transition: none;
+}
+
+.task--swiping .task__surface,
+.task--flinging .task__surface {
+ background: var(--bg);
+}
+
+/* Column-coloured underlay revealed as the surface slides, with a direction
+ glyph on the edge it uncovers. Sits behind the surface (z-index) and only
+ renders while a swipe is in flight. */
+.task__swipe-cue {
+ position: absolute;
+ inset: 0;
+ z-index: 0;
+ display: flex;
+ align-items: center;
+ padding: 0 0.85rem;
+ font-size: 1.05rem;
+ color: var(--bg);
+ pointer-events: none;
+}
+
+.task__swipe-cue--today {
+ justify-content: flex-start;
+ background: var(--c-today);
+}
+
+.task__swipe-cue--master {
+ justify-content: flex-end;
+ background: var(--c-master);
}
.task:focus-visible {
@@ -711,6 +863,7 @@ body {
justify-self: center;
border-radius: 50%;
background: var(--faint);
+ transition: background var(--dur-quick) var(--ease-brisk);
}
/* Square, not round: a recurring master is a template, not an occurrence. */
@@ -727,6 +880,9 @@ button.task__mark {
padding: 0;
border: 0;
cursor: pointer;
+ transition:
+ transform var(--dur-quick) var(--ease-settle),
+ background var(--dur-quick) var(--ease-brisk);
}
button.task__mark::after {
@@ -735,6 +891,16 @@ button.task__mark::after {
inset: -10px -7px;
}
+button.task__mark:active {
+ transform: scale(0.85);
+}
+
+@media (hover: hover) {
+ button.task__mark:hover {
+ transform: scale(1.35);
+ }
+}
+
.task__check {
grid-area: check;
justify-self: end;
@@ -742,18 +908,97 @@ button.task__mark::after {
align-items: center;
}
+/* Custom checkbox (D52): a real with native chrome
+ removed, so the tick can be *drawn* in rather than popped, and so hover/press
+ states (WP8) have something to animate. Shared by Today, Done and subtasks. */
+.task__check input[type='checkbox'],
+.subtask__check input[type='checkbox'] {
+ appearance: none;
+ -webkit-appearance: none;
+ position: relative;
+ flex: none;
+ margin: 0;
+ border: 1.5px solid var(--rule-strong);
+ border-radius: 3px;
+ background: transparent;
+ cursor: pointer;
+ transition:
+ background var(--dur-quick) var(--ease-brisk),
+ border-color var(--dur-quick) var(--ease-brisk),
+ transform var(--dur-quick) var(--ease-settle);
+}
+
.task__check input[type='checkbox'] {
width: 1.1rem;
height: 1.1rem;
- accent-color: var(--c-done);
- cursor: pointer;
}
-.task__check input[type='checkbox']:disabled {
+.subtask__check input[type='checkbox'] {
+ width: 0.95rem;
+ height: 0.95rem;
+}
+
+/* The drawn tick: a rotated corner (two borders). Hidden until checked (or
+ while a Today row is completing, where the input stays unchecked but the
+ choreography draws it anyway). */
+.task__check input[type='checkbox']::after,
+.subtask__check input[type='checkbox']::after {
+ content: '';
+ position: absolute;
+ left: 50%;
+ top: 46%;
+ width: 0.24rem;
+ height: 0.46rem;
+ border: solid var(--bg);
+ border-width: 0 2px 2px 0;
+ transform: translate(-50%, -58%) rotate(45deg) scale(0);
+ opacity: 0;
+}
+
+.task__check input[type='checkbox']:checked,
+.subtask__check input[type='checkbox']:checked,
+.task--completing .task__check input[type='checkbox'] {
+ background: var(--c-done);
+ border-color: var(--c-done);
+}
+
+.task__check input[type='checkbox']:checked::after,
+.subtask__check input[type='checkbox']:checked::after,
+.task--completing .task__check input[type='checkbox']::after {
+ animation: tick-draw 0.16s var(--ease-brisk) forwards;
+}
+
+@keyframes tick-draw {
+ from {
+ opacity: 0;
+ transform: translate(-50%, -58%) rotate(45deg) scale(0.4);
+ }
+ to {
+ opacity: 1;
+ transform: translate(-50%, -58%) rotate(45deg) scale(1);
+ }
+}
+
+.task__check input[type='checkbox']:disabled,
+.subtask__check input[type='checkbox']:disabled {
opacity: 0.5;
cursor: default;
}
+/* Checkbox micro-states (D52/WP8): grow on hover, compress on press. */
+@media (hover: hover) {
+ .task__check input[type='checkbox']:hover:not(:disabled),
+ .subtask__check input[type='checkbox']:hover:not(:disabled) {
+ border-color: var(--fg);
+ transform: scale(1.1);
+ }
+}
+
+.task__check input[type='checkbox']:active:not(:disabled),
+.subtask__check input[type='checkbox']:active:not(:disabled) {
+ transform: scale(0.9);
+}
+
.task__main {
grid-area: main;
display: flex;
@@ -789,6 +1034,16 @@ button.task__mark::after {
font-size: 0.8rem;
line-height: 1;
user-select: none;
+ transition:
+ color var(--dur-quick) var(--ease-brisk),
+ transform var(--dur-quick) var(--ease-settle);
+}
+
+@media (hover: hover) {
+ .task__drag-handle:hover {
+ color: var(--fg);
+ transform: scale(1.15);
+ }
}
.task__due {
@@ -865,6 +1120,13 @@ button.task__mark::after {
.task--dragging {
opacity: 0.4;
+ transform: scale(0.98);
+ cursor: grabbing;
+}
+
+.task--dragging .task__drag-handle {
+ color: var(--fg);
+ cursor: grabbing;
}
.task--insert-before {
@@ -885,19 +1147,164 @@ button.task__mark::after {
is not part of the animation, so it still marks the task under
prefers-reduced-motion. */
@keyframes task-flash {
- from {
+ 0% {
background: var(--flash);
}
- to {
+ 40% {
+ background: var(--flash);
+ }
+ 100% {
+ background: transparent;
+ }
+}
+
+/* Sage twin for a task landing in Done — sage is the Done colour (D34). */
+@keyframes task-flash-done {
+ 0% {
+ background: var(--flash-sage);
+ }
+ 40% {
+ background: var(--flash-sage);
+ }
+ 100% {
background: transparent;
}
}
+/* A flashed row lands rather than pops. */
+@keyframes task-enter {
+ from {
+ transform: translateY(-3px);
+ }
+ to {
+ transform: translateY(0);
+ }
+}
+
.task--flash {
- animation: task-flash 1.6s ease-out;
+ animation:
+ task-flash 2.6s ease-out,
+ task-enter 0.3s var(--ease-settle);
box-shadow: inset 3px 0 0 var(--c-today);
}
+.task--flash-done {
+ animation:
+ task-flash-done 2.6s ease-out,
+ task-enter 0.3s var(--ease-settle);
+ box-shadow: inset 3px 0 0 var(--c-done);
+}
+
+/* ----- completion choreography (D52) -----
+ The still-mounted Today row plays this before the state commit lands it in
+ Done: tick draws (via the checkbox rule above), title strikes through, the
+ row washes sage, then slides slightly right and fades. The commit fires on
+ this animation's end (useRowExit), never on a fixed timer. */
+@keyframes task-completing {
+ 0% {
+ background: transparent;
+ transform: translateX(0);
+ opacity: 1;
+ }
+ 30% {
+ background: var(--flash-sage);
+ transform: translateX(0);
+ opacity: 1;
+ }
+ 70% {
+ background: var(--flash-sage);
+ transform: translateX(0);
+ opacity: 1;
+ }
+ 100% {
+ background: transparent;
+ transform: translateX(10px);
+ opacity: 0;
+ }
+}
+
+.task--completing {
+ animation: task-completing 0.4s var(--ease-brisk) forwards;
+ pointer-events: none;
+}
+
+.task--completing .task__title {
+ background-image: linear-gradient(currentColor, currentColor);
+ background-repeat: no-repeat;
+ background-position: 0 62%;
+ background-size: 0% 1.5px;
+ animation: task-strike 0.2s 0.12s var(--ease-brisk) forwards;
+}
+
+@keyframes task-strike {
+ to {
+ background-size: 100% 1.5px;
+ }
+}
+
+/* Master row leaving for Today: a quick slide-right + fade, then the commit. */
+@keyframes task-departing-right {
+ from {
+ transform: translateX(0);
+ opacity: 1;
+ }
+ to {
+ transform: translateX(40px);
+ opacity: 0;
+ }
+}
+
+.task--departing-right {
+ animation: task-departing-right 0.25s var(--ease-brisk) forwards;
+ pointer-events: none;
+}
+
+/* Row-level "move to Today" control (D52): lives in the empty check slot of a
+ Master row, revealed on hover/focus and never on touch (swipe is the touch
+ path). Hidden state is pointer-events:none so it can't intercept tap-to-edit. */
+.task__move {
+ grid-area: check;
+ justify-self: end;
+ display: grid;
+ place-items: center;
+ width: 1.6rem;
+ height: 1.6rem;
+ padding: 0;
+ background: transparent;
+ border: 1px solid transparent;
+ color: var(--c-today);
+ cursor: pointer;
+ opacity: 0;
+ transform: translateX(-4px);
+ pointer-events: none;
+ transition:
+ opacity var(--dur-quick) var(--ease-brisk),
+ transform var(--dur-quick) var(--ease-brisk),
+ background var(--dur-quick) var(--ease-brisk),
+ border-color var(--dur-quick) var(--ease-brisk);
+}
+
+.task__move:disabled {
+ color: var(--faint);
+ cursor: default;
+}
+
+@media (hover: hover) {
+ .task:hover .task__move,
+ .task:focus-within .task__move,
+ .task__move:focus-visible {
+ opacity: 1;
+ transform: translateX(0);
+ pointer-events: auto;
+ }
+
+ .task__move:hover:not(:disabled) {
+ background: var(--c-today);
+ border-color: var(--c-today);
+ color: var(--bg);
+ }
+}
+
.task__url-pill {
display: inline-block;
max-width: 100%;
@@ -946,7 +1353,7 @@ button.task__mark::after {
/* ------------------------------------------------------------ subtasks --- */
.subtasks,
-.task > .subtask-list {
+.task__surface > .subtask-list {
grid-area: subs;
min-width: 0;
}
@@ -973,18 +1380,7 @@ button.task__mark::after {
min-width: 0;
}
-.subtask__check input[type='checkbox'] {
- flex: none;
- width: 0.85rem;
- height: 0.85rem;
- accent-color: var(--c-done);
- cursor: pointer;
-}
-
-.subtask__check input[type='checkbox']:disabled {
- opacity: 0.5;
- cursor: default;
-}
+/* Sizing + tick for subtask checkboxes is shared with .task__check above. */
.subtask__title {
color: var(--muted);
@@ -1127,6 +1523,19 @@ button.task__mark::after {
flex-direction: column;
gap: 0.5rem;
padding: 0.35rem 0;
+ animation: edit-panel-in 0.12s var(--ease-brisk);
+}
+
+/* Soft open — closing stays instant, no unmount choreography (D52/WP5). */
+@keyframes edit-panel-in {
+ from {
+ opacity: 0;
+ transform: scale(0.99);
+ }
+ to {
+ opacity: 1;
+ transform: scale(1);
+ }
}
.edit-panel__title {
@@ -1206,6 +1615,63 @@ button.task__mark::after {
margin-top: 0.1rem;
}
+/* Self-labelling, destination-coloured move button (D52/WP6): the visible
+ "Today"/"Master" text means the arrow never has to be decoded, and the tint
+ is the destination column's own colour (D34). */
+.edit-panel__move {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.3rem;
+ padding: 0.28rem 0.6rem;
+ background: transparent;
+ border: 1px solid var(--rule);
+ font-family: var(--font-display);
+ font-size: 0.66rem;
+ font-weight: 600;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ cursor: pointer;
+ transition:
+ background var(--dur-quick) var(--ease-brisk),
+ border-color var(--dur-quick) var(--ease-brisk),
+ color var(--dur-quick) var(--ease-brisk),
+ transform var(--dur-quick) var(--ease-brisk);
+}
+
+.edit-panel__move--today {
+ color: var(--c-today-text);
+}
+
+.edit-panel__move--master {
+ color: var(--c-master);
+}
+
+.edit-panel__move:hover:not(:disabled) {
+ border-color: currentColor;
+}
+
+.edit-panel__move--today:hover:not(:disabled) {
+ background: var(--c-today);
+ border-color: var(--c-today);
+ color: var(--bg);
+}
+
+.edit-panel__move--master:hover:not(:disabled) {
+ background: var(--c-master);
+ border-color: var(--c-master);
+ color: var(--bg);
+}
+
+.edit-panel__move:disabled {
+ opacity: 0.4;
+ cursor: default;
+}
+
+/* Delete sits apart, pushed to the trailing edge next to Done. */
+.edit-panel__delete {
+ margin-left: auto;
+}
+
/* Push "Done" to the trailing edge, away from the icon actions. */
.edit-panel__done {
margin-left: auto;
@@ -1562,6 +2028,31 @@ kbd {
}
}
+/* --------------------------------------------- button micro-states (WP8) --- */
+
+/* One press language for every interactive atom: real transitions on the
+ colour/border swaps, and a uniform 1px "give" on press (D52). */
+.icon-btn,
+.btn,
+.add__submit,
+.add__recur,
+.edit-panel__done {
+ transition:
+ background var(--dur-quick) var(--ease-brisk),
+ border-color var(--dur-quick) var(--ease-brisk),
+ color var(--dur-quick) var(--ease-brisk),
+ transform var(--dur-quick) var(--ease-brisk);
+}
+
+.icon-btn:active:not(:disabled),
+.btn:active:not(:disabled),
+.add__submit:active:not(:disabled),
+.add__recur:active,
+.edit-panel__move:active:not(:disabled),
+.edit-panel__done:active {
+ transform: translateY(1px);
+}
+
/* ------------------------------------------------- pointer capabilities --- */
/* Touch gets no HTML5 drag-and-drop: drop the desktop drag handle (Today
diff --git a/src/ui/useRowExit.ts b/src/ui/useRowExit.ts
new file mode 100644
index 0000000..a221e28
--- /dev/null
+++ b/src/ui/useRowExit.ts
@@ -0,0 +1,108 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+import type { AnimationEvent } from 'react';
+
+/**
+ * Two-phase exit-commit (D52).
+ *
+ * The core reducers are instant and inverse-less: `completeTask`,
+ * `moveToToday`, `removeFromToday` move a task between columns in a single
+ * state commit, so the row unmounts from one list and mounts in the other on
+ * the very next render — there is no window to animate. This hook opens that
+ * window: `beginExit` marks a still-mounted row with an animation class, waits
+ * for the animation to finish, *then* runs the actual `setState`.
+ *
+ * Why the commit must not depend on a visible duration: under
+ * `prefers-reduced-motion` every animation is clamped to ~0ms, so `animationend`
+ * still fires (near-instantly) and the row still commits. A fallback timeout
+ * covers the case where `animationend` never arrives at all (interrupted
+ * animation, a browser that drops the event). Never gate a state change on an
+ * animation that might not run.
+ */
+
+/**
+ * Longest we wait for `animationend` before committing anyway, in ms. Must
+ * exceed the slowest exit animation (completion, ~400ms). Under reduced motion
+ * the animation ends first and this never fires.
+ */
+const EXIT_FALLBACK_MS = 550;
+
+export interface UseRowExitResult {
+ /** The exit class to apply to the row `id`, or '' when it is not exiting. */
+ exitClassFor: (id: string) => string;
+ /**
+ * Start an exit on row `id`: apply `className`, then run `commit` once the
+ * row's own animation ends (or the fallback fires). A repeat call for an id
+ * already exiting is ignored, so a rapid double-click or key-repeat commits
+ * exactly once.
+ */
+ beginExit: (id: string, className: string, commit: () => void) => void;
+ /**
+ * Wire to the row's `onAnimationEnd`. Commits when the row's *own* exit
+ * animation ends — child animations (tick draw, strikethrough) bubble to the
+ * same handler and are ignored via the target/currentTarget check.
+ */
+ onRowAnimationEnd: (id: string, e: AnimationEvent) => void;
+}
+
+export function useRowExit(): UseRowExitResult {
+ const [exiting, setExiting] = useState>({});
+ // Pending commits + fallback timers keyed by row id. Refs, so the stable
+ // callbacks below never capture a stale map and cleanup can reach them.
+ const commits = useRef void>>(new Map());
+ const timers = useRef>(new Map());
+
+ const runCommit = useCallback((id: string) => {
+ const commit = commits.current.get(id);
+ // Already fired (a second animationend, or fallback racing the event) —
+ // this is the single-commit guard.
+ if (commit === undefined) return;
+ commits.current.delete(id);
+ const timer = timers.current.get(id);
+ if (timer !== undefined) {
+ window.clearTimeout(timer);
+ timers.current.delete(id);
+ }
+ setExiting((prev) => {
+ if (!(id in prev)) return prev;
+ const next = { ...prev };
+ delete next[id];
+ return next;
+ });
+ commit();
+ }, []);
+
+ const beginExit = useCallback(
+ (id: string, className: string, commit: () => void) => {
+ if (commits.current.has(id)) return; // already exiting — ignore
+ commits.current.set(id, commit);
+ setExiting((prev) => ({ ...prev, [id]: className }));
+ timers.current.set(
+ id,
+ window.setTimeout(() => runCommit(id), EXIT_FALLBACK_MS),
+ );
+ },
+ [runCommit],
+ );
+
+ const onRowAnimationEnd = useCallback(
+ (id: string, e: AnimationEvent) => {
+ // Only the row-level exit commits; descendant animations bubble here too.
+ if (e.target !== e.currentTarget) return;
+ runCommit(id);
+ },
+ [runCommit],
+ );
+
+ const exitClassFor = useCallback((id: string) => exiting[id] ?? '', [exiting]);
+
+ // Clear pending fallback timers if the column unmounts mid-exit.
+ useEffect(() => {
+ const t = timers.current;
+ return () => {
+ t.forEach((timer) => window.clearTimeout(timer));
+ t.clear();
+ };
+ }, []);
+
+ return { exitClassFor, beginExit, onRowAnimationEnd };
+}
diff --git a/src/ui/useSwipeAction.ts b/src/ui/useSwipeAction.ts
new file mode 100644
index 0000000..a7272ab
--- /dev/null
+++ b/src/ui/useSwipeAction.ts
@@ -0,0 +1,215 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+
+/**
+ * Hand-rolled horizontal swipe-to-act for touch (D52). No gesture library
+ * (D1/D47) — modelled on `useLongPress`'s conventions: pointerType gating,
+ * an intent threshold before we claim the gesture, and cleanup on unmount.
+ *
+ * The row's visible surface follows the finger while swiping; releasing past a
+ * threshold flings it out and fires `onTrigger`; releasing short springs it
+ * back. `enabled: false` (a recurring master with a live day-copy — D43) still
+ * gives a little damped travel so the resistance itself reads as "blocked",
+ * then always springs back.
+ *
+ * Commit timing mirrors `useRowExit`: the trigger fires on `transitionend`
+ * (which still fires near-instantly under `prefers-reduced-motion`, since the
+ * global rule clamps the duration rather than removing the transition), with a
+ * fallback timeout so a dropped event can never strand the commit.
+ */
+
+/** Finger travel before we decide the gesture is horizontal, in px. */
+const INTENT_PX = 12;
+/** Horizontal must dominate vertical by this ratio to claim the gesture. */
+const INTENT_RATIO = 1.5;
+/** Absolute minimum release distance to trigger, in px. */
+const THRESHOLD_MIN_PX = 72;
+/** …or this fraction of the row width, whichever is larger. */
+const THRESHOLD_FRAC = 0.3;
+/** Resistance ceiling for travel in the wrong direction / when disabled, px. */
+const RESIST_MAX_PX = 56;
+/** Commit even if `transitionend` never arrives, in ms (> --dur-move). */
+const FLING_FALLBACK_MS = 450;
+
+export interface UseSwipeActionOptions {
+ /** Which way a committing swipe goes: Master→Today is 'right', Today→Master 'left'. */
+ direction: 'right' | 'left';
+ /** False disables the trigger (D43) — travel is damped and always springs back. */
+ enabled: boolean;
+ onTrigger: () => void;
+}
+
+export interface UseSwipeActionResult {
+ handlers: {
+ onPointerDown: (e: React.PointerEvent) => void;
+ onPointerMove: (e: React.PointerEvent) => void;
+ onPointerUp: (e: React.PointerEvent) => void;
+ onPointerCancel: (e: React.PointerEvent) => void;
+ onTransitionEnd: (e: React.TransitionEvent) => void;
+ };
+ /** Horizontal offset to apply to the row surface right now (0 when idle). */
+ dx: number;
+ /** Finger down with horizontal intent — surface tracks the finger, no transition. */
+ swiping: boolean;
+ /** Released past threshold — surface is sliding out until the commit lands. */
+ flinging: boolean;
+}
+
+/** Quadratic-ish resistance: travel past `max` slows and asymptotes to `max`. */
+function rubberBand(distance: number, max: number): number {
+ const sign = Math.sign(distance);
+ const d = Math.abs(distance);
+ return sign * (1 - 1 / (d / max + 1)) * max;
+}
+
+type Intent = 'none' | 'horizontal' | 'vertical';
+
+export function useSwipeAction({
+ direction,
+ enabled,
+ onTrigger,
+}: UseSwipeActionOptions): UseSwipeActionResult {
+ const [dx, setDx] = useState(0);
+ const [swiping, setSwiping] = useState(false);
+ const [flinging, setFlinging] = useState(false);
+
+ const start = useRef<{ x: number; y: number } | null>(null);
+ const intent = useRef('none');
+ const width = useRef(0);
+ const pointerId = useRef(null);
+ const fallbackTimer = useRef(null);
+ // A fling is in progress with a commit still pending — the single-commit
+ // guard shared by transitionend and the fallback timer.
+ const armed = useRef(false);
+ // Latest trigger, read from inside the commit which is registered once.
+ const triggerRef = useRef(onTrigger);
+ useEffect(() => {
+ triggerRef.current = onTrigger;
+ }, [onTrigger]);
+ const dirSign = direction === 'right' ? 1 : -1;
+
+ const reset = useCallback(() => {
+ start.current = null;
+ intent.current = 'none';
+ pointerId.current = null;
+ setSwiping(false);
+ }, []);
+
+ const clearFallback = useCallback(() => {
+ if (fallbackTimer.current !== null) {
+ window.clearTimeout(fallbackTimer.current);
+ fallbackTimer.current = null;
+ }
+ }, []);
+
+ // The single commit path, shared by transitionend and the fallback timeout.
+ const commit = useCallback(() => {
+ if (!armed.current) return;
+ armed.current = false;
+ clearFallback();
+ setFlinging(false);
+ setDx(0);
+ triggerRef.current();
+ }, [clearFallback]);
+
+ const onPointerDown = useCallback((e: React.PointerEvent) => {
+ if (e.pointerType !== 'touch') return; // mouse keeps drag-reorder / click
+ start.current = { x: e.clientX, y: e.clientY };
+ intent.current = 'none';
+ pointerId.current = e.pointerId;
+ width.current = e.currentTarget.getBoundingClientRect().width;
+ }, []);
+
+ const onPointerMove = useCallback(
+ (e: React.PointerEvent) => {
+ const origin = start.current;
+ if (origin === null || pointerId.current !== e.pointerId) return;
+ const rawX = e.clientX - origin.x;
+ const rawY = e.clientY - origin.y;
+
+ if (intent.current === 'none') {
+ // Wait to declare intent so a vertical scroll that begins on a row is
+ // never hijacked. Horizontal must clear the threshold and dominate.
+ if (Math.abs(rawX) > INTENT_PX && Math.abs(rawX) > INTENT_RATIO * Math.abs(rawY)) {
+ intent.current = 'horizontal';
+ setSwiping(true);
+ e.currentTarget.setPointerCapture(e.pointerId);
+ } else if (Math.abs(rawY) > INTENT_PX) {
+ intent.current = 'vertical'; // it's a scroll — leave it to the browser
+ start.current = null;
+ return;
+ } else {
+ return;
+ }
+ }
+ if (intent.current !== 'horizontal') return;
+
+ const forward = rawX * dirSign > 0;
+ if (!enabled) {
+ // Blocked (D43): a little damped give in either direction, no commit.
+ setDx(rubberBand(rawX, RESIST_MAX_PX));
+ } else if (forward) {
+ setDx(rawX); // follow the finger 1:1 toward the action
+ } else {
+ setDx(rubberBand(rawX, RESIST_MAX_PX)); // resist the wrong way
+ }
+ },
+ [dirSign, enabled],
+ );
+
+ const springBack = useCallback(() => {
+ // Leave `swiping` false so the row's transition is live, and animate to 0.
+ setSwiping(false);
+ setDx(0);
+ }, []);
+
+ const onPointerUp = useCallback(
+ (e: React.PointerEvent) => {
+ if (pointerId.current !== e.pointerId) return;
+ const origin = start.current;
+ const rawX = origin ? e.clientX - origin.x : 0;
+ const wasHorizontal = intent.current === 'horizontal';
+ reset();
+ if (!wasHorizontal) return;
+
+ const threshold = Math.max(THRESHOLD_MIN_PX, THRESHOLD_FRAC * width.current);
+ if (enabled && rawX * dirSign >= threshold) {
+ // Fling the surface off-screen; commit lands on transitionend.
+ armed.current = true;
+ setFlinging(true);
+ setDx(dirSign * (width.current + 48));
+ clearFallback();
+ fallbackTimer.current = window.setTimeout(commit, FLING_FALLBACK_MS);
+ } else {
+ springBack();
+ }
+ },
+ [dirSign, enabled, reset, springBack, clearFallback, commit],
+ );
+
+ const onPointerCancel = useCallback(
+ (e: React.PointerEvent) => {
+ if (pointerId.current !== e.pointerId) return;
+ reset();
+ springBack();
+ },
+ [reset, springBack],
+ );
+
+ const onTransitionEnd = useCallback(
+ (e: React.TransitionEvent) => {
+ // Only the surface's own transform transition commits.
+ if (e.propertyName !== 'transform' || e.target !== e.currentTarget) return;
+ commit();
+ },
+ [commit],
+ );
+
+ useEffect(() => clearFallback, [clearFallback]);
+
+ return {
+ handlers: { onPointerDown, onPointerMove, onPointerUp, onPointerCancel, onTransitionEnd },
+ dx,
+ swiping,
+ flinging,
+ };
+}