Pre-execution review against the actual codebase (Codex unavailable this session) surfaced three mismatches: kon-llm uses LlmEngine with a synchronous generate(prompt, config), AppState exposes llm_engine as a direct Arc not behind RwLock, and llmTags persistence requires a real SQLite migration plus Tauri command extension because saveHistory() is a no-op stub today (which also means manualTags edits weren't persisting). Plan now adds Task 8.5 for migration v14 + storage and Tauri-command extension. Spec data-model section corrected to match. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
54 KiB
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
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. kon-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_tagsis a real SQLite column (added by an earlier migration). The Rust storage fndb_update_transcriptacceptsmanual_tags.- The Tauri
update_transcriptcommand currently exposes onlytext+title, NOTmanual_tags— so existing manualTags edits aren't persisting today either (latent bug). - The frontend
saveHistory()is a no-op stub. transcripts.llm_tagsdoes 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 onmanual_tagsfirst). - Modify:
crates/storage/src/database.rs— addllm_tagsto every SELECT / INSERT / UPDATE that touches transcripts; extenddb_update_transcriptto accept anOption<&str>forllm_tags. - Modify:
src-tauri/src/commands/transcripts.rs—TranscriptDtogetsllm_tags: String(camelCase via serde).add_transcriptaccepts and writesmanualTags+llmTagson insert.update_transcriptnow acceptsmanualTags: 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— hydratellmTagsinmapTranscriptRow; rewritesaveHistory()from no-op to callupdate_transcriptwith the currentmanualTags+llmTagsof a target entry. New helperpersistTags(item)that handles the actual write.
TDD steps:
- Storage test: migration v14 adds
llm_tags TEXT NOT NULL DEFAULT ''column; pre-existing rows default to empty string. - Storage test:
db_update_transcript(.., None, Some("topic:x,intent:y"))writes onlyllm_tagsand leavesmanual_tagsuntouched. - Tauri command compile-check via
cargo build -p kon. - Frontend
npm run checkclean.
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): …, orpolish(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 checkis 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:
$derivedcannot export at.svelte.tsmodule scope — useexport functioninstead.#[derive(sqlx::FromRow)]is not available inkon-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 onprefers-reduced-motion).src/lib/types/app.ts—llmTagson history entry;ContentTagstype.src/lib/stores/page.svelte.ts— hydrate / persistllmTags.src/lib/utils/frontmatter.ts— includellmTagsinbuildFrontmattertag union.src/lib/components/CompletionSparkline.svelte— friendlier aria-label, per-bar<title>,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(grepsrc-tauri/src/commands/first; if a singleutil.rsor similar already hosts utility commands, append there rather than creating a new file — match the convention). -
Test: inline
#[cfg(test)] mod testsin 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:
#[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
/// 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
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):
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
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
// 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
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—exportMarkdownfunction (line 343). -
Step 1: Replace the function body
Current body at line 343-353 writes the markdown to clipboard. Replace with:
async function exportMarkdown(item) {
await saveTranscriptAsMarkdown(item);
}
And add the import near the existing imports:
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
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:
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):
{#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:
<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):
$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
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— addCONTENT_TAGS_GRAMMAR. -
Step 1: Add the types in
prompts.rs
#[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
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:
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
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— addextract_content_tags. -
Create:
crates/llm/tests/content_tags_smoke.rs. -
Step 1: Write the failing smoke test
// 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
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
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 forkon_llm::to find it; typically something likellm.rsorai.rs). If absent, createsrc-tauri/src/commands/llm.rsand register the module incommands/mod.rs. -
Modify:
src-tauri/src/lib.rs— register the command. -
Step 1: Add the command
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!:
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
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— addllmTags, addContentTags. -
Modify:
src/lib/stores/page.svelte.ts— hydrate + persistllmTagsalongsidemanualTags(line 145-195 area is wheremanualTagsis handled). -
Modify:
src/lib/utils/frontmatter.ts— includellmTagsinbuildFrontmatter's tag union. -
Step 1: Types
In src/lib/types/app.ts, add near the transcript types:
export interface ContentTags {
topic: string;
intent: string;
}
Extend TranscriptEntry (or whichever type shapes history items in this file) to include:
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):
// 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
llmTagsin the exported frontmatter
In src/lib/utils/frontmatter.ts buildFrontmatter (line 89-104), change the tag union:
Before:
const manual = Array.isArray(item.manualTags) ? item.manualTags : [];
const tags = Array.from(new Set([...auto, ...manual.map(normaliseTag)])).filter(Boolean);
After:
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
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:
<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:
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):
{#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:
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:
{#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:
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
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
<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
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:
<!--
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:
<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:
{#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:
{#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
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:
<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:
<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
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/*.svelteorsrc/lib/components/*.sveltethat 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
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.cssor 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
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
## 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
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.