# Opal Web PWA — Architecture Design **Target environment:** Internal product. Android Chrome and desktop Chrome/Firefox. Browser versions are controlled. iOS is not in scope. **CSS philosophy:** Prefer modern HTML/CSS over JavaScript wherever possible. Use `dvh` units, `has()`, container queries, `flex-wrap`, `popover`, `@starting-style`, and other modern features freely — no legacy browser concerns. ## 1. Architecture Overview The redesign transforms opal-web from a multi-page form-based app into a single-screen CLI-passthrough interface. The frontend becomes a thin shell over the opal command language, with all parsing handled server-side. ```mermaid graph TD subgraph "Frontend (SvelteKit Static PWA)" Layout["+layout.svelte"] Home["+page.svelte — Single Screen"] Settings["/settings"] Home --> Header Home --> TaskList Home --> InputBar Header --> ReportPicker["ReportPicker dropdown"] Header --> GearIcon["⚙ → /settings"] InputBar --> PropertyPills InputBar --> TextInput["CLI text input"] TaskList --> TaskItem["TaskItem (swipe + checkbox)"] end subgraph "Backend (Go + chi)" API["REST API"] Parser["Modifier Parser"] Reports["Report Engine"] DB["SQLite"] API --> Parser API --> Reports Parser --> DB Reports --> DB end TextInput -- "POST /tasks/parse\n{input: raw string}" --> API TaskList -- "GET /tasks?report=pending" --> API TaskItem -- "POST /tasks/:uuid/complete" --> API ``` ### Page Structure ``` / ........................ Single-screen task view (auth-gated) /settings ................ Settings page (gear icon) /auth/login .............. OAuth login /auth/callback ........... OAuth callback ``` All other routes (`/tasks/new`, `/tasks/[uuid]`, `/projects`, `/tags`) are **removed**. The bottom navigation bar is removed. The input bar replaces it. --- ## 2. Component Design ### 2.1 Root Layout (`+layout.svelte`) **Responsibility:** App shell — auth gating, global styles, routing. **Changes from current:** - Remove `BottomNav` component entirely - No conditional nav rendering — the layout is just a slot ### 2.2 Home Page (`+page.svelte`) **Responsibility:** The single-screen orchestrator. Owns the three vertical zones: header, task list, input bar. **Interface (reactive state):** ```typescript // Active report selection let activeReport: string = 'pending' // Task data let tasks: Task[] = [] let loading: boolean = true // Input bar let inputValue: string = '' let inputFocused: boolean = false let inputError: string | null = null ``` **Behavior:** - On mount: redirect to `/auth/login` if not authenticated, otherwise load tasks for default report (`pending`) - On report change: reload tasks with `GET /tasks?report={name}` - On input submit: `POST /tasks/parse` with raw string. On success, the response contains the created task — insert it into the list at the top (rather than re-fetching the entire list). The next report load will sort it into the correct position - On task complete (swipe or checkbox): `POST /tasks/:uuid/complete`, remove from list with animation ### 2.3 Header **Responsibility:** Shows current report name, report picker trigger, settings link. **Interface:** ```typescript interface HeaderProps { activeReport: string onReportChange: (report: string) => void } ``` **Layout:** `[Report Name ▾]` left-aligned, `[⚙]` right-aligned. Tapping the report name or chevron opens the `ReportPicker`. ### 2.4 ReportPicker **Responsibility:** Dropdown overlay listing all available reports. **Interface:** ```typescript interface ReportPickerProps { activeReport: string open: boolean onSelect: (report: string) => void onClose: () => void } ``` **Reports list:** | Group | Reports | |--------------|----------------------------------------------| | Common | Pending, Next, Active, Ready | | Time-based | Overdue, Waiting, Newest, Oldest | | Recurring | Recurring, Template | | Archive | Completed | The dropdown is grouped with subtle dividers to manage the 11-item density on mobile. The active report has a visual indicator (checkmark or highlight). **Implementation:** Use the native Popover API (`popover` attribute + `popovertarget`). This gives us light-dismiss (tap outside to close), top-layer rendering, and `::backdrop` styling for free — no JS for open/close state or click-outside detection. Entry/exit animations via `@starting-style` + `transition-behavior: allow-discrete`. **Dismiss:** Tapping outside (popover light-dismiss), selecting a report, or pressing Escape. ### 2.5 TaskList **Responsibility:** Scrollable list of tasks between header and input bar. **Interface:** ```typescript interface TaskListProps { tasks: Task[] loading: boolean activeReport: string onComplete: (uuid: string) => void } ``` **Empty states:** - Loading: skeleton or spinner - No tasks at all (first use): "Type a task below to get started" - No tasks for report: "No {report} tasks" (e.g., "No overdue tasks") **Scroll region:** Uses `flex: 1` with `overflow-y: auto` between the fixed-position header and input bar. Must account for keyboard open state. ### 2.6 TaskItem **Responsibility:** Single task row with completion interactions. **Interface:** ```typescript interface TaskItemProps { task: Task onComplete: (uuid: string) => void } ``` **Visual layout:** ``` [○] Buy groceries due:Feb 15 +errand project:Home pri:H ``` - Left: completion circle/checkbox - Center: description on first line, metadata pills on second line (only if metadata exists) - Metadata: project badge, priority indicator, due date, tags — all inline with subtle color coding **Completion interactions:** 1. **Checkbox tap:** Calls `onComplete` immediately 2. **Swipe right:** See section 2.7 **Priority colors:** - High (3): red/warm - Medium (2): amber - Low (0): muted gray - Default (1): no indicator **Due date colors:** - Overdue: red - Due today: amber - Due within 7 days: default - Future/none: muted ### 2.7 SwipeAction **Responsibility:** Wraps TaskItem to provide swipe-to-complete gesture. **Interface:** ```typescript interface SwipeActionProps { onSwipe: () => void threshold?: number // pixels, default 100 children: Snippet // TaskItem content } ``` **Gesture model (addresses Design Note on swipe/scroll conflict):** 1. Touch starts — record `startX`, `startY` 2. On move, compute `deltaX` and `deltaY` 3. **Lock-in rule:** If `|deltaX| > 10px` AND `|deltaX| > |deltaY| * 2` (angle < ~27°), lock to horizontal swipe and call `preventDefault()` on the touch event to suppress scrolling 4. If vertical movement dominates first, do nothing (allow scroll) 5. During swipe: translate the task row, reveal green background with checkmark icon underneath 6. **Threshold:** If `deltaX > 100px` on release → trigger completion 7. **Cancel:** If released before threshold → CSS transition back to `translate(0)` (set `transition: transform 0.2s ease` on release, remove it on touch start) 8. **Completion animation:** On threshold met, add a `completing` class that triggers a CSS transition (`opacity: 0; height: 0; margin: 0; padding: 0; overflow: hidden; transition: all 0.3s ease`). The `transitionend` event fires the API call and removes the DOM element 9. The swipe tracking (touch start/move/end, deltaX/deltaY, translateX) requires JS. But all animations (snap-back, completion fade-out) are CSS transitions ### 2.8 InputBar **Responsibility:** Fixed-to-bottom CLI input with property pills. **Interface:** ```typescript interface InputBarProps { value: string error: string | null loading: boolean onSubmit: (input: string) => void onInput: (value: string) => void onFocusChange: (focused: boolean) => void } ``` **Layout (bottom-up):** ``` ┌─────────────────────────────────────┐ │ [Due] [Pri] [Project] [Tag] ... │ ← Pills (visible when focused) ├─────────────────────────────────────┤ │ [ Add a task... ] [→] │ ← Input + submit button ├─────────────────────────────────────┤ │ (error message if any) │ ← Inline error └─────────────────────────────────────┘ ``` **Keyboard handling (addresses Design Note):** - The entire page layout uses `height: 100dvh` (`dvh` = dynamic viewport height, which accounts for the virtual keyboard on Android Chrome/Firefox) - The layout is a CSS grid or flexbox column: `header | task-list (flex:1) | input-bar`. When the keyboard opens, `100dvh` shrinks, the flex container shrinks, and the input bar stays at the bottom — no JS needed - This is a controlled-browser internal product (Android Chrome / desktop Firefox+Chrome); `dvh` is fully supported **Error display:** - Errors appear as a small red text line between the input and the pills - Input does NOT clear on error — user can fix and retry - Error clears on next input change ### 2.9 PropertyPills **Responsibility:** Horizontal row of tappable shortcut pills. **Interface:** ```typescript interface PropertyPillsProps { visible: boolean onInsert: (text: string) => void } ``` **Pills (ordered per requirements):** | Label | Inserts | |------------|--------------| | Due | `due:` | | Pri | `priority:` | | Project | `project:` | | Tag | `+` | | Recur | `recur:` | | Scheduled | `scheduled:` | | Wait | `wait:` | | Until | `until:` | **Behavior:** - Tapping a pill calls `onInsert(text)` — the parent `InputBar` handles cursor positioning via `selectionStart`/`selectionEnd` on the input element - Pills wrap over multiple lines using `display: flex; flex-wrap: wrap; gap` - Appears with a brief slide-up animation when input focuses - Disappears when input blurs (with short delay to allow pill tap to register before blur fires) --- ## 3. Data Model No changes to the task entity. The existing `Task` type from `src/lib/api/types.js` is sufficient. **New derived type for API responses:** ```typescript // Report metadata returned alongside task list interface ReportResponse { report: string // Report name tasks: Task[] // Filtered/sorted tasks count: number // Total count } // Parse endpoint response interface ParseResponse { task: Task // The created task } // Parse endpoint error interface ParseError { error: string // Human-readable error message field?: string // Which modifier failed, if applicable } ``` --- ## 4. API Design ### 4.1 New Endpoint: Parse and Create Task Resolves **Q6** from requirements. ``` POST /tasks/parse Content-Type: application/json Authorization: Bearer Request: { "input": "Buy groceries due:tomorrow +errand priority:H" } Success Response (201 Created): { "success": true, "data": { "task": { ...Task object... } } } Error Response (400 Bad Request): { "success": false, "error": "invalid date value for 'due': neverday" } ``` **Backend implementation:** The handler inlines the trivial arg classification from `cmd/add.go:parseAddArgs()` (split on whitespace, args with `+`/`-` prefix or containing `:` are modifiers, the rest is description). The real parsing is done by `engine.ParseModifier()` and `engine.CreateTaskWithModifier()`, which are already in the engine package. Handler steps: 1. Split `input` string on whitespace 2. Classify args: modifier if starts with `+`/`-` or contains `:`, otherwise description word 3. Call `engine.ParseModifier(modifierArgs)` 4. Call `engine.CreateTaskWithModifier(description, modifier)` 5. Handle recurrence if `recur` is set (same logic as `cmd/add.go:addRecurringTask`) 6. Return the created task or a parse error ### 4.2 Updated Endpoint: Report-Based Task Listing Resolves **Q7** from requirements. ``` GET /tasks?report=pending Authorization: Bearer Response (200 OK): { "success": true, "data": { "report": "pending", "tasks": [ ...Task objects... ], "count": 42 } } ``` **Query parameters:** - `report` — Report name (default: `pending`). One of: `pending`, `next`, `active`, `ready`, `overdue`, `completed`, `waiting`, `recurring`, `template`, `newest`, `oldest` - Existing filter params (`project`, `tag`, etc.) are merged with the report's base filter, allowing further narrowing **Backend implementation:** The report engine already exists in `internal/engine/report.go`. The handler: 1. Looks up the report by name (map in `report.go` already has all reports) 2. Applies the report's base filter + any additional query params 3. Executes the query 4. Applies post-filters, sort, and limit functions 5. Returns tasks in the report's sort order **Mapping of frontend report names to backend:** | Frontend | Backend report name | |-------------|-------------------| | Pending | `list` | | Next | `next` | | Active | `active` | | Ready | `ready` | | Overdue | `overdue` | | Completed | `completed` | | Waiting | `waiting` | | Recurring | `recurring` | | Template | `template` | | Newest | `newest` | | Oldest | `oldest` | ### 4.3 Existing Endpoints (No Changes) These are already sufficient: - `POST /tasks/{uuid}/complete` — Used by swipe and checkbox - `GET /health` — Health check - All auth endpoints — Unchanged ### 4.4 Error Response Format All endpoints already use the standard envelope: ```typescript { success: boolean, data?: any, error?: string } ``` No change needed. The parse endpoint follows the same pattern, with `error` containing a human-readable message suitable for display below the input bar. --- ## 5. Technical Decisions ### ADR-1: Single-screen layout replaces multi-page routing **Context:** The current app has bottom nav with routes for tasks, projects, tags, and settings. Requirements specify a single-screen design. **Decision:** Remove bottom nav and all routes except `/`, `/settings`, `/auth/*`. The home page owns the full screen layout. **Alternatives:** Keep routes but hide nav (rejected — adds routing complexity for no benefit since there's only one functional view). **Consequences:** Simpler mental model. Projects/tags pages (stubs) are removed. Task detail route is removed (out of scope per requirements). `tasks/new` is removed (replaced by input bar). ### ADR-2: Server-side parsing via new `/tasks/parse` endpoint **Context:** The frontend needs to accept raw CLI syntax. Parsing is complex (dates, recurrence, relative expressions). **Decision:** New `POST /tasks/parse` endpoint. Frontend sends the raw string; backend splits and parses using existing `parseAddArgs()` + `ParseModifier()`. **Alternatives:** - Client-side parsing (rejected — would duplicate 500+ lines of Go date/modifier logic in JS, creating a maintenance burden) - Sending pre-split args array (rejected — adds no value; splitting on whitespace is trivial) **Consequences:** Clean separation. Frontend is truly a thin shell. Parsing logic stays in one place. Trade-off: requires network round-trip for every add, but this matches the "CLI-passthrough" philosophy. ### ADR-3: Report-based queries via `?report=` query parameter **Context:** The frontend needs to show 11 different task views. Reports involve complex server-side logic (urgency calculation, date filtering, sorting). **Decision:** Extend the existing `GET /tasks` endpoint with a `report` query parameter. When present, it uses the report engine instead of raw filter queries. **Alternatives:** - Dedicated endpoints per report (`GET /reports/next`) — rejected: adds 11 routes with boilerplate - Client-side filtering/sorting — rejected: urgency calculation is complex and should stay server-side **Consequences:** Single endpoint serves both raw filter queries (backward compatible) and report-based queries. The report engine already exists and is well-tested. ### ADR-4: Swipe gesture with angle-based lock-in **Context:** Horizontal swipe on a vertical scroll list has ambiguity. Must not accidentally complete tasks during normal scrolling. **Decision:** Use angle detection. If initial movement angle is < ~27° from horizontal (|deltaX| > |deltaY| * 2) after 10px of horizontal movement, lock into swipe mode. Otherwise, allow scroll. Threshold of 100px to trigger completion. **Alternatives:** - Time-based delay (rejected — feels sluggish) - Long-press then swipe (rejected — adds friction) - Only allow checkbox completion (rejected — swipe is a MUST requirement) **Consequences:** Natural gesture feel. Small risk of mis-detection near the boundary angle, mitigated by the 100px release threshold (mid-swipe cancel is always safe). ### ADR-5: Pure CSS keyboard-aware layout with `dvh` units **Context:** Mobile keyboards push fixed elements off-screen. The input bar must stay visible above the keyboard. **Decision:** Use `height: 100dvh` on the app shell with a flexbox column layout (`header | task-list flex:1 | input-bar`). The `dvh` (dynamic viewport height) unit automatically accounts for the virtual keyboard — when the keyboard opens, `100dvh` shrinks, the flex container reflows, and the input bar stays anchored at the bottom. No JavaScript needed. **Alternatives:** - `visualViewport` API with JS resize listener (rejected — unnecessary complexity; `dvh` solves this in CSS) - `position: fixed; bottom: 0` (rejected — doesn't reflow the scroll area) - `interactive-widget=resizes-content` meta tag (viable fallback, but `dvh` alone is sufficient on target browsers) **Consequences:** Zero JS for keyboard handling. Depends on `dvh` support, which is available in all target browsers (Chrome 108+, Firefox 108+). Not an issue since this is an internal product with controlled browser versions. ### ADR-6: Blur-delay for property pill interaction **Context:** Tapping a pill above the input causes the input to blur (since the pill is a separate element), which would hide the pills before the tap registers. **Decision:** Add a ~150ms delay before hiding pills on blur. If a pill tap occurs during that window, cancel the hide and re-focus the input. **Alternatives:** - Use `mousedown`/`touchstart` with `preventDefault` on pills to prevent blur (simpler, but may have side effects on mobile) - Keep pills always visible (rejected — requirement F-23 says pills disappear on blur) **Consequences:** Slight visual delay before pills disappear, but the interaction is reliable. The `preventDefault` approach could be used as a refinement if the delay feels wrong. --- ## 6. File & Module Structure ### New / Modified Files ``` src/ ├── lib/ │ ├── api/ │ │ └── endpoints.js # MODIFY: add tasks.parse(), update tasks.list() │ ├── components/ │ │ ├── Header.svelte # NEW: report name + picker trigger + gear │ │ ├── ReportPicker.svelte # NEW: dropdown with grouped reports │ │ ├── InputBar.svelte # NEW: CLI input + submit + error display │ │ ├── PropertyPills.svelte # NEW: horizontal pill row │ │ ├── SwipeAction.svelte # NEW: swipe gesture wrapper │ │ ├── TaskList.svelte # MODIFY: empty states, remove loading card │ │ ├── TaskItem.svelte # MODIFY: layout tweaks, remove click nav │ │ └── ui/ # KEEP: existing UI primitives │ ├── stores/ │ │ └── tasks.js # MODIFY: add loadReport(), parseAndCreate() │ └── mock/ │ └── tasks.js # KEEP: existing mock data ├── routes/ │ ├── +layout.svelte # MODIFY: remove BottomNav │ ├── +page.svelte # REWRITE: single-screen orchestrator │ ├── settings/+page.svelte # KEEP: unchanged │ ├── auth/ │ │ ├── login/+page.svelte # KEEP: unchanged │ │ └── callback/+page.svelte # KEEP: unchanged │ ├── tasks/ # DELETE: entire directory │ ├── projects/+page.svelte # DELETE │ └── tags/+page.svelte # DELETE └── app.css # MODIFY: add swipe/pill/input-bar styles ``` ### Deleted Files - `src/routes/tasks/new/+page.svelte` — replaced by InputBar - `src/routes/tasks/[uuid]/+page.svelte` — out of scope - `src/routes/projects/+page.svelte` — out of scope - `src/routes/tags/+page.svelte` — out of scope - `src/lib/components/BottomNav.svelte` — replaced by InputBar ### Backend Changes ``` opal-task/ └── internal/ └── api/ ├── server.go # MODIFY: add POST /tasks/parse route ├── handlers.go # MODIFY: add HandleParseTask(), │ # update HandleListTasks() for ?report= └── (no new files needed) ``` --- ## 7. Integration Points ### Frontend → Backend API | Action | Endpoint | When | |---------------------|-------------------------------|----------------------------| | Load tasks | `GET /tasks?report={name}` | On mount, report change | | Create task | `POST /tasks/parse` | Input bar submit | | Complete task | `POST /tasks/{uuid}/complete` | Swipe or checkbox | | Auth (existing) | `GET /auth/login`, etc. | Login flow (unchanged) | | Sync (existing) | `POST /sync/*` | Background sync (unchanged)| ### Mock Mode Mock mode (`VITE_MOCK_MODE=true`) continues to work: - `tasks.loadReport(name)` returns mock data filtered client-side by status (good enough for development) - `tasks.parseAndCreate(input)` does a naive client-side split (description = non-modifier words, ignore modifiers) and adds to mock array - No backend needed for development ### PWA / Service Worker No changes to PWA config. The new endpoint (`/tasks/parse`) is covered by the existing Workbox runtime cache pattern (`/api/*` with NetworkFirst strategy). --- ## 8. Deferred Items Items explicitly not included in this design, to be addressed in future iterations: | Req ID | Description | Priority | Notes | |--------|-------------|----------|-------| | F-39 | Pull-to-refresh to reload task list | COULD | Low priority; report switching already reloads. Can be added later with `overscroll-behavior` + a small JS handler. |