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>
1629 lines
54 KiB
Markdown
1629 lines
54 KiB
Markdown
# 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)
|
|
|
|
---
|
|
|
|
## 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_tags` is a real SQLite column (added by an earlier migration). The Rust storage fn `db_update_transcript` accepts `manual_tags`.
|
|
- The Tauri `update_transcript` command currently exposes only `text` + `title`, NOT `manual_tags` — so existing manualTags edits aren't persisting today either (latent bug).
|
|
- The frontend `saveHistory()` is a no-op stub.
|
|
- `transcripts.llm_tags` does not exist.
|
|
|
|
Fix in scope (decided 2026-04-24, "do it properly so we don't need to revisit"): Phase 9 picks up the persistence work as new **Task 8.5**, then Tasks 9 + 10 are rewired to call the proper update command instead of the no-op `saveHistory`.
|
|
|
|
### NEW Task 8.5: Migration v14 + storage extension + Tauri command extension for tags
|
|
|
|
**Why here:** Tasks 9 + 10 cannot persist `llmTags` (or even `manualTags`) without this. Bundling fixes the latent manualTags persistence bug for free.
|
|
|
|
**Files:**
|
|
- Modify: `crates/storage/src/migrations.rs` — append migration v14 entry; update the FTS5 / table-recreate migrations if any of them rebuild the schema with explicit columns (verify with grep on `manual_tags` first).
|
|
- Modify: `crates/storage/src/database.rs` — add `llm_tags` to every SELECT / INSERT / UPDATE that touches transcripts; extend `db_update_transcript` to accept an `Option<&str>` for `llm_tags`.
|
|
- Modify: `src-tauri/src/commands/transcripts.rs` — `TranscriptDto` gets `llm_tags: String` (camelCase via serde). `add_transcript` accepts and writes `manualTags` + `llmTags` on insert. `update_transcript` now accepts `manualTags: Option<Vec<String>>` + `llmTags: Option<Vec<String>>` — joins to comma-strings before calling storage.
|
|
- Modify: `src/lib/types/app.ts` — `TranscriptRow.llmTags: string` (storage shape) + `TranscriptEntry.llmTags: string[]` (in-memory).
|
|
- Modify: `src/lib/stores/page.svelte.ts` — hydrate `llmTags` in `mapTranscriptRow`; rewrite `saveHistory()` from no-op to call `update_transcript` with the current `manualTags` + `llmTags` of a target entry. New helper `persistTags(item)` that handles the actual write.
|
|
|
|
**TDD steps:**
|
|
1. Storage test: migration v14 adds `llm_tags TEXT NOT NULL DEFAULT ''` column; pre-existing rows default to empty string.
|
|
2. Storage test: `db_update_transcript(.., None, Some("topic:x,intent:y"))` writes only `llm_tags` and leaves `manual_tags` untouched.
|
|
3. Tauri command compile-check via `cargo build -p kon`.
|
|
4. Frontend `npm run check` clean.
|
|
|
|
Commit message: `feat(phase9): migration v14 + tag persistence wiring`.
|
|
|
|
### Adjusted Task 9 — Frontend types + persistence rewire
|
|
|
|
The original Task 9 body remains a guide for the data-shape changes, but the persistence path replaces no-op `saveHistory()` with calls to a new `persistTags(item)` helper that wraps `invoke("update_transcript", { id, manualTags, llmTags })`. All call sites in `HistoryPage.svelte` that currently call `saveHistory()` after touching `manualTags` or `llmTags` swap to `persistTags(item)`.
|
|
|
|
### Adjusted Task 10 — HistoryPage Tag UI
|
|
|
|
Same code as written; replace every `saveHistory()` call site in the Tag handlers with `await persistTags(item)`. Errors from the persist call surface via the existing `toasts.error` pattern.
|
|
|
|
### Renumbering
|
|
|
|
Tasks 8.5 inserted between 8 and 9. Subsequent task numbers (11..16) unchanged. Total task count: **16 + 1 = 17**.
|
|
|
|
---
|
|
|
|
## Conventions
|
|
|
|
- Branch: `main` (Jake's standing rule — phases ship on main, not feature branches).
|
|
- Commit after each task. Format: `feat(phase9): [what changed]`, `fix(phase9): …`, or `polish(phase9): …` for visual / a11y polish commits. Each task's final step is the commit.
|
|
- Test runner on Rust: `cargo test -p <crate>`. Frontend has no vitest wired; `npm run check` is the only automated gate and correctness on the frontend relies on manual dogfood during Phase 10a QC.
|
|
- British English in user-facing copy.
|
|
- No em / en dashes in prose or strings. Use full stops or commas.
|
|
- Every Phase 9 addition with motion respects `prefers-reduced-motion`.
|
|
- Phase 8 gotchas carried forward:
|
|
- `$derived` cannot export at `.svelte.ts` module scope — use `export function` instead.
|
|
- `#[derive(sqlx::FromRow)]` is not available in `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/<llm commands file>` — `extract_content_tags_cmd`.
|
|
- `src/lib/pages/HistoryPage.svelte` — save dialog, bulk selection, bulk export, Tag button, LLM tag chips.
|
|
- `src/lib/pages/SettingsPage.svelte` — full regroup into Start-here + 6 collapsed groups, sparkline toggle relocation, search box.
|
|
- `src/lib/pages/TasksPage.svelte` — badge entrance motion (guarded on `prefers-reduced-motion`).
|
|
- `src/lib/types/app.ts` — `llmTags` on history entry; `ContentTags` type.
|
|
- `src/lib/stores/page.svelte.ts` — hydrate / persist `llmTags`.
|
|
- `src/lib/utils/frontmatter.ts` — include `llmTags` in `buildFrontmatter` tag union.
|
|
- `src/lib/components/CompletionSparkline.svelte` — friendlier aria-label, per-bar `<title>`, `tabindex`, stagger entrance.
|
|
- `docs/roadmap/2026-04-23-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"}</title>
|
|
</rect>
|
|
{/each}
|
|
</svg>
|
|
|
|
<style>
|
|
.bar {
|
|
transform-origin: bottom;
|
|
animation: bar-in 240ms ease var(--bar-delay, 0ms) both;
|
|
}
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.bar { animation: none; }
|
|
}
|
|
@keyframes bar-in {
|
|
from { transform: scaleY(0); opacity: 0; }
|
|
to { transform: scaleY(1); opacity: var(--opacity, 0.85); }
|
|
}
|
|
</style>
|
|
```
|
|
|
|
(Note: the inline `opacity` on `<rect>` already sets the final value; the keyframe's `to` just needs to be a truthy end state. Keep both for robustness.)
|
|
|
|
- [ ] **Step 2: Badge entrance motion on `TasksPage`**
|
|
|
|
Add a tiny CSS class + bind to `todayCount`:
|
|
|
|
```svelte
|
|
<span
|
|
class="text-[11px] text-text-tertiary badge-today"
|
|
aria-label={`${todayCount} ${todayCount === 1 ? "task" : "tasks"} completed today`}
|
|
data-count={todayCount}
|
|
>
|
|
{todayCount} today
|
|
</span>
|
|
|
|
<!-- …in the page <style> block… -->
|
|
<style>
|
|
.badge-today {
|
|
animation: badge-pop 180ms ease;
|
|
}
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.badge-today { animation: none; }
|
|
}
|
|
@keyframes badge-pop {
|
|
from { opacity: 0; transform: translateY(2px); }
|
|
to { opacity: 1; transform: translateY(0); }
|
|
}
|
|
</style>
|
|
```
|
|
|
|
(The animation re-runs when `todayCount` changes because the element remounts via `{#if todayCount > 0}`. If the current Phase 8 code renders the badge unconditionally, wrap it in `{#if todayCount > 0}{/if}` to make the animation re-trigger cleanly.)
|
|
|
|
- [ ] **Step 3: Type-check**
|
|
|
|
Run: `npm run check`
|
|
Expected: 0 errors.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add src/lib/components/CompletionSparkline.svelte src/lib/pages/TasksPage.svelte
|
|
git commit -m "$(cat <<'EOF'
|
|
polish(phase9): sparkline + badge motion and a11y
|
|
|
|
Sparkline: friendlier aria-label ("3 completed today. 14 total over
|
|
the last 7 days."), keyboard-focusable tabindex=0, per-bar <title>
|
|
tooltips with absolute date + count, 30ms staggered entrance.
|
|
Badge: 180ms opacity + translateY entrance on mount. Both animations
|
|
respect prefers-reduced-motion.
|
|
|
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|
EOF
|
|
)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 14: Keyboard + focus-ring + icon-button a11y sweep
|
|
|
|
**Files:**
|
|
- Modify: any `src/lib/pages/*.svelte` or `src/lib/components/*.svelte` that flunks the checklist.
|
|
|
|
This is a discovery-driven task. Use grep and a dev-server walkthrough to find offenders.
|
|
|
|
- [ ] **Step 1: Icon-only button audit**
|
|
|
|
Run: `grep -nE '<button[^>]*>[^<]*<[A-Z]' src/lib/pages src/lib/components -r | grep -v 'aria-label'`
|
|
Every match that prints is an icon-only button missing `aria-label`. For each, add a specific label (not "Button").
|
|
|
|
- [ ] **Step 2: Focus-visible ring audit**
|
|
|
|
Run: `grep -nE '<button|<a href|<input|<select|<textarea|role="button"' src/lib/pages src/lib/components -r | grep -v 'focus-visible'`
|
|
Not every match needs the ring (some are nested in labelled wrappers), but any top-level interactive element without a visible focus style in the markup or its shared class should gain `focus-visible:outline focus-visible:outline-2 focus-visible:outline-focus` or equivalent.
|
|
|
|
Define the `outline-focus` Tailwind utility in `src/app.css` (or wherever the Tailwind config lives) if it doesn't exist — map to a high-contrast colour variable that works in both themes.
|
|
|
|
- [ ] **Step 3: Keyboard traversal walkthrough**
|
|
|
|
Start `npm run dev:frontend`. For each page (Dictation, Tasks, History, Settings, Files), Tab from the address bar into the app and note:
|
|
- Does focus land somewhere sensible first?
|
|
- Does Shift+Tab cycle back predictably?
|
|
- Are any focused elements invisible (focus sink)?
|
|
- Can every button be activated via Enter / Space?
|
|
|
|
For each failing page, record the fix in the commit message body.
|
|
|
|
If the dev server isn't runnable in this environment, explicitly defer the walkthrough to Phase 10a QC and record the deferral in the handover.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add <touched files>
|
|
git commit -m "$(cat <<'EOF'
|
|
polish(phase9): icon-button aria labels + focus-visible rings
|
|
|
|
Closes the icon-only buttons that were missing screen reader labels
|
|
and the interactive elements that lacked a visible focus ring in one
|
|
or both themes. No behaviour change.
|
|
|
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|
EOF
|
|
)"
|
|
```
|
|
|
|
(If the commit touches many files, record the list in the body.)
|
|
|
|
---
|
|
|
|
### Task 15: Contrast audit + dark-mode parity + typography / spacing pass
|
|
|
|
**Files:**
|
|
- Modify: `src/app.css` or equivalent Tailwind theme file for colour variables.
|
|
- Modify: individual pages where typography or spacing offend the checklist.
|
|
|
|
- [ ] **Step 1: Contrast check**
|
|
|
|
With `npm run dev:frontend`, open each page in light and dark mode. Use Chromium DevTools' colour picker / contrast panel on:
|
|
- Body text (target 4.5:1).
|
|
- Tertiary text like `text-text-tertiary` (same target).
|
|
- Buttons and links (target 3:1 on large, 4.5:1 on small).
|
|
- Focus rings (target 3:1 against background).
|
|
|
|
Fix any shortfall by adjusting the Tailwind theme variable, not by per-site override.
|
|
|
|
- [ ] **Step 2: Typography scale normalisation**
|
|
|
|
Run: `grep -rnE 'text-[2-6]?xl|text-\[' src/lib/pages src/lib/components | head -40`
|
|
Scan for pages using ad-hoc sizes where a `text-xl` / `text-lg` / `text-sm` would match `TasksPage`'s established scale. Normalise without changing layout width/height intentionally.
|
|
|
|
- [ ] **Step 3: Spacing pass on SettingsPage**
|
|
|
|
Inside the new `SettingsGroup` wrappers, consistent `py-3` rows, `gap-4` between label and control. Grep for outliers:
|
|
`grep -nE 'class="[^"]*(py-[0-9]|pt-[0-9]|pb-[0-9])' src/lib/pages/SettingsPage.svelte | head -40`
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add <touched files>
|
|
git commit -m "$(cat <<'EOF'
|
|
polish(phase9): contrast + typography + spacing
|
|
|
|
WCAG AA ratios met on body, tertiary, and interactive element colours
|
|
in both themes. Settings page converges on the three-step typographic
|
|
scale. Row spacing inside SettingsGroup wrappers is uniform.
|
|
|
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|
EOF
|
|
)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 16: Full verification pass + roadmap + HANDOVER
|
|
|
|
**Files:**
|
|
- Modify: `docs/roadmap/2026-04-23-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**.
|