# Search, Nav, Management & Error States — Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add server-side search (dedicated `SongSearchService`), bottom nav shell, song edit/delete management, and proper error states. **Architecture:** Backend — `SongSearchPort` + `SongSearchService` decouple search from CRUD; `update_meta` extends the repository port; `SqliteSongRepository` is made `Clone` so a single pool is shared. Frontend — a layout route wraps all pages with a bottom nav bar; home uses URL-based search with debounce; song detail gains a `⋯` menu; errors show inline or as toasts. **Tech Stack:** Rust/Axum, SQLx/SQLite, React Router 7, TailwindCSS, shadcn/ui (DropdownMenu, AlertDialog, Toaster from sonner). --- ## File Map **New Rust:** - `crates/domain/src/ports.rs` — add `SongSearchPort`, `update_meta` to `SongRepositoryPort` - `crates/common/src/lib.rs` — add `SongSearchService`, `SongService::update_meta` - `crates/infrastructure/persistence/src/lib.rs` — derive `Clone`, impl `SongSearchPort`, impl `update_meta` - `crates/api/src/routes/songs.rs` — update `list_songs` with `?q=`, add `update_song` - `crates/api/src/routes/tabs.rs` — add `search: SongSearchService` to `AppState` - `crates/api/src/main.rs` — wire `SongSearchService`, add `PATCH /songs/{id}` **New Frontend:** - `app/app/routes/layout.tsx` — shell with `` + `` + `` - `app/app/components/bottom-nav.tsx` — single Library tab - `app/app/components/edit-song-sheet.tsx` — edit title/artist/key sheet - `app/app/components/delete-song-dialog.tsx` — confirm delete AlertDialog **Modified Frontend:** - `app/app/routes.ts` — wrap routes in layout - `app/app/routes/home.tsx` — URL-based search, error state, revalidator - `app/app/routes/songs.$id.tsx` — edit/delete integration, error fallback - `app/app/components/transpose-bar.tsx` — add `onEdit`/`onDelete` + DropdownMenu - `app/app/lib/api.ts` — `listSongs(q?)`, `updateSong` - `app/app/lib/types.ts` — add `UpdateSongRequest` --- ## Task 1: Backend search service **Files:** - Modify: `crates/domain/src/ports.rs` - Modify: `crates/domain/src/lib.rs` - Modify: `crates/common/src/lib.rs` - Modify: `crates/infrastructure/persistence/src/lib.rs` - Modify: `crates/api/src/routes/tabs.rs` - Modify: `crates/api/src/routes/songs.rs` - Modify: `crates/api/src/main.rs` - [ ] **Add `SongSearchPort` to `crates/domain/src/ports.rs`** (append after `SongRepositoryPort`): ```rust #[async_trait] pub trait SongSearchPort: Send + Sync { async fn search(&self, query: &str) -> Result, RepositoryError>; } ``` - [ ] **Re-export `SongSearchPort` in `crates/domain/src/lib.rs`**: ```rust pub use ports::{FetchError, ParseError, RepositoryError, SongRepositoryPort, SongSearchPort, TabFetcherPort, TabParserPort, TabSource}; ``` - [ ] **Add `SongSearchService` to `crates/common/src/lib.rs`** (append after `SongService`): ```rust use domain::SongSearchPort; pub struct SongSearchService { search: Box, } impl SongSearchService { pub fn new(search: Box) -> Self { Self { search } } pub async fn search(&self, query: &str) -> Result, domain::RepositoryError> { self.search.search(query).await } } ``` - [ ] **Derive `Clone` on `SqliteSongRepository` and implement `SongSearchPort`** in `crates/infrastructure/persistence/src/lib.rs`: Add `#[derive(Clone)]` to `SqliteSongRepository`: ```rust #[derive(Clone)] pub struct SqliteSongRepository { pool: SqlitePool, } ``` Append at the bottom of the file: ```rust use domain::SongSearchPort; #[async_trait] impl SongSearchPort for SqliteSongRepository { async fn search(&self, query: &str) -> Result, RepositoryError> { let pattern = format!("%{}%", query); let rows = sqlx::query_as::<_, SongRow>( "SELECT id, title, artist, original_key, preview_chords, body FROM songs \ WHERE title LIKE ? OR artist LIKE ? ORDER BY created_at DESC" ) .bind(&pattern) .bind(&pattern) .fetch_all(&self.pool) .await .map_err(|e| RepositoryError::Internal(e.to_string()))?; rows.into_iter() .map(|row| { let id = Uuid::parse_str(&row.id) .map_err(|e| RepositoryError::Internal(e.to_string()))?; let preview_chords: Vec = serde_json::from_str(&row.preview_chords) .map_err(|e| RepositoryError::Internal(e.to_string()))?; Ok(SongSummary { id, meta: SongMeta { title: row.title, artist: row.artist, original_key: row.original_key, capo: None, tuning: None, tempo: None, }, preview_chords, }) }) .collect() } } ``` - [ ] **Add `search: SongSearchService` to `AppState`** in `crates/api/src/routes/tabs.rs`: ```rust pub struct AppState { pub fetcher: Box, pub parser: Box, pub songs: common::SongService, pub search: common::SongSearchService, } ``` - [ ] **Update `list_songs` to branch on `?q=`** in `crates/api/src/routes/songs.rs`: Add import at top: ```rust use axum::extract::Query; use serde::Deserialize; ``` Add struct before `list_songs`: ```rust #[derive(Deserialize)] pub struct ListQuery { pub q: Option, } ``` Replace `list_songs`: ```rust pub async fn list_songs( State(state): State>, Query(params): Query, ) -> Result>, (StatusCode, Json)> { let result = if let Some(q) = params.q.filter(|s| !s.is_empty()) { state.search.search(&q).await } else { state.songs.list().await }; result .map(Json) .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: e.to_string() }))) } ``` - [ ] **Wire `SongSearchService` in `crates/api/src/main.rs`**: ```rust mod routes; use axum::{Router, routing::{delete, get, patch, post}}; use common::{SongSearchService, SongService}; use persistence::SqliteRepositoryFactory; use routes::songs::{create_song, delete_song, get_song, list_songs, update_song}; use routes::tabs::{AppState, parse_tab}; use std::sync::Arc; use tower_http::cors::{Any, CorsLayer}; use ug_parser::{UgHtmlParser, UgTabFetcher}; #[tokio::main] async fn main() { tracing_subscriber::fmt::init(); let database_url = std::env::var("DATABASE_URL") .unwrap_or_else(|_| "sqlite://./pocket-chords.db".into()); let repo = SqliteRepositoryFactory::create(&database_url) .await .expect("failed to connect to database"); let songs = SongService::new(Box::new(repo.clone())); let search = SongSearchService::new(Box::new(repo)); let state = Arc::new(AppState { fetcher: Box::new(UgTabFetcher::new()), parser: Box::new(UgHtmlParser), songs, search, }); let cors = CorsLayer::new() .allow_origin(Any) .allow_methods(Any) .allow_headers(Any); let app = Router::new() .route("/tabs/parse", post(parse_tab)) .route("/songs", post(create_song).get(list_songs)) .route("/songs/{id}", get(get_song).delete(delete_song).patch(update_song)) .layer(cors) .with_state(state); let listener = tokio::net::TcpListener::bind("0.0.0.0:8000").await.unwrap(); tracing::info!("listening on {}", listener.local_addr().unwrap()); axum::serve(listener, app).await.unwrap(); } ``` - [ ] **Build and test** ```bash cd /mnt/drive/dev/pocket-chords && cargo build --workspace 2>&1 | tail -5 cargo test --workspace 2>&1 | tail -5 ``` Expected: clean build, all tests pass. - [ ] **Smoke test search endpoint** ```bash cd /mnt/drive/dev/pocket-chords && DATABASE_URL=sqlite://./pocket-chords.db cargo run -p api & sleep 2 curl -s "http://localhost:8000/songs?q=ocean" | head -c 200 kill %1 ``` Expected: JSON array (may be empty if DB is fresh, non-empty if songs exist). - [ ] **Commit** ```bash cd /mnt/drive/dev/pocket-chords git add crates/ git commit -m "feat: add SongSearchService and GET /songs?q= search endpoint" ``` --- ## Task 2: Backend edit endpoint **Files:** - Modify: `crates/domain/src/ports.rs` - Modify: `crates/common/src/lib.rs` - Modify: `crates/infrastructure/persistence/src/lib.rs` - Modify: `crates/api/src/routes/songs.rs` - [ ] **Add `update_meta` to `SongRepositoryPort`** in `crates/domain/src/ports.rs`: ```rust #[async_trait] pub trait SongRepositoryPort: Send + Sync { async fn save(&self, song: &Song) -> Result; async fn list(&self) -> Result, RepositoryError>; async fn get(&self, id: Uuid) -> Result, RepositoryError>; async fn delete(&self, id: Uuid) -> Result<(), RepositoryError>; async fn update_meta( &self, id: Uuid, title: Option<&str>, artist: Option<&str>, original_key: Option<&str>, ) -> Result; } ``` - [ ] **Add `update_meta` to `SongService`** in `crates/common/src/lib.rs`: ```rust pub async fn update_meta( &self, id: Uuid, title: Option<&str>, artist: Option<&str>, original_key: Option<&str>, ) -> Result { self.repo.update_meta(id, title, artist, original_key).await } ``` - [ ] **Implement `update_meta` on `SqliteSongRepository`** in `crates/infrastructure/persistence/src/lib.rs`: Add inside the `impl SongRepositoryPort for SqliteSongRepository` block: ```rust async fn update_meta( &self, id: Uuid, title: Option<&str>, artist: Option<&str>, original_key: Option<&str>, ) -> Result { let id_str = id.to_string(); // Fetch current row let row = sqlx::query_as::<_, SongRow>( "SELECT id, title, artist, original_key, preview_chords, body FROM songs WHERE id = ?" ) .bind(&id_str) .fetch_optional(&self.pool) .await .map_err(|e| RepositoryError::Internal(e.to_string()))? .ok_or(RepositoryError::NotFound)?; // Patch the body JSON let mut song: Song = serde_json::from_str(&row.body) .map_err(|e| RepositoryError::Internal(e.to_string()))?; if let Some(t) = title { song.meta.title = t.to_string(); } if let Some(a) = artist { song.meta.artist = a.to_string(); } if let Some(k) = original_key { song.meta.original_key = Some(k.to_string()); } let new_body = serde_json::to_string(&song) .map_err(|e| RepositoryError::Internal(e.to_string()))?; let new_title = title.unwrap_or(&row.title); let new_artist = artist.unwrap_or(&row.artist); let new_key = original_key.or(row.original_key.as_deref()); sqlx::query( "UPDATE songs SET title = ?, artist = ?, original_key = ?, body = ? WHERE id = ?" ) .bind(new_title) .bind(new_artist) .bind(new_key) .bind(&new_body) .bind(&id_str) .execute(&self.pool) .await .map_err(|e| RepositoryError::Internal(e.to_string()))?; let preview_chords: Vec = serde_json::from_str(&row.preview_chords) .map_err(|e| RepositoryError::Internal(e.to_string()))?; Ok(SongSummary { id, meta: song.meta, preview_chords, }) } ``` - [ ] **Add `update_song` handler** in `crates/api/src/routes/songs.rs`: Add import: `use axum::extract::Path;` (already present). Add: ```rust #[derive(serde::Deserialize)] pub struct UpdateSongRequest { pub title: Option, pub artist: Option, pub original_key: Option, } pub async fn update_song( State(state): State>, Path(id): Path, Json(body): Json, ) -> Result, (StatusCode, Json)> { let uuid = Uuid::parse_str(&id).map_err(|_| { (StatusCode::BAD_REQUEST, Json(ErrorResponse { error: "Invalid ID".into() })) })?; state.songs .update_meta( uuid, body.title.as_deref(), body.artist.as_deref(), body.original_key.as_deref(), ) .await .map(Json) .map_err(|e| match e { domain::RepositoryError::NotFound => (StatusCode::NOT_FOUND, Json(ErrorResponse { error: "Not found".into() })), e => (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: e.to_string() })), }) } ``` - [ ] **Build** ```bash cd /mnt/drive/dev/pocket-chords && cargo build --workspace 2>&1 | tail -5 ``` Expected: clean. - [ ] **Commit** ```bash cd /mnt/drive/dev/pocket-chords git add crates/ git commit -m "feat: add update_meta to SongRepositoryPort and PATCH /songs/{id}" ``` --- ## Task 3: Frontend layout shell and bottom nav **Files:** - Modify: `app/app/routes.ts` - Create: `app/app/routes/layout.tsx` - Create: `app/app/components/bottom-nav.tsx` - [ ] **Check if DropdownMenu and AlertDialog are installed** ```bash ls /mnt/drive/dev/pocket-chords/app/app/components/ui/ | grep -E "dropdown|alert-dialog" ``` If missing, install: ```bash cd /mnt/drive/dev/pocket-chords/app && npx shadcn add dropdown-menu alert-dialog 2>&1 ``` - [ ] **Update `app/app/routes.ts`** ```ts import { type RouteConfig, index, layout, route } from "@react-router/dev/routes"; export default [ layout("routes/layout.tsx", [ index("routes/home.tsx"), route("songs/:id", "routes/songs.$id.tsx"), ]), ] satisfies RouteConfig; ``` - [ ] **Create `app/app/components/bottom-nav.tsx`** ```tsx import { NavLink } from "react-router"; import { Music } from "lucide-react"; import { cn } from "~/lib/utils"; export function BottomNav() { return ( ); } ``` - [ ] **Create `app/app/routes/layout.tsx`** ```tsx import { Outlet } from "react-router"; import { Toaster } from "sonner"; import { BottomNav } from "~/components/bottom-nav"; export default function Layout() { return (
); } ``` - [ ] **Typecheck** ```bash cd /mnt/drive/dev/pocket-chords/app && npm run typecheck 2>&1 ``` - [ ] **Run typegen if needed** (if `+types/layout` errors): ```bash cd /mnt/drive/dev/pocket-chords/app && npx react-router typegen 2>&1 ``` - [ ] **Commit** ```bash cd /mnt/drive/dev/pocket-chords git add app/app/routes.ts app/app/routes/layout.tsx app/app/components/bottom-nav.tsx git commit -m "feat(app): add layout shell with bottom nav and Toaster" ``` --- ## Task 4: Live server-side search **Files:** - Modify: `app/app/lib/api.ts` - Modify: `app/app/lib/types.ts` - Modify: `app/app/routes/home.tsx` - [ ] **Update `listSongs` in `app/app/lib/api.ts`** to accept optional query: ```ts export async function listSongs(q = ""): Promise { const url = q.trim() ? `${getApiBase()}/songs?q=${encodeURIComponent(q.trim())}` : `${getApiBase()}/songs`; const res = await fetch(url); if (!res.ok) throw new Error(`Failed to load songs: ${res.status}`); return res.json(); } ``` - [ ] **Add `UpdateSongRequest` to `app/app/lib/types.ts`**: ```ts export interface UpdateSongRequest { title?: string; artist?: string; original_key?: string; } ``` - [ ] **Add `updateSong` to `app/app/lib/api.ts`**: ```ts export async function updateSong(id: string, patch: UpdateSongRequest): Promise { const res = await fetch(`${getApiBase()}/songs/${id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(patch), }); if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error((data as { error?: string }).error ?? `HTTP ${res.status}`); } return res.json(); } ``` Add `import type { Song, SongSummary, StoredSong, UpdateSongRequest } from "./types";` to the top of `api.ts`. - [ ] **Rewrite `app/app/routes/home.tsx`** ```tsx import { useCallback, useEffect, useRef, useState } from "react"; import { useNavigate, useSearchParams, useRevalidator } from "react-router"; import type { Route } from "./+types/home"; import { Button } from "~/components/ui/button"; import { Input } from "~/components/ui/input"; import { Card, CardContent } from "~/components/ui/card"; import { Plus } from "lucide-react"; import { SongCard } from "~/components/song-card"; import { AddSongSheet } from "~/components/add-song-sheet"; import { listSongs } from "~/lib/api"; import type { SongSummary } from "~/lib/types"; export function meta({}: Route.MetaArgs) { return [ { title: "PocketChords" }, { name: "description", content: "Your personal chord chart library" }, ]; } export async function loader({ request }: Route.LoaderArgs) { const q = new URL(request.url).searchParams.get("q") ?? ""; try { const songs = await listSongs(q); return { songs, q, error: false }; } catch { return { songs: [], q, error: true }; } } export default function Home({ loaderData }: Route.ComponentProps) { const { songs, q: initialQ, error } = loaderData; const [searchParams, setSearchParams] = useSearchParams(); const [sheetOpen, setSheetOpen] = useState(false); const [localSongs, setLocalSongs] = useState([]); const revalidator = useRevalidator(); // Input value tracks immediately; URL updates are debounced const [inputValue, setInputValue] = useState(initialQ); const debounceRef = useRef | null>(null); const handleSearch = useCallback((value: string) => { setInputValue(value); if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(() => { setSearchParams(value.trim() ? { q: value.trim() } : {}, { replace: true }); }, 300); }, [setSearchParams]); // Clear local songs when search changes (they may not match) useEffect(() => { setLocalSongs([]); }, [initialQ]); const allSongs = [...songs, ...localSongs]; return (
{/* Header */}

PocketChords

{/* Search */}
handleSearch(e.target.value)} className="w-full" />
{/* Error state */} {error && (

Couldn't load your songs. Is the API running?

)} {/* Grid */}
{!error && allSongs.length === 0 && (

{initialQ ? "No songs match your search." : "No songs yet. Tap Add to get started."}

)}
{allSongs.map((song) => ( ))} setSheetOpen(true)} >
setLocalSongs((prev) => [...prev, summary])} />
); } ``` - [ ] **Typecheck** ```bash cd /mnt/drive/dev/pocket-chords/app && npm run typecheck 2>&1 ``` - [ ] **Commit** ```bash cd /mnt/drive/dev/pocket-chords git add app/app/lib/api.ts app/app/lib/types.ts app/app/routes/home.tsx git commit -m "feat(app): live server-side search with 300ms debounce" ``` --- ## Task 5: Song management — edit and delete **Files:** - Create: `app/app/components/edit-song-sheet.tsx` - Create: `app/app/components/delete-song-dialog.tsx` - Modify: `app/app/components/transpose-bar.tsx` - Modify: `app/app/routes/songs.$id.tsx` - [ ] **Create `app/app/components/edit-song-sheet.tsx`** ```tsx import { useState } from "react"; import { Sheet, SheetContent, SheetHeader, SheetTitle, } from "~/components/ui/sheet"; import { Input } from "~/components/ui/input"; import { Button } from "~/components/ui/button"; import { toast } from "sonner"; import { updateSong } from "~/lib/api"; import type { SongMeta, SongSummary } from "~/lib/types"; interface Props { id: string; meta: SongMeta; open: boolean; onOpenChange: (open: boolean) => void; onUpdated: (summary: SongSummary) => void; } export function EditSongSheet({ id, meta, open, onOpenChange, onUpdated }: Props) { const [title, setTitle] = useState(meta.title); const [artist, setArtist] = useState(meta.artist); const [key, setKey] = useState(meta.original_key ?? ""); const [loading, setLoading] = useState(false); async function handleSubmit(e: React.FormEvent) { e.preventDefault(); setLoading(true); try { const updated = await updateSong(id, { title: title.trim() || undefined, artist: artist.trim() || undefined, original_key: key.trim() || undefined, }); onUpdated(updated); onOpenChange(false); } catch (err) { toast.error("Failed to save changes", { description: err instanceof Error ? err.message : undefined, }); } finally { setLoading(false); } } return ( Edit Song
setTitle(e.target.value)} disabled={loading} />
setArtist(e.target.value)} disabled={loading} />
setKey(e.target.value)} placeholder="e.g. Em, G, Bb" disabled={loading} />
); } ``` - [ ] **Create `app/app/components/delete-song-dialog.tsx`** ```tsx import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "~/components/ui/alert-dialog"; import { toast } from "sonner"; import { deleteSong } from "~/lib/api"; import { useNavigate } from "react-router"; import { useState } from "react"; interface Props { id: string; title: string; open: boolean; onOpenChange: (open: boolean) => void; } export function DeleteSongDialog({ id, title, open, onOpenChange }: Props) { const navigate = useNavigate(); const [loading, setLoading] = useState(false); async function handleDelete() { setLoading(true); try { await deleteSong(id); navigate("/"); } catch { toast.error("Failed to delete song"); setLoading(false); onOpenChange(false); } } return ( Delete "{title}"? This cannot be undone. The song will be permanently removed. Cancel {loading ? "Deleting..." : "Delete"} ); } ``` - [ ] **Update `app/app/components/transpose-bar.tsx`** — add `onEdit`/`onDelete` props and DropdownMenu: Replace entire file: ```tsx import { useState } from "react"; import { Button } from "~/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "~/components/ui/dropdown-menu"; import { ChevronUp, ChevronDown, Minus, Plus, MoreHorizontal, Pencil, Trash2 } from "lucide-react"; import type { SongMeta } from "~/lib/types"; interface Props { meta: SongMeta; offset: number; onOffsetChange: (offset: number) => void; onEdit?: () => void; onDelete?: () => void; } export function TransposeBar({ meta, offset, onOffsetChange, onEdit, onDelete }: Props) { const [expanded, setExpanded] = useState(true); const label = offset === 0 ? "±0" : offset > 0 ? `+${offset}` : `${offset}`; const menuButton = (onEdit || onDelete) ? ( {onEdit && ( Edit )} {onDelete && ( Delete )} ) : null; if (!expanded) { return (
{meta.title}
{menuButton}
); } return (
{meta.title} {meta.artist}
{menuButton}
{meta.original_key && Key: {meta.original_key}} {meta.capo != null && Capo: {meta.capo}} {meta.tuning && {meta.tuning}}
{label}
); } ``` - [ ] **Update `app/app/routes/songs.$id.tsx`** ```tsx import { useState } from "react"; import { data, Link } from "react-router"; import type { Route } from "./+types/songs.$id"; import { TransposeBar } from "~/components/transpose-bar"; import { ChordChart } from "~/components/chord-chart"; import { EditSongSheet } from "~/components/edit-song-sheet"; import { DeleteSongDialog } from "~/components/delete-song-dialog"; import { transposeSong } from "~/lib/transpose"; import { getSong } from "~/lib/api"; import type { Song, SongSummary } from "~/lib/types"; export function meta({ data }: Route.MetaArgs) { if (!data?.song) return [{ title: "PocketChords" }]; return [ { title: `${data.song.meta.title} — PocketChords` }, { name: "description", content: data.song.meta.artist }, ]; } export async function loader({ params }: Route.LoaderArgs) { const id = params.id ?? ""; try { const song = await getSong(id); if (!song) throw data("Song not found", { status: 404 }); return { song, id }; } catch (err: any) { if (err?.status === 404) throw err; return { song: null as unknown as Song, id }; } } export default function SongDetail({ loaderData }: Route.ComponentProps) { const { song: initialSong, id } = loaderData; const [song, setSong] = useState(initialSong ?? null); const [offset, setOffset] = useState(0); const [editOpen, setEditOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false); if (!song) { return (

Song not found or unavailable.

← Back to library
); } const displayed = transposeSong(song, offset); function handleUpdated(summary: SongSummary) { setSong((prev) => prev ? { ...prev, meta: summary.meta } : prev); } return (
setEditOpen(true)} onDelete={() => setDeleteOpen(true)} />
); } ``` - [ ] **Typecheck** ```bash cd /mnt/drive/dev/pocket-chords/app && npm run typecheck 2>&1 ``` - [ ] **Commit** ```bash cd /mnt/drive/dev/pocket-chords git add app/app/components/ app/app/routes/songs.\$id.tsx git commit -m "feat(app): add song edit and delete with dropdown menu" ``` --- ## Task 6: Verification - [ ] **Start API** ```bash cd /mnt/drive/dev/pocket-chords && DATABASE_URL=sqlite://./pocket-chords.db cargo run -p api & sleep 2 ``` - [ ] **Start frontend** ```bash cd /mnt/drive/dev/pocket-chords/app && npm run dev & sleep 3 ``` - [ ] **Verify search** Open `http://localhost:5173/`. Type in search bar — requests should fire 300ms after typing stops (check browser network tab). Empty query returns full list. - [ ] **Verify nav** Bottom nav shows Library tab. Active on `/`, inactive on song detail. Tapping it from a song detail navigates to `/`. - [ ] **Verify edit** Open a song → tap `⋯` → Edit → change title → Save. Header updates immediately. Refresh — change persists. - [ ] **Verify delete** Open a song → tap `⋯` → Delete → confirm → navigates to library, song gone. - [ ] **Verify error state** Stop the API (`kill %1`). Reload library page — "Couldn't load your songs" inline with Retry button. Try navigating to a song URL directly — "Song not found or unavailable" with back link. - [ ] **Final checks** ```bash cd /mnt/drive/dev/pocket-chords && cargo test --workspace 2>&1 | tail -5 cd /mnt/drive/dev/pocket-chords/app && npm run typecheck 2>&1 ``` Both clean. - [ ] **Stop dev servers** ```bash kill %1 %2 2>/dev/null; true ```