- 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>
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%)totranslateY(0)via CSS transition - Scrim fades in (
opacity: 0 → 0.5) — samergba(0,0,0,0.5)as FilterModal backdrop - Body scroll is locked (
overflow: hiddenon<html>) while sheet is open - Focus is trapped inside the sheet (keyboard accessibility)
Close (four ways):
- Tap scrim — light-dismiss, same as FilterModal
- Drag down — swipe the sheet down past a threshold (see 2.3)
- Close button — explicit X in top-right (accessibility fallback)
- 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:
- Touch starts on the drag handle or sheet header area
- If
|deltaY| > 10pxAND|deltaY| > |deltaX| * 2→ lock to vertical drag - During drag:
translateY(deltaY)(only positive — can't drag up past 0) - On release: if
deltaY > 150pxor velocity > threshold → close with animation. Otherwise snap back totranslateY(0) - 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:
- Editable fields — the fields users interact with
- Read-only metadata — timestamps, urgency, UUID
- 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:
- User taps field value → value becomes an input (the label stays)
- User modifies → on Enter or blur or selection →
onUpdate(uuid, { field: newValue })is called - Optimistic: UI shows new value immediately. On API failure, revert and show a brief error toast
- 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 callsDELETE /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 toPOST /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>withshowModal()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
translateYpositioning 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:
- 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.
- 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
durationms (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-widthand 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
undoafterdoneon 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
onConfirmthen closes - Cancel/Escape/backdrop calls
onCancelthen 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. |