Files
gems/docs/design/web-bottom-sheet-design.md
joakim 0e3750e755 fix: resolve all 15 svelte-check type errors
- Type headers as Record<string, string> in apiRequest (client.js)
- Annotate SyncResult on result object, cast catch vars to any (sync.js)
- Widen sync.push param to Partial<Task>[] (endpoints.js)
- Fix parse() return type to reflect {task?: Task} shape (endpoints.js)
- Narrow add() param from Partial<Task> to Task (tasks.js)
- Cast parseAndCreate result to Task (tasks.js)
- Type tasksByProject grouped object as Record<string, Task[]> (tasks.js)

svelte-check now reports 0 errors and 0 warnings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 01:09:18 +01:00

48 KiB

Task Detail Bottom Sheet — Design

Status: Draft Implements: web-cli-parity.md 1.1 (Task Detail View) + 1.2 (Task Editing) Depends on: Existing Task type, PUT /tasks/:uuid, tag endpoints


1. Overview

A bottom sheet that slides up when a user taps a task row. It shows every non-null field on the task and allows inline editing of mutable fields. This is the single biggest new surface in the web app — it becomes the hub for viewing, editing, starting/stopping, and deleting tasks.

┌─────────────────────────────────┐
│  Header  │  ReportPicker  │  ⚙  │
├─────────────────────────────────┤
│                                 │
│  Task list (partially visible,  │
│  dimmed behind scrim)           │
│                                 │
├─────────────────────────────────┤  ← scrim starts here
│ ┌─────────────────────────────┐ │
│ │  ─── drag handle ───        │ │
│ │                             │ │
│ │  Buy groceries        [edit]│ │  ← description (tappable to edit)
│ │                             │ │
│ │  Status      Pending        │ │  ← read-only
│ │  Priority    ● High         │ │  ← tappable
│ │  Project     Home           │ │  ← tappable
│ │  Due         Tomorrow       │ │  ← tappable
│ │  Tags        errand, food   │ │  ← tappable (add/remove)
│ │  Created     Feb 18         │ │  ← read-only
│ │  Modified    Feb 19         │ │  ← read-only
│ │  Urgency     8.2            │ │  ← read-only
│ │                             │ │
│ │  [Start]  [Delete]          │ │  ← action buttons
│ └─────────────────────────────┘ │
├─────────────────────────────────┤
│  InputBar                       │
└─────────────────────────────────┘

2. Component: BottomSheet.svelte

A generic, reusable bottom sheet. Owns the open/close lifecycle, scrim, drag-to-dismiss, and snap positioning. Content is a slot.

2.1 Interface

interface BottomSheetProps {
  open: boolean
  onClose: () => void
}

Controlled via the open prop. No exposed methods needed.

2.2 Behavior

Open:

  • Sheet translates from translateY(100%) to translateY(0) via CSS transition
  • Scrim fades in (opacity: 0 → 0.5) — same rgba(0,0,0,0.5) as FilterModal backdrop
  • Body scroll is locked (overflow: hidden on <html>) while sheet is open
  • Focus is trapped inside the sheet (keyboard accessibility)

Close (four ways):

  1. Tap scrim — light-dismiss, same as FilterModal
  2. Drag down — swipe the sheet down past a threshold (see 2.3)
  3. Close button — explicit X in top-right (accessibility fallback)
  4. Escape key — keyboard dismiss

Position:

  • Sheet takes up to 85% of viewport height (max-height: 85dvh)
  • Content scrolls internally (overflow-y: auto) if it exceeds the sheet height
  • On desktop (>768px), sheet is capped at 480px width and centered horizontally

2.3 Drag-to-Dismiss Gesture

