# Phase 9 — Polish Debt 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:** Ship all six polish-debt items from the roadmap's Phase 9 plus the Phase 8 carryover backlog. Concretely: file-system markdown save dialog, bulk select + export on History, on-demand LLM content tags, progressive-disclosure Settings restructure, visual polish, accessibility sweep.

**Architecture:** See companion spec for full detail. Four sub-phases: 9a export plumbing, 9b LLM tags, 9c Settings restructure, 9d visual + a11y polish.

**Tech Stack:** Rust + Tauri 2, `tauri-plugin-dialog` (already installed), `lumotia-llm` (already wired to llama-cpp-2), Svelte 5 runes, TypeScript, Tailwind v4, Lucide icons.

**Spec:** [docs/superpowers/specs/2026-04-24-phase9-polish-debt-design.md](../specs/2026-04-24-phase9-polish-debt-design.md)

---

## Plan corrections (added during execution 2026-04-24)

Critical-review pass against the actual codebase (after Codex was unavailable for a cross-model read) surfaced three mismatches between the plan's prescribed code and what the repo actually contains. Implementations adapt as follows.

**1. `lumotia-llm` API.** Plan referenced `LlamaEngine::generate_chat(messages, config).await`. Actual: `LlmEngine::generate(prompt: &str, config: &GenerationConfig) -> Result<String, EngineError>` — *synchronous*, prompt is a single rendered string, no chat-message vec. The engine struct is `LlmEngine` not `LlamaEngine`. Loaded via `engine.load(model_path)` (sync, &self). The closest in-tree analogue to the new `extract_content_tags` is `LlmEngine::cleanup_text` — same crate, same generate-with-prompt shape; copy that pattern. Tasks 7 + 8 code samples are guides, not literals.

**2. Tauri `AppState`.** Plan referenced `state.llm.read().await.engine.as_ref()`. Actual: `AppState.llm_engine: Arc<LlmEngine>` — direct, no RwLock, accessed as `state.llm_engine.as_ref()`. Existing LLM commands file is `src-tauri/src/commands/llm.rs` (already there — do not create a new one).

**3. STRUCTURAL — tag persistence path.**
Plan assumed `llmTags` could mirror `manualTags` as a comma-joined string in a JS-only persistence path. Actual:
- `transcripts.manual_tags` is a real SQLite column (added by an earlier migration). The Rust storage fn `db_update_transcript` accepts `manual_tags`.
- The Tauri `update_transcript` command currently exposes only `text` + `title`, NOT `manual_tags` — so existing manualTags edits aren't persisting today either (latent bug).
- The frontend `saveHistory()` is a no-op stub.
- `transcripts.llm_tags` does not exist.

Fix in scope (decided 2026-04-24, "do it properly so we don't need to revisit"): Phase 9 picks up the persistence work as new **Task 8.5**, then Tasks 9 + 10 are rewired to call the proper update command instead of the no-op `saveHistory`.

### NEW Task 8.5: Migration v14 + storage extension + Tauri command extension for tags

**Why here:** Tasks 9 + 10 cannot persist `llmTags` (or even `manualTags`) without this. Bundling fixes the latent manualTags persistence bug for free.

**Files:**
- Modify: `crates/storage/src/migrations.rs` — append migration v14 entry; update the FTS5 / table-recreate migrations if any of them rebuild the schema with explicit columns (verify with grep on `manual_tags` first).
- Modify: `crates/storage/src/database.rs` — add `llm_tags` to every SELECT / INSERT / UPDATE that touches transcripts; extend `db_update_transcript` to accept an `Option<&str>` for `llm_tags`.
- Modify: `src-tauri/src/commands/transcripts.rs` — `TranscriptDto` gets `llm_tags: String` (camelCase via serde). `add_transcript` accepts and writes `manualTags` + `llmTags` on insert. `update_transcript` now accepts `manualTags: Option<Vec<String>>` + `llmTags: Option<Vec<String>>` — joins to comma-strings before calling storage.
- Modify: `src/lib/types/app.ts` — `TranscriptRow.llmTags: string` (storage shape) + `TranscriptEntry.llmTags: string[]` (in-memory).
- Modify: `src/lib/stores/page.svelte.ts` — hydrate `llmTags` in `mapTranscriptRow`; rewrite `saveHistory()` from no-op to call `update_transcript` with the current `manualTags` + `llmTags` of a target entry. New helper `persistTags(item)` that handles the actual write.

**TDD steps:**
1. Storage test: migration v14 adds `llm_tags TEXT NOT NULL DEFAULT ''` column; pre-existing rows default to empty string.
2. Storage test: `db_update_transcript(.., None, Some("topic:x,intent:y"))` writes only `llm_tags` and leaves `manual_tags` untouched.
3. Tauri command compile-check via `cargo build -p lumotia`.
4. Frontend `npm run check` clean.

Commit message: `feat(phase9): migration v14 + tag persistence wiring`.

### Adjusted Task 9 — Frontend types + persistence rewire

The original Task 9 body remains a guide for the data-shape changes, but the persistence path replaces no-op `saveHistory()` with calls to a new `persistTags(item)` helper that wraps `invoke("update_transcript", { id, manualTags, llmTags })`. All call sites in `HistoryPage.svelte` that currently call `saveHistory()` after touching `manualTags` or `llmTags` swap to `persistTags(item)`.

### Adjusted Task 10 — HistoryPage Tag UI

Same code as written; replace every `saveHistory()` call site in the Tag handlers with `await persistTags(item)`. Errors from the persist call surface via the existing `toasts.error` pattern.

### Renumbering

Tasks 8.5 inserted between 8 and 9. Subsequent task numbers (11..16) unchanged. Total task count: **16 + 1 = 17**.

---

## Conventions

