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 ` +
    + + {task.isRecurring && repeat} +
    + {task.dueDate ? ( + + ) : ( + + )} + +
  • ); } 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 && ( + + )} {reorder && ( <> - )}
    + + ); +} 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, + }; +}