Files
Lumotia/docs/superpowers/specs/2026-04-24-phase9-polish-debt-design.md
Claude 89c63891fa chore: rebrand from Kon/Corbie to Magnotia
Replace all instances of the legacy product names "Kon" and "Corbie" with
"Magnotia" across user-facing copy, code identifiers, package names, bundle
ids, file paths, and documentation. Preserves the unrelated "konsole" (KDE
terminal) reference and the parent CORBEL company name.

- Renames 10 Rust crates (kon-* → magnotia-*) and the tauri binary
- Updates package.json, tauri.conf.json (productName + identifier)
- Renames CSS classes (kon-rh-* → magnotia-rh-*) and animations
- Renames brand and roadmap docs
- Regenerates Cargo.lock and package-lock.json

Verified: svelte-check passes; pure-rust crates compile under new names.
2026-04-30 13:06:55 +00:00

21 KiB

name, description, type, tags, created, status, phase, roadmap, author
name description type tags created status phase roadmap author
Phase 9 — Polish debt (design) Design spec for Magnotia Phase 9. Closes six polish-debt items from the feature-complete roadmap plus the Phase 8 carryover backlog. File-system save dialog, bulk History export, on-demand LLM content tags, progressive-disclosure Settings restructure, visual polish, accessibility sweep. spec
spec
phase-9
magnotia
polish
accessibility
settings
export
llm
2026/04/24 approved 9 docs/roadmap/2026-04-23-magnotia-feature-complete-roadmap.md Wren (CORBEL's resident agent) on behalf of Jake Sames

Phase 9 — Polish debt

Summary

Close the last polish-debt items before Phase 10 (QC + rename + release). Six items from the roadmap plus the Phase 8 carryover backlog.

  1. File-system .md save dialog — replaces clipboard-only export on HistoryPage + DictationPage + FilesPage.
  2. Bulk select + bulk export in History — multi-select rows, export as a directory of .md files.
  3. LLM content tags — on-demand topic:* + intent:* extraction using magnotia-llm, persisted on history rows.
  4. Settings UX overhaul — progressive disclosure. High-frequency settings always-visible; advanced behind <details> groups. Phase 8 sparkline toggle relocated out of Rituals.
  5. Visual polish pass — spacing, typography, motion curves, dark-mode parity. Absorbs Phase 8 motion backlog on badge + sparkline.
  6. Accessibility pass — keyboard navigation, focus order, screen reader labels (friendlier sparkline aria copy), WCAG AA contrast. Absorbs Phase 8 a11y backlog.

No new Rust crates. No new dependencies (tauri-plugin-dialog + @tauri-apps/plugin-dialog both already installed; capability already allowed).

This is the last phase before Phase 10. On completion: cargo + clippy + fmt + svelte-check + npm build all clean; manual dogfood walkthrough of items 1-6 passes; roadmap + HANDOVER updated.

Design decisions locked 2026/04/24

  1. Save dialog — no silent clipboard fallback if user cancels. Default filename derived from transcript title via the existing slug path (<title or "transcript">-<YYYY-MM-DD>.md).
  2. Bulk export format — directory of per-transcript files (chosen via open({ directory: true })). Concatenated single-file is not offered; users can copy-paste or open selected items to achieve it.
  3. LLM tags — on-demand per row plus a batch "Tag all untagged" affordance. Stored on item.llmTags: string[] alongside existing manualTags. Grammar-constrained JSON extraction: { topic: string, intent: enum } with intent from a closed set. Re-generation replaces prior llmTags for that row (no history).
  4. Settings restructure — progressive disclosure. Top group "Start here" always expanded (model, microphone, hotkey, theme). Six collapsed <details> groups below: Transcription, Tasks, Rituals, Notifications, Accessibility, Advanced. Phase 8 sparkline toggle moves from Rituals to Tasks.
  5. Visual polish + a11y — include in Phase 9, not deferred to post-v0.1. Matches roadmap as written. Polish + a11y constrained to a checklist (see §Visual polish + §Accessibility below), not an open-ended pass.

Architecture

Item 1 — File-system .md save dialog

Rust side (src-tauri/src/commands/fs.rs, new file — or added to an existing commands file if the one-command-per-file convention is loose):

#[tauri::command]
pub async fn write_text_file_cmd(path: String, contents: String) -> Result<(), String> {
    // Safety:
    //   - Path comes from a user-selected save dialog. We do not validate
    //     traversal or extension — the OS file picker already constrains
    //     the user's choice to what they can write to.
    //   - Parent directory is expected to exist (save dialog guarantees it).
    //   - Writes UTF-8 via tokio::fs. No atomic-rename dance — a transcript
    //     save is not mid-flight safety-critical; clobber on overwrite is fine.
    tokio::fs::write(&path, contents)
        .await
        .map_err(|e| format!("Failed to write {path}: {e}"))
}

Frontend call sites — replace the navigator.clipboard.writeText(md) tail with:

import { save } from "@tauri-apps/plugin-dialog";

const defaultName = suggestedFilename(item); // "<slug>-<YYYY-MM-DD>.md"
const path = await save({
  title: "Save transcript as Markdown",
  defaultPath: defaultName,
  filters: [{ name: "Markdown", extensions: ["md"] }],
});
if (!path) return; // user cancelled — no toast, no fallback
await invoke("write_text_file_cmd", { path, contents: md });
toasts.success(`Saved to ${basename(path)}`);

A new utility src/lib/utils/saveMarkdown.ts centralises suggestedFilename + saveTranscriptAsMarkdown(item) so three pages (HistoryPage / DictationPage / FilesPage) each call one function.

Call site scope. HistoryPage exportMarkdown (line 343) is the primary target. DictationPage + FilesPage currently do clipboard-only copies of raw transcript text (not markdown); they are out of scope for a markdown export button in Phase 9 — their clipboard semantics are right for their UX. Only HistoryPage exposes an "Export .md" button.

Item 2 — Bulk select + bulk export in History

Selection model. New frontend-only state on HistoryPage: let selected: Set<string> = $state(new Set()). Not persisted across refresh. Selection toolbar appears when selected.size > 0.

Selection UI.

  • Checkbox on each row, left of existing content. Row click still opens the expanded view; only the checkbox toggles selection.
  • A header bar surfaces when selection is non-empty: "N selected · Select all / Clear · Export selected · Delete selected".
  • "Delete selected" uses existing deleteFromHistory, looped, with one confirm.

Bulk export.

  • save dialog in directory mode: await open({ directory: true, title: "Choose export folder" }).
  • For each selected item, call the same buildMarkdown path, then write_text_file_cmd({ path: ${dir}/${suggestedFilename(item)}, contents }).
  • Collision handling: if a filename already exists in the chosen dir, append (N) suffix until unique. Implement in the frontend (single OS-call query per filename is simpler than a Rust batch).
  • Toast on completion: "Exported N transcripts to …".

Keyboard. Escape clears selection. Ctrl/Cmd+A selects all visible rows when the HistoryPage has focus.

Item 3 — LLM content tags

Data model. Add llmTags: string[] to history entries alongside manualTags. Persisted via a real SQLite column transcripts.llm_tags TEXT NOT NULL DEFAULT '' added in migration v14, exposed through an extended update_transcript Tauri command. (Earlier draft of this spec assumed the existing saveHistory() path was a real persist; closer review showed it's a no-op stub. The migration + command extension is in-scope as Task 8.5 of the plan, and additionally fixes a latent bug whereby existing manualTags edits weren't persisting either.)

Tag schema.

  • topic:<1-3 noun phrase> — free-form, lowercase, hyphen-joined. Examples: topic:interview-prep, topic:grant-application, topic:personal-finance. Limit one per transcript (the dominant subject).
  • intent:<enum> — closed set: planning | reflection | venting | capture | decision | question. Limit one.

Generation. New function in crates/llm/src/prompts.rs:

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContentTags {
    pub topic: String,   // hyphen-joined, lowercase, 1-3 tokens
    pub intent: String,  // one of INTENT_CLOSED_SET
}

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

pub async fn extract_content_tags(
    engine: &LlamaEngine,
    transcript: &str,
) -> Result<ContentTags, EngineError>;

Grammar-constrained GBNF in crates/llm/src/grammars.rs that restricts output to the schema:

root ::= "{" ws "\"topic\":" ws topic "," ws "\"intent\":" ws intent ws "}"
topic  ::= "\"" [a-z0-9-]{3,60} "\""
intent ::= "\"planning\"" | "\"reflection\"" | "\"venting\"" | "\"capture\"" | "\"decision\"" | "\"question\""
ws     ::= [ \t\n]*

Prompt sketch (British English, deterministic, temperature 0.0):

You tag a transcript with ONE topic and ONE intent.

TOPIC is a 1-3 token lowercase hyphen-joined noun phrase naming the dominant
subject. Examples: "interview-prep", "grant-application", "daily-standup".

INTENT is exactly one of: planning, reflection, venting, capture, decision,
question.

Return JSON only, with this exact shape:
{"topic":"...","intent":"..."}

Transcript:
<<<
{{transcript}}
>>>

Guardrails:

  • Truncate transcript to the last 2000 chars if longer (LLM context budget).
  • On any error (JSON-parse, grammar rejection, timeout): return Err. Caller surfaces "Tagging failed — try again" toast, writes nothing.
  • Re-tagging a previously-tagged row replaces llmTags; no history.

Tauri command. extract_content_tags_cmd(transcript: String) -> Result<ContentTags, String> in src-tauri/src/commands/llm.rs (co-located with existing LLM commands, or a new file if absent).

Frontend UX.

  • "Tag" button per history row, in the row's action cluster (next to "Export .md"). Icon: Tag from lucide-svelte.
  • Click → spinner → on resolve: llmTags = [topic:${topic}, intent:${intent}], saveHistory().
  • Chips render in the tag area alongside manual tags but with a distinct visual treatment: border-dashed, italicised, subtly lighter ink. Click-through to promote to manual (manualTags), with animation-free chip move.
  • Batch affordance: on the History toolbar, "Tag all untagged" button (only shows if ≥ 1 untagged row). Iterates and calls the same command. Progress toast: "Tagged N / M…" updating every item.

Item 4 — Settings restructure

Pattern. Single page (SettingsPage.svelte), flat scroll, no sidebar or tabs. Top block always expanded. Below it, six <details> groups each with a clear <summary> label.

Architecture.

  • New file src/lib/components/SettingsGroup.svelte — reusable <details> wrapper with styled chevron, summary padding, animated open/close (@starting-style + interpolate-size: allow-keywords for modern browsers; fall back to no animation).
  • Search box at the page top (new). Filters the visible controls by label text; empty filter shows everything. Non-matching groups collapse; matching groups open.
  • Settings content itself is not rewritten — only re-ordered + re-grouped. Each control keeps its current markup and behaviour.

Group membership.

Group Expanded by default? Contents
Start here Yes Model download + status, microphone picker, global hotkey, theme (light/dark/system)
Transcription No Whisper / Parakeet backend, language, punctuation, vocabulary profile, file-upload defaults
Tasks No Match-my-energy default, WIP limit, Show momentum sparkline (Phase 8, relocated), MicroSteps generation preset
Rituals No Morning triage on/off + time, Evening shutdown on/off
Notifications No Notifications enabled/muted, per-trigger toggles (inactivity, pending triage, micro-step idle), TTS nudge voice + rate
Accessibility No Font size, line height, bionic reading, reduced motion, high contrast
Advanced No Database path / open-data-dir button, export app data, reset settings, model tier override, debug logging

Phase 8 toggle relocation. The showMomentumSparkline toggle moves from its current Rituals placement into the new Tasks group. Discovered in Phase 8 code review (carryover backlog).

Open/close state. Not persisted — groups reset to default on each app launch. Persisting open-state adds storage noise for marginal value; users who live in Settings can bookmark their group with a search query instead.

Item 5 — Visual polish (bounded checklist)

Scoped list. Not an open-ended pass.

  • Motion on Phase 8 badge + sparkline. Badge enters via a 180 ms opacity + 2 px translate-y on todayCount increment. Sparkline bars ease in with a 30 ms stagger on mount only (not per refresh). prefers-reduced-motion disables both.
  • Typography scale audit. One read-through of every page's <h1>/<h2>/<h3>; normalise to the three-step scale already used on TasksPage. No new fonts.
  • Dark-mode parity check. Toggle every page in dark mode once. Fix contrast regressions against WCAG AA minimum (see §Accessibility) and fix any baked-in text-black / text-white strings to text-text / text-text-inverse.
  • Spacing pass on SettingsPage only. Consistent py-3 rows inside groups, gap-4 between label + control, pt-6 on group open. Other pages already converged in Phase 2.
  • Lucide icon audit. Any custom SVG chip or inline path that could swap to a Lucide icon — swap for visual consistency. List before changing (expect ≤ 5 swaps).

Deliberately out of scope: redesigning layouts, introducing new colours, changing the typographic scale, animating page transitions.

Item 6 — Accessibility (bounded checklist)

  • Sparkline aria-label rewrite. Replace numeric list ("0, 1, 3, 2, 0, 4, 3") with friendlier summary: "3 completed today. 14 total over the last 7 days." Expose to any SR on focus of the sparkline SVG (add tabindex="0", keyboard-focusable).
  • Sparkline per-day tooltip. On hover / keyboard-focus of each bar, show <title> with absolute date + count. Purely additive; doesn't change the existing aria-label structure.
  • Keyboard traversal of every Phase 1-8 page. Tab order starts top-left, ends bottom-right. No hidden focus sinks. Checklist per page.
  • Focus-visible rings. Every interactive element has a visible focus ring in both themes. The existing Tailwind focus-visible: ring utility is present but inconsistently applied — sweep.
  • Screen reader labels on icon-only buttons. Every <button> whose content is a Lucide icon gets an aria-label. Run a grep -n 'aria-label' src/lib/pages src/lib/components audit; flag missing ones.
  • Contrast audit against WCAG AA. Use the Chromium DevTools contrast panel on every page-level text colour in both themes. Target ratios: 4.5:1 body, 3:1 large headings / UI. Fix variables in the Tailwind theme, not per-site overrides.
  • prefers-reduced-motion respected on every animation added in Phase 5-9. Timers, sparkline, badge, settings accordion all guard the motion block.

Testing

Rust (cargo test in the touched crates)

  • write_text_file_cmd — round-trip a small UTF-8 string; assert file contents match and that nonexistent parent dir errors with a readable message.
  • extract_content_tags — integration test in crates/llm/tests/ against a fixture transcript. Uses the real engine with a tiny tier-0 model. Assert topic matches a regex, intent is in the closed set, JSON parse succeeds.
  • Grammar unit test — feed a few malformed completions through the grammar parser and assert rejection.

Frontend (npm run check)

  • suggestedFilename tests (new src/lib/utils/saveMarkdown.test.ts if a vitest config appears; otherwise covered in smoke dogfood).
  • svelte-check must be zero-zero across 3955+ files.

Manual dogfood (folds into Phase 10a QC)

  • Export one transcript via save dialog. File written to chosen path. Cancel dialog → no toast, no write.
  • Select 3 history rows, export to a folder. Three files land with suggestedFilename names. Collision: repeat the export to the same folder; confirm (2) / (3) suffixes appear.
  • Tag one history row. Chip appears with dashed border. Click to promote; chip moves to manual and the dashed border disappears.
  • "Tag all untagged" processes remaining rows; toast updates progress; no duplicates.
  • Settings page: top group expanded; other groups collapsed. Search "sparkline" — filters to the Tasks group, opens it. Clear search — returns to default state.
  • Keyboard-only traversal of Dictation → Tasks → History → Settings pages. Every control reachable. Focus rings visible in both themes.
  • prefers-reduced-motion on — badge / sparkline / details animations all stop.

Automated a11y gate (new)

  • Add a one-shot axe-core run to the dev cycle via @axe-core/cli invoked on npm run build's preview URL. Document in HANDOVER but don't block merge on it (axe flags non-determinism that needs human triage); the CI gate stays npm run check + cargo test.

Invariants

  • write_text_file_cmd is only called with a path that came from a save / open dialog. Never hard-coded, never concatenated from user text input elsewhere. (Path-traversal risk is mitigated at the UI layer.)
  • LLM tags are additive to manualTags, never replace them. Promoted LLM tags leave llmTags untouched (no moving-between-arrays mutation).
  • Settings groups reset to their default expansion on each launch. The search box is the only persistence surface, and even it is not persisted.
  • No new user-facing string anywhere violates §Conventions: British English, no em / en dashes.
  • Motion in all Phase 9 additions respects prefers-reduced-motion.

Acceptance (from roadmap)

All of:

  1. Export one transcript from HistoryPage → save() dialog opens → file is written to the chosen path.
  2. Select 3 rows → Export selected → directory of 3 .md files lands in the chosen folder.
  3. Click "Tag" on one row → topic:* + intent:* chips appear within a few seconds.
  4. Settings page — top "Start here" group expanded; every other group collapsed; search filters + opens matching groups.
  5. Phase 8 sparkline toggle appears in Tasks group, not Rituals.
  6. Every interactive element in Dictation / Tasks / History / Settings is reachable by keyboard with a visible focus ring in both themes.
  7. Sparkline SR label reads: "3 completed today. 14 total over the last 7 days." (or the current day's equivalent numbers).
  8. prefers-reduced-motion disables badge entrance + sparkline stagger + settings accordion animation.

Out of scope

  • Atomic file writes (rename-after-write) for the save dialog — a mid-write crash on a transcript export is not data-loss territory; the source stays intact in the History store.
  • Server-side or cloud tag storage. Tags stay local.
  • Re-training LLM on user corrections to tags. Tags are one-shot outputs.
  • Search-across-transcripts UI. Settings search is the only search surface added in Phase 9.
  • Moving SettingsPage to multiple files / router sub-routes. Stays as one scroll-view with groups.
  • Per-group open-state persistence (covered under "Open/close state" above).

File map

Created

  • src-tauri/src/commands/fs.rs (or addition to existing commands file)write_text_file_cmd.
  • src/lib/utils/saveMarkdown.tssuggestedFilename, saveTranscriptAsMarkdown, exportTranscriptsToDir.
  • src/lib/components/SettingsGroup.svelte<details> wrapper with chevron + animation.
  • crates/llm/tests/content_tags_smoke.rs — integration test for extract_content_tags.
  • docs/superpowers/plans/2026-04-24-phase9-polish-debt.md — implementation plan (companion to this spec).

Modified

  • src-tauri/src/lib.rs — register write_text_file_cmd + extract_content_tags_cmd.
  • crates/llm/src/prompts.rs — add ContentTags, INTENT_CLOSED_SET, extract_content_tags.
  • crates/llm/src/grammars.rs — add content-tag GBNF grammar.
  • crates/llm/src/lib.rs — re-export the new symbols.
  • src-tauri/src/commands/*.rs — add extract_content_tags_cmd (file choice depends on the existing LLM command placement).
  • src/lib/pages/HistoryPage.svelte — save dialog, bulk select, bulk export, LLM tag button, llmTags chip rendering.
  • src/lib/pages/SettingsPage.svelte — full regrouping into the 7 groups (Start here + 6 collapsed). Sparkline toggle relocation.
  • src/lib/types/app.tsllmTags field on history entry type; optional ContentTags type.
  • src/lib/stores/page.svelte.ts — hydrate / persist llmTags alongside manualTags.
  • src/lib/utils/frontmatter.ts — include llmTags in buildFrontmatter's tags union.
  • src/lib/components/CompletionSparkline.svelte — friendlier aria-label, per-bar <title> tooltips, tabindex="0" for SR focus.
  • src/lib/pages/TasksPage.svelte — badge motion on increment (behind prefers-reduced-motion).
  • Tailwind theme if contrast audit forces colour variable changes.

Deleted

None.

Effort estimate

1 to 2 days of focused work. Items 1 + 2 ≈ half day. Item 3 ≈ half day (includes the grammar + smoke test). Item 4 ≈ half day (the regrouping + search). Items 5 + 6 ≈ half day combined (checklists, not discoveries).

Natural split for the plan: 9a (items 1 + 2), 9b (item 3), 9c (item 4), 9d (items 5 + 6).

Anchors

  • Roadmap: docs/roadmap/2026-04-23-magnotia-feature-complete-roadmap.md
  • Phase 8 plan + spec + handover — for task-format reference and carryover backlog.
  • Phase 8 gotchas to respect in this phase:
    • $derived does not export at .svelte.ts module scope — use export function instead.
    • #[derive(sqlx::FromRow)] is not available in magnotia-storage (no change in Phase 9, but noted if schema work drifts there).
  • Progressive-disclosure pattern reference: NN/g on progressive disclosure; Material Design settings patterns; UX Collective on settings redesign.