Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 42 additions & 11 deletions .claude/skills/verify/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<li>`.
- 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,
Expand Down Expand Up @@ -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
"`<section class="col col--master">` 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.
Expand Down
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<button>` 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 `<button>` 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 `<li>`, its border, or its row-state backgrounds (which stay on the `<li>` 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)
Loading
Loading