diff --git a/docs/design/config-redesign.md b/docs/design/config-redesign.md deleted file mode 100644 index c1765ae..0000000 --- a/docs/design/config-redesign.md +++ /dev/null @@ -1,482 +0,0 @@ -# Config System Redesign - -## 1. Problem Statement - -`LoadConfig()` panics on the server because the config system assumes a writable -config directory. On the server, `OPAL_CONFIG_DIR=/etc/opal` is read-only -(systemd `ReadOnlyPaths`), `opal.yml` doesn't exist (Ansible only deploys -`opal.env`), and the attempt to create it fails. The nil `*Config` propagates -unchecked through `GetConfig()` callers to `BuildUrgencyCoefficients(nil)`, -causing a nil-pointer panic. - -### Panic chain - -``` -sortByUrgency() report.go:426 - cfg, _ := GetConfig() ← error discarded, cfg = nil - coeffs := BuildUrgencyCoefficients(cfg) - return &UrgencyCoefficients{ - Due: cfg.UrgencyDue, ← nil dereference → PANIC - } -``` - -### Root cause - -The config system was designed for the CLI (user-writable `~/.config/opal/`) -and has three interacting issues: - -1. **Write-on-read**: `LoadConfig()` creates directories and writes a default - `opal.yml` as a side effect of *reading* config. This fails when the - filesystem is read-only. -2. **Error swallowing**: All internal callers use `cfg, _ := GetConfig()`, - turning a load failure into a nil-pointer panic instead of a graceful error. -3. **No mode awareness**: The same code path runs for CLI users (writable home - dir, interactive, config file is useful) and for the server (read-only - `/etc/opal`, headless, defaults are fine). - ---- - -## 2. Current Architecture - -```mermaid -graph TD - subgraph "Config Sources" - ENV["opal.env
(systemd EnvironmentFile)"] - YML["opal.yml
(Viper / YAML)"] - DEFAULTS["Hardcoded defaults
(in LoadConfig)"] - end - - subgraph "Loading Mechanisms" - AUTH_LOAD["auth.LoadConfig()
os.Getenv() → auth.Config"] - ENGINE_LOAD["engine.LoadConfig()
Viper → engine.Config"] - end - - subgraph "Consumers" - SERVER["API Server
(handlers, middleware)"] - CLI["CLI Commands
(cmd/ package)"] - end - - ENV --> AUTH_LOAD - YML --> ENGINE_LOAD - DEFAULTS --> ENGINE_LOAD - - AUTH_LOAD --> SERVER - ENGINE_LOAD --> SERVER - ENGINE_LOAD --> CLI -``` - -### Two independent config subsystems - -| Aspect | `engine.Config` | `auth.Config` | -|--------|----------------|---------------| -| **Source** | `opal.yml` via Viper | Environment variables | -| **Loaded by** | `engine.LoadConfig()` | `auth.LoadConfig()` | -| **Caching** | Singleton (`globalConfig`) | None (re-read each call) | -| **Write side effects** | Creates dir + file on load | None | -| **Error model** | Returns `(*Config, error)` | Returns `*Config` (no error) | -| **Used by** | CLI + Server (lazy) | Server only | - -### `engine.Config` field categories - -| Category | Fields | CLI | Server | Notes | -|----------|--------|-----|--------|-------| -| **Display** | `DefaultFilter`, `DefaultSort`, `DefaultReport`, `ColorOutput` | Yes | No | Terminal-only | -| **Date** | `WeekStartDay`, `DefaultDueTime` | Yes | Yes | Used by `ParseDate()` | -| **Urgency** | 13 urgency coefficients | Yes | Yes | Core scoring logic | -| **Limits** | `NextLimit` | Yes | Indirectly | Report limit | -| **Sync** | 6 sync fields | Yes | No | Client-side sync config | - -Key observation: The server only needs **urgency coefficients** and **date -settings** from `engine.Config`. It doesn't need display preferences or sync -settings. But all 30 fields are loaded through the same mechanism. - -### `engine.LoadConfig()` flow - -``` -1. Check globalConfig singleton → return if cached -2. GetConfigDir() → resolve directory path -3. os.MkdirAll(configDir) ← FAILS on read-only FS -4. Viper.ReadInConfig() -5. If read fails: - a. Viper.WriteConfigAs() ← FAILS on read-only FS - b. Re-read written file -6. Unmarshal → Config struct -7. Cache in globalConfig -``` - -Steps 3 and 5a are the failure points on the server. - -### Caller error handling audit - -| Caller | File:Line | Error handling | -|--------|-----------|----------------| -| `FormatTaskListWithFormat` | display.go:24 | `cfg, _ := GetConfig()` — **ignored** | -| `formatMinimalLine` | display.go:119 | `cfg, _ := GetConfig()` — **ignored** | -| `NextReport.SortFunc` | report.go:168 | `cfg, _ := GetConfig()` — **ignored** | -| `NextReport.LimitFunc` | report.go:187 | `cfg, _ := GetConfig()` — **ignored** | -| `sortByUrgency` | report.go:426 | `cfg, _ := GetConfig()` — **ignored** | -| `PopulateUrgency` | task.go:769 | `cfg, _ := GetConfig()` — **ignored** | -| `getWeekStart` | dateparse.go:484 | Checked — falls back to Monday | -| All cmd/ callers | root.go, sync.go | Checked — exits on error | - -Every engine-internal caller except `dateparse.go` discards the error. - ---- - -## 3. Additional Issues Found - -### 3a. Config file clobbering - -`LoadConfig()` line 296-306 catches **any** `ReadInConfig` error (not just -"file not found") and overwrites the config file with defaults. A YAML syntax -error in the user's `opal.yml` silently destroys their customizations. - -### 3b. `SaveConfig()` manual field sync - -Adding a new config field requires updating three places: -1. `Config` struct definition -2. `LoadConfig()` — `v.SetDefault(...)` call -3. `SaveConfig()` — `v.Set(...)` call - -Missing any one of these causes silent data loss on save or missing defaults. - -### 3c. `auth.LoadConfig()` silent parse failures - -```go -jwtExpiry, _ := strconv.Atoi(getEnv("JWT_EXPIRY", "3600")) -``` - -If `JWT_EXPIRY=abc`, `Atoi` returns `(0, error)`, error is discarded, and -`JWTExpiry` silently becomes 0 (tokens expire immediately). - -### 3d. Insecure JWT default - -`JWT_SECRET` defaults to `"change-me-in-production"`. While `validateServerConfig()` -checks for empty values, it doesn't check for the insecure default. - -### 3e. Sync API key in plaintext - -`sync_api_key` is stored in `opal.yml` with no special file permissions. - -### 3f. No env-var override of YAML config - -Viper's `AutomaticEnv()` is never called, so there's no way to override YAML -config values via environment variables. This is an obstacle for 12-factor -server deployments. - ---- - -## 4. Design Options - -### Option A: Minimal fix — graceful fallback to defaults - -**Change**: Make `LoadConfig()` return a `DefaultConfig()` when it can't read -or write the config file, instead of returning nil. - -```go -func DefaultConfig() *Config { - return &Config{ - DefaultFilter: "status:pending", - DefaultSort: "due,priority", - // ... all defaults ... - UrgencyDue: 12.0, - // ... - } -} - -func LoadConfig() (*Config, error) { - if globalConfig != nil { - return globalConfig, nil - } - - cfg, err := loadFromFile() - if err != nil { - // File not available — use defaults (common in server mode) - cfg = DefaultConfig() - } - - globalConfig = cfg - return cfg, nil -} -``` - -**Pros**: Smallest change, fixes the panic, no behavior change for CLI users. -**Cons**: Doesn't address the architectural confusion. Error-swallowing callers -remain. Write-on-read side effect remains for CLI. Three-source config stays. - ---- - -### Option B: Separate load paths for CLI and server - -**Change**: Split `LoadConfig()` into two functions: -- `LoadConfigFromFile()` — current behavior for CLI (read/write YAML) -- `LoadConfigFromDefaults()` — returns `DefaultConfig()` for server - -The server startup code calls `LoadConfigFromDefaults()` eagerly during init, -populating the singleton before any lazy `GetConfig()` call. - -```go -// cmd/server.go — in serverStartCmd.Run, before InitDB() -engine.LoadConfigFromDefaults() -``` - -**Pros**: Fixes the panic. Server path never touches the filesystem for config. -Explicit about which mode is running. CLI behavior unchanged. -**Cons**: Two code paths to maintain. Server can't be customized without code -changes (urgency tuning, etc.). - ---- - -### Option C: Read-only load with env-var overrides (recommended) - -**Change**: Restructure `LoadConfig()` to: -1. Start with hardcoded defaults (always succeeds) -2. Layer YAML file on top **if it exists** (read-only, never create) -3. Layer environment variables on top (via Viper `AutomaticEnv`) -4. Never write to the filesystem as a side effect of loading - -```go -func LoadConfig() (*Config, error) { - if globalConfig != nil { - return globalConfig, nil - } - - v := viper.New() - v.SetConfigType("yaml") - - // 1. Hardcoded defaults (always present) - setDefaults(v) - - // 2. YAML file overlay (optional, read-only) - if configPath, err := GetConfigPath(); err == nil { - v.SetConfigFile(configPath) - if err := v.ReadInConfig(); err != nil { - if _, ok := err.(viper.ConfigFileNotFoundError); !ok { - // File exists but is malformed — report, don't clobber - if _, statErr := os.Stat(configPath); statErr == nil { - return nil, fmt.Errorf("config file %s is invalid: %w", configPath, err) - } - // File doesn't exist — that's fine, use defaults - } - } - } - - // 3. Environment variable overlay - v.SetEnvPrefix("OPAL") - v.AutomaticEnv() - - cfg := &Config{} - if err := v.Unmarshal(cfg); err != nil { - return nil, fmt.Errorf("failed to unmarshal config: %w", err) - } - - globalConfig = cfg - return cfg, nil -} -``` - -File creation moves to an explicit `InitConfig()` function, called only during -`opal setup` and CLI first-run: - -```go -func InitConfig() error { - configDir, err := GetConfigDir() - if err != nil { - return err - } - if err := os.MkdirAll(configDir, 0755); err != nil { - return fmt.Errorf("failed to create config directory: %w", err) - } - return SaveConfig(DefaultConfig()) -} -``` - -Environment variable mapping: - -| YAML key | Env var | -|----------|---------| -| `urgency_due_coefficient` | `OPAL_URGENCY_DUE_COEFFICIENT` | -| `default_filter` | `OPAL_DEFAULT_FILTER` | -| `week_start_day` | `OPAL_WEEK_START_DAY` | -| ... | `OPAL_` | - -**Pros**: -- Fixes the panic — loading never fails fatally (missing file = defaults) -- Malformed YAML is reported, not silently clobbered -- Server can tune urgency via `opal.env` without deploying `opal.yml` -- 12-factor compatible (env vars override everything) -- CLI behavior unchanged (reads existing `opal.yml` + env overrides) -- Single code path for both CLI and server -- No filesystem write side effects during load - -**Cons**: -- `SaveConfig()` still needs the manual field-sync (separate improvement) -- Slightly more Viper configuration - ---- - -### Option D: Full restructure with typed config sections - -**Change**: Split `Config` into domain-specific sub-configs and unify the -loading mechanism for all config (merge `auth.Config` into the same system). - -```go -type Config struct { - Display DisplayConfig `mapstructure:"display"` - Urgency UrgencyConfig `mapstructure:"urgency"` - Sync SyncConfig `mapstructure:"sync"` - Server ServerConfig `mapstructure:"server"` // absorbs auth.Config -} -``` - -**Pros**: Clean separation. Single config system. Extensible. -**Cons**: Large refactor. Breaks existing `opal.yml` format. `auth.Config` -works fine as-is for its purpose. Over-engineered for the current problem. - ---- - -## 5. Recommendation - -**Option C** — read-only load with env-var overrides. - -It fixes the immediate panic, eliminates the write-on-read footgun, enables -server customization via environment variables, and does it with a focused -change to one function. It doesn't over-engineer or restructure things that -work. - -### Implementation plan - -#### Step 1: Extract `DefaultConfig()` constructor - -Create a `DefaultConfig()` function that returns a `*Config` with all defaults -populated. This is the single source of truth for default values — used by -`LoadConfig()`, `InitConfig()`, and `SaveConfig()`. - -**Files**: `internal/engine/config.go` - -#### Step 2: Rewrite `LoadConfig()` to be read-only - -Remove directory creation and file writing. Layer: defaults → YAML (if -exists) → env vars. Return `DefaultConfig()` on missing file instead of nil. -Return error only on malformed YAML (file exists but can't be parsed). - -**Files**: `internal/engine/config.go` - -#### Step 3: Extract `InitConfig()` for file creation - -Move the "create config directory + write defaults" logic into `InitConfig()`. -Call it from `initializeApp()` (CLI first-run path) and `opal setup`. - -**Files**: `internal/engine/config.go`, `cmd/root.go`, `cmd/setup.go` - -#### Step 4: Fix error-swallowing callers - -Two options, from least to most invasive: - -**4a (recommended)**: Make `GetConfig()` never return nil. Since `LoadConfig()` -now always returns a valid `*Config` (defaults on failure), `GetConfig()` also -always returns non-nil. The `cfg, _ := GetConfig()` pattern becomes safe. The -error return is kept for callers that want to distinguish "loaded from file" vs -"using defaults" but nil is never returned. - -**4b (defensive)**: Also make `BuildUrgencyCoefficients()` accept nil and return -default coefficients. Belt-and-suspenders. - -**Files**: `internal/engine/config.go`, `internal/engine/urgency.go` - -#### Step 5: Enable env-var overrides via Viper - -Add `v.SetEnvPrefix("OPAL")` and `v.AutomaticEnv()` so that any config key -can be overridden by `OPAL_`. Update `opal.env` in deployment docs to -show urgency tuning examples. - -**Files**: `internal/engine/config.go`, `docs/deployment.md` - -#### Step 6: Eliminate `SaveConfig()` field duplication - -Replace the manual `v.Set()` calls with Viper's `mapstructure` round-trip or -a reflect-based helper so new fields are automatically included. - -```go -func SaveConfig(cfg *Config) error { - v := viper.New() - v.SetConfigFile(configPath) - v.SetConfigType("yaml") - - // Use mapstructure tags to populate viper from struct - data, _ := mapstructure.Decode(cfg) // or manual marshal - for k, val := range data { - v.Set(k, val) - } - return v.WriteConfig() -} -``` - -Alternatively, marshal to YAML directly with `yaml.Marshal` and write the -bytes, bypassing Viper for the write path entirely. - -**Files**: `internal/engine/config.go` - -### Deployment changes - -After this redesign, the Ansible role needs **no changes** — `opal.env` is -sufficient. To customize urgency coefficients on the server, add lines to -`opal.env`: - -```bash -# Server-side urgency tuning (optional) -OPAL_URGENCY_DUE_COEFFICIENT=12.0 -OPAL_URGENCY_AGE_MAX=180 -``` - -No `opal.yml` deployment needed. The server runs on defaults + env overrides. - ---- - -## 6. Technical Decisions - -### ADR-1: Config loading must not write to the filesystem - -- **Context**: `LoadConfig()` creates directories and writes `opal.yml` as a - side effect. This fails on read-only filesystems (server) and is surprising - behavior for a "load" function. -- **Decision**: `LoadConfig()` becomes pure read. File creation moves to - `InitConfig()`, called explicitly during setup/first-run. -- **Alternatives**: Deploy `opal.yml` via Ansible; make `/etc/opal` writable. -- **Consequences**: CLI first-run code must call `InitConfig()` explicitly. - `IsFirstRun()` check remains in `initializeApp()`. - -### ADR-2: Missing config file returns defaults, not an error - -- **Context**: On the server, `opal.yml` doesn't exist and doesn't need to. - The current code treats this as a fatal error. -- **Decision**: Missing file = use defaults silently. Malformed file = return - error (don't clobber). `GetConfig()` never returns nil. -- **Alternatives**: Require `opal.yml` everywhere; use separate load functions - per mode. -- **Consequences**: Callers that discard errors (`cfg, _ := GetConfig()`) are - now safe. The error return still exists for callers that need to distinguish - "file loaded" from "using defaults". - -### ADR-3: Environment variables can override any config key - -- **Context**: The server is configured entirely via env vars (`opal.env` + - systemd). Currently urgency coefficients can only be set via `opal.yml`. -- **Decision**: Enable Viper's `AutomaticEnv()` with prefix `OPAL_`. Any YAML - key `foo_bar` can be overridden by `OPAL_FOO_BAR`. -- **Alternatives**: Add a server-specific config file; keep config values - hardcoded for server. -- **Consequences**: Layered config: defaults < YAML file < env vars. Aligns - with 12-factor app principles. Deployment can tune behavior without deploying - additional files. - -### ADR-4: Do not merge auth.Config into engine.Config - -- **Context**: `auth.Config` and `engine.Config` are completely independent - systems. Merging them would create a single unified config. -- **Decision**: Keep them separate. `auth.Config` loads from env vars, works - correctly, and is server-only. No reason to change it. -- **Alternatives**: Unified config struct with sections. -- **Consequences**: Two config types remain. This is acceptable because they - serve different purposes, have different lifecycles, and the current - `auth.Config` has no bugs. diff --git a/docs/design/web-bottom-sheet-design.md b/docs/design/web-bottom-sheet-design.md new file mode 100644 index 0000000..da10bd2 --- /dev/null +++ b/docs/design/web-bottom-sheet-design.md @@ -0,0 +1,1633 @@ +# Task Detail Bottom Sheet — Design + +**Status:** Draft +**Implements:** [web-cli-parity.md](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 + +```typescript +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 ``) 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 `
` rather than `` (see ADR-7 below for rationale). + +```svelte +
+
+
+
+
+
+ +
+
+
+``` + +### 2.5 CSS + +```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 + +```typescript +interface TaskDetailProps { + task: Task + onUpdate: (uuid: string, updates: Partial) => Promise + onStart: (uuid: string) => Promise + onStop: (uuid: string) => Promise + 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 | `` | 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 ``. 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 `