Same angle-based detection model as SwipeAction, but vertical:

  1. Touch starts on the drag handle or sheet header area
  2. If |deltaY| > 10px AND |deltaY| > |deltaX| * 2 → lock to vertical drag
  3. During drag: translateY(deltaY) (only positive — can't drag up past 0)
  4. On release: if deltaY > 150px or velocity > threshold → close with animation. Otherwise snap back to translateY(0)
  5. CSS transition on release (transition: transform 0.3s ease-out)

The drag handle is a visual affordance (small pill-shaped bar at the top), but the entire header region is the drag target.

2.4 Implementation Pattern

Uses a <div> rather than <dialog> (see ADR-7 below for rationale).

<div class="sheet-scrim" class:open on:click={close}>
  <div class="sheet" class:open style:transform="translateY({dragOffset}px)"
       on:click|stopPropagation>
    <div class="sheet-handle" on:touchstart={handleDragStart}
         on:touchmove={handleDragMove} on:touchend={handleDragEnd}>
      <div class="handle-bar"></div>
    </div>
    <div class="sheet-content">
      <slot />
    </div>
  </div>
</div>

2.5 CSS

.sheet-scrim {
  position: fixed;
  inset: 0;
  z-index: 50;
  background: rgba(0, 0, 0, 0.5);
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.3s ease;
}

.sheet-scrim.open {
  opacity: 1;
  pointer-events: auto;
}

.sheet {
  position: fixed;
  bottom: 0;
  left: 0;
  right: 0;
  max-height: 85dvh;
  background: var(--bg-primary);
  border-radius: 1rem 1rem 0 0;
  box-shadow: var(--shadow-lg);
  transform: translateY(100%);
  transition: transform 0.3s ease-out;
  display: flex;
  flex-direction: column;
  z-index: 51;
}

.sheet.open {
  transform: translateY(0);
}

.sheet-handle {
  display: flex;
  justify-content: center;
  padding: var(--spacing-sm) 0;
  cursor: grab;
  touch-action: none;
}

.handle-bar {
  width: 2.5rem;
  height: 0.25rem;
  background: var(--text-tertiary);
  border-radius: 9999px;
}

.sheet-content {
  flex: 1;
  overflow-y: auto;
  overscroll-behavior: contain;
  padding: 0 var(--spacing-md) var(--spacing-lg);
}

/* Desktop constraint */
@media (min-width: 769px) {
  .sheet {
    max-width: 480px;
    left: 50%;
    transform: translateX(-50%) translateY(100%);
  }
  .sheet.open {
    transform: translateX(-50%) translateY(0);
  }
}

3. Component: TaskDetail.svelte

The content rendered inside the BottomSheet. Owns the task field layout and inline editing logic.

3.1 Interface

interface TaskDetailProps {
  task: Task
  onUpdate: (uuid: string, updates: Partial<Task>) => Promise<void>
  onStart: (uuid: string) => Promise<void>
  onStop: (uuid: string) => Promise<void>
  onDelete: (uuid: string) => void   // opens confirmation, doesn't delete directly
  onComplete: (uuid: string) => void
  onClose: () => void
}

3.2 Field Layout

Fields are displayed in a two-column key-value layout. Each row is a field-row with a label (left) and value (right). Editable fields show a subtle edit affordance on tap.

Field display order and editability:

Field Editable Display Shown when
Description Yes Large text at top, outside the key-value grid Always
Status No Badge: "Pending" / "Completed" / "Deleted" / "Recurring" Always
Priority Yes Colored dot + label. Tap → cycles through H/M/D/L Always
Project Yes Text. Tap → inline text input Non-null, or "Add..."
Due Yes Relative date + absolute in parentheses. Tap → date input Non-null, or "Set..."
Scheduled Yes Same format as due Non-null, or "Set..."
Wait Yes Same format Non-null, or "Set..."
Until Yes Same format Non-null, or "Set..."
Start (active since) No Relative time Only if task is started
Recurrence Yes* Duration label (e.g. "every 1 week") Only on templates
Parent No "Recurring instance" with parent link Only on instances
Tags Yes Pill list with + button to add, x on each to remove Always (empty = just [+])
Created No Absolute date Always
Modified No Absolute date Always
End No Absolute date Only if completed
UUID No Truncated, tap to copy full UUID Always
Urgency No Numeric score Always (> 0)

*Recurrence is editable only on template tasks (status === 'R'), not on instances. For all other editable fields on recurring instances, an "edit instance or template?" prompt appears first (see section 3.8).

Null-field rule: Fields with null values are hidden entirely. No empty rows or placeholder text. Exception: editable fields that are commonly set show an "Add..." or "Set..." affordance when null (project, due, scheduled, wait, until).

3.3 Visual Layout

 ─── handle ───

 Buy groceries                                    [✏]
 ─────────────────────────────────────────────────

 Status        Pending
 Priority      ● High                             [tap to change]
 Project       Home                               [tap to edit]
 Due           Tomorrow (Feb 20)                   [tap to edit]
 Tags          [errand] [food] [+]

 ─── divider ───

 Created       Feb 18, 2026
 Modified      Feb 19, 2026
 Urgency       8.2
 UUID          8f3a1b2c...                         [tap to copy]

 ─── divider ───

 [ ▶ Start ]    [ ✓ Complete ]    [ 🗑 Delete ]

Three visual sections separated by subtle dividers:

  1. Editable fields — the fields users interact with
  2. Read-only metadata — timestamps, urgency, UUID
  3. Actions — start/stop, delete, complete/uncomplete

3.4 Inline Editing Model

When a user taps an editable field value, the value transforms in-place into an input control. No modal, no separate edit screen.

Per-field edit controls:

Field Edit Control Behavior
Description Text input (auto-expanding) Replace text, Enter to save, Escape to cancel
Priority Cycle on tap No input — tapping cycles H → M → default → L → H
Project Text input Inline text replaces the value. Enter to save. Empty = clear
Due, Scheduled, Wait, Until <input type="date"> Native date picker. Change event saves. Clear button to null
Tags Pill list + input [x] on tag to remove, [+] opens a small text input for new tag
Recurrence Text input Accepts duration format like 1w, 2d, 1mo. Enter to save

Edit lifecycle:

  1. User taps field value → value becomes an input (the label stays)
  2. User modifies → on Enter or blur or selection → onUpdate(uuid, { field: newValue }) is called
  3. Optimistic: UI shows new value immediately. On API failure, revert and show a brief error toast
  4. Only one field is editable at a time. Tapping another field saves the current one first

Date fields use the native <input type="date">. This gives us the OS date picker on Android and a calendar dropdown on desktop — no library needed. The value is converted to/from Unix timestamp via the existing toUnix/fromUnix utils.

3.5 Description Editing

The description sits at the top of the sheet as a prominent heading. An edit icon (pencil) sits beside it. Tapping the icon (or the description text) replaces it with a <textarea> that auto-sizes to content.

 Before:  Buy groceries                    [✏]
 During:  [Buy groceries and also milk_____|]   [✓] [✕]
 After:   Buy groceries and also milk      [✏]

Save on Enter (without Shift). Cancel on Escape. Explicit save/cancel buttons for touch users who can't easily hit Enter/Escape.

3.6 Tags Editing

Tags are a special case — they use add/remove endpoints rather than a single PUT.

 Tags:   [errand ✕] [food ✕] [+ Add tag]
  • Tapping on a tag calls DELETE /tasks/:uuid/tags/:tag
  • Tapping [+ Add tag] shows a small inline text input. Enter to add (POST /tasks/:uuid/tags), Escape to cancel
  • Optimistic add/remove with rollback on failure

3.7 Action Buttons

At the bottom of the sheet, a row of action buttons:

For pending tasks:

[ ▶ Start ]    [ ✓ Complete ]    [ 🗑 Delete ]

For active (started) tasks:

[ ⏹ Stop ]    [ ✓ Complete ]    [ 🗑 Delete ]

For completed tasks:

[ ↩ Uncomplete ]    [ 🗑 Delete ]
  • Start/Stop calls onStart/onStop — wired to POST /tasks/:uuid/start|stop
  • Complete calls onComplete — same as checkbox/swipe in the list
  • Delete calls onDelete — opens the confirmation (designed separately in 1.5)
  • Uncomplete calls onUpdate(uuid, { status: 'P' }) — sets status back to pending

3.8 Recurring Instance Edit — Instance vs Template

When a user taps an editable field on a recurring instance (a task with parent_uuid !== null), a brief confirmation appears before the edit control opens:

 ┌──────────────────────────────┐
 │  Edit this instance only     │
 │  Edit template (all future)  │
 │  Cancel                      │
 └──────────────────────────────┘

"Edit this instance" — applies the change to the current task only via PUT /tasks/:uuid. Future instances spawned from the template are unaffected.

"Edit template" — applies the change to the parent template via PUT /tasks/:parent_uuid. The current instance is NOT modified — the change takes effect on the next spawned instance. The sheet updates to show the template after the edit.

Which fields trigger this? Only fields that exist on both instance and template: description, priority, project, due (as relative offset), tags, recurrence. Read-only and instance-specific fields (status, start, created, etc.) don't trigger the prompt.

Implementation: A small inline popover or action sheet that appears on first tap. Selection is remembered for the duration of the sheet session — if the user picks "Edit this instance" once, subsequent edits in the same sheet opening default to instance without re-prompting. Switching tasks (closing and reopening the sheet) resets this.

Non-recurring tasks: No prompt. Edits apply directly.


Button styling uses the existing --color-* tokens:

  • Start/Stop: --color-primary
  • Complete: --color-success
  • Delete: --color-danger (ghost variant — text only, no background)
  • Uncomplete: --color-primary

4. Integration with +page.svelte

4.1 State

Add to the page's reactive state:

/** @type {Task|null} */
let selectedTask = null;

4.2 Opening

TaskItem gets a new onTap prop. Tapping the task content area (not the checkbox) calls onTap(task). The page sets selectedTask = task.

The tap target is the .task-content div that already exists in TaskItem. The checkbox already has stopPropagation so it won't trigger the tap.

<!-- +page.svelte -->
<TaskList
  {tasks}
  {loading}
  {activeReport}
  onComplete={handleComplete}
  onTap={(task) => selectedTask = task}
/>

<BottomSheet open={selectedTask !== null} onClose={() => selectedTask = null}>
  {#if selectedTask}
    <TaskDetail
      task={selectedTask}
      onUpdate={handleUpdate}
      onStart={handleStart}
      onStop={handleStop}
      onDelete={handleDelete}
      onComplete={handleComplete}
      onClose={() => selectedTask = null}
    />
  {/if}
</BottomSheet>

4.3 Data Flow

sequenceDiagram
    participant User
    participant TaskItem
    participant Page as +page.svelte
    participant Sheet as BottomSheet
    participant Detail as TaskDetail
    participant API

    User->>TaskItem: tap task row
    TaskItem->>Page: onTap(task)
    Page->>Page: selectedTask = task
    Page->>Sheet: open=true
    Sheet-->>User: sheet slides up

    User->>Detail: tap Priority field
    Detail->>Detail: cycle to next value
    Detail->>API: PUT /tasks/:uuid {priority: 3}
    API-->>Detail: updated task
    Detail->>Page: onUpdate resolves
    Page->>Page: update task in tasks[]

    User->>Sheet: drag down / tap scrim
    Sheet->>Page: onClose()
    Page->>Page: selectedTask = null
    Sheet-->>User: sheet slides down

4.4 Keeping Task State Fresh

When onUpdate succeeds, the page must update both the tasks[] array (so the list reflects the change) and the selectedTask (so the sheet shows the new value):

async function handleUpdate(uuid, updates) {
  const updated = await tasksStore.updateTask(uuid, updates);
  if (selectedTask?.uuid === uuid) {
    selectedTask = { ...selectedTask, ...updates };
  }
}

5. Technical Decisions

ADR-7: Bottom sheet as a custom div, not <dialog>

Context: The FilterModal uses <dialog> with showModal(). Should the bottom sheet do the same?

Decision: Use a custom <div> with manual scrim and focus trap.

Rationale:

  • <dialog> with showModal() renders to the top-layer and centers content by default. Fighting the centering for a bottom-anchored sheet adds complexity.
  • The sheet has drag-to-dismiss and translateY positioning that is easier to control on a regular div.
  • Focus trapping can be done with a small tab-key handler.

Trade-off: We lose <dialog>'s built-in focus management and Escape-to-close. We add a keydown listener for Escape and a simple focus trap.

ADR-8: Inline editing, not a separate edit mode

Context: The spec mentions two approaches: "inline (tap field to edit) or form-based (edit mode that makes all fields editable)."

Decision: Inline tap-to-edit. One field at a time.

Rationale:

  • Inline editing is lower friction for single-field changes (the most common case — "change the due date", "add a tag")
  • A form mode would require an edit/save/cancel flow for the entire form
  • Single-field editing means each change is a separate API call, which is simpler and gives immediate feedback

Trade-off: Multiple field changes require multiple taps and API calls. For batch changes, the CLI's modify command remains more efficient.

ADR-9: Native <input type="date"> for date fields

Context: Date editing needs a picker. Options: native input, custom calendar, or CLI-style text input ("tomorrow", "fri").

Decision: Native <input type="date">.

Rationale:

  • Android Chrome shows the system date picker — familiar, touch-friendly
  • Desktop Chrome/Firefox show a calendar dropdown
  • Zero dependency cost
  • The input bar already supports CLI-style relative dates for task creation

Trade-off: No relative date input in the edit view. Users who prefer "tomorrow" can use the CLI or the input bar.

ADR-10: Priority cycles on tap, no dropdown

Context: Priority has 4 values (H/M/default/L). How should it be edited?

Decision: Tap the priority value to cycle: H → M → default → L → H.

Rationale: 4 values is too few for a dropdown. Cycling is fast and the visual dot + label makes the current value clear.

Trade-off: Going from H to L is 3 taps. Acceptable.


6. New/Modified Files

src/lib/components/
├── BottomSheet.svelte          # NEW — generic bottom sheet
├── TaskDetail.svelte           # NEW — task detail content
├── TaskItem.svelte             # MODIFY — add onTap prop + click handler on .task-content
└── TaskList.svelte             # MODIFY — pass onTap through

src/routes/
└── +page.svelte                # MODIFY — selectedTask state, handlers, sheet mount

No new API endpoints. No store changes (uses existing tasksStore.updateTask, tasks.start, tasks.stop).


7. Interaction Summary

User Action Component API Call Result
Tap task row TaskItem → page Sheet opens with task detail
Tap scrim / drag down / Escape BottomSheet Sheet closes
Tap editable field TaskDetail Field becomes input
Edit field + Enter/blur TaskDetail PUT /tasks/:uuid Optimistic update
Tap tag ✕ TaskDetail DELETE /tasks/:uuid/tags/:tag Tag removed
Tap + Add tag TaskDetail POST /tasks/:uuid/tags Tag added
Tap priority value TaskDetail PUT /tasks/:uuid Priority cycles
Tap Start TaskDetail POST /tasks/:uuid/start Task becomes active
Tap Stop TaskDetail POST /tasks/:uuid/stop Task stops
Tap Complete TaskDetail POST /tasks/:uuid/complete Task completed, sheet closes
Tap Delete TaskDetail Opens delete confirmation (separate design)
Tap Uncomplete TaskDetail PUT /tasks/:uuid Status → pending
Tap UUID TaskDetail Copies to clipboard
Swipe task left (not started) SwipeAction → TaskItem → page POST /tasks/:uuid/start Task becomes active, row gets indicator
Swipe task left (started) SwipeAction → TaskItem → page POST /tasks/:uuid/stop Task stops, indicator removed


Swipe Left to Start/Stop — Design

Implements: web-cli-parity.md 1.3 (Start / Stop Timer) Modifies: SwipeAction.svelte, TaskItem.svelte, +page.svelte


9. Overview

Mirrors the existing swipe-right-to-complete gesture. Swiping a task row left toggles the task's started/stopped state. The row does NOT collapse (unlike completion) — it stays in place with an updated active indicator.

 Right swipe (existing):
   ───────────────►  Green + ✓   → Complete (row collapses)

 Left swipe (new):
   ◄───────────────  Primary + ▶  → Start  (row stays, gets active indicator)
   ◄───────────────  Primary + ⏹  → Stop   (row stays, active indicator removed)

10. SwipeAction Changes

The current SwipeAction is extended from right-only to bidirectional. The component gains a second callback and a second background layer.

10.1 Updated Interface

interface SwipeActionProps {
  onSwipeRight: () => void     // was: onSwipe — complete
  onSwipeLeft: () => void      // new — start/stop toggle
  leftIcon: 'start' | 'stop'  // controls which icon shows on left background
}

All existing call sites are updated to use onSwipeRight (renamed from onSwipe) and must provide onSwipeLeft.

10.2 Gesture Changes

Current (line 56): offsetX = Math.max(0, deltaX) — clamps to positive.

New: Allow negative deltaX when onSwipeLeft is provided.

if (swiping) {
  if (e.cancelable) e.preventDefault();
  offsetX = deltaX;  // allow both directions
}

Threshold detection on touch end:

if (offsetX >= THRESHOLD) {
  // Right swipe — complete (existing behavior)
  completed = true;
  offsetX = window.innerWidth;
  setTimeout(() => onSwipeRight(), 200);
} else if (offsetX <= -THRESHOLD) {
  // Left swipe — start/stop (NEW)
  triggered = true;
  offsetX = -window.innerWidth;
  setTimeout(() => {
    onSwipeLeft();
    // Snap back — row stays visible
    offsetX = 0;
    triggered = false;
  }, 200);
} else {
  offsetX = 0;
}

Key difference: right-swipe sets completed = true and the row collapses (via TaskItem's completing class). Left-swipe sets triggered = true temporarily, animates off-screen left, then snaps back to 0 and the row remains. The task stays in the list with its active state toggled.

10.3 Dual Background Layers

Two background layers, one on each side. Only the relevant one is visible based on swipe direction.

<!-- Left background (revealed on RIGHT swipe — complete) -->
<div class="swipe-bg swipe-bg-right"
     style:opacity={rightProgress}>
  <svg class="swipe-icon" viewBox="0 0 24 24">
    <path d="M5 13l4 4L19 7" />  <!-- checkmark -->
  </svg>
</div>

<!-- Right background (revealed on LEFT swipe — start/stop) -->
<div class="swipe-bg swipe-bg-left"
     style:opacity={leftProgress}>
  {#if leftIcon === 'stop'}
    <svg class="swipe-icon" viewBox="0 0 24 24">
      <rect x="6" y="6" width="12" height="12" rx="1" />  <!-- stop square -->
    </svg>
  {:else}
    <svg class="swipe-icon" viewBox="0 0 24 24">
      <polygon points="6,4 20,12 6,20" />  <!-- play triangle -->
    </svg>
  {/if}
</div>

Progress calculations:

$: rightProgress = offsetX > 0 ? Math.min(offsetX / THRESHOLD, 1) : 0;
$: leftProgress = offsetX < 0 ? Math.min(Math.abs(offsetX) / THRESHOLD, 1) : 0;

10.4 CSS

.swipe-bg {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
}

.swipe-bg-right {
  background-color: var(--color-success);
  padding-left: var(--spacing-lg);
  justify-content: flex-start;
}

.swipe-bg-left {
  background-color: var(--color-primary);
  padding-right: var(--spacing-lg);
  justify-content: flex-end;
}

.swipe-icon {
  width: 1.5rem;
  height: 1.5rem;
  color: white;
  fill: none;
  stroke: currentColor;
  stroke-width: 3;
  stroke-linecap: round;
  stroke-linejoin: round;
}

/* Override for filled icons (play/stop use fill, not stroke) */
.swipe-bg-left .swipe-icon {
  fill: white;
  stroke: none;
}

11. TaskItem Changes

11.1 New Props

interface TaskItemProps {
  task: Task
  onComplete: (uuid: string) => void
  onTap: (task: Task) => void          // from bottom sheet design
  onStartStop: (uuid: string) => void  // new — toggle start/stop
}

11.2 Active Indicator

When a task has task.start !== null, it is active and needs a visual indicator in the list. Two changes:

1. Left border accent:

A colored left border on the task row. Subtle but visible in a list scan.

.task-item.active {
  border-left: 3px solid var(--color-primary);
  padding-left: calc(var(--spacing-md) - 3px);  /* compensate for border */
}

2. "Active" meta pill:

A small pill in the meta row, similar to priority/project pills, using the primary color.

{#if task.start}
  <span class="meta-item active-pill">Active</span>
{/if}
.active-pill {
  background-color: rgba(var(--color-primary-rgb), 0.15);
  color: var(--color-primary);
}

Note: This requires the theme to expose a --color-primary-rgb token (just the RGB values without rgb()) for the alpha background. Alternatively, use a dedicated --color-active-bg / --color-active-text pair in each theme, following the existing badge pattern.

11.3 SwipeAction Wiring

<SwipeAction
  onSwipeRight={() => onComplete(task.uuid)}
  onSwipeLeft={() => onStartStop(task.uuid)}
  leftIcon={task.start ? 'stop' : 'start'}
>
  <!-- task-item content -->
</SwipeAction>

The leftIcon prop is derived from the task's current state — if started, show stop icon; if not started, show start icon. This gives the user a visual cue during the swipe about what action will happen.

11.4 Tap Handler

The .task-content div gets a click handler for opening the bottom sheet (from the bottom sheet design):

<div class="task-content" on:click={() => onTap(task)}>

This is separate from the swipe gesture — taps are point events, swipes require movement past the 10px angle-lock threshold.


12. Page Integration

12.1 New Handler in +page.svelte

async function handleStartStop(uuid) {
  const task = tasks.find(t => t.uuid === uuid);
  if (!task) return;

  try {
    if (task.start) {
      await tasksStore.stopTask(uuid);
    } else {
      await tasksStore.startTask(uuid);
    }
  } catch (error) {
    console.error('Failed to start/stop task:', error);
  }
}

12.2 Store Methods

tasksStore needs startTask and stopTask methods that call the existing API endpoints and update the local task array:

async startTask(uuid) {
  // Optimistic: set start to now
  const now = Math.floor(Date.now() / 1000);
  update(tasks => tasks.map(t =>
    t.uuid === uuid ? { ...t, start: now } : t
  ));
  try {
    await tasks.start(uuid);
  } catch (err) {
    // Rollback
    update(tasks => tasks.map(t =>
      t.uuid === uuid ? { ...t, start: null } : t
    ));
    throw err;
  }
}

async stopTask(uuid) {
  // Optimistic: clear start
  let prevStart;
  update(tasks => tasks.map(t => {
    if (t.uuid === uuid) {
      prevStart = t.start;
      return { ...t, start: null };
    }
    return t;
  }));
  try {
    await tasks.stop(uuid);
  } catch (err) {
    // Rollback
    update(tasks => tasks.map(t =>
      t.uuid === uuid ? { ...t, start: prevStart } : t
    ));
    throw err;
  }
}

12.3 Wiring

<TaskList
  {tasks}
  {loading}
  {activeReport}
  onComplete={handleComplete}
  onTap={(task) => selectedTask = task}
  onStartStop={handleStartStop}
/>

TaskList passes onStartStop through to each TaskItem.


13. Interaction Flow

sequenceDiagram
    participant User
    participant SwipeAction
    participant TaskItem
    participant Page
    participant API

    User->>SwipeAction: swipe left on task row
    SwipeAction->>SwipeAction: offsetX goes negative, left bg reveals
    SwipeAction->>SwipeAction: release past -100px threshold
    SwipeAction->>SwipeAction: animate to -100vw, then snap back to 0
    SwipeAction->>TaskItem: onSwipeLeft()
    TaskItem->>Page: onStartStop(uuid)
    Page->>Page: optimistic: toggle task.start
    TaskItem-->>User: active indicator appears/disappears
    Page->>API: POST /tasks/:uuid/start (or /stop)
    API-->>Page: success
    Note over Page: on failure, rollback optimistic update

14. Technical Decision

ADR-11: Extend SwipeAction vs. new component

Context: Left-swipe needs different behavior from right-swipe (toggle vs. collapse, different colors/icons). Should we extend the existing SwipeAction or create a separate component?

Decision: Extend SwipeAction to be bidirectional. Rename onSwipe to onSwipeRight, add onSwipeLeft. All call sites are updated.

Rationale:

  • Touch gesture handlers can't be cleanly nested — two swipe components on the same element would fight over touch events
  • The angle-lock, threshold, and snap-back logic is identical in both directions; only the threshold action differs
  • One component, one touch handler, two callbacks

Trade-off: SwipeAction becomes slightly more complex (dual backgrounds, direction detection). This is manageable — the core gesture logic stays the same, with a direction check at threshold.


15. Theme Additions

Each theme needs new tokens for the active state badge:

/* Add to each theme block in app.css */
--color-active-bg: rgba(57, 208, 186, 0.15);  /* obsidian: primary @ 15% */
--color-active-text: #39d0ba;                   /* obsidian: primary */

Paper and midnight themes follow the same pattern with their respective primary colors (#6366f1 for paper, #8b5cf6 for midnight).


16. Updated File List

Combining bottom sheet + swipe-left changes:

src/lib/components/
├── BottomSheet.svelte          # NEW — generic bottom sheet
├── TaskDetail.svelte           # NEW — task detail content
├── SwipeAction.svelte          # MODIFY — bidirectional swipe support
├── TaskItem.svelte             # MODIFY — onTap, onStartStop, active indicator
└── TaskList.svelte             # MODIFY — pass onTap + onStartStop through

src/lib/stores/
└── tasks.js                    # MODIFY — add startTask(), stopTask()

src/routes/
└── +page.svelte                # MODIFY — selectedTask, handleStartStop, sheet

src/app.css                     # MODIFY — add --color-active-bg/text tokens


Undo Toast — Design

Implements: web-cli-parity.md 1.4 (Uncomplete / Revert) New component: Toast.svelte Modifies: +page.svelte, TaskDetail.svelte


17. Overview

Two paths to undo a completion:

  1. Undo toast — appears immediately after completing a task (swipe or checkbox). Shows the task description and an "Undo" button. Auto-dismisses after 5 seconds. This is the fast path for accidental completions.
  2. Uncomplete in detail view — available on any completed task via the Completed report. The "Uncomplete" action button in TaskDetail (already designed in section 3.7) handles this path.

Both paths use the same API call: PUT /tasks/:uuid with { status: 'P', end: null }.

┌─────────────────────────────────┐
│  Task list                      │
│                                 │
│  ...                            │
│                                 │
├─────────────────────────────────┤
│ ┌─────────────────────────────┐ │
│ │ ✓ "Buy groceries"   [Undo] │ │  ← toast
│ └─────────────────────────────┘ │
├─────────────────────────────────┤
│  InputBar                       │
└─────────────────────────────────┘

The toast sits above the InputBar, inside the scroll area's bottom margin.


18. Component: Toast.svelte

A generic, reusable toast/snackbar. Not specific to undo — can be used for any brief feedback message with an optional action button.

18.1 Interface

interface ToastProps {
  message: string
  action?: { label: string, handler: () => void }
  duration?: number    // auto-dismiss ms, default 5000. 0 = no auto-dismiss
  onDismiss: () => void
}

18.2 Behavior

  • Appears with a slide-up + fade-in animation from the bottom
  • Auto-dismisses after duration ms (default 5s)
  • Dismisses immediately if the action button is tapped (after calling handler)
  • Dismisses on swipe-right (quick manual dismiss without action)
  • Only one toast is visible at a time. A new toast replaces the current one (the previous action is lost — same as Android snackbar)

18.3 Position

  • Fixed above the InputBar: bottom: calc(InputBar height + spacing)
  • Horizontally: full width with var(--spacing-md) margin on each side
  • On desktop (>768px): capped at --content-max-width and centered

18.4 Layout

<div class="toast" class:visible transition:fly={{ y: 20, duration: 200 }}>
  <span class="toast-message">{message}</span>
  {#if action}
    <button class="toast-action" on:click={handleAction}>
      {action.label}
    </button>
  {/if}
</div>

18.5 CSS

.toast {
  position: fixed;
  bottom: calc(3.5rem + var(--spacing-md));  /* above InputBar */
  left: var(--spacing-md);
  right: var(--spacing-md);
  max-width: var(--content-max-width);
  margin: 0 auto;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--spacing-md);
  padding: var(--spacing-sm) var(--spacing-md);
  background-color: var(--bg-tertiary);
  border: 1px solid var(--border-color);
  border-radius: var(--border-radius);
  box-shadow: var(--shadow-md);
  z-index: 40;  /* below sheet (50) but above content */
}

.toast-message {
  font-size: var(--font-size-sm);
  color: var(--text-primary);
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.toast-action {
  font-size: var(--font-size-sm);
  font-weight: 600;
  color: var(--color-primary);
  background: none;
  border: none;
  cursor: pointer;
  padding: var(--spacing-xs) var(--spacing-sm);
  border-radius: 0.25rem;
  white-space: nowrap;
  min-width: unset;
  min-height: 36px;  /* touch target */
}

.toast-action:hover {
  background-color: var(--bg-secondary);
}

19. Undo Toast Integration

19.1 State in +page.svelte

/** @type {{ uuid: string, description: string } | null} */
let undoToast = null;

19.2 Completion Flow Change

When a task is completed, stash its info for the toast before removing it from the list:

async function handleComplete(uuid) {
  const task = tasks.find(t => t.uuid === uuid);
  if (!task) return;

  try {
    await tasksStore.complete(uuid);
    // Show undo toast
    undoToast = { uuid: task.uuid, description: task.description };
  } catch (error) {
    console.error('Failed to complete task:', error);
  }
}

19.3 Undo Handler

async function handleUndo() {
  if (!undoToast) return;
  const { uuid } = undoToast;
  undoToast = null;  // dismiss toast immediately

  try {
    await tasksStore.updateTask(uuid, { status: 'P', end: null });
    // Reload current report to show the restored task
    await loadReport(activeReport);
  } catch (error) {
    console.error('Failed to undo completion:', error);
  }
}

19.4 Template

{#if undoToast}
  <Toast
    message={'Completed "' + undoToast.description + '"'}
    action={{ label: 'Undo', handler: handleUndo }}
    onDismiss={() => undoToast = null}
  />
{/if}

19.5 Edge Cases

  • Completing another task while toast is showing: Replace the toast. The previous undo opportunity is lost. This matches the standard snackbar pattern — keeping a queue adds complexity for a rare scenario.
  • Completing a recurring task: The toast shows "Completed" with undo. If undo is tapped, the completion is reverted but the spawned next instance is NOT automatically deleted. The user may end up with a duplicate. This is acceptable — it matches the CLI behavior where undo after done on a recurring task restores the completed instance but leaves the new one. The detail view can be used to clean up.
  • Toast while bottom sheet is open: The toast should appear below the sheet scrim (z-index 40 < sheet 50). If the user completes from the detail view's action button, close the sheet first, then show the toast.

Delete Confirmation — Design

Implements: web-cli-parity.md 1.5 (Delete Confirmation) New component: ConfirmDialog.svelte Modifies: +page.svelte, TaskDetail.svelte


20. Overview

A confirmation dialog that appears before any delete action. Shows the task description so the user can verify what they're about to delete. Uses the native <dialog> element, matching the FilterModal pattern.

 ┌──────────────────────────────┐
 │                              │
 │  Delete task?                │
 │                              │
 │  "Buy groceries"             │
 │                              │
 │  This cannot be undone.      │
 │                              │
 │      [Cancel]    [Delete]    │
 │                              │
 └──────────────────────────────┘

21. Component: ConfirmDialog.svelte

A generic confirmation dialog. Not specific to delete — can be reused for any destructive action (bulk operations in Tier 2, etc.).

21.1 Interface

interface ConfirmDialogProps {
  title: string
  message: string           // task description or details to show
  detail?: string           // secondary text (e.g. "This cannot be undone.")
  confirmLabel: string      // button text, e.g. "Delete"
  confirmVariant: 'danger' | 'primary'  // button color
  onConfirm: () => void
  onCancel: () => void
}

Exposed method: open() — opens the dialog (matches FilterModal pattern).

21.2 Template

<dialog bind:this={dialogEl} class="confirm-dialog" on:click={handleBackdropClick}>
  <div class="dialog-content">
    <h3 class="dialog-title">{title}</h3>

    <p class="dialog-message">"{message}"</p>

    {#if detail}
      <p class="dialog-detail">{detail}</p>
    {/if}

    <div class="dialog-actions">
      <button class="btn btn-ghost" on:click={handleCancel} type="button">
        Cancel
      </button>
      <button class="btn btn-{confirmVariant}" on:click={handleConfirm} type="button">
        {confirmLabel}
      </button>
    </div>
  </div>
</dialog>

21.3 Behavior

  • Opens via dialogEl.showModal() — blocks interaction, provides backdrop
  • Closes on Cancel, Confirm, Escape, or backdrop click
  • Confirm calls onConfirm then closes
  • Cancel/Escape/backdrop calls onCancel then closes
  • Auto-focuses the Cancel button on open (safe default — user must deliberately choose Delete)

21.4 CSS

.confirm-dialog {
  border: none;
  border-radius: var(--border-radius);
  background: transparent;
  padding: 0;
  margin: auto;
  max-width: min(360px, calc(100vw - 2rem));
  width: 100%;
}

.confirm-dialog::backdrop {
  background: rgba(0, 0, 0, 0.5);
}

.dialog-content {
  background-color: var(--bg-primary);
  border: 1px solid var(--border-color);
  border-radius: var(--border-radius);
  box-shadow: var(--shadow-lg);
  padding: var(--spacing-lg);
}

.dialog-title {
  font-size: var(--font-size-lg);
  font-weight: 600;
  color: var(--text-primary);
  margin: 0 0 var(--spacing-md) 0;
}

.dialog-message {
  font-size: var(--font-size-base);
  color: var(--text-primary);
  margin: 0 0 var(--spacing-xs) 0;
  word-break: break-word;
}

.dialog-detail {
  font-size: var(--font-size-sm);
  color: var(--text-secondary);
  margin: 0 0 var(--spacing-lg) 0;
}

.dialog-actions {
  display: flex;
  justify-content: flex-end;
  gap: var(--spacing-sm);
}

.btn {
  padding: var(--spacing-sm) var(--spacing-md);
  border: none;
  border-radius: var(--border-radius);
  font-size: var(--font-size-sm);
  font-family: inherit;
  font-weight: 500;
  cursor: pointer;
  min-height: 40px;
  min-width: unset;
}

.btn-ghost {
  background: none;
  color: var(--text-secondary);
}

.btn-ghost:hover {
  background-color: var(--bg-secondary);
  color: var(--text-primary);
}

.btn-danger {
  background-color: var(--color-danger);
  color: white;
}

.btn-danger:hover {
  opacity: 0.9;
}

22. Delete Flow Integration

22.1 State in +page.svelte

/** @type {{ uuid: string, description: string } | null} */
let deleteTarget = null;

/** @type {ConfirmDialog} */
let confirmDialog;

22.2 Triggering Delete

Delete is triggered from the TaskDetail action buttons. The onDelete callback sets the target and opens the dialog:

function handleDeleteRequest(uuid) {
  const task = tasks.find(t => t.uuid === uuid)
    ?? (selectedTask?.uuid === uuid ? selectedTask : null);
  if (!task) return;

  deleteTarget = { uuid: task.uuid, description: task.description };
  confirmDialog.open();
}

22.3 Confirm/Cancel Handlers

async function handleDeleteConfirm() {
  if (!deleteTarget) return;
  const { uuid } = deleteTarget;
  deleteTarget = null;

  // Close the bottom sheet if it's open
  selectedTask = null;

  try {
    await tasksStore.deleteTask(uuid);
  } catch (error) {
    console.error('Failed to delete task:', error);
  }
}

function handleDeleteCancel() {
  deleteTarget = null;
  // Bottom sheet stays open — user cancelled
}

22.4 Template

<ConfirmDialog
  bind:this={confirmDialog}
  title="Delete task?"
  message={deleteTarget?.description ?? ''}
  detail="This cannot be undone."
  confirmLabel="Delete"
  confirmVariant="danger"
  onConfirm={handleDeleteConfirm}
  onCancel={handleDeleteCancel}
/>

22.5 Z-Index Layering

The dialog uses <dialog> with showModal(), which renders to the top-layer — it will appear above the bottom sheet scrim (z-index 50) and above the toast (z-index 40) without any manual z-index management.

Top layer (browser-managed):  ConfirmDialog (<dialog> showModal)
z-index 51:                   BottomSheet panel
z-index 50:                   BottomSheet scrim
z-index 40:                   Toast
Content:                      TaskList, Header, InputBar

23. Interaction Flows

23.1 Undo After Completion

sequenceDiagram
    participant User
    participant TaskItem
    participant Page
    participant Toast
    participant API

    User->>TaskItem: swipe right / checkbox
    TaskItem->>Page: onComplete(uuid)
    Page->>API: POST /tasks/:uuid/complete
    Page->>Page: remove task from list
    Page->>Toast: show "Completed 'Buy groceries'" + [Undo]
    Toast-->>User: toast visible for 5s

    alt User taps Undo
        User->>Toast: tap [Undo]
        Toast->>Page: handleUndo()
        Page->>API: PUT /tasks/:uuid {status: 'P', end: null}
        Page->>Page: reload report (task reappears)
        Toast-->>User: toast dismissed
    else Toast expires
        Toast-->>Page: onDismiss()
        Page->>Page: undoToast = null
    end

23.2 Delete from Detail View

sequenceDiagram
    participant User
    participant Detail as TaskDetail
    participant Page
    participant Dialog as ConfirmDialog
    participant API

    User->>Detail: tap [Delete] button
    Detail->>Page: onDelete(uuid)
    Page->>Dialog: open()
    Dialog-->>User: "Delete task? 'Buy groceries'"

    alt User confirms
        User->>Dialog: tap [Delete]
        Dialog->>Page: onConfirm()
        Page->>Page: close bottom sheet
        Page->>API: DELETE /tasks/:uuid
        Page->>Page: remove task from list
    else User cancels
        User->>Dialog: tap [Cancel] / Escape / backdrop
        Dialog->>Page: onCancel()
        Note over Page: bottom sheet stays open
    end

24. Technical Decisions

ADR-12: <dialog> for confirmation, <div> for toast

Context: Both components are overlaid UI. Should they use the same pattern?

Decision: ConfirmDialog uses <dialog> with showModal(). Toast uses a positioned <div>.

Rationale:

  • The confirmation dialog IS a modal — it blocks interaction and requires a deliberate choice. <dialog> provides backdrop, focus trapping, and Escape handling for free.
  • The toast is NOT a modal — the user can ignore it and keep interacting with the task list. A <dialog> would be wrong here because it would block interaction.
  • Different semantics → different elements.

ADR-13: Single toast, not a queue

Context: What happens if two tasks are completed quickly? Should toasts queue?

Decision: Latest toast replaces the current one. No queue.

Rationale:

  • Queuing toasts adds complexity (queue data structure, stacking layout or sequential display, timing coordination)
  • The scenario is rare — completing two tasks within 5 seconds
  • When it does happen, the user most likely wants to undo the most recent one
  • Matches Android snackbar behavior, which is the established pattern

Trade-off: The undo opportunity for the first completion is lost when the second one fires. Acceptable — the Completed report + Uncomplete button is the fallback path.

ADR-14: Auto-focus Cancel on confirm dialog

Context: Which button should be focused when the dialog opens?

Decision: Auto-focus Cancel.

Rationale: Delete is destructive and irreversible. Focusing Cancel means an accidental Enter keypress doesn't delete the task. The user must deliberately tab to or click Delete.


25. Updated File List

Full list combining all Tier 1 designs:

src/lib/components/
├── BottomSheet.svelte          # NEW — generic bottom sheet (section 2)
├── TaskDetail.svelte           # NEW — task detail + inline edit (section 3)
├── Toast.svelte                # NEW — generic toast/snackbar (section 18)
├── ConfirmDialog.svelte        # NEW — generic confirm dialog (section 21)
├── SwipeAction.svelte          # MODIFY — bidirectional (section 10)
├── TaskItem.svelte             # MODIFY — onTap, onStartStop, active indicator
└── TaskList.svelte             # MODIFY — pass new callbacks through

src/lib/stores/
└── tasks.js                    # MODIFY — add startTask(), stopTask()

src/routes/
└── +page.svelte                # MODIFY — all Tier 1 state + handlers

src/app.css                     # MODIFY — add --color-active-bg/text tokens

New components: 4 (BottomSheet, TaskDetail, Toast, ConfirmDialog) Modified components: 4 (SwipeAction, TaskItem, TaskList, +page.svelte) Modified stores: 1 (tasks.js) Modified styles: 1 (app.css)

No new API endpoints — all Tier 1 features use existing endpoints.


8. Resolved Questions

# Question Resolution
Q1 Should the sheet remember scroll position when the same task is re-opened? No. Always scroll to top on open.
Q2 Should editing a recurring instance offer "edit this instance" vs "edit template"? Yes. Show a confirmation prompt: "Edit this instance" or "Edit template". See section 3.8.
Q3 Should we show a loading state in the sheet while an edit is in flight? No. Optimistic updates only. Show error on failure.