diff --git a/docs/superpowers/plans/2026-04-24-phase9-polish-debt.md b/docs/superpowers/plans/2026-04-24-phase9-polish-debt.md new file mode 100644 index 0000000..5bc05b0 --- /dev/null +++ b/docs/superpowers/plans/2026-04-24-phase9-polish-debt.md @@ -0,0 +1,1578 @@ +# 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), `kon-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) + +--- + +## 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 `. 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 `kon-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/` — `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 ``, `tabindex`, stagger entrance. +- `docs/roadmap/2026-04-23-corbie-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-kon-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 kon 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 kon 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 kon` +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 kon-llm && cargo clippy -p kon-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 kon_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 kon-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<kon_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 = kon_llm::recommend_tier(); + let info = kon_llm::LlmModelInfo::from_id(&LlmModelId::from_tier(tier))?; + let path = info.local_path().ok()?; + if !path.exists() { return None; } + kon_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 kon-llm +cargo test -p kon-llm # excludes #[ignore] — should still be green +cargo clippy -p kon-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 `kon_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 kon_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 kon && cargo clippy -p kon -- -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 kon_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"} + + {/each} + + + +``` + +(Note: the inline `opacity` on `` 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 + + {todayCount} today + + + + +``` + +(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 +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-corbie-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-corbie-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**.