- Branch: `main` (Jake's standing rule — phases ship on main, not feature branches).
- Commit after each task. Format: `feat(phase9): [what changed]`, `fix(phase9): …`, or `polish(phase9): …` for visual / a11y polish commits. Each task's final step is the commit.
- Test runner on Rust: `cargo test -p <crate>`. Frontend has no vitest wired; `npm run check` is the only automated gate and correctness on the frontend relies on manual dogfood during Phase 10a QC.
- British English in user-facing copy.
- No em / en dashes in prose or strings. Use full stops or commas.
- Every Phase 9 addition with motion respects `prefers-reduced-motion`.
- Phase 8 gotchas carried forward:
  - `$derived` cannot export at `.svelte.ts` module scope — use `export function` instead.
  - `#[derive(sqlx::FromRow)]` is not available in `lumotia-storage` (not expected to fire in this phase; noted for safety).

---

## File Structure

**Files created:**
- `src-tauri/src/commands/fs.rs` *(or inline into an existing commands file if the codebase prefers — see Task 1 for placement)*.
- `src/lib/utils/saveMarkdown.ts`.
- `src/lib/components/SettingsGroup.svelte`.
- `crates/llm/tests/content_tags_smoke.rs`.

**Files modified:**
- `src-tauri/src/lib.rs` — register new commands.
- `crates/llm/src/prompts.rs` — `ContentTags`, `INTENT_CLOSED_SET`, `extract_content_tags`.
- `crates/llm/src/grammars.rs` — content-tag GBNF.
- `crates/llm/src/lib.rs` — re-exports.
- `src-tauri/src/commands/<llm commands file>` — `extract_content_tags_cmd`.
- `src/lib/pages/HistoryPage.svelte` — save dialog, bulk selection, bulk export, Tag button, LLM tag chips.
- `src/lib/pages/SettingsPage.svelte` — full regroup into Start-here + 6 collapsed groups, sparkline toggle relocation, search box.
- `src/lib/pages/TasksPage.svelte` — badge entrance motion (guarded on `prefers-reduced-motion`).
- `src/lib/types/app.ts` — `llmTags` on history entry; `ContentTags` type.
- `src/lib/stores/page.svelte.ts` — hydrate / persist `llmTags`.
- `src/lib/utils/frontmatter.ts` — include `llmTags` in `buildFrontmatter` tag union.
- `src/lib/components/CompletionSparkline.svelte` — friendlier aria-label, per-bar `<title>`, `tabindex`, stagger entrance.
- `docs/roadmap/2026-04-23-lumotia-feature-complete-roadmap.md` — mark Phase 9 shipped.
- `HANDOVER.md` — end-of-session state.

**Files deleted:** None.

---

# Sub-phase 9a — Export plumbing (Tasks 1-5)

---

### Task 1: `write_text_file_cmd` Rust command

**Files:**
- Create or modify: `src-tauri/src/commands/fs.rs` (grep `src-tauri/src/commands/` first; if a single `util.rs` or similar already hosts utility commands, append there rather than creating a new file — match the convention).
- Test: inline `#[cfg(test)] mod tests` in the same file.

- [ ] **Step 1: Locate the right file**

Run: `ls src-tauri/src/commands/ && grep -rln "async fn .*_cmd" src-tauri/src/commands/ | head -5`
Pick the existing file that holds similar "thin-wrapper" commands (anything I/O-ish, not task-specific). If none fits, create `src-tauri/src/commands/fs.rs` and add `pub mod fs;` to `src-tauri/src/commands/mod.rs`.

- [ ] **Step 2: Write the failing test**

Append:

```rust
#[cfg(test)]
mod tests {
    use super::*;

    #[tokio::test]
    async fn write_text_file_roundtrips_utf8() {
        let dir = tempfile::tempdir().expect("tempdir");
        let path = dir.path().join("out.md").display().to_string();
        write_text_file_cmd(path.clone(), "hello\nwørld\n".into())
            .await
            .expect("write");

        let round = tokio::fs::read_to_string(&path).await.expect("read");
        assert_eq!(round, "hello\nwørld\n");
    }

    #[tokio::test]
    async fn write_text_file_errors_on_bad_parent() {
        // Parent dir does not exist → expect Err.
        let result = write_text_file_cmd(
            "/definitely-not-a-real-path-lumotia-phase9/out.md".into(),
            "x".into(),
        )
        .await;
        assert!(result.is_err(), "expected error for nonexistent parent");
    }
}
```

If `tempfile` is not already a workspace dev-dep, add it to `src-tauri/Cargo.toml` `[dev-dependencies]`: `tempfile = "3"`.

- [ ] **Step 3: Run test to verify it fails**

Run: `cargo test -p lumotia write_text_file -- --nocapture`
Expected: FAIL — `write_text_file_cmd` not defined.

- [ ] **Step 4: Add the command**

```rust
/// Phase 9. Writes UTF-8 text to a user-chosen path. The caller is
/// responsible for obtaining `path` via the save-dialog plugin; this
/// command does not validate traversal, extension, or parent-dir
/// existence beyond what the OS filesystem reports.
#[tauri::command]
pub async fn write_text_file_cmd(path: String, contents: String) -> Result<(), String> {
    tokio::fs::write(&path, contents)
        .await
        .map_err(|e| format!("Failed to write {path}: {e}"))
}
```

- [ ] **Step 5: Tests pass**

Run: `cargo test -p lumotia write_text_file -- --nocapture`
Expected: PASS.

- [ ] **Step 6: Commit**

```bash
git add src-tauri/src/commands/ src-tauri/Cargo.toml
git commit -m "$(cat <<'EOF'
feat(phase9): write_text_file_cmd

Thin UTF-8 writer used by the new save-dialog path. Caller owns path
safety — source path is always OS-dialog-provided.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

### Task 2: Register `write_text_file_cmd` in Tauri invoke_handler

**Files:**
- Modify: `src-tauri/src/lib.rs` — `invoke_handler!` block.

- [ ] **Step 1: Find the block**

Run: `grep -n "invoke_handler" src-tauri/src/lib.rs`

- [ ] **Step 2: Register the command**

Add the line inside the block (alphabetical neighbourhood or end — match local convention):

```rust
            commands::<module>::write_text_file_cmd,
```

(Substitute `<module>` with whatever path Task 1 chose.)

- [ ] **Step 3: Compile**

Run: `cargo build -p lumotia`
Expected: clean build.

- [ ] **Step 4: Commit**

```bash
git add src-tauri/src/lib.rs
git commit -m "$(cat <<'EOF'
feat(phase9): register write_text_file_cmd in invoke_handler

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

### Task 3: `saveMarkdown.ts` utility

**Files:**
- Create: `src/lib/utils/saveMarkdown.ts`.

- [ ] **Step 1: Create the file**

```typescript
// Phase 9 shared helpers. Centralises the save-dialog + write-file dance
// that HistoryPage (and, later, any consumer that wants to export a
// transcript as Markdown) calls. Keeps three responsibilities in one
// place: filename suggestion, single-file save, and bulk-to-directory
// export.

import { save, open } from "@tauri-apps/plugin-dialog";
import { invoke } from "@tauri-apps/api/core";
import { toasts } from "$lib/stores/toasts.svelte";
import { buildMarkdown } from "$lib/utils/frontmatter";
import type { TranscriptEntry } from "$lib/types/app";

function hasTauriRuntime(): boolean {
  return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
}

// "<slug>-<YYYY-MM-DD>.md". Slug keeps a-z0-9-; everything else collapses
// to a hyphen. Empty titles fall back to "transcript". Trailing hyphens
// and runs collapse.
export function suggestedFilename(item: TranscriptEntry): string {
  const raw = (item.title || "transcript").toLowerCase();
  const slug = raw
    .replace(/[^a-z0-9]+/g, "-")
    .replace(/^-|-$/g, "")
    .slice(0, 60) || "transcript";
  const iso = new Date(item.createdAt || item.date || Date.now())
    .toISOString()
    .slice(0, 10);
  return `${slug}-${iso}.md`;
}

/** Save a single transcript via the system Save dialog. Resolves to the
 *  chosen path on success, null if the user cancelled. No clipboard
 *  fallback. */
export async function saveTranscriptAsMarkdown(
  item: TranscriptEntry,
): Promise<string | null> {
  if (!hasTauriRuntime()) return null;
  const md = buildMarkdown(item);
  const defaultPath = suggestedFilename(item);
  const path = await save({
    title: "Save transcript as Markdown",
    defaultPath,
    filters: [{ name: "Markdown", extensions: ["md"] }],
  });
  if (!path) return null;
  try {
    await invoke("write_text_file_cmd", { path, contents: md });
    toasts.success(`Saved to ${basename(path)}`);
    return path;
  } catch (err) {
    toasts.error("Couldn't save transcript", String(err));
    return null;
  }
}

/** Bulk export N transcripts to a user-chosen directory. One file per
 *  transcript, filename collisions suffixed " (2)", " (3)" etc.
 *  Resolves to the count of files actually written. */
export async function exportTranscriptsToDir(
  items: TranscriptEntry[],
): Promise<number> {
  if (!hasTauriRuntime() || items.length === 0) return 0;

  const dir = await open({
    directory: true,
    multiple: false,
    title: "Choose export folder",
  });
  if (!dir || typeof dir !== "string") return 0;

  const used = new Set<string>();
  let written = 0;
  for (const item of items) {
    const base = suggestedFilename(item);
    const finalName = uniquify(base, used);
    used.add(finalName.toLowerCase());
    const path = `${dir}${pathSeparator(dir)}${finalName}`;
    try {
      await invoke("write_text_file_cmd", {
        path,
        contents: buildMarkdown(item),
      });
      written += 1;
    } catch (err) {
      console.error("exportTranscriptsToDir write failed", path, err);
    }
  }
  toasts.success(
    written === items.length
      ? `Exported ${written} transcripts to ${basename(dir)}`
      : `Exported ${written} of ${items.length} transcripts to ${basename(dir)}`,
  );
  return written;
}

function uniquify(name: string, taken: Set<string>): string {
  if (!taken.has(name.toLowerCase())) return name;
  const dot = name.lastIndexOf(".");
  const stem = dot > 0 ? name.slice(0, dot) : name;
  const ext = dot > 0 ? name.slice(dot) : "";
  let n = 2;
  while (taken.has(`${stem} (${n})${ext}`.toLowerCase())) n += 1;
  return `${stem} (${n})${ext}`;
}

function basename(p: string): string {
  const sep = p.includes("\\") ? "\\" : "/";
  const parts = p.split(sep);
  return parts[parts.length - 1] || p;
}

function pathSeparator(dir: string): string {
  return dir.includes("\\") ? "\\" : "/";
}
```

- [ ] **Step 2: Type-check**

Run: `npm run check`
Expected: 0 errors.

- [ ] **Step 3: Commit**

```bash
git add src/lib/utils/saveMarkdown.ts
git commit -m "$(cat <<'EOF'
feat(phase9): saveMarkdown utility — filename, save, bulk export

Centralises the save-dialog + write-file plumbing. One file per caller
avoids scattering plugin-dialog imports and collision logic across
every page that eventually wants .md export.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

### Task 4: HistoryPage — replace clipboard tail of exportMarkdown

**Files:**
- Modify: `src/lib/pages/HistoryPage.svelte` — `exportMarkdown` function (line 343).

- [ ] **Step 1: Replace the function body**

Current body at line 343-353 writes the markdown to clipboard. Replace with:

```typescript
  async function exportMarkdown(item) {
    await saveTranscriptAsMarkdown(item);
  }
```

And add the import near the existing imports:

```typescript
import {
  saveTranscriptAsMarkdown,
  exportTranscriptsToDir,
} from "$lib/utils/saveMarkdown";
```

- [ ] **Step 2: Update the button label if needed**

The button currently reads `"Export .md"` (line 643). No change needed — the behaviour (opens save dialog) matches the label.

- [ ] **Step 3: Type-check**

Run: `npm run check`
Expected: 0 errors.

- [ ] **Step 4: Manual smoke (deferred to Phase 10a, but record the path)**

Walk-through: click Export .md on one row → save dialog opens → choose path → file written. Cancel → no toast.

- [ ] **Step 5: Commit**

```bash
git add src/lib/pages/HistoryPage.svelte
git commit -m "$(cat <<'EOF'
feat(phase9): HistoryPage .md export via save dialog

Replaces the clipboard-only path with saveTranscriptAsMarkdown. User
picks a location; cancellation leaves no side effect. Toast on success
names the file.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

### Task 5: HistoryPage — bulk select + bulk export

**Files:**
- Modify: `src/lib/pages/HistoryPage.svelte` — state, row checkbox, selection toolbar, bulk export + bulk delete handlers.

- [ ] **Step 1: Add selection state**

In the `<script lang="ts">` block, add:

```typescript
let selected = $state(new Set<string>());

function toggleSelected(id: string) {
  const next = new Set(selected);
  if (next.has(id)) next.delete(id);
  else next.add(id);
  selected = next;
}

function clearSelection() {
  selected = new Set();
}

function selectAllVisible() {
  selected = new Set(filtered.map((i) => i.id));
}

let selectedItems = $derived(
  history.filter((i) => selected.has(i.id)),
);

async function bulkExport() {
  const items = selectedItems;
  if (items.length === 0) return;
  await exportTranscriptsToDir(items);
  clearSelection();
}

async function bulkDelete() {
  if (selected.size === 0) return;
  const yes = confirm(`Delete ${selected.size} transcript${selected.size === 1 ? "" : "s"}?`);
  if (!yes) return;
  for (const id of selected) deleteFromHistory(id);
  clearSelection();
}
```

(`filtered` is the existing filtered-list variable in this file; grep for it to confirm the name and use it as-is.)

- [ ] **Step 2: Add checkbox column + selection toolbar in the template**

Above the existing list markup, add the toolbar (visible only when selection is non-empty):

```svelte
{#if selected.size > 0}
  <div class="sticky top-0 z-10 bg-surface border-b border-border-subtle flex items-center gap-3 px-7 py-2 text-sm"
       role="toolbar"
       aria-label="Bulk actions">
    <span class="text-text-tertiary">{selected.size} selected</span>
    <button class="text-text hover:underline" onclick={selectAllVisible}>Select all</button>
    <button class="text-text hover:underline" onclick={clearSelection}>Clear</button>
    <div class="ml-auto flex gap-4">
      <button class="text-text hover:underline" onclick={bulkExport}>Export selected</button>
      <button class="text-red-500 hover:underline" onclick={bulkDelete}>Delete selected</button>
    </div>
  </div>
{/if}
```

On each row, add a checkbox as the leftmost control:

```svelte
<input
  type="checkbox"
  class="mr-3"
  aria-label={`Select transcript ${item.title || item.id}`}
  checked={selected.has(item.id)}
  onclick={(e) => { e.stopPropagation(); toggleSelected(item.id); }}
/>
```

(Place it inside the row container, before the existing content. `e.stopPropagation()` prevents the click bubbling into the row-expand behaviour.)

- [ ] **Step 3: Keyboard shortcuts**

Attach to the page (e.g. via `$effect` that binds `document` listeners):

```typescript
$effect(() => {
  function onKey(e: KeyboardEvent) {
    if (e.key === "Escape" && selected.size > 0) {
      clearSelection();
      e.preventDefault();
    }
    if ((e.key === "a" || e.key === "A") && (e.metaKey || e.ctrlKey)) {
      // Only hijack Cmd/Ctrl+A when the History page owns focus — cheap
      // check: active element is inside the history list container.
      if (listEl?.contains(document.activeElement)) {
        selectAllVisible();
        e.preventDefault();
      }
    }
  }
  window.addEventListener("keydown", onKey);
  return () => window.removeEventListener("keydown", onKey);
});
```

- [ ] **Step 4: Type-check**

Run: `npm run check`
Expected: 0 errors.

- [ ] **Step 5: Commit**

```bash
git add src/lib/pages/HistoryPage.svelte
git commit -m "$(cat <<'EOF'
feat(phase9): History bulk select + bulk export

Checkbox column on every row. Selection toolbar surfaces when N > 0
with select-all, clear, export, delete. Cmd/Ctrl+A selects all visible
when History owns focus; Esc clears. Bulk export writes one .md per
item via exportTranscriptsToDir; collisions auto-suffix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

# Sub-phase 9b — LLM content tags (Tasks 6-10)

---

### Task 6: `ContentTags` struct, intent closed set, GBNF grammar

**Files:**
- Modify: `crates/llm/src/prompts.rs` — add struct + constants + signature.
- Modify: `crates/llm/src/grammars.rs` — add `CONTENT_TAGS_GRAMMAR`.

- [ ] **Step 1: Add the types in `prompts.rs`**

```rust
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ContentTags {
    pub topic: String,
    pub intent: String,
}

pub const INTENT_CLOSED_SET: &[&str] = &[
    "planning",
    "reflection",
    "venting",
    "capture",
    "decision",
    "question",
];

pub fn is_valid_intent(s: &str) -> bool {
    INTENT_CLOSED_SET.iter().any(|v| *v == s)
}
```

- [ ] **Step 2: Add the grammar in `grammars.rs`**

```rust
pub const CONTENT_TAGS_GRAMMAR: &str = r##"
root   ::= "{" ws "\"topic\":" ws topic ws "," ws "\"intent\":" ws intent ws "}" ws
topic  ::= "\"" [a-z0-9-]{3,60} "\""
intent ::= "\"planning\"" | "\"reflection\"" | "\"venting\"" | "\"capture\"" | "\"decision\"" | "\"question\""
ws     ::= [ \t\n]*
"##;
```

- [ ] **Step 3: Re-export from `lib.rs`**

In `crates/llm/src/lib.rs`, add alongside existing re-exports:

```rust
pub use grammars::CONTENT_TAGS_GRAMMAR;
pub use prompts::{ContentTags, INTENT_CLOSED_SET, is_valid_intent};
```

(Merge into existing `pub use` blocks.)

- [ ] **Step 4: Type-check**

Run: `cargo build -p lumotia-llm && cargo clippy -p lumotia-llm -- -D warnings`
Expected: clean.

- [ ] **Step 5: Commit**

```bash
git add crates/llm/src/prompts.rs crates/llm/src/grammars.rs crates/llm/src/lib.rs
git commit -m "$(cat <<'EOF'
feat(phase9): ContentTags schema + GBNF grammar

ContentTags serde-serialisable. INTENT_CLOSED_SET is the single source
of truth for the enum values the grammar also restricts. Grammar is
strict: lowercase hyphen-joined topic 3..=60 chars, intent from the
closed set, JSON-only output.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

### Task 7: `extract_content_tags` function + smoke test

**Files:**
- Modify: `crates/llm/src/prompts.rs` — add `extract_content_tags`.
- Create: `crates/llm/tests/content_tags_smoke.rs`.

- [ ] **Step 1: Write the failing smoke test**

```rust
// crates/llm/tests/content_tags_smoke.rs
use lumotia_llm::{extract_content_tags, is_valid_intent, model_manager::LlmModelId};

// Marked #[ignore] so it does not run in default cargo test (the model
// load is heavy). Run explicitly: cargo test -p lumotia-llm --test
// content_tags_smoke -- --ignored --nocapture.
#[tokio::test]
#[ignore]
async fn extract_content_tags_on_short_transcript() {
    let Some(engine) = load_local_tier0_engine().await else {
        eprintln!("skipping: no local tier-0 model found");
        return;
    };

    let transcript = "Tomorrow I need to run through the grant application one more time \
                      and make sure the figures add up. I also need to book a slot with \
                      Rachmann for the Mac test.";
    let tags = extract_content_tags(&engine, transcript)
        .await
        .expect("extraction");

    assert!(tags.topic.len() >= 3, "topic present: {tags:?}");
    assert!(tags.topic.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'),
            "topic lowercased + slugged: {tags:?}");
    assert!(is_valid_intent(&tags.intent), "intent in closed set: {tags:?}");
}

async fn load_local_tier0_engine() -> Option<lumotia_llm::LlamaEngine> {
    // Use the existing recommend_tier / model_manager path to find a
    // locally-downloaded model file. Skip the test if none is present.
    let tier = lumotia_llm::recommend_tier();
    let info = lumotia_llm::LlmModelInfo::from_id(&LlmModelId::from_tier(tier))?;
    let path = info.local_path().ok()?;
    if !path.exists() { return None; }
    lumotia_llm::LlamaEngine::load(&path).await.ok()
}
```

(If the exact signatures `LlamaEngine::load`, `LlmModelInfo::from_id`, `from_tier`, `local_path` do not exist, match the actual names — grep `crates/llm/src/model_manager.rs` first and adjust. The test's purpose is smoke coverage, not exhaustive model-API verification.)

- [ ] **Step 2: Add the function in `prompts.rs`**

```rust
pub async fn extract_content_tags(
    engine: &LlamaEngine,
    transcript: &str,
) -> Result<ContentTags, EngineError> {
    // Guard against huge transcripts — we only need the tail for
    // topic / intent extraction, and GBNF plus max_tokens would reject
    // overlong prompts anyway.
    const MAX_CHARS: usize = 2000;
    let tail = if transcript.len() > MAX_CHARS {
        &transcript[transcript.len() - MAX_CHARS..]
    } else {
        transcript
    };

    let user = format!(
        "You tag a transcript with ONE topic and ONE intent.\n\n\
         TOPIC is a 1-3 token lowercase hyphen-joined noun phrase naming \
         the dominant subject. Examples: interview-prep, grant-application, \
         daily-standup.\n\n\
         INTENT is exactly one of: planning, reflection, venting, capture, \
         decision, question.\n\n\
         Return JSON only, with this exact shape:\n\
         {{\"topic\":\"...\",\"intent\":\"...\"}}\n\n\
         Transcript:\n<<<\n{tail}\n>>>",
    );

    let config = GenerationConfig {
        max_tokens: 96,
        temperature: 0.0,
        stop_sequences: Vec::new(),
        grammar: Some(CONTENT_TAGS_GRAMMAR.to_string()),
    };

    let raw = engine.generate_chat(&[
        LlamaChatMessage::new("user".into(), user)
            .map_err(|e| EngineError::Inference(format!("chat msg: {e}")))?,
    ], &config).await?;

    let tags: ContentTags = serde_json::from_str(raw.trim())
        .map_err(|e| EngineError::InvalidJson(format!("{e}: raw={raw:?}")))?;

    if !is_valid_intent(&tags.intent) {
        return Err(EngineError::InvalidJson(format!(
            "intent out of closed set: {}",
            tags.intent,
        )));
    }
    Ok(tags)
}
```

(If `engine.generate_chat` has a different name — grep `crates/llm/src/lib.rs` for the existing public async generation method and use that. Do not invent.)

- [ ] **Step 3: Build + tests**

```
cargo build -p lumotia-llm
cargo test -p lumotia-llm           # excludes #[ignore] — should still be green
cargo clippy -p lumotia-llm -- -D warnings
cargo fmt --check
```

Expected: clean.

- [ ] **Step 4: Commit**

```bash
git add crates/llm/src/prompts.rs crates/llm/tests/content_tags_smoke.rs
git commit -m "$(cat <<'EOF'
feat(phase9): extract_content_tags + smoke test

Grammar-constrained topic + intent extraction over the last 2000 chars
of the transcript. Temperature 0 for determinism; max_tokens 96 is
enough for the JSON envelope. Smoke test is #[ignore]d so the default
cargo test run doesn't require a loaded model.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

### Task 8: `extract_content_tags_cmd` Tauri wrapper

**Files:**
- Modify: the existing LLM-commands file under `src-tauri/src/commands/` (grep for `lumotia_llm::` to find it; typically something like `llm.rs` or `ai.rs`). If absent, create `src-tauri/src/commands/llm.rs` and register the module in `commands/mod.rs`.
- Modify: `src-tauri/src/lib.rs` — register the command.

- [ ] **Step 1: Add the command**

```rust
use lumotia_llm::{extract_content_tags as llm_extract_content_tags, ContentTags};
// …alongside existing LLM imports.

#[tauri::command]
pub async fn extract_content_tags_cmd(
    state: tauri::State<'_, AppState>,
    transcript: String,
) -> Result<ContentTags, String> {
    // Acquire the engine from the shared state. Match how existing
    // LLM commands do this — e.g. state.llm.read().await.engine.as_ref().
    let guard = state.llm.read().await;
    let engine = guard
        .engine
        .as_ref()
        .ok_or_else(|| "LLM not loaded. Download an AI model in Settings.".to_string())?;

    llm_extract_content_tags(engine, &transcript)
        .await
        .map_err(|e| e.to_string())
}
```

(Substitute `state.llm.read().await.engine.as_ref()` with the actual engine-access path used by neighbour commands.)

- [ ] **Step 2: Register**

In `src-tauri/src/lib.rs` `invoke_handler!`:

```rust
            commands::<module>::extract_content_tags_cmd,
```

- [ ] **Step 3: Build**

Run: `cargo build -p lumotia && cargo clippy -p lumotia -- -D warnings && cargo fmt --check`
Expected: clean.

- [ ] **Step 4: Commit**

```bash
git add src-tauri/src/commands/ src-tauri/src/lib.rs
git commit -m "$(cat <<'EOF'
feat(phase9): extract_content_tags_cmd Tauri wrapper

Bridges lumotia_llm::extract_content_tags to the frontend. Returns a
ContentTags object serialised to the {topic, intent} shape the
frontend stores on item.llmTags.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

### Task 9: Frontend types + hydrate/persist `llmTags`

**Files:**
- Modify: `src/lib/types/app.ts` — add `llmTags`, add `ContentTags`.
- Modify: `src/lib/stores/page.svelte.ts` — hydrate + persist `llmTags` alongside `manualTags` (line 145-195 area is where `manualTags` is handled).
- Modify: `src/lib/utils/frontmatter.ts` — include `llmTags` in `buildFrontmatter`'s tag union.

- [ ] **Step 1: Types**

In `src/lib/types/app.ts`, add near the transcript types:

```typescript
export interface ContentTags {
  topic: string;
  intent: string;
}
```

Extend `TranscriptEntry` (or whichever type shapes history items in this file) to include:

```typescript
  llmTags?: string[]; // Phase 9. "topic:foo", "intent:planning" etc.
```

Also update the persistence row type (the one that uses `manualTags: string`) to carry `llmTags: string` (comma-joined), mirroring how `manualTags` is stored.

- [ ] **Step 2: Hydrate / persist in `page.svelte.ts`**

Near the existing `manualTags` handling (line 145-195):

```typescript
// Hydrate
const rawLlmTags = row.llmTags ?? "";
const llmTags = rawLlmTags ? rawLlmTags.split(",").filter(Boolean) : [];

// …add `llmTags` alongside manualTags in the rehydrated entry.

// Persist (mirror the patch.manualTags handling at line 287-291):
llmTags: patch.llmTags == null
  ? undefined
  : (Array.isArray(patch.llmTags)
      ? patch.llmTags.join(",")
      : String(patch.llmTags)),
```

- [ ] **Step 3: Include `llmTags` in the exported frontmatter**

In `src/lib/utils/frontmatter.ts` `buildFrontmatter` (line 89-104), change the tag union:

**Before:**
```typescript
const manual = Array.isArray(item.manualTags) ? item.manualTags : [];
const tags = Array.from(new Set([...auto, ...manual.map(normaliseTag)])).filter(Boolean);
```

**After:**
```typescript
const manual = Array.isArray(item.manualTags) ? item.manualTags : [];
const llm = Array.isArray(item.llmTags) ? item.llmTags : [];
const tags = Array.from(new Set([
  ...auto,
  ...manual.map(normaliseTag),
  ...llm.map(normaliseTag),
])).filter(Boolean);
```

- [ ] **Step 4: Type-check**

Run: `npm run check`
Expected: 0 errors.

- [ ] **Step 5: Commit**

```bash
git add src/lib/types/app.ts src/lib/stores/page.svelte.ts src/lib/utils/frontmatter.ts
git commit -m "$(cat <<'EOF'
feat(phase9): llmTags persistence + frontmatter union

New optional field on TranscriptEntry persisted exactly like
manualTags (comma-joined in the storage row, array in memory).
buildFrontmatter unions auto + manual + llm so exported markdown
surfaces all tag sources in one `tags:` list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

### Task 10: HistoryPage — Tag button, LLM chip rendering, promote-to-manual, batch "Tag all untagged"

**Files:**
- Modify: `src/lib/pages/HistoryPage.svelte`.

- [ ] **Step 1: Add the per-row Tag button**

Near the existing row action cluster (around line 641 where "Export .md" lives), add:

```svelte
<button
  class="text-xs text-text-tertiary hover:text-text"
  aria-label="Generate content tags with AI"
  onclick={(e) => { e.stopPropagation(); tagRow(item); }}
>
  {#if tagging.has(item.id)}
    Tagging…
  {:else}
    Tag
  {/if}
</button>
```

Handler:

```typescript
let tagging = $state(new Set<string>());

async function tagRow(item) {
  if (!hasTauriRuntime()) return;
  if (tagging.has(item.id)) return;
  const next = new Set(tagging); next.add(item.id); tagging = next;
  try {
    const result = await invoke("extract_content_tags_cmd", {
      transcript: item.text || "",
    });
    const { topic, intent } = result as { topic: string; intent: string };
    item.llmTags = [`topic:${topic}`, `intent:${intent}`];
    saveHistory();
  } catch (err) {
    toasts.error("Tagging failed", String(err));
  } finally {
    const rest = new Set(tagging); rest.delete(item.id); tagging = rest;
  }
}
```

- [ ] **Step 2: Render LLM chips distinctly**

Near the existing manualTag loop (around line 598):

```svelte
{#each (item.llmTags || []) as t (t)}
  <button
    class="text-[11px] italic border border-dashed border-border-subtle rounded px-1.5 py-0.5 text-text-tertiary hover:text-text"
    aria-label={`Promote ${t} to manual tag`}
    onclick={(e) => { e.stopPropagation(); promoteLlmTag(item, t); }}
  >
    {t}
  </button>
{/each}
```

Handler:

```typescript
function promoteLlmTag(item, tag: string) {
  const normalised = normaliseTag(tag);
  const manual = new Set((item.manualTags || []).map(normaliseTag));
  manual.add(normalised);
  item.manualTags = [...manual];
  item.llmTags = (item.llmTags || []).filter((t) => normaliseTag(t) !== normalised);
  saveHistory();
}
```

- [ ] **Step 3: "Tag all untagged" toolbar button**

In the HistoryPage top toolbar (not the bulk-select one — the always-visible one), add:

```svelte
{#if history.some((i) => !i.llmTags || i.llmTags.length === 0)}
  <button
    class="text-xs text-text-tertiary hover:text-text"
    onclick={tagAllUntagged}
    disabled={bulkTagging}
  >
    {bulkTagging ? `Tagging ${bulkTaggingProgress}` : "Tag all untagged"}
  </button>
{/if}
```

Handler:

```typescript
let bulkTagging = $state(false);
let bulkTaggingProgress = $state("");

async function tagAllUntagged() {
  if (bulkTagging) return;
  bulkTagging = true;
  try {
    const untagged = history.filter((i) => !i.llmTags || i.llmTags.length === 0);
    let i = 0;
    for (const item of untagged) {
      i += 1;
      bulkTaggingProgress = `${i} / ${untagged.length}`;
      try {
        const result = await invoke("extract_content_tags_cmd", {
          transcript: item.text || "",
        });
        const { topic, intent } = result as { topic: string; intent: string };
        item.llmTags = [`topic:${topic}`, `intent:${intent}`];
      } catch (err) {
        console.error("bulk tag failed for", item.id, err);
      }
    }
    saveHistory();
    toasts.success(`Tagged ${untagged.length} transcripts`);
  } finally {
    bulkTagging = false;
    bulkTaggingProgress = "";
  }
}
```

- [ ] **Step 4: Type-check**

Run: `npm run check`
Expected: 0 errors.

- [ ] **Step 5: Commit**

```bash
git add src/lib/pages/HistoryPage.svelte
git commit -m "$(cat <<'EOF'
feat(phase9): History LLM tag button, chips, promote, batch

Per-row Tag button calls extract_content_tags_cmd and writes llmTags.
Dashed-border italicised chips visually distinguish AI tags from
manual. Click a chip to promote it into manualTags (the LLM tag
disappears, the manual one stays). Top-toolbar "Tag all untagged"
iterates. Errors toast but don't block the rest of the batch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

# Sub-phase 9c — Settings restructure (Tasks 11-12)

---

### Task 11: `SettingsGroup.svelte` reusable `<details>` wrapper

**Files:**
- Create: `src/lib/components/SettingsGroup.svelte`.

- [ ] **Step 1: Create the component**

```svelte
<script lang="ts">
  // Phase 9. Progressive-disclosure wrapper. Animated open/close via
  // modern browsers' `interpolate-size: allow-keywords` + `@starting-style`.
  // Falls back to no animation on older engines.
  //
  // prefers-reduced-motion disables the transition entirely.
  import { ChevronRight } from "lucide-svelte";

  interface Props {
    title: string;
    description?: string;
    open?: boolean;
    children?: import("svelte").Snippet;
  }

  let { title, description = "", open = false, children }: Props = $props();
  let detailsEl: HTMLDetailsElement | undefined = $state();
</script>

<details
  bind:this={detailsEl}
  {open}
  class="settings-group border-t border-border-subtle py-2"
>
  <summary class="flex items-start gap-2 cursor-pointer list-none py-3 px-1 rounded hover:bg-surface-elevated focus-visible:outline focus-visible:outline-2 focus-visible:outline-focus">
    <ChevronRight class="chevron size-4 mt-0.5 shrink-0 text-text-tertiary" />
    <div class="flex-1">
      <p class="text-sm text-text font-medium">{title}</p>
      {#if description}
        <p class="text-[11px] text-text-tertiary mt-0.5">{description}</p>
      {/if}
    </div>
  </summary>
  <div class="pl-6 pr-2 pb-3">
    {@render children?.()}
  </div>
</details>

<style>
  .settings-group :global(summary::-webkit-details-marker) { display: none; }
  .chevron {
    transition: transform 180ms ease;
  }
  details[open] > summary .chevron {
    transform: rotate(90deg);
  }

  /* Animated open/close where supported. */
  @supports (interpolate-size: allow-keywords) {
    :global(:root) { interpolate-size: allow-keywords; }
    details {
      height: 0;
      overflow: clip;
      transition: height 220ms ease;
    }
    details[open] { height: auto; }
  }

  @media (prefers-reduced-motion: reduce) {
    .chevron { transition: none; }
    details { transition: none; }
  }
</style>
```

- [ ] **Step 2: Type-check**

Run: `npm run check`
Expected: 0 errors.

- [ ] **Step 3: Commit**

```bash
git add src/lib/components/SettingsGroup.svelte
git commit -m "$(cat <<'EOF'
feat(phase9): SettingsGroup component

Reusable <details>/<summary> wrapper with animated chevron and
optional height transition on engines that support interpolate-size.
prefers-reduced-motion disables all motion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

### Task 12: SettingsPage regroup + search + sparkline relocation

**Files:**
- Modify: `src/lib/pages/SettingsPage.svelte` — full regroup.

This is the largest single task in Phase 9. The file is 2309 lines today. Approach: re-section without rewriting individual control markup.

- [ ] **Step 1: Inventory the file**

Run: `grep -n '<!--\s*Section\|^\s*<section\|^\s*<h[123]\|Toggle' src/lib/pages/SettingsPage.svelte | head -40` to map the current sectioning. Record the existing top-to-bottom order in a temporary comment block at the top of the file (will be removed at the end of the task). This anchors the move.

- [ ] **Step 2: Plan the new order**

Draft the new order as a comment block:

```svelte
<!--
Phase 9 regroup:
  [always expanded]  Start here: model, microphone, hotkey, theme
  [details closed]   Transcription: backend, language, punct, vocab, file upload
  [details closed]   Tasks: match-my-energy, WIP limit, SPARKLINE (moved from Rituals), MicroSteps
  [details closed]   Rituals: morning triage, evening shutdown
  [details closed]   Notifications: global mute, per-trigger, TTS
  [details closed]   Accessibility: font, line height, bionic, reduced motion, high contrast
  [details closed]   Advanced: data dir, export app data, reset, model tier override, debug log
-->
```

- [ ] **Step 3: Wire up the search input**

At the top of the page:

```svelte
<script lang="ts">
  import SettingsGroup from "$lib/components/SettingsGroup.svelte";

  let search = $state("");
  let normalised = $derived(search.trim().toLowerCase());

  function matches(...tokens: string[]): boolean {
    if (!normalised) return true;
    return tokens.some((t) => t.toLowerCase().includes(normalised));
  }
</script>

<div class="px-7 pt-6">
  <input
    type="search"
    placeholder="Search settings"
    class="w-full max-w-md text-sm px-3 py-2 rounded border border-border-subtle bg-surface"
    aria-label="Search settings"
    bind:value={search}
  />
</div>
```

- [ ] **Step 4: Wrap each group**

For each of the 7 groups, wrap its existing control markup in:

```svelte
{#if matches("transcription", "whisper", "parakeet", "language", "vocab", "punctuation", "file upload")}
  <SettingsGroup
    title="Transcription"
    description="How transcripts are produced."
    open={normalised !== ""}
  >
    <!-- existing transcription section markup, unchanged -->
  </SettingsGroup>
{/if}
```

The "Start here" group does not use `SettingsGroup` — it renders as a plain block at the top, always expanded:

```svelte
{#if matches("model", "microphone", "hotkey", "theme", "start here")}
  <section class="px-7 py-4">
    <h2 class="text-sm font-medium text-text">Start here</h2>
    <p class="text-[11px] text-text-tertiary mt-0.5">The things most people change.</p>
    <!-- model download + picker, microphone, hotkey, theme controls -->
  </section>
{/if}
```

Tokens per group (for search matching — get these right or search becomes surprising):

| Group | Tokens |
|---|---|
| Start here | "model", "microphone", "hotkey", "theme", "start here" |
| Transcription | "transcription", "whisper", "parakeet", "language", "vocab", "punctuation", "file upload" |
| Tasks | "tasks", "energy", "wip", "sparkline", "microsteps", "momentum" |
| Rituals | "rituals", "morning triage", "evening shutdown", "triage" |
| Notifications | "notifications", "nudge", "mute", "inactivity", "tts" |
| Accessibility | "accessibility", "font", "line height", "bionic", "reduced motion", "high contrast" |
| Advanced | "advanced", "data dir", "export", "reset", "debug", "log", "model tier" |

- [ ] **Step 5: Relocate the Phase 8 sparkline toggle**

The sparkline toggle is currently inside the Rituals section (leftover from Phase 8). Move its entire markup block into the Tasks group. No behaviour change — `bind:checked={settings.showMomentumSparkline}` and `onchange={() => saveSettings()}` stay exactly as they are.

- [ ] **Step 6: Remove the temporary inventory comment from Step 1**

- [ ] **Step 7: Type-check + build**

```
npm run check
npm run build
```

Expected: 0 errors, clean production build.

- [ ] **Step 8: Manual smoke**

Open Settings in `npm run dev:frontend`:
- Start here expanded; other groups closed.
- Search "sparkline" → only Tasks group remains visible + open.
- Clear search → all groups visible, Tasks collapsed again.
- Toggle the sparkline from inside Tasks → matches the pre-move behaviour (sparkline on TasksPage respects the toggle).

If running the dev server is blocked in this environment, record the omission in the handover for Phase 10a QC.

- [ ] **Step 9: Commit**

```bash
git add src/lib/pages/SettingsPage.svelte
git commit -m "$(cat <<'EOF'
feat(phase9): SettingsPage progressive disclosure + search

Start here group (model, microphone, hotkey, theme) always expanded.
Transcription, Tasks, Rituals, Notifications, Accessibility, Advanced
collapsed by default behind SettingsGroup wrappers. Free-text search
filters + opens matching groups. Phase 8 momentum-sparkline toggle
moves from Rituals to Tasks where it belongs. No control markup
rewritten; only the surrounding sectioning.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

# Sub-phase 9d — Visual polish + accessibility (Tasks 13-16)

---

### Task 13: Sparkline a11y + tooltips + stagger; badge entrance

**Files:**
- Modify: `src/lib/components/CompletionSparkline.svelte`.
- Modify: `src/lib/pages/TasksPage.svelte` — badge motion.

- [ ] **Step 1: Friendlier sparkline aria-label + per-bar `<title>` + tabindex**

Replace the existing `ariaLabel` derivation and rect markup in `CompletionSparkline.svelte`:

```svelte
<script lang="ts">
  // …existing imports + props…

  let total = $derived(data.reduce((sum, d) => sum + d.count, 0));
  let today = $derived(data.at(-1)?.count ?? 0);

  let ariaLabel = $derived.by(() => {
    if (data.length === 0) return "";
    return `${today} completed today. ${total} total over the last ${data.length} days.`;
  });
</script>

<!-- …existing {#if hasAnyCompletion} wrapper… -->

<svg
  {width}
  {height}
  viewBox={`0 0 ${width} ${height}`}
  role="img"
  aria-label={ariaLabel}
  tabindex="0"
  class="text-text-tertiary focus-visible:outline focus-visible:outline-2 focus-visible:outline-focus rounded"
>
  {#each data as d, i}
    {@const x = i * (barWidth + BAR_GAP)}
    {@const proportion = d.count / maxCount}
    {@const barHeight = Math.max(1, Math.round(proportion * height))}
    {@const y = height - barHeight}
    <rect
      {x}
      {y}
      width={barWidth}
      height={barHeight}
      fill="currentColor"
      opacity={d.count === 0 ? 0.35 : 0.85}
      rx="1"
      class="bar"
      style={`--bar-delay: ${i * 30}ms`}
    >
      <title>{d.day}: {d.count} {d.count === 1 ? "task" : "tasks"}</title>
    </rect>
  {/each}
</svg>

<style>
  .bar {
    transform-origin: bottom;
    animation: bar-in 240ms ease var(--bar-delay, 0ms) both;
  }
  @media (prefers-reduced-motion: reduce) {
    .bar { animation: none; }
  }
  @keyframes bar-in {
    from { transform: scaleY(0); opacity: 0; }
    to   { transform: scaleY(1); opacity: var(--opacity, 0.85); }
  }
</style>
```

(Note: the inline `opacity` on `<rect>` already sets the final value; the keyframe's `to` just needs to be a truthy end state. Keep both for robustness.)

- [ ] **Step 2: Badge entrance motion on `TasksPage`**

Add a tiny CSS class + bind to `todayCount`:

```svelte
<span
  class="text-[11px] text-text-tertiary badge-today"
  aria-label={`${todayCount} ${todayCount === 1 ? "task" : "tasks"} completed today`}
  data-count={todayCount}
>
  {todayCount} today
</span>

<!-- …in the page <style> block… -->
<style>
  .badge-today {
    animation: badge-pop 180ms ease;
  }
  @media (prefers-reduced-motion: reduce) {
    .badge-today { animation: none; }
  }
  @keyframes badge-pop {
    from { opacity: 0; transform: translateY(2px); }
    to   { opacity: 1; transform: translateY(0); }
  }
</style>
```

(The animation re-runs when `todayCount` changes because the element remounts via `{#if todayCount > 0}`. If the current Phase 8 code renders the badge unconditionally, wrap it in `{#if todayCount > 0}{/if}` to make the animation re-trigger cleanly.)

- [ ] **Step 3: Type-check**

Run: `npm run check`
Expected: 0 errors.

- [ ] **Step 4: Commit**

```bash
git add src/lib/components/CompletionSparkline.svelte src/lib/pages/TasksPage.svelte
git commit -m "$(cat <<'EOF'
polish(phase9): sparkline + badge motion and a11y

Sparkline: friendlier aria-label ("3 completed today. 14 total over
the last 7 days."), keyboard-focusable tabindex=0, per-bar <title>
tooltips with absolute date + count, 30ms staggered entrance.
Badge: 180ms opacity + translateY entrance on mount. Both animations
respect prefers-reduced-motion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

### Task 14: Keyboard + focus-ring + icon-button a11y sweep

**Files:**
- Modify: any `src/lib/pages/*.svelte` or `src/lib/components/*.svelte` that flunks the checklist.

This is a discovery-driven task. Use grep and a dev-server walkthrough to find offenders.

- [ ] **Step 1: Icon-only button audit**

Run: `grep -nE '<button[^>]*>[^<]*<[A-Z]' src/lib/pages src/lib/components -r | grep -v 'aria-label'`
Every match that prints is an icon-only button missing `aria-label`. For each, add a specific label (not "Button").

- [ ] **Step 2: Focus-visible ring audit**

Run: `grep -nE '<button|<a href|<input|<select|<textarea|role="button"' src/lib/pages src/lib/components -r | grep -v 'focus-visible'`
Not every match needs the ring (some are nested in labelled wrappers), but any top-level interactive element without a visible focus style in the markup or its shared class should gain `focus-visible:outline focus-visible:outline-2 focus-visible:outline-focus` or equivalent.

Define the `outline-focus` Tailwind utility in `src/app.css` (or wherever the Tailwind config lives) if it doesn't exist — map to a high-contrast colour variable that works in both themes.

- [ ] **Step 3: Keyboard traversal walkthrough**

Start `npm run dev:frontend`. For each page (Dictation, Tasks, History, Settings, Files), Tab from the address bar into the app and note:
- Does focus land somewhere sensible first?
- Does Shift+Tab cycle back predictably?
- Are any focused elements invisible (focus sink)?
- Can every button be activated via Enter / Space?

For each failing page, record the fix in the commit message body.

If the dev server isn't runnable in this environment, explicitly defer the walkthrough to Phase 10a QC and record the deferral in the handover.

- [ ] **Step 4: Commit**

```bash
git add <touched files>
git commit -m "$(cat <<'EOF'
polish(phase9): icon-button aria labels + focus-visible rings

Closes the icon-only buttons that were missing screen reader labels
and the interactive elements that lacked a visible focus ring in one
or both themes. No behaviour change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

(If the commit touches many files, record the list in the body.)

---

### Task 15: Contrast audit + dark-mode parity + typography / spacing pass

**Files:**
- Modify: `src/app.css` or equivalent Tailwind theme file for colour variables.
- Modify: individual pages where typography or spacing offend the checklist.

- [ ] **Step 1: Contrast check**

With `npm run dev:frontend`, open each page in light and dark mode. Use Chromium DevTools' colour picker / contrast panel on:
- Body text (target 4.5:1).
- Tertiary text like `text-text-tertiary` (same target).
- Buttons and links (target 3:1 on large, 4.5:1 on small).
- Focus rings (target 3:1 against background).

Fix any shortfall by adjusting the Tailwind theme variable, not by per-site override.

- [ ] **Step 2: Typography scale normalisation**

Run: `grep -rnE 'text-[2-6]?xl|text-\[' src/lib/pages src/lib/components | head -40`
Scan for pages using ad-hoc sizes where a `text-xl` / `text-lg` / `text-sm` would match `TasksPage`'s established scale. Normalise without changing layout width/height intentionally.

- [ ] **Step 3: Spacing pass on SettingsPage**

Inside the new `SettingsGroup` wrappers, consistent `py-3` rows, `gap-4` between label and control. Grep for outliers:
`grep -nE 'class="[^"]*(py-[0-9]|pt-[0-9]|pb-[0-9])' src/lib/pages/SettingsPage.svelte | head -40`

- [ ] **Step 4: Commit**

```bash
git add <touched files>
git commit -m "$(cat <<'EOF'
polish(phase9): contrast + typography + spacing

WCAG AA ratios met on body, tertiary, and interactive element colours
in both themes. Settings page converges on the three-step typographic
scale. Row spacing inside SettingsGroup wrappers is uniform.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

### Task 16: Full verification pass + roadmap + HANDOVER

**Files:**
- Modify: `docs/roadmap/2026-04-23-lumotia-feature-complete-roadmap.md` — mark Phase 9 shipped.
- Modify: `HANDOVER.md` — end-of-session state.

- [ ] **Step 1: Full Rust gate**

```
cargo fmt --check
cargo clippy --all-targets -- -D warnings
cargo test
```

All must be clean. Record the test count.

- [ ] **Step 2: Full frontend gate**

```
npm run check
npm run build
```

Both clean.

- [ ] **Step 3: Manual dogfood against the §Acceptance list from the spec**

Run through each acceptance item from the spec. Record pass / defer / fail for each. Any fail is a fix before commit; any defer must be Phase 10a QC material, not silent.

- [ ] **Step 4: Update roadmap Phase 9 status**

```markdown
## Phase 9 — Polish debt — **SHIPPED 2026/04/24**
```

Append a Shipped note at the end of the section summarising commits (e.g. "Landed on main across commits `<first>` to `<last>` (N feature + M polish commits).").

- [ ] **Step 5: Update `HANDOVER.md`**

Replace the session summary with today's state:
- Phase 9 shipped. Save dialog, bulk export, LLM tags, progressive-disclosure Settings, motion + a11y pass.
- cargo test count, clippy clean, fmt clean, npm run check clean, npm run build clean.
- Items deferred to Phase 10a QC (if any).
- Open for Phase 10: QC + rename + release.

- [ ] **Step 6: Commit**

```bash
git add docs/roadmap/2026-04-23-lumotia-feature-complete-roadmap.md HANDOVER.md
git commit -m "$(cat <<'EOF'
docs(phase9): mark Phase 9 shipped + refresh HANDOVER

Phase 9 polish debt closed end to end. Save dialog, bulk export, LLM
content tags, progressive-disclosure Settings with search, motion and
a11y sweep all on main. HANDOVER points to Phase 10a QC as the next
session.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Completion criteria

All 16 tasks checked off. `cargo test` green, `cargo clippy --all-targets -- -D warnings` clean, `cargo fmt --check` clean, `npm run check` 0/0, `npm run build` clean. Spec §Acceptance items 1-8 verified by dogfood or explicitly deferred to Phase 10a QC with a note in HANDOVER.

Natural next session: **Phase 10a — QC**.
