Quill: Product Requirements Document

A magical, screen-reader-first writing and document environment, built in wxPython

Status: This document specifies Quill 1.0. The current shipping build is 0.7.0 Beta, which implements the v1.0 checklist (section 21.1–21.16) plus the post-1.0 foundation work in section 21.17 and later. Section 21 is the living implementation map and is kept current as features land. Public name: QUILL for All Owner: Community Access Independence notice: QUILL for All is developed independently of any assistive-technology vendor. Vendor and screen-reader names appear throughout this document for compatibility and certification purposes only; they remain the property of their respective owners. Target platform: Windows 10 and Windows 11 Target screen readers: NVDA (primary), JAWS, Narrator UI framework: wxPython (wxWidgets 3.2 or newer) Language: Python 3.12 or newer


1. Vision

Quill is a focused writing and document-reading workstation for blind and low-vision users. It opens almost anything, turns it into clean, navigable text in a familiar Windows edit field, and gives the writer a quiet, predictable place to think.

  1. Open by default. Free, open source, with no proprietary scripting layer required. Works first class with NVDA out of the box, and equally well with JAWS or Narrator.
  2. Screen-reader-native. Every surface is a standard Windows control (wx.TextCtrl multiline, wx.ListBox, wx.Dialog) so MSAA, UIA, IAccessible2 and braille routing all just work. No custom drawn controls in the writing path.
  3. Magical, not flashy. “Magic” means the right thing happens without ceremony. PDFs become readable. Bookmarks survive edits. Backups are automatic. The command palette knows what you mean. Nothing surprises the screen reader.
  4. Local first, cloud optional. Heavy lifting (PDF extraction, OCR, layout repair, spell checking) runs locally when possible. Cloud assistance is opt-in per action, transparent, and never silent.

If a sighted person watched a Quill user work, they would see almost nothing on screen. That is the point.


2. Goals and non-goals

2.1 Goals

2.2 Non-goals

2.3 Update Strategy and Micro-Updates

Quill uses app_updater (AccessibleApps, MIT license) for cross-platform incremental updates, enabling smaller, faster patches:

This enables shipping bug fixes and security patches as micro-updates without re-downloading the entire application, while maintaining security and platform best practices.

2.3.1 Remote feature kill switch (signed feature advisories)

QUILL can remotely disable a feature if a serious problem is found after release, without shipping a new build. This is a safety valve, deliberately narrow and fail-safe.


3. Target users and primary scenarios

3.1 Personas

  1. Mira, blind university student. Reads PDF lecture notes daily. Needs them to make sense linearly, with reliable search and bookmarks she can name.
  2. Ade, blind technical writer. Writes long Markdown and HTML. Wants to write in a quiet edit field, preview structure in a browser, and export cleanly.
  3. Pat, blind admin assistant. Opens invoices and Word documents from email. Wants to copy reference numbers and totals quickly.
  4. Sam, low-vision novelist. Uses Narrator with a large font and high contrast. Writes long manuscripts, restores from yesterday’s backup occasionally.
  5. Jamie, blind sysadmin. Edits INI, JSON, log and config files. Wants a calm editor that does not fight tooling.

3.2 Primary scenarios


4. Product principles

  1. The edit field is sacred. The writing surface is a standard multi-line wx.TextCtrl. Nothing exotic. Screen readers see plain text. Always.
  2. Predictable keyboard. Shortcuts match Windows conventions where they exist. New shortcuts are documented, discoverable, and reassignable.
  3. Speak the result, not the process. After every meaningful action, Quill announces a short, useful result through the screen reader.
  4. Originals are read-only by default. Anything rendered or extracted saves through Save As. Plain-text formats save in place.
  5. No silent network calls. Any cloud assistance asks first, shows progress, and reports the outcome.
  6. Quiet by default. No surprise toasts. No animated UI. No tabs that announce themselves on every switch. Cues that merely duplicate what the screen reader already says ship off: e.g. announce_dialog_transitions (the spoken “Entered/Exited name dialog”) defaults to off, since every supported reader already announces a dialog and reads its title on focus. The lever stays in Preferences > Accessibility for anyone who wants the extra cue. Settings reach existing users on upgrade because the on-disk store is a delta from code defaults (see settings_migration), so a changed default flows to everyone who never deliberately overrode it.
  7. Recoverable. Backups happen on save. Crash recovery on launch. Nothing the user wrote should ever be lost.
  8. Discoverable. A searchable command palette lists every action with its current binding.

5. Functional requirements

5.1 Application shell

5.1a The magical menu bar

The menu bar is standards-based, predictable, and exhaustive. Every menu item is also a command in the palette, every item shows its current keybinding to the right of its label, and the displayed binding updates live when the user rebinds it. No menu item is hidden; disabled items remain visible with a short tooltip explaining why they are disabled in the current context.

Menu structure:

All menu strings are translatable. Every menu item has a unique mnemonic. The menu bar may be hidden via View; when hidden, pressing Alt reveals it temporarily and Esc dismisses it again, matching Windows convention.

5.1b The magical status bar

The status bar is not a passive label strip. It is a fully keyboard-navigable row of items that any user can reach with F6 and arrow through with Left and Right. Each item is a small interactive cell, and Enter on a cell either toggles a state, opens a chooser, or runs a related command. This is modelled on Visual Studio Code’s status bar, adapted for screen-reader-first interaction.

Design rules:

Cells shipped in v1.0, in left-to-right order:

Cell Default value Primary action (Enter) Context menu
Document name chapter-7.md or Untitled 1 Opens Quick Switcher (palette ~) Switch document, Reveal in Explorer, Copy Path, Close
Modified state Modified / Saved Save Save, Save As, Reload From Disk, Restore Backup
Line / Column Line 12, Column 7 Opens Go To Line Go To Line, Go To Page, Go To Bookmark
Selection Selection: 3 lines, 47 words or hidden when no selection Opens Document Statistics on selection Statistics on selection, Convert case…, Sort selected lines
Word count 1,248 words Opens Document Statistics on document Statistics, Configure reading rate
Page Page 4 of 23 (only when document has page markers) Opens Go To Page Next page, Previous page, Page list
Search term Find: "reading order" (only when a search term is set) Opens Find with the term pre-filled Find Next, Find Previous, Find All, Clear Search
Encoding UTF-8 Opens Reload With Encoding Reload With Encoding, Save With Encoding, Auto-detect
Line endings LF / CRLF / CR Cycles to the next style and prompts to confirm Choose explicitly, Convert on save
Indent Spaces: 4 / Tabs Opens indent settings Convert to tabs, Convert to spaces, Set width
Language en-GB + tech + personal (the active spell-check dictionary stack) Opens dictionary stack chooser Add dictionary, Pin to document, Per-paragraph (v1.2)
Spell check Spell check on / Spell check off. When errors exist: 3 errors Toggles as-you-type spell check Run spell check, Jump to next misspelling, Reset learning
Read aloud Read aloud: ready or Read aloud: speaking Toggles read-aloud playback Voice…, Speed…, Read selection, Read document
Accessibility audit Audit: clean or Audit: 2 warnings (only after a manual or auto audit) Opens the Issues panel Run audit, Configure rules, Ignore for this document
AI status AI: Ready / AI: Needs attention / AI: Not checked, with a short detail line Opens AI provider settings Verify connection, Switch provider, View last response
Background tasks Idle or Extracting PDF, 42% Opens a small Tasks dialog listing in-flight operations with Cancel Cancel all, Show task log
Notifications No new messages or 2 messages Opens the Notifications dialog (release notes, available updates, backup recovery offers) Dismiss all, Open Notifications
Quill (rightmost) Quill 1.0 Opens About Check for Updates, Release Notes

Default visibility: Document name, Modified state, Line/Column, Word count, Encoding, Line endings, Spell check, Background tasks, Notifications. Other cells are present but tucked into overflow until first relevant use; they auto-surface when their context becomes relevant (for example Page appears as soon as a paged document is opened; Search term appears the moment a search term is set).

When title mode is configured to show the full file path, Quill suppresses a duplicate file-path status item to avoid repeated location noise.

5.1c Trust and verification

Quill explains what it did, how confident it is, and how to recover if extraction looked wrong.

5.1f Profile safety and recovery

Feature profiles must be safe, explainable, reversible, and recoverable.

5.1g Feature registry and user profiles

Quill’s feature-profile system is a first-class product surface, not a cosmetic preference layer.

5.1d Power search, regex, and special characters

Search and Unicode cleanup are first-class accessibility features, not power-user extras.

Menu and window stability requirement:

5.1e Feature flags and profiles

Quill should stay calm by default and unlock power features intentionally.

5.2 Documents and the editor

5.2a QUILL Quick Nav mode

Quill provides a browse-style, cursor-only navigation mode for long-form reading and structural movement in editable text surfaces.

Default Quick Nav keys:

Artifact tracking model:

Edit-surviving bookmark anchors (“Points”). A bookmark stores more than a bare character offset: core/bookmark_anchor.py captures a small text window around the caret (before/after snippet + line) at set time. On jump, resolve_anchor relocates the bookmark to where that snippet actually lives now — fast-path when the stored offset still matches, else the nearest occurrence of the snippet to the old offset, else a clamped fallback when the text was deleted. Named bookmarks and numbered quick-bookmarks both capture anchors; anchors persist per-document in document_memory.json alongside the legacy int map and are fully backward-compatible (older files without anchors simply use the offset). This realizes the “Bookmarks survive edits” principle so a mark no longer drifts when text is inserted or removed above it. - Sentence anchors: sentence-ending punctuation boundaries.

Performance and invalidation:

Customization:

5.3 File operations

5.3a Extended format support

The table below lists every format family Quill adds, why each matters, and how Quill renders it. Every renderer follows the same rules: present text in a standard edit field, preserve the original file, escalate to the enhanced extractor or AI repair only when local extraction is poor and the user asks.

Format family Specific formats added Why it matters How Quill renders it
Spreadsheets xlsx, xlsm, xls, ods, numbers, csv, tsv Blind users routinely receive spreadsheet attachments; current accessible tooling forces Excel Each sheet becomes a section. Tables are emitted as accessible plain-text columns with header row, Ctrl+Shift+Right/Left navigates by column, Ctrl+Shift+Down summarises totals
OpenDocument odt, odp, odg, ods Common in EU public sector and academia Direct extractor via odfpy; structure preserved as headings, lists, tables
Apple iWork pages, key, numbers Cross-platform collaboration is increasingly common; iWork files arrive in email frequently Local extractor via the iWork archive format; AI escalation for complex layouts
Legacy office ppt, xls, wpd, wps, wri, sxw/sxc/sxi Older institutional archives still circulate Best-effort local readers; AI escalation by sending the original file when local reading fails
E-books (proprietary) azw, azw3, mobi, kfx, fb2, lit, lrf, prc, pdb, tcr EPUB-only support excludes most Kindle and older e-book libraries Reuse Calibre’s conversion engine where present (plugin in v1.1) or bundled minimal readers for the open formats
Comics with text cbz, cbr Educational comics and graphic textbooks ship in CBZ Extract pages, OCR each, present per-page text with page navigation
DjVu and XPS djvu, xps, oxps Academic scans (DjVu) and Microsoft alternatives to PDF (XPS) are common in research Local extractors; PDF-style page navigation
PostScript ps, eps Academic preprints and older typeset documents Convert via Ghostscript to text + page markers
TeX and Typst tex, latex, bib, bibtex, typ Academic and scientific writing community is largely TeX-based; no accessible plain editor handles it gracefully Plain-text editing with optional rendered preview to PDF/HTML via local engines; bibtex entries readable as structured records
Lightweight markup rst, adoc/asciidoc, textile, org, wiki/mediawiki, bbcode Documentation toolchains for Python, Ruby, Asciidoctor, Emacs Org-mode, wikis Plain-text editing; preview via the appropriate renderer; export to HTML/DOCX
DAISY and braille daisy 2.02/3, nimas, brf, brl, pef, ueb Accessible publishing standards that ironically lack accessible editors. Braille files need round-trip print/braille translation Linear text presentation with chapter/section navigation; liblouis for braille back-translation; export to BRF
MHTML and web archives mhtml/mht, warc, wacz, browser “single file” saves, Safari .webarchive Saved web pages are the second-most common research artefact after PDF Extract the primary document; show resources list; allow Preview as HTML
SVG svg SVG is text; titles, descriptions, and <text> content carry semantic meaning often inaccessible elsewhere Show as XML in edit field plus a “Reading View” that extracts titles, descriptions, and text in document order
Subtitles (full set) sbv, ass/ssa, sub, ttml/dfxp, scc, stl, cap broadcast and YouTube captioners need the rest Render as plain text with optional timecode column; export between formats
Email and mail archives eml, msg, mbox, pst Email frequently arrives as standalone files (forwarded, archived, exported); .msg and .pst are pure Microsoft and historically painful Headers as a labelled block, body as plain text, attachments listed with “Open in Quill” actions; pst and mbox get an index UI
Calendar and contacts ics, vcf Common attachments; today opened by heavy apps Render events and contacts as structured plain text records
Notes ecosystems one, enex, Apple Notes export, bear, Simplenote JSON People have years of notes locked in proprietary apps Read-only renderers; each note opens as its own document
Source code and config Full polyglot list in 5.3 Programmers and sysadmins use Quill for quick edits; the screen-reader story for VS Code is good but heavyweight Syntax-aware tokeniser informs spell check and word navigation; presentation remains plain text
Build, package, IaC Dockerfile, *.tf, *.bicep, *.k8s.yaml, pyproject.toml, etc. DevOps work is text-heavy and benefits from a calm reader Plain text with section folding via shortcut, schema-aware error reporting in the ! palette mode
Diff and patch diff, patch, gitconfig, gitignore Code review and merge work Hunks rendered with +/- summarised aloud; Ctrl+] next hunk
Notebooks ipynb, qmd, rmd Data science and research; notebooks are JSON and unreadable in basic editors Linearised: each cell becomes a section with role header (“Code cell 3”, “Markdown cell 4”, “Output of cell 3”), outputs included where text
Data files sqlite/db, parquet, feather Researchers and analysts frequently inspect data files Read-only schema view + first N rows as a table; refuses to edit and explains why
Logs and trace evtx, gzipped logs, single-file archives Sysadmin and support workflows Auto-extract; render with timestamp column
Web feeds rss, atom, opml, json-feed Accessible feed consumption Render as a list of articles, Enter to open one
Chat exports WhatsApp, Telegram, Slack, Discord, Teams, Zoom, Otter, ChatGPT People archive conversations and need to search them Linearised conversation with speaker headers, timestamps optional
Image OCR png, jpg, tif (multi-page), bmp, webp, heic, avif, gif, jp2, plus djvu and scanned PDFs Photographed receipts, signage, scanned mail Local Tesseract OCR by default; AI escalation for handwriting or poor scans
Audio with transcript .mp3/.wav/.m4a/.opus paired with .vtt/.srt/.txt Podcasts, lectures, interviews with transcripts Opens the transcript; optional playback plugin syncs cursor to audio
Code-block exchange Gist URLs, Pastebin URLs, snippet bundles Developers share text via URLs daily One-shot fetch + open as unsaved document

Some renderers (notably KFX, PST, legacy proprietary office, and some iWork variants) require optional helper tools installed locally. Quill detects what is present at startup and the Open dialog only advertises formats it can actually read on this machine. The long-term design is a full Settings → Format Support page; the current beta already ships an External Tools and Format Support dialog that shows supported helpers, the capabilities they unlock, and copy-to-clipboard installation hints (never an automatic install).

Editable vs. read-only matrix

Not every format we open is something a user should edit. Quill is explicit about which formats round-trip in place vs. require Save As:

Plugin escalation

Where bundling a large dependency would balloon installer size (Calibre, Ghostscript, Tesseract trained-data packs, MeCab for Japanese, LibreOffice headless for legacy office), Quill ships a thin shim and offers a one-click plugin install from the Format Support page. Plugins announce their license clearly before installing.

5.3a.1 Pandoc Import / Export and Batch Conversion (issue #262)

QUILL ships a curated Tier-1 list of Pandoc-supported formats in the File menu and a four-page batch conversion wizard under Tools. The list is curated rather than exhaustive because not every Pandoc format is a good fit for a screen-reader-first editor; the menu and the wizard show only formats that meet the bar.

Tier-1 inputs: Markdown, CommonMark, GitHub-Flavored Markdown, HTML, Word documents (.docx), OpenDocument Text (.odt), Rich Text (.rtf), plain text, CSV / TSV tables, EPUB books, LaTeX / TeX.

Tier-1 outputs: the same set plus PDF (export only).

Non-Pandoc export — DAISY talking book (#251): File > Export > DAISY Talking Book writes a DAISY 2.02 text-only talking book. This export does not go through Pandoc; the wx-free, strict-typed quill/io/daisy.py (write_daisy_textonly) renders the live buffer directly. Because a DAISY book is a folder rather than a single file, the chosen name becomes a folder holding ncc.html (Navigation Control Center: dc:/ncc: metadata, multimediaType=textNCX, and heading links), content.html (XHTML 1.0 with an id on every readable element), and book.smil (SMIL 1.0 reading-order container with zero-duration <par>s, since the book carries no audio). Headings become player navigation points; a document with no h1 gets a synthetic title heading so navigation is well-formed. The output opens in DAISY software readers and hardware players (Victor Reader Stream, Plextalk, APH units) and in APH Book Wizard Producer for adding TTS audio.

5.3a.1.1 Single-file Import / Export

File > Import > converts a single file from disk into a new Markdown buffer in a new tab. File > Export > converts the current buffer to the named format on a background thread. Both routes use Pandoc and quill.stability.safe_subprocess.run_subprocess_safely so a misbehaving Pandoc cannot take QUILL down.

Post-conversion prompt rule (issue #262): when the target format is editable in QUILL (Markdown, CommonMark, GFM, HTML, plain text, CSV / TSV) the editor asks whether to open the new file in a new window. PDF, DOCX, EPUB, ODT, and RTF do not prompt; the file path is on the clipboard for pasting into File Explorer.

5.3a.1.1a Save As conversion pipeline and format truthfulness (shipped 0.9.0 Beta 1)

Save As converts; it never just renames. The editor’s canonical text is QUILL Markdown-style markup. quill/io/export.py::write_document_as is the single dispatcher every save routes through, keyed by target extension: .rtf re-serializes through the native RTF writer, .docx through write_docx_document, .html/.htm/.xhtml through the standalone HTML renderer, and .txt/.md/unknown text extensions are written verbatim (#649 round-trip contract; the explicit Save As Plain Text command is the stripping path, with the Illumination options).

The mark-saved contract (Caroline’s 0.8.0 report). Every writer in the dispatch — including both docx branches — must call document.mark_saved(target) on success, so the window title, the modified flag, the tab title, and the next plain Ctrl+S are truthful. The .docx writer’s missing mark_saved produced the “Untitled [modified] after a successful save, then an empty Save As dialog” failure; regression tests pin the contract for every format.

Line-break policy — one editor line is one paragraph, everywhere. The editor is line-oriented (what a screen reader user hears line by line is the document’s structure). The native writers already work this way (markdown_to_rich maps each editor line to a paragraph); every Pandoc call whose source is QUILL canonical text must therefore use gfm+hard_line_breaks, never bare gfm (where a single newline is a soft wrap that silently joins the user’s lines). This applies to the docx Pandoc fallback and to every File > Export format. Convert File / Batch Conversion of arbitrary on-disk files keep their detected source semantics.

Export-only extension guard (data-loss invariant). EXPORT_ONLY_SUFFIXES (.pdf .doc .odt .epub .pages .ppt .pptx .xls .xlsx .sqlite .db) marks formats QUILL can open (as extracted text) but cannot write. write_document_as raises UnsupportedSaveFormatError for them: Ctrl+S on an opened PDF/EPUB/spreadsheet must never overwrite the binary original with text (the UI explains and routes to Save As), and a typed notes.pdf in Save As must never mint a Markdown file wearing a .pdf name (the UI offers the Pandoc Export hand-off for .pdf/.odt/.epub and refuses the rest). This implements the “Save As only (originals protected)” row of the editable-vs-read-only matrix above as an enforced io-layer invariant rather than a UI convention. .brf-family and unknown text extensions stay verbatim by design.

The editing surface stays QUILL text — audibly. After a converting Save As (.docx/.rtf/HTML) the file on disk is the converted artifact and each subsequent save re-converts; the editor does not switch to the target format. _announce_save_as_conversion speaks this (“Saved as report.docx, Word format. You are still editing QUILL text; each save converts it to Word.”) so the model is explicit for a screen reader user. No auto-reload is offered for .docx: a reload would round-trip through a different engine (MarkItDown) and silently lose formatting just saved.

Filename suggestion from the first line. first_line_as_title (default on) pre-fills Save/Export dialogs for an untitled document from its first meaningful line via quill/core/titles.py (markup leaders stripped, Windows-invalid characters removed, 60-char cap). It only ever suggests a name for an untitled document.

Rich-mode Save As converts the RichDocument, not the plain-text mirror. When a .docx opens in rich mode, document.text is a flattened plain-text mirror, so routing a .md/.html Save As through the classic writer lost all formatting. main_frame_rich_mode._save_rich_as_markup now intercepts .md/.markdown/.html/.htm/.xhtml in rich mode, reconstructs the RichDocument from the control’s RTF (rtf_to_rich), and renders it with rich_to_markdown (Markdown) / markdown_to_html (HTML). The Save As dialog and its extension routing were correct; the defect was a missing converter wiring on the rich path.

Cross-format fidelity matrix (measured; tests/unit/io/test_save_as_format_fidelity.py). Everything routes through the shared RichDocument / canonical-Markdown hub, so preservation is per-feature, not per-pair. native, carried as portable markup/text, lost.

Feature Markdown HTML Word (.docx) RTF Text
Headings, bold, italic, links, bullet + numbered lists
Underline / strike / super-sub / colour / highlight / font ◑¹
Blockquote / code block / horizontal rule ◑² ◑²
Tables ◑³
Images ◑⁴ ✕⁴
Alignment ◑¹
Page breaks ◑¹
  1. Hidden-codes markup ([text]{…} spans / ::: {…} divs) in Markdown; materialised natively on HTML/Word/RTF export.
  2. Markdown-native and native in HTML; literal markers in Word/RTF (the RichDocument model has no blockquote/code/hr structure).
  3. Tables round-trip as real tables across Markdown, HTML, and Word: Word tables read → GFM tables (_table_to_rich_paragraphs), and GFM-table paragraphs written to Word are reconstructed into native w:tbl objects (_table_block_length/_emit_table in docx_writer), with the header row bold and marked <w:tblHeader> (repeating header, screen-reader-announced). RTF remains the exception — a table written to RTF is pipe-text, not a native RTF table.
  4. Image references round-trip in Markdown and render as <img> in HTML; images are not embedded into Word, and embedded Word images are not read.

Round-trip improvements (0.9.0, quill/io). docx hyperlinks round-trip both ways — rich_to_docx writes a real w:hyperlink with an OPC relationship, read_docx_rich recovers the href via iter_inner_content (previously links degraded to plain text). Numbered lists are first-class: a numbered RichParagraph style plus list_number carried through markdown_to_rich/rich_to_markdown (identity, keeps a non-1 start), docx List Number read/write (sequential numbering assigned on read), and the offset map. markdown_to_html now emits <img> for images and <blockquote> for quote lines. Tables round-trip as real tables between Markdown, HTML, and Word: docx_writer detects consecutive GFM-table paragraphs (_table_block_length) and rebuilds a native w:tbl (_emit_table) with a bold, repeating <w:tblHeader> header row — so Markdown→Word and HTML→Word (via the hub) now produce editable Word tables, and Word→Markdown/HTML already preserved them. Remaining honest gaps: RTF drops alignment and page breaks and writes tables as pipe-text; docx drops page breaks on read; inline formatting inside a Markdown-authored table cell flattens to plain text on the way to Word; Word-embedded images are not extracted.

Conversion engine preferences. Two settings expose the engine choice with speakable outcome descriptions: docx_read_engine (auto | markitdown | pandoc — auto is MarkItDown-first with the raw python-docx extract as last resort; the pandoc preference degrades to auto when Pandoc is missing, so a preference never fails an open) and docx_write_engine (auto | native | pandoc — native is the hidden-codes-preserving python-docx writer; pandoc maps structure to Word styles and drops run-level font/size/color). The Convert File dialog carries a per-operation Conversion engine choice (Auto/Pandoc/MarkItDown) whose description follows the selection; MarkItDown is honored only where it honestly applies (Office/PDF source, Markdown/plain output) and the handler asks before substituting Pandoc, never silently. Engine evidence: docs/qa/converter-bakeoff.md (2026-07-04) — MarkItDown and Pandoc passed the full corpus; the python-docx reader loses tables/footnotes/links (fallback only); pydocx is rejected permanently (cannot import on Python 3.10+, last release 2016); mammoth is not adopted (no current gap) with a standing decision tree: if a MarkItDown fidelity gap appears, mammoth is the candidate and would be bundled (pure-Python; frozen builds cannot pip-install at runtime), lazily imported, and offered as a third read-engine choice.

5.3a.1.2 Batch Conversion wizard

File > Import > Batch Conversion… and File > Export > Batch Conversion… (or QUILL key, B) open a hand-rolled wx.Dialog modeled on setup_wizard_pages.py. Four pages: Introduction (with live Pandoc version probe), Folder and options (folder picker, recursive checkbox, output-layout radio, overwrite radio), Format and profile (direction radio, Tier-1 source/target lists, profile picker), Review and start (human-readable summary).

Defaults come from Settings.import_export_recursive, import_export_output_layout, import_export_overwrite, and the wizard-overridable import_export_last_folder. The wizard can override any of these per run; the Preferences dialog is the canonical place to change defaults.

The wizard’s _SummaryPage.refresh(choices) reads the plan aloud through _announce() so the user hears what they are about to apply before the batch starts. Back / Next / Start / Cancel are stock wx.Button controls under the standard apply_modal_ids modal-id contract; the wizard is keyboard-first end to end and never depends on the mouse.

5.3a.1.3 Batch execution

The wizard returns a BatchRequest carrying a BatchPlan dataclass. The caller submits the plan to MainFrame._run_background_task so the work runs on stability.task_manager.QuillTaskManager (a ThreadPoolExecutor wrapper) instead of the UI thread. Progress is reported through the Status Page (Help > Status Page > Tasks & Downloads) with one live (Task, Status, Progress, Started, Finished) row per file. The worker honours a threading.Event cancel between files and raises PandocCancelledError if cancellation fires mid-run.

Output naming follows the issue #262 rule verbatim: keep the originating stem, replace the extension. With output_layout="subfolder" (the default) the output lands in an Output/ subfolder created lazily per file; with output_layout="same_folder" the output lands next to the source. The three-way overwrite policy — ask, never, always — is enforced both at the batch level (one prompt per batch for the ask policy) and per file (skip on never, overwrite on always).

5.3a.1.4 Conversion profiles

Seven built-in conversion profiles ship in this release (quill.core.convert_profiles):

Each profile is a ConvertProfile dataclass holding its CLI flags and a plain-language description. The wizard’s profile picker reads each profile aloud so the screen reader can announce what is being applied before the batch starts.

5.3a.1.5 Completion announcement

When the batch finishes, _announce() is called with the completion line:

“Batch conversion complete. of files converted in seconds. skipped. failed.”

The line routes through the existing announce() -> _announce() -> AnnouncementEngine path, which already honours announcement_backend and verbosity_speech_enabled. The Status Page row updates regardless of the user’s verbosity setting so sighted and low-vision users see the same result.

A short report dialog lists every file that produced warnings or failed, with the exact error string. Successful files do not appear in the report, so the dialog stays small and quick to read.

5.3a.1.6 Settings

Three new SettingSpec entries appear in Preferences > Editing (issue #262):

A fourth field, import_export_last_folder (string, default ""), is intentionally not exposed in Preferences. The wizard writes it when it starts a batch so the next run lands the user where they left off. All four fields are validated in Settings.from_dict against the issue #262 value sets.

5.3a.1.7 Key binding

QUILL key, B opens the Batch Conversion wizard. The chord was added to quill.core.keymap and is wired through MainFrame._on_quill_key_b. The B key was chosen because it does not collide with any existing QUILL-key second-key in main_frame_quill_key.py.

5.3a.1.8 Out of scope for this release

Cross-links: this section is referenced from ### 5.25b Watch Folder automation (the Watch Folder Quillin can use the same BatchPlan shape when it needs batch-style conversion) and from §22 Startup Wizard (the Startup wizard’s “What kind of writing do you do?” intent picker exposes the Import / Export and Batch Conversion entries only when the chosen profile warrants them).

5.3b Microsoft Word document support (DOCX / DOC)

This section is the canonical, scoped statement of what Quill 1.0 does with Microsoft Word files (.docx and .doc). Anything not listed here is explicitly out of scope for v1.0 and tracked in the backlog.

5.3b.1 Complexity and Risk Context

Microsoft Word documents (.docx = XML-based, .doc = binary OLE2 format) present genuine complexity risks:

  1. Formatting metadata: fonts, sizes, colors, styles, themes, templates
  2. Complex structure: headers, footers, text boxes, shapes, comments, tracked changes
  3. Media embedding: images, charts, SmartArt, embedded objects, videos
  4. Advanced features: fields, macros (VBA), content controls, form controls, ActiveX
  5. Reading order challenges: columns, text boxes in non-linear positions, floating shapes
  6. Legacy cruft: corrupted documents, OLE objects, mixed encodings, malformed XML

Quill’s design principle (“The edit field is sacred”) prevents visual formatting preservation in v1.0. Word files must be extracted to plain, linear text in wx.TextCtrl for guaranteed screen-reader compatibility with NVDA, JAWS, and Narrator.

5.3b.2 Supported Features (What Gets Extracted)

Core content (via Pandoc extraction):

Conversion support (via Pandoc bridge):

Metadata (stored in Document.source_metadata):

5.3b.3 Unsupported Features (Not Extracted, User Warned)

Formatting (intentionally dropped for v1.0 compatibility):

Advanced structures (extracted with limitations or skipped):

Security-sensitive (explicitly blocked or sanitized):

Deferred to v1.1+ (not attempted in v1.0):

5.3b.4 Architecture: I/O Module Design

New module: quill/io/word.py

Enhanced module: quill/io/pandoc.py

Add Word-specific conversion routes:

READER_MAP: dict[str, str] = {
    "docx": "docx",  # NEW: from .docx
    "doc": "doc",  # NEW: from .doc (legacy)
}

WRITER_MAP: dict[str, str] = {
    "word": "docx",  # NEW: export to .docx
    "word-old": "doc",  # NEW: export to .doc (legacy)
}


@dataclass(frozen=True, slots=True)
class WordMetadata:
    title: str | None
    author: str | None
    subject: str | None
    created: str | None
    modified: str | None
    word_count: int
    page_count: int

Updated module: quill/io/detect.py

TEXT_EXTENSIONS = {
    # ... existing ...
    ".docx",  # NEW
    ".doc",  # NEW
}


def looks_like_word_document(path: Path) -> bool:
    return path.suffix.lower() in {".docx", ".doc"}

5.3b.5 Error Handling: Graceful Cascade

Word extraction implements a three-tier fallback strategy to ensure the app never crashes on corrupted, malicious, or malformed files:

Tier 1: Pandoc (primary path)

Tier 2: python-docx (fallback)

Tier 3: Emergency UTF-8 read

5.3b.6 User-Facing Messaging (Document Intake Report)

When opening a .docx or .doc file, Quill displays an in-app banner (stored in source_metadata, displayed by main_frame.py):

📄 Word Document Opened
This document has been converted to plain text for editing in Quill.
• Formatting (fonts, colors, styles) has been removed.
• Tables and lists have been simplified for readability.
• Images and charts have been replaced with captions only.
• If you need the original formatting, open this file in Microsoft Word.

Original file: Z:\documents\report.docx
Engine: Pandoc 3.1.0 | Quality score: 85/100

[🔄 Retry extraction]  [💾 Save as .txt]  [📖 Show details]

The banner is concise and accessible: each item is announced separately via screen reader, and action buttons are navigable via Tab.

Where Am I enhancement: When cursor is at the start of an extracted Word document, Ctrl+Alt+W (Where Am I) announces: > “Word document. Title: Q2 Report. Author: Jane Doe. 8 pages, 2,451 words. Extracted by Pandoc 3.1.0. Quality: 85 out of 100. Formatting not preserved.”

5.3b.7 Pandoc Integration and Format Bridge

Pandoc is a universal document converter supporting 30+ input and output formats. Full integration enables:

Phase 1 (v1.0 — this release):

Phase 2 (v1.1):

Phase 3 (v1.2+):

Installation and resource limits:

5.3b.8 Editable Rendering, Protected Original

Word files follow Quill’s principle: originals are read-only by default.

5.3b.9 Metadata and Quality Scoring

Extracted metadata is stored in the Document.source_metadata dict:

source_metadata = {
    "source_kind": "word",
    "format": "docx",  # or "doc"
    "engine": "pandoc",
    "engine_version": "3.1.0",
    "quality_score": 85,  # 0–100
    "extraction_warnings": [
        "Images not extracted (captions only)",
        "Tracked changes simplified to plain text",
        "Columns flattened to single column",
    ],
    "word_metadata": {
        "title": "Q2 Report",
        "author": "Jane Doe",
        "subject": "Financial Review",
        "created": "2026-03-15T10:30:00Z",
        "modified": "2026-05-28T14:22:00Z",
        "word_count": 2451,
        "page_count": 8,
    },
}

Quality score calculation (0–100):

5.3b.10 Risk Mitigation and Safety

Risk Impact Mitigation
Large .docx files (10+ MB) cause extraction to hang Application freeze, loss of edits to other docs 60-second Pandoc timeout, async extraction option for large files
Corrupted Word files crash parser Application crash Graceful error cascade: Pandoc → python-docx → plaintext; never crash
Complex formatting confuses extraction order User sees garbled text Clear in-app banner: “Word formatting not preserved” + retry option
VBA macros or malicious code in .docx Security vulnerability Pandoc subprocess isolation; no code execution; explicit security testing
.doc (OLE) files fail silently Silent data loss Pandoc attempts .doc → XML; if it fails, fallback to python-docx
User saves edits back to .docx, loses formatting Data loss if user relies on styles Always prompt before save: “Saving as plain text. Continue?” + option to cancel
Memory explosion from embedded media Out-of-memory crash Pre-check file size; if > 50 MB, warn; if > 100 MB, offer async extraction

5.3b.10a Extraction Quality Diagnostics

Quill applies accessibility-aware diagnostic techniques to deeply analyze Word documents and produce actionable extraction warnings:

Paragraph-level diagnostics:

Document-level diagnostics:

Extraction engine diagnostics:

Example source_metadata structure with accessibility-aware diagnostics:

source_metadata = {
    "source_kind": "word",
    "format": "docx",
    "engine": "pandoc",
    "engine_version": "3.1.0",
    "quality_score": 78,  # Reduced from 85 due to diagnostics
    # accessibility-aware diagnostics
    "extraction_diagnostics": {
        "fake_lists_detected": 2,  # Manually typed bullets
        "all_caps_paragraphs": 1,  # Heading-like caps text
        "long_sections_without_headings": ["Pages 5-6 (23 paragraphs)"],
        "repeated_space_paragraphs": 3,  # Layout abuse detected
        "non_arial_fonts": {"Times New Roman": 5, "Courier": 2},
        "hyphenation_enabled": True,
        "heading_hierarchy_issues": ["H1 at page 1, then H3 at page 2"],
        "extraction_engine_switches": ["Pandoc pages 1-8, python-docx pages 9-10"],
    },
    "extraction_warnings": [
        {
            "id": "FAKE_LISTS_DETECTED",
            "severity": "warning",
            "message": "2 paragraphs detected with manually typed bullet characters instead of built-in list styles",
            "location": "Paragraph 5, Paragraph 12",
            "user_action": "review",
            "suggestion": "Consider reformatting as proper lists in Word",
        },
        {
            "id": "LONG_SECTION_NO_HEADING",
            "severity": "info",
            "message": "23 paragraphs without a heading (pages 5-6)",
            "location": "Paragraphs 45-68",
            "user_action": "review",
            "suggestion": "Add section heading for readability",
        },
        {
            "id": "HYPHENATION_ENABLED",
            "severity": "warning",
            "message": "Automatic hyphenation is enabled; hyphenated words may reflow unexpectedly in Quill",
            "location": "Document Settings",
            "user_action": "accept",
            "suggestion": "Consider disabling hyphenation in Word for cleaner reflow",
        },
    ],
    "word_metadata": {
        "title": "Q2 Report",
        "author": "Jane Doe",
        "subject": "Financial Review",
        "created": "2026-03-15T10:30:00Z",
        "modified": "2026-05-28T14:22:00Z",
        "word_count": 2451,
        "page_count": 8,
        "language": "en-US",
        "margins": {
            "top_in": 1.0,
            "bottom_in": 1.0,
            "left_in": 1.0,
            "right_in": 1.0,
        },
    },
}

User experience enhancement:

Instead of a flat banner, Quill displays diagnostics with context:

📄 Word Document Opened
Extracted via Pandoc 3.1.0 | Quality: 78/100 (down from 85 due to structural issues)

✓ 8 pages extracted, 2,451 words
✓ 12 headings (H1-H3) converted
⚠ 2 paragraphs with typed bullet characters (should be proper lists)
⚠ Long section without headings (23 paragraphs on pages 5-6)
⚠ Hyphenation enabled (may cause unexpected line breaks)
ℹ Document title: "Q2 Report" | Language: en-US

[🔍 View diagnostics] [💾 Save as .txt] [Learn more]

Clicking [🔍 View diagnostics] opens a detailed panel showing each diagnostic with suggestions and locations.


5.3b.11 Testing Strategy

Test corpus (stored in tests/fixtures/word/):

Document Scenario
simple_heading.docx Single heading + paragraph, minimal structure
multi_page_structured.docx 5 pages, multiple headings (H1–H3), numbered + bulleted lists, tables
embedded_images.docx 3 images with alt text; verify alt text extracted, images are not
tracked_changes.docx Paragraph with deletions/insertions marked; verify resolved to plain text
corrupted.docx Intentionally malformed XML; verify graceful fallback
legacy_format.doc MS Word 97–2003 format; verify .doc reader works
password_protected.docx Password-protected; verify clear error message
large_50mb.docx (stub) Verify timeout protection and progress indication
malicious_macros.docx Contains VBA code; verify Pandoc does NOT execute, code is dropped
mixed_language.docx Chinese, Arabic, emoji; verify encoding preserved

Unit tests (tests/unit/io/test_word.py):

def test_read_docx_simple():
    doc = read_word_document(Path("tests/fixtures/word/simple_heading.docx"))
    assert "Heading" in doc.text
    assert doc.source_metadata["quality_score"] > 80


def test_read_doc_legacy_format():
    doc = read_word_document(Path("tests/fixtures/word/legacy_format.doc"))
    assert doc.text.strip()  # Non-empty
    assert "engine" in doc.source_metadata


def test_read_corrupted_falls_back():
    doc = read_word_document(Path("tests/fixtures/word/corrupted.docx"))
    assert doc.source_metadata.get("quality_score", 0) < 50
    assert len(doc.text) > 0  # No crash; degraded gracefully


def test_read_timeout_protection():
    with pytest.raises(ExtractionTimeoutError):
        read_word_document(
            Path("tests/fixtures/word/large_50mb.docx"),
            timeout_sec=0.1,
        )


def test_macros_not_executed():
    doc = read_word_document(Path("tests/fixtures/word/malicious_macros.docx"))
    assert "VBA" not in doc.text and "Sub " not in doc.text

Integration tests (tests/integration/test_word_open.py):

Accessibility tests (tests/a11y/test_word_screen_reader.py):

Performance tests (tests/perf/test_word_extraction_speed.py):

5.3b.12 Rollout and Communication

Word documents should open through a direct/native Word-document path by default when the local engine can handle them. If that path is unavailable or fails on a particular file, Quill should offer an explicit choice to fall back to extracted-text import so the user always knows which mode they are entering.

Pre-release (release notes):

In-app messaging (on first Word document open):

Documentation:

5.3b.13 Success Criteria

By end of v1.0:

  1. Functional: Users open .docx/.doc through a direct/native Word path when available; extracted text remains readable/navigable with screen readers as a fallback, content (headings, lists, tables, links) preserved accurately, save to .txt/Markdown/new .docx
  2. Safe: No crashes on corrupted/malicious/large files, VBA not executed, timeout prevents runaway conversions, user warned before formatting loss
  3. Discoverable: File > Open shows .docx/.doc as supported, File > Save As offers Word formats, in-app banner explains changes, help docs explain workflow
  4. Tested: 10+ real-world documents tested, unit/integration/a11y/perf tests passing, no regressions in existing formats
  5. Documented: User docs explain what is and is not supported, known limitations transparent, troubleshooting guide provided

5.3b.14 Future Enhancements (Post-v1.0)

5.3c Microsoft PowerPoint document support (PPTX / PPT)

This section is the canonical specification for PowerPoint support in Quill 1.0. Anything not listed is explicitly out of scope for v1.0 and tracked in the backlog.

5.3c.1 Complexity and Risk Context

PowerPoint presentations (.pptx = XML-based, .ppt = binary OLE2 format) present unique extraction challenges:

  1. Non-linear content: Slides arranged by visual layout, not reading order; multiple shapes per slide
  2. Visual-first design: Slide transitions, animations, speaker notes separate from visible content
  3. Nested structure: Master slides, slide layouts, themes, custom templates, shapes groups
  4. Embedded media: Images, charts, SmartArt, embedded audio/video, links
  5. Layout complexity: Text boxes, shapes, grouped objects with reading-order dependencies
  6. Presenter-specific data: Speaker notes, slide timings, presentation settings
  7. Legacy cruft: .ppt binary format, corrupted presentations, mixed encoding, malformed XML

Quill prioritizes linear text extraction and speaker notes over slide visual preservation. The design principle (“The edit field is sacred”) prevents visual formatting preservation in v1.0.

5.3c.2 Supported Features (What Gets Extracted)

Read (Pandoc extraction):

Conversion support (via Pandoc bridge):

Metadata:

5.3c.3 Unsupported Features (Not Extracted, Logged, User Warned)

Visual design (intentionally dropped for v1.0 compatibility):

Advanced structures (extracted with limitations or skipped):

Security-sensitive (explicitly blocked or sanitized):

Deferred to v1.1+ (not attempted in v1.0):

5.3c.4 Slide Linearization with Reading-Order Detection

PowerPoint is inherently non-linear. Quill converts to linear text with intelligent, accessibility-aware reading-order analysis:

Screen readers follow the XML tree order of shapes, not the visual layout. When these don’t match, screen readers read slides in the wrong order. Quill analyzes both orders and extracts in visual order (what user sees) while flagging reading-order mismatches to the user.

Output: Intelligent slide extraction with reading-order diagnostics

--- Slide 2: Agenda ---
[Title appears first - reading order verified]
Agenda

[3 text boxes detected on slide; visual order verified]
Left column (top-left position):
  • Introduction
    • Background
    • Scope

Right column (top-right position):
  • Main topics
    • Topic 1
    • Topic 2

Center callout (center position):
  🔹 All topics are in scope

📊 Reading Order Analysis:
  ✓ Visual order matches XML order (correct for screen readers)
  ✓ Title reads first (best practice)
  Shapes in reading order: Title → Left → Right → Center

5.3c.5 Smart Speaker Notes Extraction

Speaker notes are always extracted and separately indexed with intelligent metadata:

Example metadata:

{
    "slide": 5,
    "slide_title": "Results",
    "notes": "Reference the chart. This slide auto-advances in 5 seconds.",
    "notes_has_timing": True,
    "notes_mentions_visuals": True,
    "notes_word_count": 18,
}

Magical UI integration: When user navigates to a slide with speaker notes, Quill announces:

“Slide 5 of 15: Results. Speaker notes mention slide visuals and timing. Notes say: ‘Reference the chart. This slide auto-advances in 5 seconds.’”

5.3c.6 Animations and Timing Detection with WCAG Analysis

Animations are detected, categorized, and intelligently reported using WCAG timing analysis:

Example output:

⏱ Animation Timing Analysis:
  • Title: "Fade in" (0.5s, starts with slide)
  • Content: "Fly in" (1.0s, appears on click)

🎯 Accessibility Notes:
  → Title auto-animates, so screen reader reads immediately
  → Content requires manual click to reveal
  → Quill has extracted ALL text; no content lost
  ⚠ Warning: Slide 3 auto-advances in 2.5s (< 3s WCAG recommendation)

5.3c.7 Slide Title Analysis

Duplicate or missing slide titles break outline navigation. Quill analyzes titles:

Example diagnostics:

"extraction_diagnostics": {
    "slide_titles": [
        {"slide": 1, "title": "Title Slide", "issue": None},
        {"slide": 5, "title": "[No title]", "issue": "missing"},
        {"slide": 6, "title": "Agenda", "issue": "duplicate", "also_on_slides": [2]},
    ],
}

Quill checks for accessibility patterns in hyperlinks and alt text:

5.3c.9 Chart and Visual Content Analysis

Quill detects charts, SmartArt, and images. For charts:

5.3c.10 Extraction Quality Diagnostics (Comprehensive Scoring)

Instead of simple text-density scoring, Quill implements findings-based quality scoring:

Quality scoring system:

Example breakdown:

Quality Score: 78/100

Baseline:                                          100
-5 (Images without alt text)                        -5
-5 (Chart without alt text)                         -5
-8 (Slide 3: Auto-advance 2.5s < 3s WCAG)          -8
-3 (Duplicate title)                                -3
+1 (Reading order verified)                         +1
___________________________________________
Quality:                                            78

In-app quality dashboard:

📊 PowerPoint Presentation Opened
Quality Score: 78/100

Extraction Summary:
  • 15 slides extracted
  • 12 slides have speaker notes
  • 5 animations detected
  • 8 images (5 with alt text, 3 missing)
  • 3 charts (1 with alt text, 2 missing)

⚠ Issues Found (3):
  1. Slide 3: Auto-advance too fast (2.5s < 3s) - WCAG 2.2.1
  2. Slides 2, 6: Duplicate title "Agenda"
  3. 3 images without alt text

✓ All text extracted successfully (no content lost)

5.3c.11 User-Facing Messaging

When opening a .pptx or .ppt file, Quill displays:

📊 PowerPoint Presentation Opened
This presentation has been converted to plain text for editing in Quill.
• Slide structure, formatting, and animations are not preserved.
• Text boxes and shapes have been simplified to plain text.
• Images, charts, and embedded media have been replaced with captions only.
• Speaker notes are available (12 slides have notes).
• If you need the original presentation, open this file in PowerPoint.

Original file: Z:\documents\Q2-review.pptx
Engine: Pandoc 3.1.0 | Slides: 15 | Quality score: 82/100

[📖 View notes]  [💾 Save as .txt]  [📖 Learn more]

Where Am I enhancement: When cursor is at document start, Ctrl+Alt+W announces:

“PowerPoint presentation. Title: Q2 Review. 15 slides, 5,847 words. Speaker notes on 12 slides. Extracted by Pandoc 3.1.0. Quality: 82 out of 100.”

5.3c.12 Editable Rendering, Protected Original

Presentations follow Quill’s principle: originals are read-only by default.

5.3c.13 Metadata and Extraction Diagnostics

Extracted metadata stored in Document.source_metadata:

source_metadata = {
    "source_kind": "powerpoint",
    "format": "pptx",  # or "ppt"
    "engine": "pandoc",
    "quality_score": 82,  # 0–100 (findings-based)
    "powerpoint_metadata": {
        "title": "Q2 Review",
        "author": "Jane Doe",
        "subject": "Quarterly Business Review",
        "created": "2026-02-15T10:30:00Z",
        "modified": "2026-05-28T14:22:00Z",
        "slide_count": 15,
        "speaker_notes_count": 12,
    },
    "extraction_diagnostics": {
        "total_slides": 15,
        "slides_with_speaker_notes": 12,
        "animations_detected": 5,
        "auto_advance_violations": 1,  # < 3s
        "images_detected": 8,
        "charts_detected": 3,
        "reading_order_mismatches": 1,
        "findings": [
            {"type": "auto_advance_fast", "slide": 3, "timing_ms": 2500},
            {"type": "duplicate_title", "slides": [2, 6], "title": "Agenda"},
        ],
    },
}

5.3c.14 Testing Strategy

Test corpus (stored in tests/fixtures/powerpoint/):

Document Scenario
simple_title_slide.pptx Single slide with title and bullet points
multi_slide_structured.pptx 10 slides with titles, nested bullets, speaker notes
with_charts.pptx 5 slides with embedded charts; extract labels only
with_images.pptx 3 slides with images and captions
with_animations.pptx Slides with animations and transitions; verify all text extracted
legacy_format.ppt MS PowerPoint 97–2003 format; verify .ppt reader works
corrupted.pptx Intentionally malformed XML; verify graceful fallback
large_100_slides.pptx Large presentation; verify timeout protection

Unit tests (tests/unit/io/test_powerpoint.py):

def test_read_pptx_simple():
    doc = read_powerpoint_presentation(Path("tests/fixtures/powerpoint/simple_title_slide.pptx"))
    assert "Slide 1" in doc.text
    assert doc.source_metadata["quality_score"] > 80


def test_speaker_notes_extracted():
    doc = read_powerpoint_presentation(Path("tests/fixtures/powerpoint/with_speaker_notes.pptx"))
    assert len(doc.source_metadata["speaker_notes"]) > 0


def test_animations_detected():
    doc = read_powerpoint_presentation(Path("tests/fixtures/powerpoint/with_animations.pptx"))
    assert doc.source_metadata["extraction_diagnostics"]["animations_detected"] > 0

5.3c.15 Safety and Security

5.3c.16 Success Criteria

  1. Functional: Users open .pptx/.ppt, extract text readable with screen readers, speaker notes accessible
  2. Safe: No crashes on corrupted/malicious files, VBA not executed, user warned before data loss
  3. Discoverable: File > Open shows .pptx/.ppt as supported, in-app banner explains changes
  4. Tested: 10+ real presentations tested, unit/integration/a11y tests passing
  5. Documented: User docs explain what is and is not supported

5.3c.17 Future Enhancements (Post-v1.0)

5.4 Excel and CSV support (XLSX / XLS / CSV)

This section specifies Quill’s support for tabular data formats: Excel workbooks (.xlsx, .xls) and CSV files (.csv, .tsv). The design prioritizes accessible grid-based editing mode as an alternative to traditional spreadsheet UIs, with keyboard-first navigation and accessibility-aware data quality analysis.

5.4.1 Design Philosophy: Accessible Grid, Not Spreadsheet

Traditional spreadsheets (Excel, LibreOffice Calc) are visual-first and mouse-oriented. Quill reimagines grid editing for keyboard and screen-reader users:

5.4.2 Scope: CSV Mode as Universal Tabular Editor

CSV files (native support):

Excel files (.xlsx / .xls) - via MarkItDown bridge:

Why CSV as universal format:

5.4.3 CSV Mode UI and Keyboard Shortcuts

Status bar in CSV Mode shows:

📊 CSV Mode (15 rows × 4 columns) | Headers: ✓ | Delimiter: Comma

Navigation (arrow keys):

Cell editing (F2 mode):

Sorting and filtering (Shift+F10 / Right-click):

Selection (Shift+arrows):

5.4.4 Transformational Features (Data Quality Analysis)

Quick stats (Ctrl+Alt+S): When a numeric column is selected, Ctrl+Alt+S opens statistics panel showing Sum, Average, Median, Min, Max, Standard Deviation.

Data validation and quality (Ctrl+Alt+V): Findings-based approach: detect empty cells, duplicates, type mismatches, outliers, inconsistent formatting with quality score and auto-fix suggestions.

Pivot table (Ctrl+Alt+P): Group by column with aggregation (Sum, Average, Count, Min, Max).

Transpose (Ctrl+Alt+T): Flip rows and columns.

Find and replace with regex (Ctrl+H): Full regex support with capture groups.

Concatenate / Split columns (Ctrl+Alt+C / Ctrl+Alt+X): Combine or split columns by delimiter.

Unique values (Ctrl+Alt+U): Extract distinct values and create new sheet or copy to clipboard.

Conditional formatting as comments (Ctrl+Alt+F): Mark cells based on conditions (e.g., “Salary > $90,000” adds comment).

5.4.5 Multi-Sheet Excel Support

When opening Excel files with multiple sheets, user is prompted to load sheet, combine all, or create separate documents. Sheet navigation via Ctrl+Page Down/Up.

5.4.6 Format Detection (Type Inference)

When opening CSV or Excel files, Quill auto-detects column types (Text, Integer, Float, Date, Boolean, Currency), date format, currency symbols, encoding, and delimiters with quality assessment.

5.4.7 Save and Export Options

Users can save to CSV, TSV, Pipe-delimited, Excel (.xlsx), JSON, HTML table, Markdown table, or SQL INSERT statements with accessibility options (include column types, freeze headers, set widths).

5.4.8 MarkItDown Integration for Universal Format Conversion

Quill uses Microsoft’s MarkItDown library as a Tier 1 converter for Excel files, PDF tables, and web tables, converting all tabular formats to CSV for consistent editing.

5.4.9 Data Quality and Accessibility Patterns

Built-in audits for column type consistency, dataset completeness, duplicate detection, and outlier analysis with actionable suggestions.

5.4.10 Testing Strategy

Comprehensive test corpus including simple CSV, headers, mixed types, large files (10k rows), special characters, sparse data, malformed CSV, multi-sheet Excel, formulas, and legacy .xls files. Unit, integration, a11y, and performance tests all included.

5.4.11 Safety and Security

5.4.12 Success Criteria

  1. Functional: Users open CSV/Excel in accessible CSV Mode; grid navigation, sorting, filtering, transformations work
  2. Accessible: Screen readers announce cell position/type/content; all operations keyboard-available
  3. Magical: Quick stats, data quality audits, pivot tables, format conversion seamless
  4. Safe: No formula injection; large files handled gracefully; macros never executed
  5. Tested: Unit, integration, a11y, performance tests passing

5.4.13 Future Enhancements (Post-v1.0)

5.4.14 Table Studio — the accessible grid (shipped, experimental)

Goal. One genuinely usable, screen-reader-first surface for building and editing tables by ear — the accessible grid the CSV/tabular story above needs — so a blind user can create a table, or open a CSV, and navigate it cell by cell with the column spoken as they cross a row.

One surface, two entry points. Tools > Table Studio starts a new table; Tools > Open CSV in Table Studio (and, when the feature is enabled, opening a .csv/.tsv from File > Open in grid view) loads a file into the same grid. This consolidates what were two grid implementations into one: the legacy CsvGridSurface remains only as the fallback when Table Studio is disabled.

Architecture. A wx-free core (quill/core/table_studio/) owns the data — a format-neutral TableDocumentModel (cells/rows/columns, listeners, Markdown + HTML serialization), a TableController with a SpokenCellFormatter (Concise / Standard / Detailed profiles, JAWS-style “changed header” announcing), and CSV I/O with delimiter auto-detection. The UI (quill/ui/table_studio.py) renders it on a virtual wx.ListCtrl (SysListView32) with a row-indexed MSAA provider so NVDA/JAWS speak the active column; an optional compiled native UIA provider (quill/native/table_uia/, pybind11 → _quill_table_uia.pyd, staged into Windows builds when the toolchain is present) supplies richer cell-level focus/value/structure events, with the MSAA path as a transparent fallback.

Interaction. Arrow keys move by cell (Left/Right speak the column); Home/End, Ctrl+Home/End, Page Up/Down navigate; F2/Enter edit; Alt+arrows move a row or column; Ctrl+Insert adds a row; Delete clears a cell. A context menu (Shift+F10 / Applications key / right-click) exposes every action: Sort Ascending/Descending (numeric-aware, blanks last), Insert/Delete/Move rows, Insert/Delete columns, Rename Column Header, Promote First Row to Header (for a CSV whose header row was read as data), and Use First Column as Row Headers. All the same actions are also on buttons.

Industry-standard markup. Column and row headers are stored and exported the standard way — <th scope="col"> / <th scope="row"> in HTML and a proper header row (with alignment markers) in Markdown — so the structure round-trips and is legible to assistive technology and downstream tools. Finish by inserting the table into the document as Markdown or HTML, or Save to CSV to write it back to a file (a full round trip for an opened CSV).

Gating & safety. Experimental opt-in behind table_studio_experimental_enabled (Preferences > Experimental), which follows the Experimental master switch; the menu items appear only when it is on. No network, no external process; the native provider is a pure enhancement that never blocks a build or a run.

5.5 PDF handling in v1.0

This section is the canonical, scoped statement of what Quill 1.0 does with PDF files. Anything not listed here is explicitly out of scope for v1.0 and tracked in the backlog (section 17).

What v1.0 ships

  1. Tier 1, local extraction (automatic on open). A layered pipeline runs pdfplumber and pypdfium2 (with pdfminer.six as a third fallback) per page, scores each extraction for readability (line count, alphabetic ratio, average line length, presence of suspicious one-word-per-line patterns), and picks the best result per page. Pages are concatenated with explicit page markers so page navigation is exact.
  2. Page navigation. Page Up / Page Down move to the previous/next page marker. Ctrl+G opens Go To Page when page markers exist. The status bar’s Page cell shows Page n of m. Reaching the first or last page is announced.
  3. Embedded bookmarks (shipped). On open, a PDF’s embedded outline (Adobe’s bookmark tree) is imported into Quill’s own bookmark store so its entries appear in the Bookmarks Manager (Ctrl+Shift+G). io/pdf.py::extract_pdf_outline flattens pypdf’s outline tree to (title, page) pairs; _format_pdf carries them in source_metadata["pdf_outline"]; and main_frame.py::_import_pdf_outline_bookmarks resolves each destination page to a character offset (via the form-feed page markers, the same mapping “Go To Page” uses) and adds a bookmark. Import runs once, on first open — it is skipped when the document already has bookmarks, so it never duplicates entries or resurrects deleted ones. They are read-only with respect to the original PDF; renaming, adding, and deleting affect Quill’s own bookmark store.
  4. Password-protected PDFs (shipped). On open, Quill detects an encrypted (real user-password) PDF and prompts for the password through a standard modal dialog (wx.PasswordEntryDialog via _show_modal_dialog). The entered password is threaded through the read path (read_open_documentread_structured_documentextract_pdf_text(path, password=…)) to both extractors — pdfplumber.open(..., password=…) and pypdf’s reader.decrypt(…) — and the read runs off the UI thread like every other office/PDF open. A wrong password re-prompts (“that password did not open …”); Cancel aborts the open. The password is used in-memory only and is never written to disk, never logged, never sent over the network — reopening the file prompts again. Permissions-only “encrypted” PDFs (empty user password) open with no prompt. (io/pdf.py, io/structured.py, io/open_read.py, ui/main_frame.py::_ask_pdf_password.)
  5. Editable rendering, protected original. The extracted text is presented in the standard editor. Ctrl+S opens Save As so the original .pdf cannot be overwritten by extracted text. The Save dialog offers Text, Markdown, HTML, and All Files.
  6. Document metadata. Author, title, subject, keywords, creation date, modification date, and page count are accessible via File > Document Properties… and announced in Where Am I when the document is a PDF.
  7. PDF document statistics. Document Statistics (5.19) reports page count, image count per page (informational), tagged-structure presence, and language metadata.
  8. Accessibility audit, informational. The Accessibility Audit (5.20) reports whether the PDF has tagged structure, whether it has embedded bookmarks, the image-only page count, language metadata, and password protection state. It does not modify the PDF.
  9. Tier 3, AI-assisted reading order (opt-in, manual) — shipped. AI > More > Improve Reading Order… (also on the command palette) sends the current document’s text to the user-configured LLM provider (the user supplies the API key; nothing is bundled) to repair a jumbled reading order — multi-column PDFs, sidebars/text boxes, out-of-sequence lines. The action always asks for confirmation before sending, naming the provider and its host plus the approximate page size, and reports the outcome on the status line. It refuses, with a clear spoken message, if the document exceeds the configured page-count limit (settings.reading_order_max_pages, default 40), and is disabled in Safe Mode. Results open as a new unsaved document whose Save defaults to Save As; the original is never changed. (core/ai/assistant.py reading_order operation, ui/main_frame_ai_reading_order.py.)
  10. OCR for scanned PDFs. When tier 1 yields no readable text on a page, Quill offers a one-click escalation to local OCR via the bundled Tesseract (English shipped, additional languages downloadable). For longer scanned PDFs the user may instead escalate to tier 3 AI rendering.
  11. Selection and copy. Standard selection works in the extracted text. Ctrl+C copies as plain text. There is no copy-with-formatting target in v1.0 (the extracted text has none).
  12. Find, Outline, Bookmarks, Word Count, Document Statistics, Read Aloud, Accessibility Audit all work over the extracted text exactly as they do for any other document.
  13. Compare (Tools > Compare With File…) works against the extracted text representation; comparing two PDFs compares their extracted plain-text forms.

What v1.0 does not ship (deferred to backlog)

5.6 Improve reading order (shipped)

A general action under the AI menu (AI > More > Improve Reading Order…) and the command palette:

5.7 HTML and Markdown

5.8 Find and replace

Find in Quill is designed to feel like Microsoft editors and Visual Studio Code at the same time: F3 just works once a term is known, the search term is remembered everywhere it makes sense, and everything is announced.

The search term, the central concept. Quill maintains a single “current search term” per editor. The term is set by any of:

The status bar’s Search term cell (5.1b) displays the current search term and its options. Enter on the cell reopens Find pre-filled.

Keystrokes

History and persistence

Incremental preview (opt-in, off by default)

Predictable announcements

Find in current selection. When the editor has a non-empty selection at the moment Find or Replace is opened, the “In selection” checkbox is pre-checked; Replace All scopes its work to the selection. F3 and Shift+F3 continue to operate document-wide regardless, because muscle memory expects that.

Find next while typing. When the Find dialog is closed and the search term is set, typing in the editor does not interfere with F3; the search term is preserved until the user clears it (Ctrl+Shift+\ or status-bar cell context menu → Clear Search).

Project-wide Find (Find in Folder) is deferred to v1.2.

5.9 Spell checking (local-first, guided F7 review)

This section was significantly expanded in v0.2 of this PRD. See section 6 for the full architecture and decision rationale, and §6.4 for the complete F7 dialog specification.

QUILL ships its own spell checking engine, built on Hunspell dictionaries, designed from the ground up for screen-reader speed and predictability. It does not depend on TinySpell or any other external spell-checker process.

5.10 Word count and navigation

5.10 Bookmarks

Note: the section above predates the shipped implementation and describes an earlier design (Ctrl+B, surrounding-text fuzzy resolution); the as-shipped named-bookmark commands (navigate.set_bookmark/go_to_bookmark/list_bookmarks, default Alt+Shift+B for List Bookmarks) live in quill/core/bookmarks.py and MainFrame, storing name→position pairs per document via DocumentMemory. Reconciling this section with the shipped design is a separate follow-up; the two additions below are accurate as of 0.9.0 Beta 3.

Temporary bookmark — shipped 0.9.0 Beta 3. A single unnamed, one-shot jump point, distinct from named bookmarks: Ctrl+Shift+K sets it at the cursor with no dialog; Alt+Shift+K jumps to it with no picker. Session-only by design (never written to DocumentMemory) — it is disposable scratch state, not a durable anchor. Implemented as MainFrame.set_temp_bookmark/go_to_temp_bookmark, aliased per-tab exactly like the named-bookmark dict.

Numbered quick bookmarks — shipped 0.9.0 Beta 3. Ten fixed slots (0-9). The original design (see the now-superseded PRD draft in x.md) proposed routing set/jump through Quick Nav’s “press a letter, then a qualifier” grammar; direct user feedback during review asked for one-keystroke access with no sub-mode, so the shipped implementation instead intercepts Alt+Shift+<digit> (set) and Ctrl+Alt+Shift+<digit> (jump) directly in MainFrame._on_char_hook, alongside the existing frame-level Ctrl+K/Ctrl+W handling — the declarative keymap table has no “any digit” wildcard, so these 20 chords are intercepted rather than declared. Deliberately not a parallel storage system: quick_slot_name(slot) in core/bookmarks.py generates a reserved name ("Quick 3") that reuses the existing named-bookmark store, so numbered bookmarks get persistence, save/load, and crash-safety for free rather than needing a new DocumentMemory schema field.

Favorite folders — shipped 0.9.0 Beta 3, community feature request. A short, user-curated list of folders (quill/core/favorite_folders.py, wx-free, modeled on BookmarkVault), distinct from Windows’ recency-based recent-folders. Ctrl+Alt+Shift+A/Ctrl+Alt+Shift+R add/remove the current document’s folder; Ctrl+Alt+Shift+O opens Open From Favorite Folder — a VSCode-Quick-Open-style type-to-filter dialog (list_files_in_favorites/filter_favorite_files, both pure and unit-tested) scanning every favorite folder’s top-level files (non-recursive by design, to keep the filter instant) and matching by case-insensitive filename substring. Quill has no single-project-root “workspace” concept to scan the way VSCode does, so this is intentionally scoped to the curated favorites list rather than the whole disk.

5.11 Selection helpers and Where Am I

5.12 Command palette (Visual Studio Code style)

This is a centrepiece of v0.2. See section 7 for the complete design.

5.13 Document backups

5.14 Settings

Ctrl+, opens Settings, organised in a wx.Treebook:

5.14a Persistence and migration contract

All persisted user state follows one release-to-release contract so upgrades never strand users, hide new defaults, or pin stale ones. Canonical reference: docs/design/persistence-and-migration.md.

5.15 Plugin system (v1.1)

5.16 Outline Navigator (heading tree)

One of Quill’s signature productivity wins for readers and writers. Opens a tree of every heading in the current document, fully accessible, and lets the user jump anywhere in two keystrokes.

5.17 Jump-by-structure shortcuts

In-editor navigation by document structure, alongside the Outline dialog:

5.18 Line and block operations

VS Code-style editor productivity, all standard text-control operations:

5.19 Document statistics and reading metrics

Local, instant, never sends content anywhere.

5.20 Accessibility auditor

Quill’s audience is exactly the audience that needs to produce accessible documents. v1.0 ships a first-class auditor.

5.21 Table mode for Markdown and HTML tables

When the editor cursor enters a Markdown pipe table, a Markdown grid table, or an HTML <table>, Quill enters Table Mode and announces it.

5.21a Structured List Studio (F2) — shipped

Build and edit lists by concept, not syntax, so the user never types -, 1., [ ], <ul>, or <dl> by hand. The design PRD was retired to git history once delivered; this section is the canonical record. Implementation: wx-free quill/core/lists/ (model, parse/interpretation, render, nesting, convert, validate, settings, announce — fully unit-tested) behind quill/ui/list_studio_dialog.py and list_studio_settings_dialog.py.

5.22 Editor essentials

A group of small, individually unremarkable features whose absence would feel cheap. All ship in v1.0.

5.23 Recent locations history (browser-style back/forward)

5.24 Mark ring

A second, lighter-weight navigation system for power users. Inspired by Emacs but adapted to Windows muscle memory. Marks are temporary jump points; bookmarks (5.10) are named and more durable.

5.25 Read Aloud (secondary voice, not the screen reader)

Quill ships a read-aloud feature that uses a secondary voice the user picks from a list. It deliberately does not compete with the screen reader: the screen reader keeps doing its job while read-aloud plays, and the user can use a different voice for each so they are easy to tell apart.

Read Document in Browser (experimental, opt-in — edge_read_aloud_enabled). The embedded WebView2 only exposes the local SAPI voices; a real browser exposes its full Web Speech voice set, including Edge’s “Online (Natural)” voices. So, gated behind Settings → Experimental (and the experimental acknowledgement), QUILL can build a self-contained, accessible reader page (quill/core/browser_reader.py: labelled voice picker, Speed, Play/Pause/Stop, an aria-live status, keyboard focus on Play, the document text carrying its own lang) and open it in the user’s chosen browser via the existing preview-open path. The page builder is wx-free and unit-tested. Privacy contract: QUILL itself makes no network call for this; on-device voices synthesize locally, but the browser’s “Online (Natural)” voices synthesize in the vendor’s cloud, so choosing one sends the selected text to that service. This is disclosed in the setting description and in the network-egress audit’s documented (out-of-package) egress notes; the generated page carries the full document text as plaintext in app-data and is therefore deleted on exit (browser_reader.remove_reader_pages) so no copy lingers. Playback speaks chunk-by-chunk and treats Stop’s interrupted/canceled events as normal teardown (never surfaced as errors).

Voice preview feedback

Voice preview (Voice Browser dialog and the Download Optional Components hub’s Test button) reports its own state: starting a new preview always stops/supersedes a prior one (no overlapping audio or announcements), a one-shot “generating, please wait” cue (earcon + optional spoken announcement, voice_preview_announce_generating, default on) fires only when synthesis is still running after a short delay, and the Preview/Test button toggles to Stop while a preview is generating or playing. No true pause/resume; Stop cancels outright. An in-flight external synthesis call that gets superseded is not force-killed — it completes in the background and its result is discarded.

Reliability fixes (2026-07-08)

A batch of fixes from real user feedback and crash reports, landed alongside voice preview feedback:

API-key onboarding for env-var-authenticated harnesses

openai_agents and claude_agent_sdk both declare requires_api_key=True but, unlike GitHub Copilot’s OAuth device flow, authenticate by reading OPENAI_API_KEY/ANTHROPIC_API_KEY directly from the process environment with no QUILL-specific hook — so a successfully installed harness previously had no in-app way to add a key at all. quill/core/ai/harness_credentials.py bridges this the same way copilot_auth.py bridges GitHub tokens: the key is persisted in QUILL’s existing per-provider secure store (assistant_ai.save_provider_api_key, keyed by pack_id) and exported to the environment immediately so the SDK picks it up without a restart; apply_all_stored_keys() runs at startup so a key saved in an earlier session is already in place before anything checks harness availability. HarnessApiKeyDialog (quill/ui/harness_api_key_dialog.py, modeled on CopilotOnboardingDialog but simpler — one pasted key, no OAuth) is reached from the AI Hub Engines tab’s Set Up action once a harness is installed.

5.25b Watch Folder automation

Quill provides an optional watch-folder workflow under Tools -> Watch Folder for low-friction document intake. Users can point Quill at one or more folders, drop supported files into them, and have Quill process those files automatically without leaving the editor.

The original single-folder watcher has been replaced by a multi-profile Watch Service built on the wx-free quill.core.watch_service facade. Each watch profile is an independent rule set with its own watched folder, recursion and polling behaviour, file filters, and action (for example: open in editor, or run an intake action). Profiles are stored through watch_profile_store and validated against a schema, so an invalid or partial profile can never start a worker.

5.25c Offline speech and transcription

Quill’s on-device speech engine (whisper.cpp by default) ships under Tools -> Speech and runs entirely on the user’s machine. The deferred, locked-off core.bw_whisperer brand surface (superseded by this flat menu) is tracked in the locked-features register in roadmap.md §2; this section describes only what ships today.

On-demand engine acquisition (release-asset). The whisper.cpp engine is not bundled (see §5.25f, Recommended host and redistribution rules): it downloads on demand from QUILL’s verified release asset to %APPDATA%\Quill\speech-engine, which the runtime searches. The first time offline dictation/transcription is used without it, QUILL offers the download in-flow (the dictation pre-flight prompts), and it is also at Tools -> Speech -> Download Offline Speech Engine.... Acquisition is quill/core/release_assets.py (wx-free): a pinned, SHA-256-verified download from QUILL’s controlled GitHub release asset (assets-v1), HTTPS-only, with retry/resumable download, atomic verified install, and a clean error on failure; gated by an explicit user action, the GATE-9 network-egress audit, and Safe Mode. Upgraders keep their existing {app}\tools\speech\whispercpp copy (Inno never removes it; [InstallDelete] does not touch it; the resolver still checks it), so upgrades never lose offline speech. Only components QUILL is licensed to redistribute are hosted this way (whisper.cpp is MIT, Kokoro Apache-2.0); license-unclear engines are not re-hosted, and ffmpeg is never re-hosted (it stays user-installed).

Kokoro voices unbundled (proof of concept). The ~120 MB Kokoro neural voices are no longer shipped in the installer; they download on demand through the same release_assets path (pinned kokoro-models.zip on assets-v1, SHA-256-verified) to %APPDATA%\Quill\kokoro-models, which the runtime prefers. This shrinks the base installer and proves release-asset hosting for a model component. Upgraders are protected automatically: Inno only overlays new [Files] and never removes old ones, [InstallDelete] does not touch kokoro-models, and runtime resolution still checks {app}\kokoro-models (read_aloud._bundled_kokoro_model_dir) — so a user upgrading from a release that bundled Kokoro keeps their copy with nothing to re-download. Kokoro is in the “safe to unbundle” class (other read-aloud voices remain available offline if the download has not happened). The kokoro-onnx package is now also unbundled (0.9.0 Beta 2): it was still shipped in the base runtime (dragging in onnxruntime, phonemizer, espeakng-loader, and babel) even though the models were on-demand. It now installs on first use via install_kokoro_onnx() into an engine-pack that activate_engine_packs() puts on sys.path, trimming the installer ~23 MB. Lesson banked (#881): the build’s runtime-prune step must not drop babel — kokoro-onnx imports babel.numbers transitively, so pruning it broke Kokoro’s onnx path on a clean build; the on-demand install now pulls babel with the rest of the tree.

DECtalk and eSpeak NG unbundled. The classic DECtalk runtime (~2 MB) and the eSpeak NG engine with its voice data (~40 MB) are no longer shipped in the installer; each downloads on demand from assets-v1 (DECtalk via quill/core/dectalk_runtime.py, pinned vs2022.zip, SHA-256-verified, to %APPDATA%\Quill\speech\dectalk; eSpeak via quill/core/speech/espeak_install.py, pinned espeak-ng.msi, SHA-256-verified and extracted admin-free with msiexec /a, to %APPDATA%\Quill\speech\espeak-ng) — both folders the resolver already searches. The two MSI/zip assets are QUILL-hosted, byte-identical re-publishes of the upstream releases (matching the bundled-build pins) so the on-demand path has one controlled, verified acquisition point. Windows’ built-in SAPI 5 voices remain the always-present offline floor while a download has not happened, so the user is never left without a voice. Upgraders keep any existing {app}\tools\speech\dectalk / espeak-ng copy (Inno never removes it; [InstallDelete] does not touch it; the resolver still checks it). eSpeak NG is GPLv3 and DECtalk is BSD-licensed; both are already covered by QUILL’s redistribution/compliance notices, so re-hosting carries the same terms as bundling did.

Cloud providers ship as Quillins, not core. Per the consolidation plan (#669), the cloud provider matrix is delivered as extensions rather than baked into core. A Quillin declares a provider through the transcription_providers manifest contribution; QUILL’s host implements it. The contract is host-mediated and least-privilege: the Quillin ships no code and requests no net capability — it only declares the provider (id, display_name, a host-vetted kind, optional credential label and max_file_mb). The host performs the upload through its existing network-egress-audited transcription path, so the sandbox never receives audio bytes or the API key, and a manifest can never target an arbitrary endpoint (only known kinds are accepted). Host adapters implement the SpeechToTextProvider ABC and register into the speech registry, so a contributed provider appears in Manage Speech Models and the transcribe pickers. Providers are tagged network-backed and are excluded from the offline paths (the offline Watch Folder transcribe action only ever uses on-device engines); they are unavailable in Safe Mode or without a configured key. Implementation: quill/core/speech/quillin_providers.py, the transcription_providers model/validation/schema in quill/core/quillins/, and the bundled reference Quillin openai-whisper-transcription. Developer reference: docs/quillins/quillins.md §“Transcription providers”.

Offline speech & dictation reengineering (#617). The full provider-architecture plan — one offline-first speech engine (whisper.cpp default, with Windows, cloud, and Faster Whisper providers), two user verbs (Dictate / Transcribe), an accessible model manager, captions, and gated voice commands — was sequenced into small waves S0–S5. Wave S0 (shipped): the dictation_engine setting was made honest — it now uses the offline/windows/cloud model (default windows, the only functional engine today), legacy vosk/whisper values migrate to offline, and the dead local-recognizer stub was removed. Dictation behavior is unchanged: it still drives the OS dictation panel until the offline engine lands in S2–S3.

Waves S1–S4 (shipped). The offline engine landed: a whisper.cpp provider with an accessible model manager (Hugging Face Hub downloads, Safe-Mode gated), Transcribe (plain text / Markdown / HTML), Generate Captions (SRT / VTT), push-to-talk Dictate at the cursor (distinct earcons, status-bar state, microphone picker, QUILL Key + Shift+D), and speaker attribution via the whisper.cpp speaker-detection model. The whisper.cpp engine downloads on demand (unbundled, §5.25c above), and sounddevice is bundled for capture. S4 added Faster Whisper as an optional, GPU-aware second engine (fasterwhisper extra): when present it appears in a Speech Engine chooser, the choice is saved in speech_provider, and it is used for transcription, captions, and dictation. whisper.cpp remains the default and needs nothing extra; Faster Whisper does not attribute speakers.

Locked Dictation (shipped, 1.0-complete). One keyboard-only dictation mode on the offline engine: Ctrl+F9 toggles a hands-free Locked session (Ctrl+Shift+F9 pause/resume, Alt+F9 speak-state, Escape keep / Shift+Escape discard). Hold-to-Dictate (hold F9) shipped in 0.7.0 and was removed: a held key repeats and announces itself endlessly under a screen reader, so the single locked gesture is the reliable one (the controller’s hold states remain in the wx-free core for compatibility, but no UI binds them). Built as a protected transaction in quill/core/speech/dictation/: an explicit DictationController state machine (single-recorder invariant; no bare boolean), audio saved to recovery/dictation/ before transcription, a five-minute / focus-loss auto-stop, and a transcript that can’t be safely inserted is kept for review rather than lost. Two accessible surfaces under Tools > Speech > Locked Dictation: a Dictation Settings panel (session time limit, stop on focus loss, intelligent spacing, reset of the one-time hint) and a Dictation History & Review window (insert/copy/discard recovered recordings; doubles as the startup-recovery prompt). Locked Dictation has distinct earcons and a one-time onboarding hint. File synthesis runs with no console-window flash and a timeout.

5.25f AI footprint, on-demand acquisition, and optimization

QUILL’s guiding aim is to be as capable as possible on whatever hardware the user has, while keeping its on-disk and in-memory footprint small — fitting more capability onto modest, CPU-only machines, never trimming features off the low end. This section is the durable reference for that stance and for the acquisition model every on-demand component obeys. It absorbs the former standalone footprint/optimization plan. The wx-free cores for the runtime-memory policy (quill/core/model_lifecycle.py), the machine-aware upgrade advisor (quill/core/model_upgrade.py), the consent-safe cloud↔︎local fallback (quill/core/ai/fallback.py), and the in-app measurement sampler (quill/core/footprint_sampler.py) have landed with unit tests, along with two accessible settings (Low-resource mode and Unload idle models after…) and a build-only Babel prune. Their live wiring into the running app, and the build/hardware-gated measurements and installer-size wins, are tracked in roadmap.md §1.2.

Design principles (firm constraints).

Already optimized on the model side. QUILL ships quantized models throughout (Faster Whisper CTranslate2 int8 on CPU / float16 on CUDA; Kokoro int8 ONNX; whisper.cpp GGML-quantized; local LLMs as GGUF Q4/Q5 via Ollama / llama_cpp_backend), with lazy, optional loading (heavy ML imports deferred via importlib.util.find_spec, imported only on use), warm/unload lifecycle primitives on the speech and Kokoro providers, optional install components with on-demand downloads, and machine-aware recommendations that size suggestions to the user’s RAM / GPU / disk. So the remaining wins are in packaging, runtime memory, and routing — not in re-quantizing models.

Reliable acquisition model. Anything QUILL does not bundle becomes something it must fetch dependably, possibly years later when an upstream URL may have moved. Every on-demand asset obeys all of the following (the quill/core/release_assets.py, ai/model_manager._download, and scripts/fetch_bootstrappers.py paths encode this):

  1. Pinned, versioned source + SHA-256. Every asset has a pinned URL (or pinned release tag/commit) and a recorded SHA-256; bytes are verified before use, and a moving ref (HEAD/latest/main) is refused for the pinned path.
  2. GATE-9 / no-silent-network. Each download call site has a reviewed entry in network_egress_audit.py and a visible, consented surface (progress dialog / explicit action). No silent fetches; Safe Mode disables all of it.
  3. Atomic + verified install. Download to a temp path, verify the SHA, then atomically place it (temp + replace) so a partial/failed download never leaves a half-installed asset that looks present.
  4. Retry + resumability. Bounded retry with backoff and resumable (HTTP Range) download so a dropped connection on a large asset does not force a full restart.
  5. Source resilience. Pin to a stable host and record a mirror/fallback URL where licensing allows, so a single upstream outage cannot strand a feature. The verified SHA makes any mirror safe to trust.
  6. Graceful offline / failure UX. On no-network, timeout, 404, checksum mismatch, rate-limit, or low disk (enough_disk_for), surface a clear, screen-reader-friendly message and leave the app fully usable for everything else. Never crash, never silently degrade.
  7. Integrity over time. A periodic/CI acquisition healthcheck resolves every pinned URL and re-checks reachability + checksum, so a dead upstream is caught by us, not by a user offline at the worst moment.
  8. Already-present is smart, not silent. An installed component is not re-downloaded; QUILL offers to replace it, and declining keeps the existing copy.
  9. Newer-version awareness. Each asset records its upstream version; a lightweight check compares installed vs. pinned manifest and offers a newer verified version — never auto-replacing a working component.

Recommended host and redistribution rules. The strongest way to satisfy source resilience is to host redistributable assets ourselves as GitHub Release assets on a Community-Access repo (the pinned assets-v1 tag). This re-points the runtime on-demand path at a release we control, with stable …/releases/download/<tag>/<asset> direct-download URLs (not the rate-limited releases API), each SHA-256-pinned and honouring HTTP Range for resumability. Hard constraints:

The download experience is accessible by contract. Each on-demand download runs behind an accessible, cancelable progress dialog (AIProgressDialog) with spoken milestones; focus is owned and returns to the feature that needed the component. Downloads are reachable where the user already is (the feature surface, a Help-menu “Download optional components…” entry, and an optional first-run Setup-Wizard offer), never block the UI thread, are disabled by Safe Mode, and present one consistent surface across OCR, Scribe, speech, and voices (the AI Hub Services framework).

The Download Optional Components hub is a first-class place (design spec: docs/superpowers/specs/2026-07-07-download-components-experience-design.md). The wx-free status model (quill/core/optional_components.py) sorts components by importance (Pandoc and the braille pack first) and covers every component QUILL can fetch — a completeness guard test asserts each hosted release_assets component is catalogued (Vosk and libmpv are hosted assets folded into other rows rather than standalone ones — see below — and are explicitly exempted from that guard), and Piper and Node.js (missing before) are now listed. The dialog (quill/ui/optional_components_dialog.py) shows a size on every row and a rich, read-only description of the focused component (describe_component), and gathers the list on a worker thread so it opens instantly rather than stalling on tool version probes. For an installed component it offers Test — a per-component self-test that maintains confidence: voice engines speak a sample (the phrase lives in scripts/phrase.txt), offline STT engines run a SAPI→transcribe→compare loop and report what they heard (verify_component), and tools report their version — and Remove (removable_path/remove_component), which deletes only QUILL-downloaded copies under the active data dir (the portable data folder in portable mode; never a system tool, a bundled {app} copy, or anything outside it) and closes the loop by resetting the active Read Aloud engine to SAPI 5 when the removed engine was in use. Any download or self-test failure is captured (DownloadFailure, plus the pip error logged to quill.log) and offered as a one-click bug report through the diagnostics bundle. The scattered startup braille-pack prompt now routes into this hub, preselected on the braille pack with a guiding popup, rather than running its own installer.

Rows were folded together for a flatter, less repetitive list (0.9.0 Beta 2, #847). Vosk (a third offline dictation engine, alongside whisper.cpp and Faster Whisper) no longer has its own row — the hub’s single “Dictation (offline speech)” row (renamed from “Offline speech engine (whisper.cpp)” so it names the outcome, not the implementation) opens a guided picker to choose an engine and a model together (quill/core/speech/guided_setup.py, quill/ui/guided_speech_dialog.py); Vosk is simply a third OfflineSpeechEngineOption there, installed via the same _ensure_offline_engine/install_vosk path as the other two. Separately, FFmpeg (compressed-audio export), mpv playback, and MP3 chapter markers — three export/playback extras — are now one row, “Audio: export, playback & chapters” (_audio_extras_installed, download_audio_extras; FFmpeg is fetched on demand at export time via download_ffmpeg, not as part of the row’s own download), so there is one place for audio extras instead of three; the standalone FFmpeg row is gone. Node.js moved to the bottom of the list as the least-used extra (priority=110).

The dictation setup is a guided Engine→Model→Test→Default journey (0.9.0 Beta 2). A wx-free brain, guided_setup.dictation_setup_status() (unit-tested), computes the three-step state (install an engine → download a model → test and set default) for the selected engine, and the Manage Speech Models panel (quill/ui/speech_setup_dialog.py) renders it as a “you are here / do this next” banner, auto-selects the recommended model, and adds an inline Test dictation button. The panel now opens on the user’s saved engine even when it is not yet ready (MainFrame._configured_speech_provider, resolved from the shared registry so the engine radio matches by identity), instead of the availability-fallback default that made it always reopen on whisper.cpp. The self-test (optional_components.verify_component_verify_stt) targets the selected provider (transcribe.transcribe_audio_file(provider_id=…) / provider_has_installed_model) so it proves the exact engine, covers all three offline engines (whisper.cpp, Faster Whisper, Vosk), and logs its OK/FAILED outcome so failures reach the diagnostics bundle rather than living only in the on-screen dialog. Testing an engine with no model installed still routes to the guided picker (open_guided_offline_speech); a successful Test also persists the engine as the dictation default, so Test doubles as “use this engine.”

Size / RAM reference (current observations, to be re-baselined by the footprint report). scripts/footprint_report.py emits the diffable size/machine baseline under docs/planning/footprint/. Offline speech models (download size; Faster Whisper’s on-disk int8 is smaller), with the RAM tier service.required_ram_gb maps each to:

Model Download Accuracy Speed Est. RAM tier
tiny 75 MB low fast ~2 GB
base 145 MB low fast ~2 GB
small 465 MB medium medium/fast ~4 GB
medium 1500 MB high slow ~6 GB
large-v3 3100 MB highest slow ~8 GB

Speech recommender (service.recommend_model_id) by total RAM: <3 GB → tiny; <6 → base; <12 → small; <16 → medium; ≥16 → medium (or large-v3 with a GPU). On-device LLM (GGUF Q4_K_M, ai/model_manager.py; auto-downloaded + SHA-256-verified): Llama 3.2 1B Instruct ~0.8 GB (default under 8 GB RAM), Phi-4-mini Instruct ~2.5 GB (8 GB+). The Windows installer (0.8.1 local build) is ≈ 245 MB, dominated by the embedded CPython 3.13 runtime + wheels; the embeddable stdlib is already lean (python313.zip 3.6 MB; tkinter, test, idlelib, ensurepip, lib2to3, distutils all absent), so the footprint lever is large data inside wheels (spell-check dictionaries, CLDR locale data — Babel is now pruned as build-only) and optional engines that qualify for on-demand, not stdlib modules. The largest optional engine, Vosk (~51 MB libvosk wheel), is no longer bundled: it installs on demand via quill/core/speech/engine_install.install_vosk (Tools → Speech → Download Vosk), preferring QUILL’s own pinned, SHA-256-verified wheel on the assets-v1 release and falling back to PyPI. No offline speech engine ships in the installer now — the tiny default whisper.cpp (~8 MB) is itself fetched on demand — so the base download stays small while capability is a one-time verified fetch away.

5.25d Batch Document-to-Speech, pronunciation, and SSML (shipped)

QUILL converts whole folders of documents to speech audio and gives the user fine control over how that audio sounds. This section records the shipped behavior; the original design plan and project-format spec were retired to git history once delivered (their remaining follow-ups are tracked in roadmap.md §1.2). All synthesis logic is wx-free and headless-testable; the UI wraps it on the background task pool.

QUILL Audio Studio (Tools → Speech → Audio Studio…). A guided, journey-aware wizard (it replaced the single-page Batch Export to Speech Audio dialog at full option parity; the tools.speech_batch_export command id is unchanged so keymaps and menu customizations keep working). The start page picks a journey — narrate documents or combine audio files into one chaptered audiobook — and the following pages adapt: What should I read? (folder, types, filters) → Who should read it? (engine, voice + preview, pace, round-robin rotation, translated editions) → How should chapters work? (mode, spoken headings, the transition sounder + volume, article/sentence/tail gaps) → Output and diagnostics (format, existing-file policy, ACX loudness, dry run, spoken-text sidecars, temp folder) → the book page → a plain-sentence summary. Back/Next/Start follow the standard wizard keyboard contract, every step is announced (“Step 2 of 7: What should I read?”), and Skip to summary fast-forwards a returning user whose project profile already fills every page. AudioStudioWizard (quill/ui/audio_studio/) collects the same BatchSpeechRequest the classic dialog produced, so the tested runner and profile persistence are unchanged. The run reports per-file progress and is cancelable; the conversion uses the same shared pipeline as live Read Aloud (normalize → pronounce → polish → synthesize), so audition matches output.

Pronunciation dictionaries (Tools → Speech → Manage Pronunciations…). Ordered substitution rules (literal or regex, optional case sensitivity, per-engine scope) stored in global (app-data) and project (<folder>/.quill/pronunciation/) dictionaries (quill/core/speech/pronunciation.py, schema pronunciation.json). Applied as a silent text transform before synthesis in both batch and live Read Aloud, so a correction is heard everywhere; the manager previews entries with live Read-Aloud audio. A bundled starter dictionary ships common terms.

TTS text normalization. quill/core/speech/text_normalize.py deterministically cleans typography (quotes, dashes, ellipses, invisibles, ligatures, bullets, symbols, fractions, emoji) and speaks structured tokens — phone numbers, emails, URLs — clearly (announce / speak / speak-then-repeat, with a long-URL threshold). It also speaks publication shorthand (Vol.→“Volume”, No.→“Number”) and resolution-style numbers (2025-02→“2025 dash 2”), both on by default and opt-out (ACB pipeline learnings). Opt-in via the tts_normalization setting and carried per project.

SSML. An SSML Builder dialog composes <speak> markup (emphasis, <break>, say-as, phoneme, prosody) from accessible controls with a source preview. Read Aloud renders SSML natively on SAPI 5 and eSpeak-NG (markup mode), passing utterances through intact (no punctuation verbalization that would corrupt the markup).

5.25e Build Audiobook from Folder — ChapterForge surface (shipped)

The ChapterForge-aligned “folder of audio → one chaptered master” feature (design source: the sibling ChapterForge project; only the surfaces that fit QUILL’s audiobook vision are ported). Audiobook building is folded into the Audio Studio (Tools → Speech → Audio Studio…) — the combine audio files journey goes straight to it, and in the narrate documents journey the book page’s Assemble the results into one audiobook reveals the book tags (title/author/narrator/genre/year), a cover-image picker, the book format, and a save-as path. After the documents are synthesized (one chapter per document), the produced — plus any pre-recorded — audio in the folder is combined into a single chaptered MP3 (ID3 CHAP/CTOC) or M4B (native MP4 chapter atoms) master. Implementation: wx-free quill/core/speech/audiobook.py — folder scan + natural sort, title_from_filename (strips a leading track prefix), find_cover (preferred-name image discovery), probe_duration_ms (ffprobe), and the ffmpeg concat-demuxer build (chapters from chapters.compute_chapters, tags + cover from FFMETADATA / attached picture; MP3 chapters via mutagen). The book’s tags and an auto-detected cover are written, and the build runs on the background task pool. (The former standalone audiobook_builder_dialog.py was retired into this dialog so the two near-identical surfaces share one accessible path.)

5.25g Universal Audio Converter (shipped, #1255)

A general-purpose audio format converter — distinct from the audiobook/speech pipeline — that changes audio files between formats and extracts audio from video, entirely on-device through the bundled FFmpeg.

5.25a Speech Experience Platform (planned before implementation)

This section defines the next speech milestone as a complete user-facing platform, not a single settings dialog.

Goals:

5.25a.1 AI menu information architecture

The top-level AI menu gains a Speech submenu with discoverable, keyboard-rebindable commands:

The Speech submenu mirrors the command palette and status surfaces, with live keybinding labels exactly like other menu items.

5.25a.2 Engine model and no-bundle policy

Supported engines for this milestone:

Policy:

5.25a.3 Voice library and download UX

Speech Center includes a searchable voice catalog with filters:

Each voice row exposes:

Download behavior:

5.25a.4 Voice preview and A/B comparison

Users can preview voices before and after install.

When pre-install preview audio is available from metadata, Quill may fetch a small sample clip on demand. If no preview clip exists, preview requires install and Quill states this clearly.

5.25a.5 Preferred voice and defaults model

Users can star one voice as Preferred with one command.

Resolution order for active voice:

  1. explicit per-document override (future-compatible field)
  2. explicit per-language preferred voice
  3. global preferred voice
  4. engine fallback voice

Setting a voice as preferred is immediate and announced in plain language.

5.25a.6 Per-voice configuration and platform defaults

Every installed voice has editable settings saved as a profile, and users can mark any profile as default.

Per-voice settings (minimum set):

Platform-aware defaults:

This gives users both broad defaults and precise per-voice control.

5.25a.7 Voice removal and storage hygiene

Manage Installed Voices supports safe removal:

If the removed voice was preferred, Quill prompts for a replacement or applies deterministic fallback and announces it.

5.25a.8 Speech onboarding flow

First-time speech onboarding starts when the user opens Speech Center with no installed downloadable voices.

Flow:

  1. Explain available engines and that voices are optional downloads.
  2. Ask preferred language/locale.
  3. Recommend starter voices for Kokoro and Piper.
  4. Offer quick preview where possible.
  5. Download selected voices.
  6. Ask user to mark preferred voice.
  7. Offer one-click “Make these settings my default”.

The onboarding flow can be re-run via Help -> Run Onboarding Again and AI -> Speech -> Open Speech Center....

5.25a.9 Accessibility and safety requirements

5.25a.10 Delivery phases (bold, realistic)

Phase 1: Foundation

Phase 2: Downloadable voices

Phase 3: Advanced polish

5.25a.11 Acceptance criteria

5.25a.12 DECtalk compatibility evaluation track

Quill should explicitly evaluate adding DECtalk as an optional speech engine path.

Why consider it:

Plan scope (evaluation first, not automatic ship):

Hard gates before general availability:

User experience requirements if approved:

Positioning:

5.26 Number lines and strip line numbers

A small tool that addresses a real recurring user request.

5.27 Open from URL

5.27a Remote Sites (FTP, SFTP, HTTPS, WebDAV, S3)

Remote I/O is the natural extension of “Open from URL”: once a user has a saved site, opening and saving is one click, with the same explicit-safety guarantees.

5.28 Format-aware bracket and quote navigation

5.28a Document Language profiles and auto-detection (#181)

Each document has a language profile (quill/core/language_profile.py) describing one programming/markup language: extensions, indent convention, comment syntax, brackets, keyword set, and a markup_kind ("html", "markdown", or "plain"). Recognised languages: HTML, Markdown, CSS, Python, JavaScript, TypeScript, C, C++, C#, PHP, Go, Rust, Kotlin, Shell, YAML, JSON, TOML, SQL, plus a plain-text fallback. The profile is wx-free, pure data.

Automatic detection (quill/core/language_detect.py, wx-free, deterministic, no ML) scores the buffer over the languages above using weighted structural signals, then applies confidence discipline modelled on VS Code’s detector: an absolute floor, a relative margin over the runner-up, ambiguity penalties (YAML/TOML/SQL), optional session-history bias, and a should_switch() hysteresis gate. It returns language=None (plain) when unsure and does not misfire on prose or ASCII-braille.

5.29 Format menu (text transforms, no styling)

A dedicated menu surfacing transforms that are otherwise reachable via Tools or shortcuts. The menu exists because discoverability matters more than purity.

5.29a Magical HTML and Markdown tag picker

5.29b Snippet insertion and trigger expansion

5.29c Word prediction and tag IntelliSense

5.30 Bookmark export and import

5.31 Trusted locations (Office-style)

5.32 Jump List and shell integration

5.33 Diagnostics and bug reporting

5.33a Crash reports offer to submit (#622)

5.34 Welcome and Keyboard Reference

5.35 Notifications centre

5.36 “What changed on save” (opt-in)

A small family of related commands that make working with links comfortable for screen-reader users.

5.38 Document properties and front-matter

5.39 Autosave control and persistent undo

5.39a Restore points — per-save document history (shipped 0.9.0 Beta 1)

The first shipped slice of the QUILL Sync plan (docs/planning/quill-sync-plan.md, section 7): document versioning, entirely offline, with no sync engine involved.

Model. Every successful save snapshots the document’s canonical text into a content-addressed store (restore_points/<doc-key>/blobs/<sha256>.txt + an atomically-written index.json, under the QUILL data dir). Content addressing makes unchanged saves free and gives dedup without reference counting. The store is wx-free, strict-typed quill/core/restore_points.py.

Invariants.

UI. File > Restore Previous Version... (command file.restore_previous_version, assignable, no default key) lists versions with speakable labels (“Today at 4:12 PM - 2,341 words”, “(before a restore)” marking pre-restore snapshots), skips the version identical to the current editor text, and offers Restore (confirmed, announced, one editor-level undo of a restore via the pre-restore snapshot) and Open as Copy (a new untitled tab). Settings: Keep restore points when saving (default on) and the disk limit, both with speakable specs.

Relationship to the other safety nets. Persistent undo is in-session; core.backups keeps the single pre-save .bak; restore points are the cross-session, long-horizon history above both. The Sync plan’s later phases reuse this exact store as the sync engine’s version layer.

5.40 Paragraph, line, and footnote refinements

5.41 Search-and-replace preview and saved searches

5.42 Sort and transform details

Tools → Sort Selected Lines… and the related transforms (5.18) are spec’d here in full:

5.43 Save All with conflict detection and per-file format memory

5.44 Per-document scratchpad

5.45 Section folding (announce-only) — shipped 0.9.0 Beta 3

A folding model designed for screen-reader users: fold state is spoken metadata, never visual line-hiding, and the document text is never mutated. Implemented in quill/core/code_folding.py (pure region detection, wx-free) plus MainFrame command wiring; see x.md’s original “PRD: Code Folding” for the accessibility rationale behind the design decisions below.

Two kinds of foldable region are detected, both reusing existing infrastructure rather than adding a new parser: heading sections (via the existing extract_outline_entries, so a heading’s region runs to the next heading at the same-or-higher level) and fenced code blocks (``````, each complete fence is one atomic region). The original spec above called for heading-only folding with a fold-to-level chord sequence; the shipped version covers headings and code fences with a simpler single-toggle model, and does not yet implement fold-to-level or Find auto-unfold — both remain open follow-ups (see below).

Not yet implemented (open follow-ups from the original spec): fold-to-level chord sequences (Ctrl+Shift+K Ctrl+Shift+0/J); Find auto-unfolding a region it matches inside (moot today since folding never hides text from Find or any other text-reading operation — only the four commands above are fold-aware); per-function/indentation-based folding for real source files edited via a Quillin (fenced-block granularity is the right scope for a writing app with embedded code, not an IDE, and was an explicit scope decision, not an oversight).

5.46 Spell-check ignore directives and manual bilingual flag

5.47 Word boundary mode, trailing whitespace, case-change announcement

5.48 Multi-format export and reading view

5.49 Welcome-back snapshot

5.51 Settings export, import, and partitioned reset

5.52 Document fingerprint

5.53 Unicode insert

5.54 Idle-time prefetch

5.55 Atomic on-disk stores

5.56 First-run onboarding

A five-step welcome flow the first time Quill launches. Each step is a normal modal page (no carousel, no animation). Skip is always available; the chosen values are written through the same Settings code path as any later change.

  1. Profile: System, Word-like, Vim, Emacs.
  2. Theme: System, Light, Dark, High Contrast.
  3. Spell-check languages: multi-select from the bundled launch set; the active document language is added automatically when later opened.
  4. AI provider: Off (default), Ollama local, Ollama Cloud, OpenAI, Anthropic/Claude, OpenRouter, Gemini, or a custom OpenAI-compatible endpoint. (Azure OpenAI is not currently supported; it is a possible future addition — see AI-15.) Providers include sensible default hosts where possible; advanced custom mode allows manual endpoint override. If a provider is picked, the API-key dialog opens when required; the key is stored via Windows Credential Manager with a DPAPI-encrypted fallback (10.11). Network is never used without explicit per-action consent.
  5. Telemetry: confirms it stays off (default). A short plain-language sentence explains what telemetry would collect if turned on later.

The onboarding completion writes %APPDATA%\Quill\onboarding-complete.json. Trust and privacy consent acknowledgement is stored separately in %APPDATA%\Quill\trust-consent.json and is versioned so policy text updates can require re-acknowledgement. Re-running Help → Run Onboarding Again reopens the flow without resetting anything else.

5.57 Privacy summary

5.58 Licenses screen

5.59 Crash recovery transparency

When Quill detects an autosave snapshot newer than the on-disk file on launch:

Offer suppression on inconclusive exits — shipped 0.9.0 Beta 3 (#940/#948). begin_session() in core/recovery.py now calls _log_shows_actionable_error(logs_dir) before appending a RecoveryOffer: it scans the tail (last 256 KB) of quill.log for ERROR/CRITICAL/Traceback markers, and suppresses the offer entirely when none are found – an exit with no error evidence in the log is indistinguishable from an external termination (OS shutdown, forced close) and gets no “Quill detected an unclean exit” prompt. A missing log file fails open (still offers recovery) since an absent log is inconclusive, not evidence of nothing having happened. The autosave snapshot itself is untouched either way; only the offer is gated.

5.59a Application Status page (Help → Status Page)

HelpStatusDialog (quill/ui/status_dialog.py) is a non-modal wx.Notebook of four tabs – Status (Overview/Whisperer/Speech key-value rows), Tasks & Downloads, Features, and Actions – each backed by a wx.ListCtrl (except Actions, a read-only text box) so screen readers get native column navigation instead of Browse-mode HTML table traversal. While the dialog is open, a wx.Timer calls refresh() every two seconds so background-task progress and downloads stay live without the user pressing the Refresh button.

Live-refresh focus loss, fixed 0.9.0 Beta 3 (#969). refresh() rebuilds each wx.ListCtrl from scratch (DeleteAllItems() then re-InsertItem() every row) so the displayed data always matches _build_help_status_data() exactly – but the rebuild silently dropped the list’s focused-row marker too. With the timer firing every two seconds, a user arrowing down the list got returned to the top (or to no focused row at all) before they could act on where they had navigated, reported verbatim as “Move down through the list. The focus will be put back at the top of the list.” refresh() now captures each list’s focused row index before the rebuild (_capture_focus) and restores it afterward (_restore_focus, clamped to the new row count in case it shrank), independently per list – so a live-update tick can never undo the user’s own navigation. Covered by tests/unit/ui/test_status_dialog.py against a real wx.ListCtrl, not a fake.

5.59b QUILL Sync (folders + GitHub, shipped 0.9.0 Beta 3)

Decision on record: QUILL does not build its own sync engine. A full QUILL-native sync service (a quill/core/sync/ engine with SQLite state, content-addressed version history, and per-provider OAuth adapters for OneDrive/Dropbox/Google Drive) was designed in detail in the now-retired docs/planning/quill-sync-plan.md and explicitly decided against for 0.9.0 Beta 3. The full reasoning is preserved in docs/engineering/sync-engine-history.md; the short version is that a folder is already a solved sync problem (OneDrive, Dropbox, Google Drive, and iCloud already replicate one reliably) and git is already a solved sync problem for structured, conflict-aware content — so “QUILL Sync” reuses both instead of reinventing either. Two small, honest features ship instead:

1. Sync via a folder (local or cloud) — quill/core/data_location.py (pre-existing, #615). Relocating QUILL’s entire settings/snippets/ dictionaries/keymap directory to a user-chosen folder already shipped for a different reason (keeping data off the system drive); it turns out to already be exactly the mechanism a lightweight sync story needs. Point that folder at one a cloud client (OneDrive, Dropbox, Google Drive, iCloud) or a folder-sync tool already mirrors, or at a USB drive, and QUILL’s data travels with it — QUILL never talks to any cloud provider’s API. The first-run wizard’s “Where should QUILL store your data?” page (_DataLocationPage in quill/ui/setup_wizard_pages.py) now states this explicitly, including the honest caveat: QUILL has no cross-device conflict resolution for this path, so two devices must not run QUILL against the same folder at the same time. No code changed here beyond the wizard’s description text — the mechanism already worked; only the framing and documentation were missing.

2. Sync Folder with GitHub — new, general-purpose (quill/core/git_sync.py + quill/ui/main_frame_git_sync.py, GitSyncMixin). Accessible Vault’s “Sync Vault” (quill/core/vault/sync.py::run_vault_sync, §Vault Phase 7) already implements commit/pull/push over a user’s own git remote with conflicts surfaced as a spoken, itemized list rather than an auto-merge — and has zero Vault-specific logic in it at all; it takes a bare folder path and three git-plumbing commands. quill/core/git_sync.py gives that exact engine a general-purpose home, adding only what Vault didn’t need:

Tools → Sync Folder with GitHub… (sync.sync_folder, empty default keymap chord, assignable) prompts for a folder (remembered in Settings.git_sync_last_folder for next time), checks its status in the background, and either syncs directly (already a git repo with a remote) or explains exactly what setup is needed and asks first: “‘<folder>’ is not a git repository yet. QUILL can set it up: this runs ‘git init’ in the folder, then adds the remote repository you provide as ‘origin’. Continue?” — never silently. On confirmation, a plain text field collects the remote URL, then init_repo_with_remote followed by sync_folder_via_git run as background tasks. Conflicts (never auto-resolved) are listed via the same accessible list dialog (show_vault_list_modal) Vault Sync’s own conflict report uses.

Credentials and safety, matching the Vault Sync precedent exactly: relies entirely on the user’s own git installation and its own credential handling (an SSH key, or a stored HTTPS credential via the system git credential manager) — QUILL does not store or inject a separate token for these subprocess calls. Blocked outright in Safe Mode. The git pull/git push network calls are subprocess-based (not an urlopen/requests call the AST-based egress scanner can see) and are manually documented in quill/tools/network_egress_audit.py for auditability, alongside the pre-existing (previously undocumented) Vault Sync call sites.

5.60 Read-only document mode

5.62 Open Recent enhancements

5.63 Document timeline

5.64 Sentence and paragraph navigation

5.65 Word-count goal per document

5.66 Reading position memory per document

5.67 Compare with clipboard

5.68 Insert from file

5.69 Selection-statistics direct shortcut

5.70 Sound notifications and QSP sound packs (opt-in)

QUILL plays short, screen-reader-respectful audio cues (earcons) at meaningful editing moments. The system is built around QSP (QUILL Sound Packs): swappable bundles that map event IDs to audio files. Playback is non-blocking, fire-and-forget, and pre-buffered so there is no perceptible lag between event and sound. Earcons supplement speech; they never replace it. (This section absorbs the former docs/wsp.md sound-design notes and docs/sound-packs.md pack guide.)

Pack selection and resolution. Bundled packs live as directories under quill/assets/sound_packs/ (the default earcon pack is ink, “QUILL’s pack”; indent_* are the separate indentation-tone overlays). sound_pack.available_sound_packs() discovers the bundled earcon packs (default first, indent_* excluded) for the first-run wizard’s Sound-pack dropdown — which replaced a *.qsp file picker that could not select the shipped packs at all, since they are folders, not files. The chosen pack is stored in settings.sound_pack_path as one of: "" (the default pack, for back-compat), bundled:<id> (another bundled pack, resolved against the install so it survives a move/reinstall), or a direct path (a user’s own pack). sound_pack.resolve_sound_pack_path() maps any of those to a pack path, and SoundManager._load_pack uses it, so enabling sound always falls back to QUILL’s pack rather than going silent.

5.70.1 QSP format

A .qsp file is a ZIP archive whose root holds manifest.json and the referenced WAV files. During development a directory with the same layout is accepted; the loader treats a directory and a ZIP identically. The manifest is validated at load time against quill/core/schemas/sound_pack.json:

{
  "format": "qsp",
  "version": "1",
  "name": "Ink",
  "author": "Jeff Bishop",
  "description": "Crisp synthesised earcons for focused writing.",
  "license": "CC0",
  "events": { "abbreviation_expanded": "expand.wav", "document_saved": "save.wav" }
}

Any event key absent from the manifest is silently skipped (no sound, no error), which allows minimal partial packs that cover only a subset of events. The QSP schema places no constraint on event-key names, so a Quillin can register additional event IDs and ship its own pack.

5.70.2 Event taxonomy

Canonical event IDs are defined as a StrEnum in quill/core/sound_events.py (no wx, no platform code). Groups: Editing (abbreviation_expanded, abbreviation_deleted, snippet_inserted, autocomplete_accepted, word_corrected), Document lifecycle (document_created/saved/closed), Navigation (heading_jumped, table_entered, list_entered, browse_mode_on/off), Search (search_found/not_found/wrapped), AI and transcription (ai_thinking_started, ai_response_received, ai_error, transcription_started/stopped/word_inserted), Connectivity (ssh_connected/disconnected), Compare (compare_enter_mode, compare_exit_mode, compare_next_difference, compare_previous_difference, compare_no_more_differences), Indentation tones (indent_level_0..7_up / _down), and System (error, warning, sound_on, sound_off). The complete table with triggers is maintained in this section’s source and surfaced to pack authors via the Sound Events dialog.

5.70.3 Audio file requirements

5.70.4 Cross-platform backend

quill/platform/sound_player.py auto-detects the best backend at startup: (1) _SoundLibBackend (BASS via the MIT-licensed accessibleapps/sound_lib, all platforms, native mixing); (2) _WinsoundBackend (Windows stdlib, serialising queue); (3) _NSSoundBackend (AppKit NSSound via pyobjc, macOS fallback); (4) _NullBackend. sound_lib is an optional, licensed extra (pip install quill[audio]) never bundled by default; absent it, QUILL falls back to winsound on Windows or NSSound on macOS (both ship with their respective build extras already) before going silent. Any object satisfying the _WavBackend protocol (play_wav(bytes), shutdown(timeout)) can be injected, which is how tests use a synchronous recording backend.

5.70.5 Module layout and posting

sound_events.py (enum) → sound_pack.py (QSP loader + validator; reads every WAV into bytes at load, zero disk I/O at play time) → sound_player.py (SoundPlayer: 80 ms per-event cooldown, mute toggle, per-event disable list) → quill/ui/sound_manager.py (singleton wired to settings and a custom wx event). Any module posts a sound without importing wx:

from quill.core.sound_events import SoundEvent
from quill.ui.sound_manager import post_sound
post_sound(SoundEvent.ABBREVIATION_EXPANDED)   # thread-safe, < 1 ms, no-op if disabled

5.70.6 Indentation tones and overlay packs

For code, an indent-tone pack maps the 16 indent_level_N_up/down events to pitched tones so the caret crossing indent levels rises and falls in pitch. Four scales ship — pentatonic, whole-tone, diatonic, chromatic — generated by scripts/gen_indent_tones.py. An indent-tone pack overlays a primary earcon pack, so the user can combine, say, the Ink earcons with pentatonic indent tones. The indent_tone_scale setting (empty = off) selects the scale; blank lines stay silent and hold the previous level.

5.70.7 Settings, packs, and Quillins

Settings (group accessibility): sound_enabled (bool), sound_pack_path (text; empty = bundled Ink pack), sound_volume (0–100), sound_events_disabled (comma-separated IDs), indent_tone_scale (choice). A global mute is bound to the sound.toggle_mute keymap action. The bundled Ink pack ships in the wheel at quill/assets/sound_packs/ink/ (generated by scripts/gen_ink_sounds.py). A Quillin manifest may declare a sound_pack directory and sound_events map; the runner registers those IDs with SoundManager at load time, and the user can silence them individually via sound_events_disabled.

5.70.8 Safe mode

When QUILL_SAFE_MODE=1, SoundPlayer.play() is a no-op, keeping safe mode strictly minimal-resource.

5.70a Post to Mastodon (lean poster)

A deliberately small way to publish a status to Mastodon from the editor — not a full client (no timelines, replies, media, or polls in v1).

5.71 Quiet mode

5.72 Temporary trust for untrusted-location opens

5.74 In-app changelog and update transparency

5.75 Crash-report opt-in (per recovery)

5.76 Interactive compare mode

Quill provides two complementary comparison workflows: an accessible interactive compare session and a generated diff report.

5.58 Ask Quill — on-device AI chat (WebView)

Ask Quill is a conversational assistant that runs entirely on the user’s machine — no cloud, no API keys. It can answer questions, write or rewrite text for the document, and run Quill commands, but it is screen-reader-first and approval-gated: nothing touches the document until the user approves it. This is the crawl-before-run 1.0 surface; a deeper native integration is future work.

On-device backends (platform-selected). make_default_backend() picks the backend by platform — Apple Foundation Models on macOS, and llama.cpp (llama-cpp-python, CPU, GGUF) on Windows and Linux. There is no server and no GPU requirement. On Windows the model runs in-process on the CPU.

The whole chat lives in a WebView (Edge WebView2 on Windows). Transcript, suggestions, and the message edit field are all rendered as HTML in wx.html2.WebView — which is Edge WebView2 on Windows (WKWebView on macOS, WebKitGTK on Linux). We deliberately render in the WebView rather than a plain list box + separate text field because it gives us formatted Markdown (the assistant’s headings, lists, and code render properly) together with the browser engine’s native, mature accessibility, and keeps the user in one place. wx.html2.WebView is a factory-created native control and cannot be meaningfully subclassed, so accessibility is driven by the HTML we render, via a thin wrapper (AccessibleWebView):

Focus lands in the web view’s edit field on open (screen-reader behavior). When the chat opens, focus is moved directly into the in-page message field (AccessibleWebView.focus() → focuses the <textarea>, via wx.CallAfter) rather than onto surrounding chrome — so the user “jumps into the web view” ready to type, and the screen reader starts inside the live conversation. After a reply we do not steal focus back (the live region already announced it); after an approval we return focus to the edit field. (Implemented in AskQuillChatDialog.show() / _focus_composer().)

Prism announcements (screen-reader-first). In addition to the WebView’s ARIA live region, Quill uses the Prism (prismatoid) bridge on Windows to send response text straight to the active screen reader, so the user hears new replies even without alt-tabbing to the chat. Prism never speaks over a running screen reader — its SAPI 5 self-voicing fallback is suppressed whenever a screen reader is detected.

Approval before anything is applied. Each turn the assistant decides (answer / insert / replace / run) but never edits the document automatically. Insert/replace text and command runs are shown as a proposal with an Approve / Discard bar; focus moves to Approve and the screen reader is told a change is proposed. Only on Approve does Quill insert, replace the selection, or run the command. There is also Copy Last Response and labeled suggestion prompts.

Discoverability and reliability. Ask Quill lives under a top-level AI menu (Alt+I) alongside the “Use Artificial Intelligence” toggle and AI Model settings. Generation runs off the UI thread. The single-instance lock self-heals (PID + creation-time identity) so a stale lock from a crash never blocks launch.

5.77 Copy Tray — twelve-slot persistent clipboard

Copy Tray gives users twelve independently addressable clipboard slots that survive application restarts. Each slot holds text explicitly placed there; slots are written atomically to disk on every change.

Motivation. The system clipboard holds one item and is shared across every running application. Screen-reader users who work across multiple documents, do research from multiple sources, or paste recurring fragments (signatures, disclaimers, code boilerplate) lose clipboard contents constantly because any copy from any app overwrites it. Copy Tray is a private, persistent alternative.

Core operations.

Multi-press paste. The paste chord supports three levels of intent:

Press count Behaviour
Single Paste slot content at cursor
Double Peek: announce slot content without pasting
Triple Open Copy Tray dialog focused on that slot

The press window is configurable via multi_press_window_ms (default 400 ms, range 100–1000 ms; found in App > Preferences > Editing). This lets expert users check what a slot holds before committing — useful for a crowded tray where memory may be unreliable.

Paste submenu slot labels. The Edit > Copy Tray > Paste from Tray menu shows each slot’s label and a text preview inline (e.g. “1 signature — Hi, I wanted to follow…”). Screen readers read both columns when navigating the submenu.

Status bar cell. The copy_tray_slots status bar cell shows Slots: X/12 (occupied count). Clicking it opens the Copy Tray dialog.

Keyboard defaults.

Action Default binding
Paste from slot 1–9 Ctrl+Shift+1 through Ctrl+Shift+9
Paste from slot 10 Ctrl+Shift+0
Paste from slot 11 Ctrl+Shift+-
Paste from slot 12 Ctrl+Shift+=
Copy to slot 1–9 Ctrl+Shift+Grave, Shift+1 through Ctrl+Shift+Grave, Shift+9
Copy to slot 10 Ctrl+Shift+Grave, Shift+0
Copy to slot 11 Ctrl+Shift+Grave, Shift+-
Copy to slot 12 Ctrl+Shift+Grave, Shift+=
Open Copy Tray dialog Ctrl+Shift+Grave, X

The paste bindings use the number row directly for maximum speed. The copy bindings use the QUILL-key prefix to distinguish from heading shortcuts. All bindings are reassignable via the Keymap Editor.

Storage. copy_tray.json in the QUILL user data directory. Atomic write (os.replace). Version-tagged JSON; corrupt files fail silently (fresh state, no crash).

Accessibility guarantees. Every operation announces through the screen-reader bridge: “Copied to slot 1”, “Pasted from slot 3 (signature)”, “Slot 5 is empty”, “Slot 2: by the way” (peek). The dialog list receives focus on open. Empty and non-empty slots are announced distinctly. The clear-all confirmation defaults to No.

Implementation map. quill/core/copy_tray.py (pure model, mypy-clean; TraySlot with pinned field, pin_slot, unpin_slot, first_empty_slot, search_slots), quill/core/multi_press.py (MultiPressDispatcher, wx-free), quill/ui/main_frame_copy_tray.py (mixin: multi-press wiring, copy_to_next_slot, search_tray_slots, _TraySearchDialog, _update_paste_tray_labels), quill/ui/copy_tray_dialog.py (dialog with Pin/Unpin button), quill/core/keymap.py (24 slot commands + 4 management commands). Menu: Edit > Copy Tray submenu including “Copy to Next Empty Slot” and “Search Tray Slots…”.


5.78 Abbreviation Expansion — TextExpander-style bare-word shortcuts

Abbreviation Expansion replaces short trigger words with longer text automatically as you type, when enabled (it ships off by default so nothing changes under the user’s hands unannounced). It complements the snippet system (which requires an explicit trigger prefix) by firing on any bare word followed by a delimiter.

Motivation. Typing common phrases repeatedly is fatiguing and slow. TextExpander and similar tools are widely used but require separate purchase and licensing. QUILL’s built-in abbreviation engine gives screen-reader users the same productivity gain with no external dependency and full keyboard control over every setting.

How it works. When the user types a trigger word (e.g. btw) followed by any delimiter character — space, period, comma, semicolon, colon, exclamation mark, question mark, closing bracket, closing brace, tab, or newline — QUILL:

  1. Detects the trigger at the cursor.
  2. Looks up the trigger in the library (longest match first; case-insensitive by default).
  3. Replaces the trigger with the expanded text.
  4. Positions the cursor as specified (at ${cursor} if present, otherwise after the expansion).
  5. Optionally plays a configured sound.
  6. Announces the expansion through the screen-reader bridge.

Default library. Fifteen built-in abbreviations ship out-of-the-box: afaik → as far as I know, afaict → as far as I can tell, asap → as soon as possible, atm → at the moment, btw → by the way, fwiw → for what it’s worth, imo → in my opinion, imho → in my humble opinion, irl → in real life, omw → on my way, tbh → to be honest, tbc → to be confirmed, tbd → to be determined, ttyl → talk to you later, wrt → with regard to. These are declared as _BUILTINS in abbreviations.py.

Variables. Expansion bodies support:

Variable Value
${cursor} Cursor position after expansion
${date} Current date (e.g. June 11, 2026)
${time} Current time (12-hour, e.g. 02:30 PM)
${clipboard} System clipboard text at expansion time

Keyboard defaults.

Action Default binding
Expand abbreviation at cursor (manual) Ctrl+Shift+Grave, A
Manage Abbreviations… Ctrl+Shift+Grave, Shift+A
Toggle expansion on/off Ctrl+Shift+Grave, E

Settings. Four settings in Editing preferences:

Status bar. The abbreviations status bar cell shows ABR: On or ABR: Off. Clicking it toggles expansion. Hidden by default; add via status bar settings.

Storage. abbreviations.json in the QUILL user data directory. Atomic write. Default library written on first launch if file is absent.

Interaction with snippets. Abbreviation expansion fires before snippet expansion in _on_text_changed. If an abbreviation match is found, snippet expansion is skipped for that keystroke. The ;-prefix snippet trigger is disjoint from bare-word abbreviation triggers so conflicts are practically impossible.

Accessibility guarantees. Every expansion announces “Expanded: <preview>” through the screen-reader bridge. Manual trigger (Ctrl+Shift+Grave, A) announces “Expanded to: <preview>” or “No abbreviation match”. Toggle announces “Abbreviation expansion on/off”.

Abbreviation Manager dialog. quill/ui/abbreviation_manager_dialog.py — A11Y-4 compliant, registered in the dialog inventory. Features: search field (filters the list in real time), Import button (merges a JSON file, skips duplicate IDs, announces count), Export button (saves full library to a JSON file). Disabled abbreviations shown with “(disabled)” suffix.

AbbreviationLibrary class API (all methods on quill/core/abbreviations.AbbreviationLibrary):

Implementation map. quill/core/abbreviations.py (pure model, mypy-clean: Abbreviation dataclass, AbbreviationLibrary, try_expand, resolve_expansion, _BUILTINS), quill/ui/main_frame_abbreviations.py (AbbreviationsMixin), quill/ui/abbreviation_manager_dialog.py (management dialog with search + import/export). Menu: Insert > Expand Abbreviation, Insert > Manage Abbreviations..., Insert > Toggle Abbreviation Expansion.


5.78a Quillin-contributed insert automation — abbreviations and smart triggers

Quillins declare two Insert Automation contribution types in their manifests (contributes.abbreviations, contributes.smart_triggers); QUILL dispatches both.

Contributed abbreviations. At Quillin load, abbreviations.build_contributed_library merges every installed manifest’s static abbreviation entries (those with an expansion) into a separate in-memory AbbreviationLibrary — deterministic namespaced ids (quillin:<id>:<trigger>), rebuilt on every reload, never persisted into the user’s abbreviations.json. Handler-based entries (e.g. Smart Insert’s qbrf) are skipped: the bare-word expander cannot run a handler mid-type, so dynamic content is reached via a smart trigger or menu command instead. Expansion order: the user’s own library is tried first, the contributed library only on a miss, so a user abbreviation always beats a contributed one with the same trigger; across Quillins, first-loaded wins. Each entry honours the Quillin’s own enable_abbrev_<trigger> setting (default: the manifest’s enabled_by_default), and the global Abbreviation Expansion master toggle gates everything. Core: quill/core/abbreviations.py; host wiring: main_frame_quillins.py (build) + main_frame_abbreviations.py (_try_expand_contributed). Unit-tested in tests/unit/core/test_abbreviations_contributed.py.

Smart triggers (=name(args)). A smart trigger is a line that is exactly =name(arg, arg) (whitespace-tolerant) activated with Enter. The parser (quill/core/quillins/smart_triggers.py, pure/wx-free, unit-tested) requires the trigger to occupy the whole line and a name-then-parens shape, so prose containing =foo(), a bare =5, or spreadsheet-style =a+b never fires. Arguments are comma-separated, stripped, empties dropped. Resolution checks: name known (index across installed manifests, first definition wins on collision), trigger enabled (per-Quillin enable_<name> setting; non-bundled commands also require the SEC-8 third-party flag), and declared min_args/max_args bounds. On dispatch the editor removes the trigger line, runs the linked command at that position with a context of {"trigger", "args"} (handlers receive the args — e.g. =todo(10) builds a ten-item list, =rand(3,4) three paragraphs of four lines, both clamped to sane maxima), and fires the smart_trigger.entered Quillin event with the same payload. A disabled or unknown trigger leaves the line untouched and Enter behaves normally. Host wiring: MainFrame._handle_smart_trigger_return (Enter interception, before Markdown list continuation) → QuillinsMenuMixin.dispatch_smart_trigger_line.


5.79 Ask AI — lightweight in-editor AI chat

Retired in 2.0 (see §5.84a). Ask AI has been folded into the single context-aware Ask Quill conversation; the standalone dialog and its command were removed so there is one chat door. This section is retained for history.

Ask AI is a modal dialog that lets users send a prompt to a configured AI provider and read the response without leaving QUILL. No document text is changed; the dialog is purely informational.

Motivation. Screen-reader users frequently need to ask a quick question while writing — define a term, check a fact, explore a phrasing option — and switching to a browser or a separate AI client breaks flow, especially when using NVDA or JAWS where switching applications involves extra navigation. Ask AI keeps the interaction entirely within the QUILL keyboard model.

Entry point. AI > Ask AI... (Command Palette) and AI > Writing Assistant... (Alt+Q default chord). The Command Palette entry is “Ask AI”. The binding is user-reassignable.

Providers. Three providers are supported. QUILL detects which keys are configured and only shows available providers.

Provider Auth Model discovery
OpenRouter API key (DPAPI-encrypted) GET /api/v1/models, cached per session
OpenAI API key (DPAPI-encrypted) GET /v1/models, filtered to gpt-*/o* families
Ollama None (local) GET /api/tags; greyed out if service not running

Smart focus. When the dialog opens, focus lands in the Prompt field if the provider and model are already configured. If not yet configured, focus starts on the Provider choice so the user is guided to set it up first.

Dialog layout.

Provider:  [OpenRouter v]
Model:     [claude-3-5-sonnet v]
Prompt:    [multiline — Ctrl+Enter to send]
[Send]  [Clear]  [Close]

The response opens in a separate read-only dialog (model label at top, scrollable text area, Copy to Clipboard, Close). Closing the response dialog returns focus to the Ask AI dialog so the user can ask a follow-up without reopening.

Settings (in Preferences > AI):

Setting Type Default Description
ai_chat_default_provider str "openrouter" Last-used provider
ai_chat_default_model str "" Last-used model
ollama_base_url str "http://localhost:11434" Ollama endpoint (override for remote Ollama)

Keys stored in Windows Credential Manager (never in settings.json): quill-openrouter-api-key, quill-openai-api-key.

Safe Mode. The Ask AI menu item and dialog are disabled when QUILL_SAFE_MODE=1.

Network egress. Audited in network_egress_audit.py: openrouter_chat, openai_chat, ollama_chat, openrouter_models, openai_models, ollama_models. Timeout: 60 s for chat, 10 s for model list.

Implementation map. quill/core/ai_chat.py (provider abstraction: send_prompt, list_models), quill/ui/ai_chat_dialog.py (Ask AI dialog + AI Response dialog, A11Y-4 hardened, registered in dialog inventory).


5.80 Check Grammar with AI

Check Grammar with AI sends the current selection (or full document if nothing is selected) to the configured AI model with a grammar-review prompt and displays the result in the AI Response dialog. No edits are applied automatically.

Entry point. Edit > Grammar > Check Grammar with AI. Command Palette: “Check Grammar with AI”. User-assignable binding; no default chord.

Default prompt.

You are a grammar and style editor. Review the following text and list
only the corrections needed. For each correction, give: the original
phrase, the corrected phrase, and a one-sentence reason. Do not rewrite
the whole passage. If the text is correct, say "No issues found."

Text:
{selection}

When Phase 3 (§5.81) is active, the command uses the user’s “Check Grammar” prompt from the Prompt Library instead, so the instruction text is fully customisable.

Model selection. Uses settings.ai_prompt_default_model when set, otherwise falls back to settings.ai_chat_default_model. This lets users choose a different (e.g. more capable) model for grammar review than for casual chat.

Implementation. Single method check_grammar_with_ai() in MainFrame. Runs on a background thread (threading.Thread, daemon=True); UI updates via wx.CallAfter. Command id: tools.check_grammar_ai.


5.81 AI Prompt Library

The Prompt Library is a named, user-expandable collection of AI instructions. Each prompt operates on the current selection or document: the user picks a prompt, QUILL sends the text and the prompt to the AI, and a response dialog shows the result. No document text changes without explicit action.

Motivation. Power users accumulate a personal set of AI instructions they run repeatedly — improve clarity, vary sentence rhythm, convert to bullets, generate an outline. Without a library, these prompts must be retyped or pasted from an external file each time. The Prompt Library turns these into first-class named commands, accessible from the keyboard, the command palette, and the dialog.

Entry point. AI > Prompt Library.... Command Palette: “Prompt Library”. User-assignable binding.

Prompt object fields.

Field Description
name Short display name (“Improve clarity”)
text Instruction text, with {selection}, {document}, {title} variables
category Editing, Writing, Structure, Research, or Custom
is_builtin True for shipped defaults — editable but not deletable
id UUID, stable across renames
shortcut Optional keyboard chord bound to this prompt
enabled False to hide from menus without deleting
source “builtin”, “user”, or Quillin id

Built-in prompts (12). Shipped defaults, always present, user-editable:

Prompt Library Dialog. Split layout: left panel shows a searchable list of all prompts (filtered by the search field in real time); right panel shows the selected prompt’s text and an optional input override. Buttons: Run with AI, New Prompt, Edit, Disable/Enable, Delete, Import .pqp, Export .pqp, Close. A11Y-4 hardened, registered in the dialog inventory.

Settings.

Setting Type Default Description
ai_prompt_default_model str "" Model used for prompt runs. Blank inherits ai_chat_default_model.

Storage. %APPDATA%\Quill\prompts.json — atomic write, schema-validated. Built-in prompts are not persisted; only user additions and overrides to built-ins are stored.

Quillin bridge. A Quillin may ship a prompts.json file alongside its manifest. QUILL loads it automatically when the Prompt Library opens, adding those prompts to the library for the session (not persisted to disk). The bundled ai-writing-prompts Quillin ships 7 additional prompts this way: Expand This, Vary Sentence Rhythm, Make More Vivid (Writing); Write a Title, Generate Outline (Structure); Suggest Supporting Evidence (Research); Plain Language (Editing).

Implementation map. quill/core/prompt_library.py (Prompt dataclass, PromptLibrary CRUD, .pqp import/export, Quillin prompt loading), quill/ui/prompt_library_dialog.py (Prompt Library dialog), quill/quillins_bundled/ai-writing-prompts/ (bundled prompt pack).


5.82 Prompt Quill Pack (.pqp) — shareable prompt collections

A .pqp (Prompt Quill Pack) file is a JSON document that packages a named collection of prompts for sharing or backup.

File format.

{
  "schema": "quill.prompt-pack/1",
  "name": "My Writing Prompts",
  "prompts": [
    {
      "name": "Make Punchy",
      "text": "Rewrite this as a punchy one-liner: {selection}",
      "category": "Editing"
    }
  ]
}

Import. Via the Import .pqp button in the Prompt Library dialog. Prompts whose name already exists in the library are skipped; the count of newly added prompts is announced.

Export. Via the Export .pqp button. Exports all user-defined prompts (or a selected subset). Built-in prompts can optionally be included when the user has edited them.

Use case. A writing team can share a .pqp file containing their house-style prompts. A power user can back up their library and restore it on a new machine. A Quillin author can distribute prompts as a .pqp for users who prefer not to install a Quillin.


5.83 Quillin Manager — install, update, and remove extensions

The Quillin Manager (Tools > Quillins) lets users discover, enable, disable, and uninstall Quillins. As of this version it also supports installing a new Quillin directly from a local folder.

Install from Folder. The Install from Folder button opens a system folder picker. QUILL reads the selected folder’s manifest.json, validates it, copies the directory into the per-user extensions root (%APPDATA%\Quill\extensions\<id>\), enables the Quillin, and refreshes the list. If an extension with the same id is already installed, it is replaced. Path containment is enforced: a crafted extension id cannot install files outside the extensions root.

Remove. Selecting an extension and pressing Remove deletes its directory and removes its state entry. Confirmation is required.

Enable/Disable. Toggle without uninstalling. A disabled Quillin’s prompts and commands are not loaded.

Manifest display. Selecting a Quillin shows its name, version, author, description, declared capabilities, and any validation errors. This gives users the information they need to make an informed trust decision before enabling.

Security model (SEC-8). Third-party Quillin discovery (auto-scanning the extensions root) is locked off for QUILL 1.0 (core.third_party_plugins feature flag is locked_off). Install from Folder is the only way to add a third-party Quillin. The user must explicitly choose the folder, providing informed consent equivalent to “install this extension”. Bundled Quillins (shipped inside the QUILL install tree) are always discovered and are not affected by the SEC-8 lock.

Implementation map. quill/core/quillins/loader.py (install_extension, remove_extension, set_enabled), quill/ui/main_frame_quillins.py (QuillinsManagerMixin — manager panel, Install from Folder button and handler).


5.83a The Quillin Hub — community distribution for every shareable artifact

The Quillin Hub (hub.quillforall.org; service code in quillin-hub/) is the community store and submission surface for every shareable QUILL artifact type, not just Quillin extensions. Seven artifact families are accepted for review and publication:

Type id Artifact Format Authoritative validator
quillin Quillin extension directory / .zip with manifest.json quill.tools.quillin_lint
agent AI agent (quill.agent/1) .md / .json quill.tools.agent_lint
verbosity-pack Verbosity pack .qvp.json quill.core.verbosity.qvp
sound-pack QSP sound pack .qsp ZIP or directory quill.core.sound_pack
keyboard-pack Keyboard Quill Pack .kqp quill.tools.kqp_validator
skill-pack Skill Quill Pack .sqp quill.tools.sqp_validator
pronunciation-dictionary Pronunciation dictionary .json pronunciation schema check

One validation authority. python -m quill.tools.artifact_validate <path> [--type ID] [--strict] [--json] detects the artifact type (by suffix, manifest sniffing, or schema markers) and dispatches to the per-type validator listed above. The Hub’s Submission Forge, CI, and the in-app submission check all run this same tool, so an author never sees three different verdicts. Exit codes follow the validator convention (0 pass, 1 issues, 2 not found/undetectable); --json emits the machine-readable report the Forge consumes.

The Submission Forge. The Hub’s /forge/submit flow accepts any supported file, auto-detects the type (with a manual override), and runs a three-stage audit: (1) artifact_validate validation; (2) for Quillins only, a Bandit scan plus the AST SecurityWatchdog capability-honesty check (undeclared fs/net/stability imports and eval/exec fail the submission); (3) metadata extraction from the artifact’s own manifest or front matter so authors never retype name/version/description. The result is an accessible plain-language Forge Report; publication itself stays GitHub-native (a guided pull request), keeping review transparent.

Registry API. /api/v1/types, /api/v1/artifacts[?type=…], and /api/v1/artifacts/<id>/latest serve the verified catalog to clients; the original /api/v1/plugins endpoints remain as Quillin-only aliases. A sync worker mirrors artifacts that land on main (bundled/example Quillins, catalog agents, bundled .sqp skills) into the storefront as Verified.

In-app tie-in. Tools > Quillins > Submit to Quillin Hub… (tools.quillin_hub_submit) runs the identical artifact_validate checks locally and reports pass/fail in an accessible hardened dialog. Picking a Quillin’s manifest.json validates the whole folder (the accessible alternative to a directory picker). The Quillin Hub website opens in the browser only on the explicit “Open the Quillin Hub” button — QUILL itself makes no network call anywhere in the flow, so the command needs no egress-audit entry.

Roadmap linkage. Public deployment of the Hub is #517 (O14); the signing/marketplace trust model that would let vetted third-party Quillins load off the SEC-8 experimental flag is #519 (O16). This section covers the submission/validation platform and the in-app surface; signing and deployment remain open acceptance criteria on those issues.

Implementation map. quill/tools/artifact_validate.py (detection + dispatch; tests in tests/unit/tools/test_artifact_validate.py), quill/ui/quillin_hub_submit.py (submission-check dialog), quill/ui/main_frame_quillins.py (menu item + command), quillin-hub/ (Flask service: app/artifacts/registry.py, app/forge/, app/api/, worker/sync_to_pages.py, smoke_test.py).


5.84 Skill Quill Pack (.sqp) — multi-step AI workflows in plain text

A .sqp (Skill Quill Pack) file is a Markdown document with YAML front matter where level-1 headings define sequential AI steps. It extends .pqp prompt packs from single instructions to multi-step workflows with parameters, variable chaining, and conditional branching.

Motivation. Many real AI tasks are not single-shot prompts. Research then draft. Analyse then rewrite. Detect intent then branch. Encoding this logic in JSON means writing a DSL nobody can author without tooling. Writing it in Markdown means any user can open the file, read every step, and edit it — no schema browser, no visual designer. The skill is the document.

Key design choices: - No streaming — each step sends a full prompt and receives a full response before the next step runs. This makes step outputs reliable as inputs to subsequent steps. - Synchronous execution from the caller’s perspective; threading is the UI layer’s responsibility. - Depth limit of 2 for nested skill calls to keep execution predictable.

File format (quill.skill/1).

---
schema: quill.skill/1
name: Research and Draft
description: Extracts topic, gathers facts, drafts a paragraph.
author: QUILL Project
version: 1.0.0
parameters:
  - name: tone
    label: Tone
    type: choice
    choices: [formal, conversational, neutral]
    default: neutral
---

# Step 1: Extract topic

Identify the main topic in: {selection}

# Step 2: Research

List five facts about "{step1.output}".

# Step 3: Draft

Write a {parameters.tone} paragraph weaving in those facts.
Facts: {step2.output}

```output
format: text
label: Drafted paragraph
accept_into: selection

**Special blocks inside steps:**

| Block type | Purpose |
| --- | --- |
| `` `input` `` | Appends literal data to the prompt text |
| `` `condition` `` | Branches execution: `if: "X" contains "Y" / then: step3 / else: step4` |
| `` `output` `` | On last step: `format` (text/list/json), `label`, `accept_into` (selection/clipboard/none) |
| `` `use-prompt` `` | Delegates to a named prompt from the Prompt Library |
| `` `use-skill` `` | Calls another skill (depth-bounded) |

**Variables.** `{selection}`, `{document}`, `{title}`, `{clipboard}`, `{stepN.output}`, `{parameters.name}`.

**Validation tool.** `python -m quill.tools.sqp_validator <path>` validates one file or a directory. Exit 0 clean, exit 1 errors, `--strict` also checks for missing metadata.

**Bundled skills.** The `ai-writing-skills` Quillin ships four sample skills: Accessible Rewrite, Research and Draft, Meeting Notes to Action Items, Argument Strengthener.

**Implementation map.**

| File | Role |
| --- | --- |
| `quill/core/skill_pack.py` | `SkillPack` dataclass, `.sqp` parser, validator, synchronous runner |
| `quill/tools/sqp_validator.py` | CLI validator |
| `quill/quillins_bundled/ai-writing-skills/` | Four bundled `.sqp` skill files |
| `tests/unit/core/test_skill_pack.py` | 23 tests: parsing, validation, runner, branching, bundled files |

---

### 5.84a Unified AI Library and the four-pillar AI menu (2.0)

**Goal.** Replace the scattered AI submenu and its overlapping dialogs with one
confident, top-level `&AI` menu built on four pillars, so the user feels QUILL has
*one* AI that knows where they are.

**The four pillars.**

1. **Ask Quill** — the single, context-aware conversation. The legacy "Ask AI" and
   "Writing Assistant" chat dialogs are retired into it (`AskAIDialog` deleted;
   `open_ask_ai` / `tools.ask_ai` removed), so there is exactly one chat door.
2. **Do** — context-first actions: the Concierge's "What can I do here?"
   (`quill/ui/concierge_menu.py`), the Selection Action Ring ("Rewrite & Improve"),
   and Run Agent.
3. **AI Library** (`quill/ui/ai_library_dialog.py`) — Prompts, Skills, and Agents in
   one `wx.Notebook` with one verb set (Run, New, Edit, Enable/Disable, Import,
   Export) and a real **Promote** continuum. `prompt_to_skill_source` grows a Prompt
   into a Skill; `skill_to_agent_markdown` + `save_user_agent` grow a Skill into a
   first-class user Agent saved in `agent_catalog.user_agents_dir`, loaded alongside
   the bundled catalog by `load_full_catalog`. Prompt Studio and Agent Center are
   retired into this surface; the full agent linter is the Agents-tab **Validate**.
4. **AI Hub** — the single configuration front door: provider, key, model, engine
   switching (Engines tab), GitHub Copilot setup, and **Session Branches** (Sessions
   tab). The old "Engine & Sessions" submenu is removed.

**The menu shape.** The top-level `&AI` menu reads, in order: Set Up AI (the on-ramp,
§5.84c) · Ask Quill (+ by Voice) · Accessibility Tune-Up · the context "Do" entries
(hidden in Basic mode) · Proofread / Translate / Read Aloud / Transcribe / More
submenus · AI Library · AI Hub · Use Artificial Intelligence (and Show advanced AI
features). Item count dropped from ~36 scattered entries to a short, scannable list,
each a single high-value action or a clearly-labeled one-level-deep submenu.

**Confirmed product decisions.** (1) AI is a real top-level `&AI` menu unconditionally
— the former `future.ai_menu_top_level` flag is retired as a placement switch. (2)
"AI Library" is the name for the unified Prompt/Skill/Agent manager. (3) The
Prompt → Skill → Agent **Promote** continuum is in scope and shipped. (4) Accessibility
Tune-Up stays a first-class, top-of-menu item given the screen-reader audience.

**Invariants.** Every list item and dynamic menu entry has a meaningful accessible
name; all dialogs go through `_show_modal_dialog` + `apply_modal_ids`; running any
action announces start and result; nothing is more than one level deep; the Library
and continuum are wx-free at the core (`quill/core/ai/library.py`,
`quill/core/skill_store.py`). One custom-prompt store: `prompt_migration` consolidates
the legacy `assistant_prompts` into the canonical `PromptLibrary` at startup
(reversible, idempotent). Deprecated dialogs retired; the live `assistant_agents`
plan/profile helpers stay (they back a live agent-run path), and `assistant_prompts`
stays until the prompt migration is sunset.

---

### 5.84b The Listening Companion — Transcript Actions and the Action Builder (2.0)

**Goal.** Make transcription the *beginning* of an agentic writing experience, not
the end: turn audio into the document the user actually needs, with a gentle, guided,
adjustable path. Folds the agentic transcription magic of BITS Whisperer into QUILL's
unified AI framework.

**Transcript Actions** (`quill/core/ai/transcript_actions.py`, wx-free). Ten
built-in actions — Meeting Minutes, Action Items, Executive Summary, Interview Notes,
Study Notes, Q&A Extraction, Clean Up & Draft, Follow-Up Email, Key Quotes, and
Decisions Log — each a named, plain-language, *adjustable* instruction with a prompt
builder. `recommend_actions` orders them for the transcript in front of the user
(multi-speaker leads with Minutes / Action Items / Decisions / a follow-up email;
question-dense with Q&A / Interview / Key Quotes; single voice with Clean Up & Draft)
while keeping every action available. Reachable two ways: the post-transcription "What
would you like me to make of this?" chooser (`quill/ui/transcript_actions_ui.py`,
hooked into `_show_transcription_result`) and an `AI > Transcribe Audio > Transcript
Actions...` item that runs them on the current selection/document. Results open in a
new buffer; the original transcript is never overwritten. Generation uses the unified
`ProviderChatBackend`; the flow degrades gently when AI is off, and offers the AI
Setup Wizard (§5.84c) at that high-intent moment instead of a dead end.

**Guided Action Builder** (`quill/ui/action_builder_dialog.py`). A no-syntax,
form-based builder (name, start-from preset, plain-language instructions, optional
**reference attachment**, Save) reached from the AI Library Skills tab. It writes a
real Skill via `action_to_skill_source` + `SkillStore`, so a user-defined action
immediately gains Run / Edit / Enable / Export / Promote. A reference document (txt /
md directly, docx and friends via markitdown) is woven into the saved action so its
output matches the user's template, terminology, and house style.

**Automation.** A watch-folder transcribe profile can chain "transcribe → run a
Transcript Action → save the document" next to the audio
(`watch_transcribe._maybe_make_action_document`), Do-Not-Disturb-aware. AI off / no
provider / a failed action skips the document step with a clear note and always keeps
the transcript.

**Principles (non-negotiable for this audience).** Easy (one choice, one keystroke),
delightful, guided, principled (consent + preview + undo + private by default),
powerful (the full agentic stack underneath), adjustable by instruction and prompt,
and gentle for learners.

**Status: complete.** Shipped: Transcript Actions (post-transcription and anytime),
the guided Action Builder, reference attachments, watch-folder automation, and the AI
onboarding wizard with Basic mode and on-ramps (§5.84c). **Live + diarized streaming
actions were deliberately not built:** most of that value is already delivered by file
transcription plus watch-folder automation, and its one genuinely unique kernel — live
captioning — is a separate accessibility feature that would need real-time audio
capture infrastructure; if it is ever wanted it should be scoped on its own merits, not
bolted onto this companion.

---

### 5.84c AI onboarding — the Setup Wizard, on-ramps, and experience modes (2.0)

**Goal.** Make getting started with AI a gentle, magical, screen-reader-first
experience across the whole AI landscape — no jargon, no dead ends, finished in
seconds — and let newcomers grow into the full surface on their own timeline.

**AI Setup Wizard** (`quill/ui/ai_setup_wizard.py` over wx-free
`quill/core/ai/onboarding.py`). A one-step-at-a-time wizard (each step a single
announced focus context): a welcome, the one real choice — **on your device with
Ollama** (private, free), **use an AI account** (Claude / OpenAI / Gemini / OpenRouter
/ Ollama Cloud, with a key pasted once and stored in the OS secure store, plus a
**Test connection** button), or **not right now** — a frictionless connect step, and a
tailored celebration. Neither path lets the user finish into a broken state: cloud
offers Test connection, and the on-device path **verifies a reachable local Ollama with
an installed model (`ollama_status`) before it commits** — if Ollama isn't running or
has no models, the step stays put with plain-language guidance (install from
ollama.com / `ollama pull ...`) rather than configuring something that won't work. The wx-free model owns the copy, the paths, the cloud-provider
options, the apply helpers (`apply_cloud_setup` / `apply_on_device_setup`), and the
persisted state, so it is fully unit-testable. Reachable as the first AI menu item
(**"Set Up AI... (start here)"** until done).

**On-ramps, not dead ends.** `maybe_offer_ai_setup` offers the wizard at the moment a
user first reaches for AI before it is configured — Ask Quill, the AI Library, and
Transcript Actions all route through it — and never nags someone who has already been
through setup. Because the AI Library's Run, Ask Quill, Transcript Actions, and agents
all share the one `ProviderChatBackend` (AI Hub) connection the wizard configures,
setting AI up once makes everything work.

**Experience modes.** `is_basic_mode()` / `save_experience_mode()` persist a Basic vs
Advanced choice (default **advanced**, so existing users never lose anything; the
wizard's "keep it simple" checkbox puts a newcomer into Basic). In Basic mode the AI
menu hides the power-user agentic entries ("What can I do here?", "Rewrite & Improve",
"Run Agent") so the surface stays calm; a **"Show advanced AI features"** toggle flips
it instantly. Everyday features (Ask Quill, Transcribe, Proofread, Translate, Read
Aloud, the AI Library) always stay.

---

### 5.84d Free / low-cost AI for everyone (writing-first)

**Goal.** Give every user a meaningful AI writing experience without a paid
flagship account, and without the project carrying unbounded per-user cost.
Flagship models are better, but the point is options for people who cannot afford
one and a sane default that meets people where they are. Agents work on the free
path too.

**Cost posture — bring-your-own free key + local, $0 to the project.** There is
no shared or hosted key. Each user either runs a local model (no key) or brings
their **own free key** (their own free daily quota), so there is no shared limit
and no cost or liability to the project. A subsidized/hosted proxy is explicitly
out of scope; the data layer is designed so a *capped* proxy could later be added
as an opt-in provider without reworking this.

**Two strongly-advised free paths, with good defaults** (`recommended_free_paths`
in `quill/core/ai/free_models.py`, surfaced on the wizard's connect step via
`FREE_PATH_GUIDANCE`):

1. **Best quality, free** — OpenRouter with the user's own free key. QUILL
   preselects a strong free writing model (`best_free_writing_model`, currently a
   Llama 3.3 70B free model). The model step's recommendation now leads with this
   free model rather than `openrouter/auto` (which can route to paid).
2. **Most private, free** — a fully on-device model (Ollama / bundled llama.cpp):
   no account, offline, nothing leaves the device; more modest quality.

The copy is honest: free hosted models can be slower and are rate-limited to the
user's own quota, and may log/train on prompts under their terms, so QUILL advises
keeping confidential writing on the on-device path.

**Get API key.** Every keyed provider in the wizard has a **Get API key** button
that opens that provider's signup page (`CloudProviderOption.signup_url`) in the
browser — no hunting for where to get a key. Disabled for on-device Ollama.

**Model dropdown (wizard + AI Hub).** The model is chosen from an editable
dropdown seeded with the provider's recommended ids (free-first for OpenRouter),
with a **List models** action to load everything the account/device exposes
(`onboarding.list_provider_models`); the AI Hub Provider tab uses the same pattern
(`_populate_hub_models` / `_on_hub_list_models`), replacing its old free-text
field, and refreshes suggestions when the provider changes.

**The free-model catalog** (`quill/core/ai/free_models.py`, pure/wx-free). Free
is **derived, not hardcoded**: a model is free when the provider reports zero
prompt+completion pricing, or (OpenRouter) its id ends in the documented `:free`
suffix — so classification survives OpenRouter rotating its free line-up. Models
carry a `cost_tier` (`free`/`low`/`flagship`), a coarse `writing_quality` rank,
and a `tool_use` capability flag. `fetch_classified_models` reuses the already
network-audited `ai_chat.list_models_raw` (no new egress site) to classify live
`/models` data by price. `resolve_model_for_task` advises the cheapest capable
model for a task verb (light verbs → free; heavy/agentic verbs → tool-capable).
The wizard's model description speaks a **Free** / **Local, free** cue
(`_cost_note`) so cost is audible where models are chosen.

**Agents degrade gracefully on small/free models.** Single-shot transform agents
(Rewrite, Summarize, Expand, Table of Contents — via `_run_agent_task` +
`build_agent_plan`) already run on any chat model. The multi-step Ask Quill
companion (`ConversationSession` tool loop) is capped to a near-single-shot step
budget on a model the catalog flags as unreliable at tool use
(`agent_editor_host._companion_loop_budget`), so a weak model **answers instead
of looping** on malformed tool calls — the turn's document context is already in
the prompt, so answering needs no tools. It is never a hard block, and the engine
label reads "simplified for a small model." Agent specs may declare
`needs_tool_use: true` (schema + `AgentSpec`) so the UI can note "works best with
a stronger model" (`free_models.stronger_model_hint`) while still offering them.

---

### 5.84e Agent harnesses — interchangeable agentic engines (shipped)

**Goal.** Let a user drive QUILL's agent with the AI agent they already pay for
— GitHub Copilot, OpenAI (ChatGPT), or Claude — without a second API key or
per-token cost, while keeping QUILL's edit-safety contract exactly as it is.

**The harness layer (`quill/core/ai/harness/`).** A *harness* is an
interchangeable engine that drives an agent session above the AI backend. QUILL
ships the **Native** harness (its own loop, always available) plus three
optional **SDK harness packs** in `quill/ai_packs/` — `copilot.py` (extra
`ai-copilot`, `github-copilot-sdk`), `claude.py` (extra `ai-claude`,
`claude-agent-sdk`), and `openai_agents.py` (extra `ai-openai`, `openai-agents`).
Every harness, native or SDK, drives the **same** `SafeEditorToolGateway` and
`PermissionBroker` and emits the **same** normalized `AgentEvent`s; a
`HarnessRegistry` tracks availability, and the Hub hides capabilities a harness
lacks rather than presenting a broken control.

**Safety law (non-negotiable).** The SDK packs run the vendor agent
**text-only** (`allowed_tools=[]`): the vendor's own file-editing and shell tools
are denied, and QUILL applies the produced text through its reviewed gateway —
permission broker, diff preview, one-step undo. The agent proposes; only QUILL
touches the document, and only with user approval. All of this is off by default
and disabled in Safe Mode.

**On-demand install (`quill/core/ai/sdk_install.py`).** The packs are never
bundled (large, fast-moving, and most users want exactly one). The first time a
user picks an engine, QUILL installs its extra with `pip` into the running
interpreter behind a consent + progress surface; the pack self-registers on the
next probe. An uninstalled pack reports `is_available() == (False, reason)` and
QUILL keeps working.

**Surfaces.** (1) **AI Hub → Engines tab** (`ai_hub_engines_panel.py`): list the
engines, **Set Up / Install...** (installs the pack, or opens the Copilot
onboarding dialog), and **Set as Active Engine**. (2) **AI Setup Wizard**: the
onboarding path *"Use an AI agent you already pay for"* finishes by opening the
Engines tab. (3) **GitHub Copilot sign-in** (`copilot_auth.py`): OAuth 2.0 device
flow (a short, speakable code) when a build provides `QUILL_GITHUB_CLIENT_ID`,
with a graceful fallback to the Copilot/GitHub CLI sign-in when it is not — so
Copilot works either way; the token lives in the OS secure store.

**Value.** Use the subscription you already hold ($0 extra), vendor-grade
orchestration inside QUILL's guardrails, and interchangeable engines that install
on demand so the base app stays lean.

---

### 5.84f Internet Radio (shipped)

**Goal.** Let a QUILL user listen to live internet radio in the background
while writing, without ever leaving the keyboard or the editor, and without
depending on an undocumented commercial API.

**Station discovery (`quill/core/radio/radio_browser.py`).** Search, tag, and
country lookups against [RadioBrowser](https://api.radio-browser.info), a
free, keyless, community-run station directory — chosen deliberately over
FastPlay's other two backends (TuneIn's OPML search, iHeartRadio's v2/v3
APIs), both undocumented, reverse-engineered commercial APIs with no public
terms. The client resolves a shuffled list of RadioBrowser's own mirror hosts
once per process and fails over across them on request failure, per
RadioBrowser's own documented recipe, rather than hammering one hardcoded
host. `core/radio/acb_media.py` bundles the American Council of the Blind's
ten Live365 stations as a static, always-available category (no network call
needed to see it) — researched from BITS's own `acb_link_desktop` project.
`core/radio/soma_fm.py` blends in [SomaFM](https://somafm.com), a second
free, keyless, curated directory, into the same Browse Stations results —
chosen over Shoutcast (revocable Developer-ID-gated API) and Live365
(partner-only distribution), neither of which offers an open public search.
`core/radio/link_finder.py` fetches one user-typed page and parses it
(`html.parser`, no embedded browser) for stream-shaped links, for stations
not in RadioBrowser's directory.

**JavaScript players — the Triton Digital / StreamTheWorld resolver
(`core/radio/triton.py`).** A large class of broadcast stations (thousands of
US AM/FM stations, and the entire `player.listenlive.co` network) put their
"Listen Live" button behind a Triton Digital web player. That player is a
JavaScript PWA: the actual stream URL is *never* in the page HTML — it is
computed at runtime when the player's JS calls Triton's provisioning API. So
the plain-HTML scanner above finds nothing on those pages, even though a Play
button is plainly visible. This was a real, reported gap
(`player.listenlive.co/34461`, "Magic 104.1"/KMGL).

The stream is nonetheless fully derivable **without running any JavaScript**,
from two static facts the page already exposes:

1. The station **callsign** (mount name) appears verbatim in the Triton PWA's
   own asset URLs — the station-logo image is served from
   `pwaimg.listenlive.co/<CALLSIGN>_<id>_config_station_logo_image_….png`
   (`callsign_from_page`, with a `station=<callsign>` query fallback).
2. Triton's **provisioning API** answers a plain HTTPS GET
   (`playerservices.streamtheworld.com/api/livestream?station=<CALLSIGN>&transports=http&version=1.9`)
   with an XML `<live_stream_config>` document listing every mountpoint (one
   per codec — MP3, HE-AAC — MP3 first) and its CDN servers. A playable URL is
   simply `https://<server>/<mount>` (`parse_livestream_config` →
   `resolve_station_streams`, parsed through the hardened `core/safe_xml` so a
   crafted response cannot mount an entity-expansion attack).

The scan only takes this path when the page *looks* like a Triton player
(`page_is_triton_player`) and advertises a callsign the API validates
(status 200), so a non-Triton page or a bad guess degrades to "nothing found",
never a wrong stream. It surfaces the resolved mount(s) as ordinary scan
candidates labelled "*MP3/AAC* stream from the station's player (*mount*)".
The one follow-on egress (`triton.py::_fetch_api`) is a reviewed entry in
`network_egress_audit.py`, reached only from the same explicit **Scan** button,
HTTPS-only, and disabled in Safe Mode via `triton.refuse_in_safe_mode`. This
also generically improves every Triton/StreamTheWorld station, not just the
one that prompted it. It cannot solve a fully generic JS player with no static
callsign hint — QUILL deliberately has no embedded arbitrary-site browser — but
Triton/listenlive.co is the large, tractable case.

**Self-healing stream recovery (`core/radio/recovery.py`, #1065).** A directory
station's listed stream can be dead even though the station is perfectly live —
usually because the real stream is behind a Triton/JavaScript player, so the URL
the directory carries (or one a user copied off the site's play button) is a
web-player address, not a stream. Rather than just failing, on a playback error
QUILL runs a confidence-ordered recovery ladder off-thread (`recover_stream`),
one attempt per station per session:

1. **Re-resolve a StreamTheWorld mount (high, always on).** If the failed URL is
   `*.streamtheworld.com/<MOUNT>`, that mount is re-resolved through Triton's API
   to its current server — deterministic, same station and provider.
2. **Refresh from the directory (high).** If the station has a RadioBrowser id,
   its current URL is re-fetched; a genuinely different URL is the directory
   self-healing (this subsumes the older byuuid-only fallback).
3. **Scan the station's website (opt-in, default on, off in Safe Mode).** The
   station's homepage — and the failed URL itself, when it is a web page — is
   scanned with the same Triton + "Listen Live" link-following logic Find Streams
   uses (`link_finder` now follows a bounded allowlist of listen/live/play/tune-in
   `<a>` links one level deep, since most homepages *link* to the player rather
   than hosting it). A single unambiguous result — a resolved Triton player, or
   exactly one stream candidate — is played automatically and self-heals the
   saved favorite; several candidates are announced so the user can pick them up
   in Find Streams, never auto-played.

Confident recoveries are announced and self-heal the favorite so the next play
starts from the good URL. Strategy 3 is a single opt-out
(`RadioHistory.recover_from_website`, Preferences) because it adds one homepage
fetch on failure; strategies 1–2 always run. All network goes through the
already-reviewed egress sites (`triton._fetch_api`, `radio_browser`,
`link_finder._fetch_html`); `recovery.py` itself makes no direct network call.

**Playback (`quill/ui/radio/player_controller.py`).** One
`RadioPlayerController`, owned by `MainFrame` for the process's lifetime,
wraps the Audio Studio's `WxMediaEngine` (never libmpv — `MpvAudioEngine`'s
poll loop gates "loaded" on a positive `duration`, which a live stream never
reports, so mpv cannot currently drive radio; this is a named, deliberately
deferred follow-up, not an oversight). Every dialog (station browser, add
custom station, website link finder) drives this same controller, so closing
any of them never stops playback — the mechanism that makes "listen in the
background while editing" work with zero new non-modal-panel architecture.
Radio's own volume (`set_volume`/`toggle_mute`) is independent of the system
volume mixer and of screen-reader speech volume.

**Surfaces.** `Tools > Media > Internet Radio` (Browse Stations, Add Custom
Station, Find Streams from a Website, Play/Pause, Stop, Mute, Volume Up/Down)
registers every command in `CommandRegistry` (command-palette visible, unlike
the Audio Studio's `tools.speech_batch_export`, found during this feature's
research to bypass the registry). A `radio_player` status-bar cell
(auto-surfaces on first play) and a system-tray right-click section
(Play/Pause, Stop, Mute, Favorite Stations, Now Playing) both drive the same
controller. Three in-app QUILL-key chords (`Ctrl+Shift+Grave, N/0/9` for
play-pause/stop/mute) cover the explicit "hotkeys from the editor" ask —
scoped to app-focused, matching every QUILL-key chord except the one
existing OS-level `RegisterHotKey` precedent (sticky notes), which stays a
narrow, Windows-only exception.

**Persistence.** `core/radio/favorites.py` — atomic JSON via
`core.storage.write_json_atomic`, the standard settings-surface pattern (no
SQLite, unlike both FastPlay and ACB Link). Each favorite carries an unused
`folder` field, present only so the podcasts feature (§5.84g) could grow real
folders out of the same on-disk shape — it ended up with its own dedicated
`PodcastFolder` model instead, since a show belongs to exactly one folder
while a radio favorite's `folder` is a looser, unenforced tag.

**Recording and scheduled recording (`quill/core/radio/recording.py`,
`recording_schedule.py`).** `RadioRecorder` launches ffmpeg via
`subprocess.Popen` (not `stability.safe_subprocess.run_subprocess_safely`,
which blocks until the process exits — a live stream recording needs a
controllable start/stop instead) and stops it by writing the `q` keypress
ffmpeg's own stdin-driven quit handler reads, the same graceful stop a
person pressing "q" in a terminal gets, so the output file's container
closes properly rather than a hard kill truncating it. `RecordingSettings`
(format, bitrate, destination, filename pattern, and a `max_duration_minutes`
safety cap applied via ffmpeg's own `-t` flag, so a forgotten recording
cannot grow unbounded even if `stop()` is never called) is a global,
persisted default. Alongside the re-encoding formats (mp3/ogg/flac/wav)
the format list offers **Raw stream — exactly as sent, no re-encoding
(lossless)** (`format="copy"`, a community request): `build_record_command`
stream-copies the server's audio packets with `-c:a copy` — no decode, no
re-encode — so the saved file is bit-for-bit the original broadcast, the
most faithful capture possible for a listener who wants to do their own
lossless editing/conversion. Because nothing is decoded, a bitrate and any
Sound Enhancements filter graph are meaningless and are dropped in this mode.
The output container matches the stream's own codec: `RadioRecorder.start`
runs a bounded `ffprobe` (`build_probe_codec_command`/`parse_probe_codec`,
`_PROBE_TIMEOUT_SECONDS`) once to read the first audio stream's codec and
picks its natural extension (`raw_capture_extension`: mp3→`.mp3`, aac→`.aac`,
vorbis→`.ogg`, opus→`.opus`, flac→`.flac`, …), falling back to Matroska audio
(`.mka`) for any unrecognized codec — a universal container that stream-copies
anything losslessly. The probed extension is resolved once and reused across
auto-reconnect continuation files (stored in `_active_params`) so a resumed
recording never re-probes or switches container; a missing/failed `ffprobe`
degrades to `.mka` rather than blocking the recording. `RecordingScheduler` is an in-process polling thread
(`_POLL_SECONDS = 20`) — deliberately not an OS-level scheduled task; "while
QUILL is running" is the explicit, simpler scope requested. Schedule entries
support `once`/`daily`/`weekly` recurrence, each carrying its own `ZoneInfo`
time zone (record an Eastern show at its Eastern time from any coast;
daylight saving handled automatically). Recording reuses the existing,
already-optional `ffmpeg` component (`quill.core.speech.ffmpeg`) rather than
introducing a second ffmpeg dependency path, and the Media submenu's
Record Now/Schedule Recording/Recording Settings items are hidden outright
(not merely disabled) when `ffmpeg_available()` is false — closing a
gap from the original Phase 1 ship where recording did not yet exist to gate.

**Recording reliability — resume across restart, the scheduler window
model, and pipeline hardening (R1-R4, 2026-07-17).** A reported round of
bugs around the Recordings list, the scheduler, and reconnects was closed
in four phases (audit + decisions in `x.md` Part 2; all lands in `quill/`,
none vendored into QUILL-AS):

- *R1 — display and dialog.* `RecordingsManagerDialog` refresh is now an
  in-place diff keyed by full path (no `DeleteAllItems`; a no-op when the
  snapshot is unchanged; selection/focus/scroll preserved), so a screen
  reader is no longer yanked to the top mid-read. The active recording is
  counted from `RadioRecorder.is_recording` + `current_destination`
  regardless of folder (a recording writing to a temp dir is no longer
  invisible; `recordings_index.py`), a firing schedule is no longer
  double-counted, completed `once` entries drop out of the scheduled count,
  the active row shows a live elapsed time, scheduled entries show their
  zone-labeled times, and the tray tooltip carries "(recording)".
- *R2 — scheduler reliability.* `recording_schedule.py` was rewritten onto
  a next-due-timestamp window model: an entry is due from `start` through
  `start + duration` (`is_due`), so a late arrival starts with the remaining
  minutes (`remaining_minutes`) and launch-time catch-up is free
  (`missed_occurrences` filters out windows still open at `now`, so catch-up
  is not double-announced). `last_fired` is stamped by entry id only on a
  successful start (`_fire`); a failure retries on the next poll within the
  window; `once` entries auto-disable after firing; a same-minute conflict
  (two schedules due while the recorder is busy) defers the second via
  `on_busy` instead of burning it. The scheduler's `self.entries` is an
  `RLock` shared with the UI thread and `_run` is wrapped in a catch-log-
  continue so the thread can never die silently. Missed-recording reporting
  and `last_seen` stamping moved into the shared `RadioMixin`
  (`_report_missed_recordings`, `_stamp_radio_last_seen`) so embedded QUILL
  gets the same behavior as standalone Quill Radio.
- *R3 — resume across restart (new feature).* The new wx-free
  `quill/core/radio/recording_resume.py` defines `ActiveRecordingMarker`
  (station, url, temp/output path, started_at, scheduled_end, duration,
  entry id), with strict-discard `from_dict` (a corrupt marker yields
  `None`, never a bogus resume), `load/save/clear_marker`,
  `remaining_minutes(marker, now)` floored at 0, `within_resume_grace`
  (`DEFAULT_RESUME_GRACE_MINUTES = 10`), and `reconcile_temp_strays` (moves
  recording-extension files from `temp_dir` to `dest_root` when their mtime
  is older than `_STILL_WRITING_SECS = 30`; leaves a file still being written
  untouched; ignores non-recording files; no-op when no temp dir or temp ==
  dest). `RadioHistory` gained `recording_resume_choice` (`ask`|`always`|
  `never`, default `ask`; bad value degrades to `ask`). The `RadioMixin`
  persists the marker from the recorder's live state on start
  (`_persist_radio_recording_marker`, via `_apply_radio_recording_changed`)
  and clears it on a clean stop — a crash leaves it for next launch. At
  startup `_reconcile_and_offer_radio_resume` (a `wx.CallAfter`) reconciles
  temp strays, then per `recording_resume_choice` asks/always/never offers
  to resume for the remaining minutes via the new
  `quill/ui/radio/resume_recording_dialog.py` (Resume = affirmative/Enter,
  Skip = Escape, "Don't ask me again" checkbox; `None` when dismissed is
  treated as skip and the marker is left to age out past grace). A periodic
  `_radio_last_seen_timer` (60 s) keeps the missed-recording window accurate
  across a crash. The dialog goes through the shared `_show_modal_dialog` /
  `apply_modal_ids` contract and is registered in the dialog inventory.
- *R4 — pipeline hardening.* (1) Absolute end-time across reconnects:
  `RadioRecorder` stores `_scheduled_end = started + duration` on a fresh
  start, and `_maybe_reconnect` records only the remaining minutes
  (`ceil((end - now)/60)`, floored at 1; gives up when `<= 0`), never a fresh
  full duration. (2) `uniquify()` (in `recording_commands.py`) replaces the
  unconditional `-y` overwrite (appends `" (2)".." (999)"` before the
  extension; returns the path unchanged if it does not exist) and
  continuation parts keep the original start timestamp in their
  `{date}`/`{time}` tokens (grouped by name, `" (part N)"` appended) instead
  of rebuilding from `now()`. (3) Fatal vs transient: `_drain_stderr`
  captures a `_stderr_tail` (`deque(maxlen=32)`); `_FATAL_STDERR_RE` (no
  space left / disk full / enospc / read-only / HTTP 4xx / 403 / 404 / 410 /
  451) classifies a drop as fatal, and `_monitor` then skips
  `_maybe_reconnect` — a stream the server took down is gone; reconnecting
  only wastes the budget and spams continuation files. A 5xx / network
  timeout / bare EOF stays transient and is retried. (4) Host-tied child +
  off-thread stop: `_assign_kill_on_close_job` puts ffmpeg in a Windows job
  object with `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` (ctypes against
  `kernel32`, best-effort — `None` off-Windows or on any failure, degrading
  to the pre-job behavior), so a crashed/killed QUILL closes the handle and
  the OS kills the child instead of stranding a bare ffmpeg in the temp dir.
  `stop()` sends `q` synchronously (a pipe write) and spawns a daemon
  `_await_stop` thread for the wait/terminate fallback (`_STOP_GRACE_SECONDS`
  then `terminate()`), so the UI/close path never blocks. `_monitor` closes
  the job handle after `process.wait()` returns (child already dead) so a
  long session leaks no handles.

**The mpv playback engine (`quill/ui/radio/mpv_radio_engine.py` +
`player_controller.py`, #1076).** Radio has two backends.
`MpvRadioEngine` — a radio-specific, live-stream-aware libmpv engine — is
preferred whenever libmpv is present (`RadioHistory.playback_engine` =
"auto", the same philosophy as the podcast/Audio Studio `create_engine`);
`WxMediaEngine` (wx.media/WMP) remains the zero-dependency fallback and the
"Windows Media (classic)" escape hatch in Preferences. The shared
`MpvAudioEngine` could not be reused: its poll gates "loaded" on a positive
`duration`, which a live stream never reports. The radio engine's readiness
state machine is a pure, unit-tested function (`next_poll_action`): loaded =
`core-idle` goes false (audio actually being output); error = a return to
`idle-active` before ready (bounded by mpv's own `network-timeout`);
finished = `eof-reached`/idle after playing — a live stream's "ended" is a
disconnect. The mpv engine is what delivers, in one stroke: **output-device
routing** (`audio-device`; enumerated via `audio-device-list` read as JSON
through a short-lived handle, pseudo-entries filtered; the Preferences
"Radio output device" dropdown keeps an unplugged saved device rather than
silently resetting it; `RadioHistory.output_device`); **live DVR** — a
seekable 50 MiB demuxer cache (~45 min at 128 kbps) makes pausing live
radio and Rewind/Forward 30 Seconds/Back to Live real commands
(`radio.rewind`/`radio.forward`/`radio.jump_to_live`, with a spoken
"N behind live" phrase); **Volume Boost** (`radio.volume_boost`,
`volume-max=150` — up to 50% past unity for quiet streams; the 0-100 user
scale is untouched, the boost is applied on the way to the engine, and the
wx engine clamps at 100 so it is safe everywhere); **wider station support**
— effectively every stream format in real-world use now plays: MP3, AAC
and HE-AAC (AAC+), Ogg Vorbis, Opus, FLAC streams, and HLS (m3u8) — WMP
cannot decode Ogg Vorbis, Opus, or HLS at all — and either direction
gets one silent **cross-engine rescue** per play attempt (a stream the
current engine cannot open is retried once on the other before an error is
declared); **engine-native track titles** (mpv `media-title` as the
What's-Playing fallback when the out-of-band ICY tap gets nothing — some
hosts reject it, HLS has no ICY); and **buffering announcements**
(`paused-for-cache` polled once per stall — "Buffering..." instead of dead
air). libmpv is the existing pinned assets-v1 component (§ Audio Studio);
Quill Radio's installer bundles it under `tools\mpv` exactly as ffmpeg under
`tools\ffmpeg`, GPL texts and source-offer note alongside.

**Sound Enhancements (`quill/core/audio_enhance.py`, shared with Podcasts
§5.84g).** A three-band equalizer (Bass/Mid/Treble sliders, -12 to +12 dB
each, `EQ_BAND_MIN_DB`/`MAX_DB`, freely adjustable — the named presets,
Flat/Bass Boost/Voice Clarity/Podcast plus the 1.1.0 additions Small
Speakers and Late Night, survive only as a "Quick preset" shortcut that
sets all three sliders at once), a compressor, and the 1.1.0
**listener-level sound options**: **mono downmix** (`pan=mono`, both
channels blended equally — an accessibility option for single-sided hearing
or a single earbud, where hard-panned stereo content simply disappears) and
**night mode** (`dynaudnorm`, real-time loudness normalization that lifts
quiet program material — the complement to the compressor's "tame the loud
parts"). Deliberately global rather than per-station: they describe the
listener's ears and situation, not a station
(`RadioHistory.mono_enabled`/`night_mode_enabled`). One filter graph
(`build_filter_graph`, order deliberate: mono first, EQ, compressor, night
mode last) drives all three delivery paths so every option behaves
identically everywhere: the loopback-only ffmpeg relay on the wx engine;
**natively via mpv's `af`** on the mpv engine — no relay, no second ffmpeg
process, and a settings change is heard live with no reconnect at all
(`set_filter_graph`); and recordings, via
`RecordingSettings.apply_sound_enhancements` threading the same graph into
`build_record_command`. Off by default; the engine loads the station's own
URL directly until the user opts in. The EQ + compressor remain
**remembered per favorite station**
(`core/radio/favorites.py::FavoriteStation.has_sound_enhancement_override`,
a whole-record override mirroring Podcasts' own per-show override below):
opening Sound Enhancements while a favorite plays edits that station's own
settings; `RadioPlayerController` resolves which applies via an injected
`resolve_enhancement` callback at the top of every `play_station`, rather
than threading the choice through each of the 11+ places a station can
start playing. (The earlier "not a new audio backend" note about BASS
stands — the second backend chosen, libmpv, was already a first-class
optional component of the product, adds no licensing exposure, and touches
nothing but radio playback: earcons and every other product sound stay on
winsound and the system default device.)

**Non-goals reversed 2026-07-17 — TuneIn and iHeart are now in scope.** The
original non-goal left TuneIn and iHeartRadio out for lack of public API terms
QUILL could safely depend on. That was reversed with explicit approval on
2026-07-17: both are now supported through their own no-auth, open endpoints —
TuneIn via RadioTime's OPML API (`opml.radiotime.com` search/browse/`Tune.ashx`
resolve; `partnerId=RadioTime` is the web player's own public string) in
`quill/core/radio/tunein.py`, and iHeart via its public XML sitemap
(`iheart.com/sitemap.xml` → livestations sub-sitemap; per-station stream URLs
extracted lazily from the page the sitemap points to) in
`quill/core/radio/iheart.py`. Both return only what the user searched for or
what a page already advertises — the same shape as the Triton provisioning
resolver QUILL already ships — and neither scrapes a competitor's data files
(per the no-competitor-data stance). Each funnels through one reviewed egress
site, HTTPS-only over a verified TLS context, and is refused in Safe Mode.

**Non-goals (deliberate).** YouTube audio (any form) —
`requirements.txt` already excludes `yt-dlp`/`youtube-transcript-api` to keep
the installed surface small, and this feature does not reopen that call. A
full DSP effects rack in FastPlay's sense — reverb, tempo/pitch/rate,
spatial audio, center-channel/vocal cancellation — remains out of scope
(Sound Enhancements, above, is a small, purpose-built EQ preset and
compressor, not a general effects rack). A full embedded interactive browser for the website link finder —
QUILL has no general-purpose accessible WebView for arbitrary-site
navigation, and `core/browser_reader.py` already shows a house preference for
the user's real browser over an embedded one on accessibility-sensitive
tasks; a static fetch-and-parse covers the real case (station pages list
literal stream links).

**Planned next, not yet built.** Recording and scheduled recording, above,
close out the two biggest items, but not everything: a read-only ACB Media
events/meetings calendar view (a new, not-yet-verified egress site against
`link.acb.org`), a live-refresh fetcher for the currently
static/hand-maintained ACB Media station list, and optional OS-level
global hotkeys remain — see `docs/planning/roadmap.md` §1.7. (The mpv
backend for live streams and ICY "now playing" metadata, once listed here,
both shipped — see the mpv playback engine above and What's Playing.)

**Value.** A genuine, on-mission "why QUILL and not a dedicated player"
answer for a screen-reader-first audience: the ACB Media category exists
nowhere else this convenient, and podcasts (§5.84g) chain a downloaded
episode directly into the Listening Companion (§5.84b) for transcribe-and-
summarize, which no standalone radio/podcast app offers.

---

### 5.84g Podcasts (Phase 1-2, shipped)

**Goal.** Subscribe to, organize, download, and play podcasts inside QUILL,
sharing Internet Radio's (§5.84f) proven "one player that outlives any
dialog" architecture rather than inventing a second one. Covers Phase 1 of
the original 5-phase plan (discovery, subscriptions,
nested folders, OPML, two-control downloads, retention, playback with
per-show speed and resume position) plus chapters, sorting, and rich
context menus pulled forward from Phase 3. Transcript UI, the Inbox, the
Play Queue, and local (imported-file) podcasts remain later phases in that
same document.

**Data model (`quill/core/podcasts/models.py`).** `PodcastShow` (one per
subscription, or one `is_local` show for a later phase) owns a flat list of
`PodcastEpisode` and an optional `PodcastSettings` override; `PodcastFolder`
is a plain adjacency-list node (`parent_folder_id`), letting a show nest
arbitrarily deep. `PodcastEpisode` carries `chapters_url`/`transcript_url`/
`transcript_type` today as forward schema the feed reader already populates,
even though Phase 1 has no UI that reads them yet — landing the on-disk
shape now means the later chapters/transcript phase needs no migration.
`position_ms` is the resume-position field the plan's sync design (§5.84f's
persistence note) already anticipated.

**Discovery and subscription.** `core/podcasts/itunes_search.py` queries
Apple's free, keyless iTunes Search API — the same starting point FastPlay
uses — for `search_podcasts()`. `core/podcasts/feed_reader.py` is a
deliberate two-step design: QUILL fetches feed bytes itself
(`_fetch_feed_bytes`, the one reviewed egress site, HTTPS-only, optional
HTTP Basic auth for private feeds sent preemptively), then hands those bytes
to `feedparser` for parsing only — never letting `feedparser`'s own fetch
path make the network call, which would move it outside QUILL's audited
egress surface. Podcasting 2.0's `<podcast:chapters>`/`<podcast:transcript>`
tags aren't reliably exposed by `feedparser` across versions, so those two
are extracted with a tolerant regex pass scoped per-`<item>` fragment as a
fallback that doesn't depend on guessing `feedparser`'s internal key
mapping. `core/podcasts/subscriptions.py`'s `PodcastLibrary` is the one
atomic-JSON store (shows, folders, global settings); `merge_episodes()`
refreshes a known episode's feed-supplied metadata but never drops one just
because a refreshed feed no longer lists it, and never resets its local
state (played, position, downloaded file, mode override) — an old episode
scrolling off a feed's live listing must not erase what you already did
with it.

**OPML (`core/podcasts/opml.py`).** Export walks the folder tree into nested
`<outline>` elements (local shows excluded — they have no feed URL to
export); import reconstructs that same tree from the nesting and reuses
`PodcastLibrary.add_show`'s existing duplicate-feed-URL detection, so
re-importing a list you already have adds nothing twice. Untrusted OPML is
parsed through `quill.core.safe_xml` (entity-expansion attacks disabled),
never the bare stdlib parser; exporting uses plain `ElementTree`
construction, which is not a parsing-of-untrusted-input operation.

**Downloads (`core/podcasts/download_queue.py`).** One dedicated worker
thread per process (not the shared `QuillTaskManager` pool), so a backlog of
podcast downloads never competes with AI calls or transcription for a pool
slot. Two independent pause controls, matching the plan's explicit
requirement that this not be one setting wearing two hats:
`pause_all`/`resume_all` stop the worker from *starting* new transfers,
letting anything mid-transfer finish; `pause_item`/`resume_item` halt one
specific transfer immediately via a per-item `threading.Event`, checked
between each bounded chunk read so pause takes effect within a chunk, not
only between whole-file attempts. Resuming reads the partial file's size and
sends an HTTP `Range` request, falling back to a clean restart when the
server doesn't honor it. `core/podcasts/retention.py` applies
`keep_last_n` pruning after every completed download and
`delete_after_play` after every finished episode — pure functions, testable
without a real download or a real file.

**Playback (`quill/ui/podcasts/player_controller.py`).** One
`PodcastPlayerController`, owned by `MainFrame` for the process's lifetime —
the exact same shape as `RadioPlayerController` (§5.84f), including the
"every dialog drives the shared controller, none of them own it" rule that
makes closing the Podcasts dialog never stop playback, and makes starting a
new episode always replace whatever was playing rather than layering two
streams. Unlike Radio, podcast episodes are bounded files (even mid-stream,
the enclosure reports a real `Content-Length`), so this uses Audio Studio's
normal `create_engine()` (mpv-preferred, `WxMediaEngine` fallback) rather
than being restricted to Radio's wx.media-only backend. Per-show playback
speed (`PodcastSettings.speed`, 0.75x-2.0x in the UI) is applied via
`set_rate()` once the engine reports loaded, not before — some backends
only accept a rate change after a file is open. Finishing an episode marks
it played and applies `delete_after_play` before the state resets.

**Surfaces.** `Tools > Media > Podcasts...` (Podcast Manager: folder tree +
episode list, Add Podcast/New Folder/Import/Export OPML, Download/Pause
Download/Remove Download, Unsubscribe) registers eight commands in
`CommandRegistry` (`feature_id="core.podcasts"`), command-palette visible.
Rich context menus close the plan's explicit "even playback and pause, etc."
requirement: the episode list's menu covers Play/Pause, Stop, Download,
Pause/Resume Download, Remove Downloaded Copy, Mark as Played/Unplayed, and
Copy Episode Link; the folder tree's menu covers Refresh Feed, Pause/Resume
Downloads for This Podcast (keeps the show and its episodes in the library
while stopping new fetches — the plan's "mark a podcast to not download but
keep in the library" ask), Unsubscribe, and New Folder. A `podcast_player`
status-bar cell (auto-surfaces on first play, same pattern as Radio's cell)
and a system-tray section (Play/Pause, Stop, Pause/Resume All Downloads)
both drive the shared controller. Two QUILL-key chords
(`Ctrl+Shift+Grave, 8/7` for play-pause/stop) sit adjacent to, not
overlapping, Radio's `N/0/9`.

**Chapters (`core/podcasts/chapters.py`).** Fetches and parses the
Podcasting 2.0 JSON chapters format the feed's `<podcast:chapters>` tag
points at, the same fetch-then-parse split `feed_reader.py` uses (one
reviewed egress site, pure parsing). `chapter_at_position()`/
`next_chapter()`/`previous_chapter()` are pure functions over a sorted
chapter list, driving both the Chapters dialog's jump action and the
global `podcasts.next_chapter`/`podcasts.previous_chapter` commands.
Chapters for the currently-playing episode are fetched once per episode
change (tracked by a `(show_id, episode_guid)` key, not re-fetched on every
play/pause toggle) via a background `_task_manager.submit()` call, never
blocking playback.

**Sorting and unheard counts (`core/podcasts/sorting.py`).** Pure
`sort_episodes()`/`sort_shows()` functions — six episode sort modes
(newest/oldest/title/duration-longest/duration-shortest/unplayed-first) and
three show sort modes (title/most-unheard-first/recently-updated-first),
each with a sensible fallback for an unrecognized mode rather than raising.
Publish dates are parsed via `email.utils.parsedate_to_datetime` (the
typical RSS `pubDate` format), falling back to the oldest possible sort
position for an unparseable or missing date rather than erroring. Unheard
counts are computed recursively per folder (a folder's count includes every
subfolder's shows) and per show, rendered as `"{name} (N unheard)"` in the
tree only when non-zero.

**Show notes (`core/podcasts/show_notes.py`).** `html_to_plain_text()`
converts an episode's HTML description to plain text with real paragraph
line breaks (block-level tag ends become newlines) so a screen reader's
line-by-line navigation moves by line, not by wrapping a single giant line
word by word — the specific, named failure mode this was built to avoid.
Links become `text (url)` rather than being silently dropped.
`strip_html_images()` removes every `<img>` tag before any HTML is handed
to the "rich" `wx.html.HtmlWindow` view: an HTML renderer that itself
fetches `<img src="...">` would be a silent, unreviewed network egress site
invisible to a static audit of QUILL's own `urlopen` call sites, so images
are removed rather than rendered. **Send Show Notes to Editor** reuses the
existing `_power_tools_open_text_in_new_buffer()` new-document path (the
same one AI transcribe/translate use).

**Global settings and delete-on-remove (`PodcastSettings.
delete_files_on_remove`).** A new `"ask"`/`"always"`/`"never"` field,
consulted in the Unsubscribe flow via `effective_settings()` (so a show's
own override wins over the library global): `"ask"` prompts a second
confirmation naming the downloaded-episode count only when there are any to
delete; `"always"`/`"never"` act without asking. `PodcastLibrary.settings`
(playback mode, retention, speed, download root) existed in the data model
since Phase 1 but had no UI until this pass's Podcast Settings dialog.

**Non-goals (deliberate).** Video podcasts, in any form — audio only,
matching every other QUILL playback surface; this was an explicit,
repeated constraint during planning, not an oversight. TuneIn/iHeartRadio
apply to Radio, not here; podcast feeds are an open standard (RSS/Atom),
so there is no equivalent commercial-API question for this feature.

**Planned next (Phase 3+), not yet built.** Transcript viewing/export/
QUILL-transcription (the feed already parses the URL; §5.84b's
transcription engines are the intended target), a separate Inbox view with
its own folder tree, a cross-show reorderable Play Queue, local
(imported-file) podcasts and watched folders, virtual views
(Favorites/New Episodes/Continue Listening), and rich filtering (including
Search Everywhere) — all shipped; see the Phase 4 block below for the full
plan.

**Value.** Closes the other half of the "why QUILL and not a dedicated
player" answer §5.84f opened: a downloaded episode chains directly into the
Listening Companion (§5.84b) for transcribe-and-summarize, something no
standalone podcast app offers, and the same QUILL Sync story that already
carries settings between machines now carries listening position too.

#### Phase 4 (shipped): views, Inbox, queue, transcripts, notes, local, sync

Everything the original phased plan deferred, now shipped. Every core is
wx-free and unit-tested; the manager UI additions live in
``quill/ui/podcasts/manager_phase4.py`` (a mixin, per the manager's CQ-1
decomposition note) and ``play_queue_dialog.py``; standalone QUILL Cast
(§5.89e) inherits all of it and adds matching menu items.

- **Pinned virtual views (P4-1).** Favorites / New Episodes / Continue
  Listening / Inbox sit above the folder tree with live counts
  (``virtual_views.py``; Favorites via ``PodcastShow.is_favorite``, toggled
  from the show context menu). Cross-show rows always carry the show name.
- **The Inbox (P4-2, ``inbox.py``).** An episode-level curation layer:
  ``route_to_inbox`` shows surface unplayed episodes in the Inbox regardless
  of library folder; a second, independent nested folder tree
  (``PodcastLibrary.inbox_folders``) files episodes
  (``inbox_assignments``); the first manual filing per show is remembered
  (``inbox_default_folder_id``, announced) with Forget to revert. Folder
  deletion only promotes contents; Inbox actions never delete an episode;
  the whole layer is excluded from OPML both directions.
- **Play Queue (P4-3, ``queue.py`` + ``PlayQueueDialog``).** Cross-show
  ordered queue persisted on the library; Play Next / Add to Queue on any
  episode; auto-advance on natural finish via ``pop_next_playable`` (stale
  slots self-heal); reordering is nudge (Move Up/Down) plus mark-and-move
  (Move Marked Above/Below) -- the Interactive Rebase pattern.
- **Search Everywhere + filters (P4-4, ``filtering.py``).** One query over
  shows, episodes, episode notes, and cached transcripts (never a network
  fetch), grouped by type with jump-to-result; episode-state and show
  filters narrow the manager live.
- **Transcripts (P4-5, ``transcripts.py``).** Feed-provided Podcasting 2.0
  transcripts (VTT/SRT/JSON parsed to plain text) save to a file or open in
  the editor; fetched transcripts cache locally (searchable, instant
  reopen).
- **Episode notes (P4-6, ``episode_notes.py``).** Timestamped notes on the
  playing episode; the notes dialog jumps playback to a note's moment.
- **Local podcasts + watched folders (P4-7, ``local_import.py``).** Audio
  files become an ``is_local`` show (one episode per file); stored under
  ``~/.quill-local/podcasts`` -- outside the syncable data directory *by
  construction*, per the sync-safety requirement. A per-show
  ``watched_folder`` turns dropped files into episodes on scan. Never
  exported to OPML.
- **ACB Media directory (P4-8, ``acb_media_podcasts.py``).** One idempotent
  command subscribes the live ACB directory into its own folder, every
  arrival stream-only (``import_opml``'s ``stream_only``/``into_folder``).
- **Always Sync (P4-9).** ``always_sync_full_catalog`` (per-show
  overridable): refresh backfills the catalog and queues downloads for
  download-mode shows; the settings checkbox nudges retention to keep-all
  (announced) since backfill fights keep-last-N.
- **Download processing + volume boost (P4-10, ``audio_processing.py``).**
  Optional auto-trim-silence and loudness-normalization on each finished
  download (the audiobook builder's ffmpeg passes, off-thread);
  ``set_volume_boost`` (0.5x-3.0x clamp) scales live gain only --
  ``volume_percent`` stays unboosted so the Sleep Timer's restore is honest.
- **Position checkpoints (P4-11).** ``on_position_checkpoint`` fires with
  the outgoing episode's position at pause/stop/switch/shutdown and the
  mixin persists it -- the write half of resume, which previously only read.
- **Status page rows (P4-12, ``status_report.py``).** Podcast library
  summary rows and download-task rows (with started/finished timestamps)
  for the Help Status page.

#### Phase 5 (shipped): Sound Enhancements + Smart Speed

Shares Radio's ``core/audio_enhance.py`` (§5.84f) rather than duplicating
it, with the one real wrinkle Radio doesn't have: episodes support seeking
and a duration/scrub bar, which a live one-way ffmpeg relay has neither of
natively. Full parity was built, not a degraded no-seek mode.

- **Three-band EQ + compressor.** Same as Radio (§5.84f): Bass/Mid/Treble
  sliders plus a compressor, applied live via the shared relay. Off by
  default; a "Quick preset" shortcut still sets all three sliders at once
  from the four original named presets.
- **Smart Speed (podcasts only).** A ``silenceremove`` filter trims silence
  anywhere in the audio (not just leading/trailing), for the gaps between
  sentences a spoken-word episode is full of -- reversible and live, not the
  one-time, permanent leading/trailing trim ``audio_processing.py`` (P4-10)
  already does to a saved download. Not exposed for Radio: a live stream has
  no fixed content to trim ahead of time, and "silence" in music is often
  intentional.
- **Seek while enhanced (``player_controller.py``).** There is no way to
  seek within an already-running relay, so scrubbing restarts it with an
  ffmpeg ``-ss`` offset -- an async reload, not the engine's normal instant
  seek. ``_pending_play_after_load`` carries play/pause intent through that
  reload so scrubbing or toggling enhancement mid-episode never forces a
  paused episode to resume.
- **Duration (``probe_source_duration_ms``).** The relay's own MP3 output
  never declares a length for the engine to compute a scrub bar from, so
  duration comes from an independent ``ffprobe`` call instead.

#### Phase 6 (shipped): Download All Episodes / Remove All Episodes

Two new show-level context-menu actions, implemented once in
``quill/ui/podcasts/show_actions.py`` and called from both surfaces that act
on a subscribed show (the Podcast Manager dialog and QUILL Cast's own
library tree) rather than duplicating the logic a third time.

- **Download All Episodes.** Queues every not-yet-downloaded,
  not-already-queued episode of a show -- purely additive, no confirmation,
  matching the existing single-episode Download action's own behavior.
- **Remove All Episodes.** A two-step confirm mirroring Unsubscribe's shape:
  confirm the removal, then -- only if any episode has a downloaded file --
  a follow-up asking whether to also delete those files. The show stays
  subscribed (unlike Unsubscribe); a future feed refresh can repopulate its
  episode list from the feed itself. Cancels any in-flight/queued download
  for a removed episode first, so nothing is left running against a guid
  about to disappear.

#### Phase 7 (shipped): Inbox grouping + per-podcast sort for every cross-show view

The Inbox (and every other cross-show virtual view -- New Episodes,
Continue Listening, Favorites) previously rendered ``(show, episode)``
pairs in raw feed-fetch order, with the Podcast Manager's existing "Sort
episodes" control silently doing nothing outside a single show's own
episode list. Root-caused from a direct user question about whether Inbox
episodes were grouped by show or interleaved, and whether that order could
be controlled; the first shipped design (a single "Group by Show" checkbox)
was corrected mid-review into the fuller shape below once it became clear
"folder" meant a real tree, and "resettable per podcast" meant a genuine
per-show override, not a global-only toggle.

- **``PodcastSettings.episode_list_view_mode``** (global only -- a single
  show has no "grouped vs flat" shape of its own): ``"flat"`` (one stream
  sorted by the library's global sort mode across every show), ``"grouped"``
  (the pre-existing look -- pairs grouped contiguously by show, shows
  ordered by title), or ``"folders"`` (the same per-show grouping, presented
  as real expandable tree nodes instead of a flat list). Default
  ``"grouped"`` matches the pre-existing de-facto order.
- **``PodcastSettings.episode_sort_mode``**, per-show overridable the same
  way ``speed`` already was. **``core/podcasts/sorting.py::sort_pairs``**
  (pure, unit-tested): in ``"flat"`` mode every pair sorts by the one global
  mode (per-show overrides don't apply -- there's no single well-defined
  order once different shows compare by different keys); in
  ``"grouped"``/``"folders"`` mode each show's own group sorts by *that
  show's* effective mode, so one podcast can read oldest-first while
  another reads newest-first.
- **``PodcastLibrary.apply_show_override``**, the one correct way to write
  any per-show settings override: clones the currently effective settings
  (the show's own override if it has one, else the global default) via
  ``dataclasses.replace``, so setting one field never resets sibling
  overrides to class defaults. Also fixed ``_on_speed_choice``, which
  predates this and hand-cloned only 5 of the (now) 14 ``PodcastSettings``
  fields -- setting a show's playback speed silently wiped any other
  override that show already had.
- **UI (``manager_dialog.py``, ``manager_phase4.py``).** A "View cross-show
  lists as" combo box (Flat list / Grouped in list / Folders per podcast)
  and a context-aware "Sort episodes" control: it reads and writes the
  selected podcast's own override when a single show (or its Folders tree
  node) is selected, or the shared global default otherwise.
  ``_add_virtual_view_show_children`` builds the Folders mode's per-podcast
  tree nodes fresh on every ``refresh_tree()`` -- auto-generated, never
  persisted, distinct from the existing manual freeform Inbox folder tree.

#### Phase 8 (shipped): per-podcast Sound Enhancements, Skip Forward/Back, auto-skip intro/outro

A competitive pass against Downcast/Overcast/Pocket Casts/Castro surfaced two
gaps: Sound Enhancements (Phase 5, above) was global-only, and there was no
skip-by-N-seconds command at all (only absolute chapter-boundary seeks).

- **Per-podcast Sound Enhancements.** ``PodcastSettings`` gained
  ``eq_bass_db``/``eq_mid_db``/``eq_treble_db``/``compressor_enabled``/
  ``smart_speed_enabled``, per-show overridable via ``apply_show_override``
  exactly like ``episode_sort_mode`` (Phase 7). Opening Sound Enhancements
  while an episode is playing edits that show's own override; with nothing
  playing, it edits the shared default. Every ``play_episode`` call site (6,
  across the Manager, the Play Queue, Recently Played, and both standalone
  apps) resolves ``effective_settings(show)`` and passes the result through
  new optional ``bass_db``/``mid_db``/``treble_db``/``compressor_enabled``/
  ``smart_speed_enabled`` kwargs.
- **Skip Forward / Skip Back.** ``PodcastSettings.skip_forward_seconds``/
  ``skip_back_seconds`` (global default + per-show override, 30/15 by
  default) back two new commands (``podcasts.skip_forward``/``skip_back``,
  Episode-menu items, default chords) that jump the player controller's
  position by that many seconds, clamped to ``[0, length_ms]``.
- **Auto-skip intro/outro** (``auto_skip_intro_seconds``/
  ``auto_skip_outro_seconds``, per-show only -- a global "skip N seconds of
  every podcast" default isn't a thing anyone wants). Intro-skip applies
  once, only on a fresh start (``resume_ms <= 0``) -- a checkpointed
  position is never jumped past. Outro-skip is a new 1-second
  ``PodcastPlayerController`` position poll that ends the episode early
  through the exact same ``_on_finished`` path a natural end uses, so
  auto-advance and delete-after-play still fire.
- **New context-aware Skip Settings... dialog** (``skip_settings_dialog.py``)
  mirrors Sound Enhancements exactly: edits the currently-loaded show's
  override, or the shared default with the intro/outro fields hidden
  entirely when nothing is loaded (they have no meaningful global value).
- Also fixed a real bug found while touching ``PodcastSettingsDialog``:
  ``_on_save`` built a fresh ``PodcastSettings(...)`` that silently reset 7
  unedited fields (view mode, sort mode, EQ, smart speed) to class defaults
  on every save. Now ``dataclasses.replace(self._settings, **edits)``, the
  same pattern ``apply_show_override`` already uses.

#### Phase 9 (shipped): saved Playlists -- Smart (rule-based) and manual

The other half of the same competitive pass: no way to save a curated or
rule-based cross-show episode list, only the four fixed pinned views and the
transient Play Queue -- a real gap against Pocket Casts' Smart Playlists/
Filters and curated Playlists.

- **``Playlist``/``PlaylistRules``** (``models.py``, mirrors ``QueueItem``'s
  own data-class-in-``models.py``/operations-in-a-sibling-module split).
  ``kind="smart"`` resolves live against ``rules`` every time it's opened
  (which shows -- empty means every show, episode status, published-within-
  days, min/max duration, sort mode, reusing ``sorting.py``'s own sort-key
  builder so results order exactly like every other episode list in the
  app); ``kind="manual"`` is a named, ordered, persistent list of specific
  episode references (``QueueItem``s) -- the saved counterpart to the
  transient Play Queue, self-healing against a since-unsubscribed show or
  vanished episode the same way the Play Queue already does.
  ``core/podcasts/playlists.py::resolve_playlist`` is pure and fully unit
  tested.
- **``PodcastLibrary.playlists``** + find/add/remove/rename CRUD, persisted
  the same hand-rolled-dict way ``queue``/``inbox_folders`` already are.
- **Tree integration** (``manager_phase4.py``) mirrors the existing
  pinned-views/Inbox-folder pattern exactly: a "Playlists" node (context
  menu: New Smart Playlist.../New Playlist...), one child per saved
  playlist showing its live-resolved count, Edit Rules.../Rename/Delete on
  each, F2 rename support, and the episode list fills via
  ``resolve_playlist`` the same way a virtual view fills via
  ``virtual_view_pairs``.
- **New ``playlist_rules_dialog.py``** (the Smart Playlist rule editor) uses
  individual ``wx.CheckBox`` controls in a ``wx.ScrolledWindow`` for the show
  picker, not ``wx.CheckListBox`` -- caught by the banned-patterns gate
  (A11Y-SR-1: screen readers do not announce ``CheckListBox`` item checked
  state as it's navigated, only the label text).
- Episode context menu gained "Add to Playlist..." (a native
  ``wx.SingleChoiceDialog`` among existing manual playlists, or create one
  inline).

---

### 5.84h Shared media Sleep Timer, and start-at-Windows-login

**Goal.** A sleep timer that covers both Internet Radio (§5.84f) and
Podcasts (§5.84g) from one place, since duplicating it per-feature would
mean two timers, two dialogs, and no guarantee they agree on what "active"
means; and a way to launch QUILL automatically at login, requested
alongside the media work though unrelated to it in scope.

**Sleep timer (`quill/ui/media_sleep_timer.py`).** `SleepTimerController`
is owned by `MainFrame`, not either media mixin, and takes `get_radio_
controller`/`get_podcast_controller` callables rather than direct
references, so it works whether one, both, or neither feature is enabled.
A single `wx.Timer` ticks every second; over the final `_FADE_WINDOW_
SECONDS` (20) of the countdown, every controller currently in a
`PLAYING`/`PAUSED` state has its volume faded linearly toward zero
(captured once at fade-start per controller, so a mid-fade cancel restores
the pre-fade level exactly, not whatever the fade had reached). At zero,
every active controller is stopped, then every faded controller's volume is
restored — stopping first, then restoring, so the fade-out is never briefly
audible again between "stopped" and "volume restored." Radio and Podcasts
are independent players (nothing stops one when the other starts), so both
are faded/stopped if both happen to be active at once. `PodcastPlayerController`
gained a `volume_percent` property/tracked field for this — Phase 1 shipped
with a `set_volume()` method but no readable state, since nothing needed to
read it back before the sleep timer did.

**Start at Windows login (`quill/platform/windows/startup.py`).**
`set_launch_at_startup()`/`is_launch_at_startup_enabled()` read/write a
per-user `HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Run`
value — the same no-elevation, easily-reversible mechanism most everyday
Windows apps use, not an installer or Task Scheduler change. A new
`Settings.launch_at_windows_startup` field, rendered automatically by the
generic Preferences dialog from its `SettingSpec` entry (placed immediately
after `tray_enabled`'s), is applied in `_settings_dialog_apply_refresh()` —
the same shared hook every Settings-apply path (OK, Apply, Reset, Import)
already funnels through. Checking it also forces `tray_enabled` on: a QUILL
that launches silently at login with no tray icon would leave no visible
way back into a hidden window, so the two settings are kept consistent
rather than left to conflict. A no-op, never-raising module on non-Windows
platforms (`winreg` import guarded, mirroring `shell_integration.py`'s
existing pattern).

---

### 5.84z Signed offline unlock codes (Help > Redeem Unlock Code...)

**Goal.** Enable a locked feature (`FeatureDefinition.locked_off`) for a
specific user — early testers, partners, staged rollouts — without a build,
a server, or an account. A code is a signed capability, not a serial number.

**Model (`quill/core/unlock_codes.py`, wx-free).** A code is
`QUILL-<base32(payload || Ed25519 signature)>` where the payload is
`feature_id|expiry` (expiry optional, ISO date). Verification uses a bundled
Ed25519 public key (PyNaCl, the optional `[signing]` extra) entirely
offline; `quill/tools/mint_unlock_code.py` mints codes with the private key,
which never ships. Redeemed codes persist in an atomic JSON store
(`UnlockCodeStore`), and `unlocked_feature_ids()` re-verifies every stored
code on each read — a tampered or expired stored code silently stops
unlocking, it is never trusted from disk.

**Feature-gate integration.** `FeatureManager.unlocked_feature_ids` is
populated from the store at load and refreshed after a successful
redemption; `state_for` treats an unlocked id as ON where `locked_off`
would otherwise force OFF. This is the single sanctioned path past a lock.

**UI.** **Help > Redeem Unlock Code...** (`help.redeem_unlock_code`, also in
the command palette) opens a one-field dialog; the result announcement names
the unlocked feature (from `FEATURE_DEFINITIONS`) or speaks the specific
rejection reason. Absent PyNaCl, redemption reports the missing extra rather
than failing silently.

### 5.84z2 Sticky Notes Browser and user-definable global hotkeys

**Goal.** Two connected capture-and-recall features: find any sticky note in
seconds from anywhere in Windows, and let users put their own keys on the
handful of commands that are genuinely safe to trigger without QUILL having
focus.

**Sticky Notes Browser (`quill/ui/sticky_notes_browser.py`).** A modal
finder: search field (live substring filter over title + body,
case-insensitive, newest first — `filter_notes` is pure and unit-tested),
Down moves from search into the results list, Tab reaches a read-only
multi-line preview, Edit/Enter/double-click opens the existing
`StickyNoteEditorDialog` for the selected note, Escape closes. Every control
is named via `dialog_contract.set_accessible_name`. Command id
`notes.sticky_browser` (Tools menu + palette + global-hotkey allowlist).

**Global hotkeys (`quill/ui/main_frame_hotkeys.py`).** Generalizes the
original single hardcoded sticky-note `RegisterHotKey` into a
user-configurable table with one hard rule: **only
`GLOBAL_HOTKEY_SAFE_COMMANDS` can ever register**, whatever
`Settings.global_hotkeys` contains — the allowlist is the enforcement
boundary, not the settings file, and a meta-test pins the list's shape
(media transport / notes / the Mastodon *compose* dialog only; nothing
document-editing or destructive). Requirements:

- Dispatch goes through `CommandRegistry.run`, so feature gating and the
  remote kill switch govern global presses exactly as they do menus and the
  palette.
- Commands flagged needs-window restore the frame from the tray before
  running; transport commands act in place. Every outcome routes through
  the announcement engine, so a press from another app is always spoken.
- The manager (`quill/ui/global_hotkeys_dialog.py`): per-command Assign
  (captures the next combination; must include Ctrl or Alt so a bare key is
  never swallowed system-wide), Clear, cross-command steal on reassign;
  nothing applies until Save. `RegisterHotKey` refusal (another app owns
  the key) is reported naming the failed binding, which stays inactive.
- `Settings.global_hotkeys: dict[str, str]` persists command_id → binding
  with defensive load; the legacy `tools.sticky_note_capture` keymap
  binding keeps registering unless overridden by the table.
- Windows-only (`RegisterHotKey`); macOS states the limitation once via the
  status bar, and every allowlisted command remains reachable in-app.

**Show/Hide to the tray — a family-wide global hotkey, one unique chord per
app.** A new allowlisted command `view.toggle_window_to_tray`
(`toggle_window_to_tray` on `GlobalHotkeysMixin`) hides QUILL to the tray when
it is showing and restores+focuses it when hidden, reusing the existing
`send_to_tray` / `_restore_from_tray` paths. It is a **window-visibility**
command — not document-editing, not destructive — so the allowlist meta-test
was widened to admit exactly this one new prefix, keeping the boundary explicit.
Because the command toggles both directions itself, it is registered *not*
needs-window (the dispatcher must not pre-restore before it runs). Requirements:

- A unique default per app so the family never collides: QUILL
  **Ctrl+Alt+Shift+Q**, Quill Radio **Ctrl+Alt+Shift+R**, Quill Weather
  **Ctrl+Alt+Shift+W**. In QUILL the default is supplied by
  `_global_hotkey_bindings` when the settings table has no entry, so it works
  out of the box yet remains an ordinary, rebindable/clearable row in the
  manager.
- The standalone apps (`AppShellFrame`) gain `_register_tray_hotkey(chord)` and
  `toggle_window_to_tray`, reusing the same `RegisterHotKey` + `EVT_HOTKEY`
  pattern already proven by `_register_media_keys` (hardware media keys), with
  the hotkey released on teardown alongside the media keys. Hiding uses
  `frame.Hide()` (the tray icon keeps the app reachable); the toggle speaks
  "hidden to the tray" / "shown".
- The chord→(flags, virtual-key) parser is the new wx-free, unit-tested
  `quill/ui/tray_hotkey.py` (`parse_hotkey(wx, chord)`), shared by QUILL and
  both standalone apps so the parse is identical everywhere.
- Best-effort and non-fatal: a chord another program already owns simply is not
  grabbed (registration fails quietly), never blocking startup or shutdown.

### 5.85 Portable API key store

By default QUILL stores AI provider keys in the Windows Credential Manager, which ties them to the current Windows user account. Portable mode offers an alternative: a DPAPI-encrypted file (`keys.enc`) in the QUILL data directory, activated by the presence of a `data/` folder next to `quill.exe` in the portable bundle.

**Motivation.** Some users run QUILL from a self-contained folder on a network share or external drive. They want all QUILL data — settings, data files, and keys — to live in one directory without requiring Credential Manager access on each machine. A DPAPI-encrypted file achieves this: everything stays in the folder, and the file is protected by the Windows user-account key.

**Access priority chain (highest wins):**

1. Environment variable (`QUILL_OPENROUTER_KEY`, `QUILL_OPENAI_KEY`, `QUILL_OLLAMA_KEY`, `QUILL_ASSISTANT_KEY`) — for CI pipelines and developer overrides.
2. Portable file store (`keys.enc`) — used automatically when the running install is recognised as portable (a verified bundle anchor with `quill.exe` + `data/`).
3. Windows Credential Manager — default for standard installations.

**Activation.** Portable mode is a property of the bundle, not of the running environment. The portable build ships `quill.exe` at the bundle root and an empty `data/` folder next to it; that folder is the deliberate filesystem opt-in. No environment variable to set. If you want to convert an installed build into a portable one, copy the install folder to a USB drive and create an empty `data/` folder at its root; QUILL will switch to portable mode automatically. The previous `QUILL_PORTABLE=1` activation mechanism is no longer required and is ignored — detection is filesystem-driven. The `keys.enc` file is created automatically in `app_data_dir()` on first key save.

**Security properties.** The file is encrypted with Windows DPAPI using a QUILL-specific entropy token. It is decryptable only on the same Windows machine by the same user account that encrypted it. Moving `keys.enc` to a different machine or a different Windows account will fail to decrypt; the user must re-enter their keys.

**Implementation map.**

| File | Role |
| --- | --- |
| `quill/platform/windows/credential_store.py` | Unified load/save/delete with env-var, portable file, and Credential Manager backends |
| `quill/platform/windows/dpapi.py` | DPAPI `protect_secret`/`unprotect_secret` (existing) |
| `quill/ui/ai_chat_dialog.py` | `_load_api_key`/`_save_api_key` updated to use credential_store |
| `quill/core/assistant_ai.py` | `_load/save/delete_api_key_from_credential_manager` updated to use credential_store |

---

### 5.86 Configurable data location (#615)

Where QUILL stores its data directory (`app_data_dir()` — settings, recovery, undo history, logs, and everything else under it) is a user choice, not a hardcoded path, satisfying the "Portable mode clarity" goal in §5.

**Storage modes.** `quill/core/storage_mode.py` persists one of three modes to `storage-mode.json`:

1. `appdata` (default) — `%APPDATA%\Quill`.
2. `portable` — `<app root>/data`, only available when the running install is verified portable.
3. `custom` — any user-chosen folder, stored alongside the mode as a `path` field.

**Portable detection without reopening L-9.** A prior security fix (commit `a4fec36`) gated `QUILL_PORTABLE_ROOT` behind `_DEV_BUILD` because trusting an attacker-controlled environment variable's *value* could redirect a user's data directory. That fix stands: this feature never trusts an env var's value directly, in any build. `storage_mode._resolve_app_root()` derives a candidate anchor from `QUILL_APP_ROOT` or by walking up from `sys.executable`, then only treats it as a real portable install when `quill.exe` and a sibling `data/` folder both exist at that anchor — filesystem evidence, not the env var's say-so. `run-quill.cmd` is accepted as back-compat evidence so a beta-1 portable bundle without a `data/` folder keeps working. `tests/unit/core/test_storage_mode.py::test_arbitrary_quill_app_root_alone_does_not_redirect_data` is a regression test mirroring the original L-9 threat model, and `test_quill_exe_alone_without_data_folder_is_not_portable` and `test_data_folder_alone_without_quill_exe_is_not_portable` lock in the new evidence rule.

**Where it's surfaced.**
- The first-run Setup Wizard's new Data Location page (`quill/ui/setup_wizard_pages.py::_DataLocationPage`) offers AppData, Portable (when available), or a custom folder via `wx.DirDialog`. On Finish this writes `storage-mode.json` directly — there is nothing to migrate yet on a fresh install.
- **Preferences → General** has the same three-way choice (`quill/ui/main_frame.py::_build_data_location_block`), for changing the location on an existing install.

**Restart-deferred migration.** A live move is unsafe: `CopyTray` caches its data directory at construction, `Settings` is loaded once at startup, and Windows has no atomic directory-move primitive that's safe against transient file locks. `quill/core/data_location.py` instead:
1. `request_data_location_change(mode, custom_path=None)` validates the target and writes a `pending-data-location.json` marker into the *current* data directory. Nothing moves yet; the current install keeps working normally until restart.
2. `apply_pending_data_location_migration()` runs first in `quill/__main__.py::main()`, before `ensure_app_directories()`. If a marker is present, it moves the old directory's contents to the new location (per-entry, via `core.storage.retry_on_transient_lock` for Windows' transient `EACCES`/`EAGAIN`/`EBUSY` locks), writes the new `storage-mode.json` at the destination, and leaves a one-time migration notice. On failure, the old location is left untouched and the notice explains what went wrong — data is never silently lost.
3. Preferences shows a "Restart Now" / "Later" prompt (`MainFrame._confirm_restart_for_data_location`) immediately after a change is requested, since the move only takes effect on the next launch.

**Implementation map.**

| File | Role |
| --- | --- |
| `quill/core/storage_mode.py` | Mode persistence; trusted-anchor + filesystem-evidence portable detection |
| `quill/core/data_location.py` | Pending-migration marker, restart-deferred move, migration notice |
| `quill/core/paths.py` | `app_data_dir()` resolves `appdata`/`portable`/`custom` via `storage_mode` |
| `quill/core/storage.py` | `retry_on_transient_lock` (shared by atomic JSON writes and directory moves) |
| `quill/ui/setup_wizard_pages.py` | `_DataLocationPage` (first-run choice) |
| `quill/ui/main_frame.py` | Preferences control, restart prompt, relaunch |

---

### 5.87 Timer Events

**Design goals.** Let a Quillin perform periodic, low-frequency background work — refreshing a status cell, polling a watched resource, housekeeping — without polling on the UI thread or observing keystrokes. A Quillin declares a `schedule` contribution: one or more named timers, each with an `interval_seconds` and a handler.

**Interval constraints.** `interval_seconds` is bounded to 60–86400 (one minute to one day). The lower bound keeps timers from becoming a high-frequency surface that could degrade screen-reader predictability or burn CPU; the upper bound keeps the value meaningful (anything longer is better modelled as a lifecycle event).

**Threading model.** Each tick runs its handler in a daemon thread via the out-of-process `ExtensionHost`, so a slow or faulty handler can never block the editor. The timer itself is a `wx.Timer` owned by the host frame; timers start when the Quillin loads, stop on disable/remove/reload, and never run in Safe Mode.

**Error handling.** A handler that raises is caught in the worker thread; the error is marshalled back to the UI thread and shown in the status bar. A timer error never crashes the editor and never stops the timer — the next tick still fires.

### 5.88 File-Type Contributions

**Design goals.** Let a Quillin react to opening a document of a declared file type — announce a braille page count for `.brf`, report CSV headers for `.csv`, timestamp a `.log` — without subscribing to every `document.opened` event and filtering by hand.

**Extension matching.** A `file_types` entry lists lowercase, dot-prefixed extensions (`.csv`, `.brf`). On open, the host looks up the document's suffix (lower-cased) in a per-extension index built at load time and fires every registered handler for that suffix. Because the handler runs real out-of-process code, `file_types` reuses the `document.events` capability and requires a `main` module.

**Dispatch model.** File-type handlers fire immediately after the general `document.opened` event, on a background thread, with a context of `{file_path, extension, filename}`. Dispatch is non-blocking; multiple Quillins may register for the same extension and all fire.

### 5.89 Snippet Gallery

**Design goals.** Give Quillins a way to ship a library of named, parameterized templates that users can browse and insert, without binding each to a command or hotkey. Gallery snippets run no code — pure text expansion — so they need no capability and no `main` module.

**Picker UX.** **Insert → Snippet Gallery...** opens a list of every gallery snippet from every enabled Quillin, grouped by Quillin, with a read-only preview of the selected snippet's body. Insert expands the snippet at the cursor (replacing any selection); Cancel closes the dialog.

**Parameter prompting.** A snippet body may contain `{param_name}` placeholders. Each declared param has a `name` (matching a placeholder), a `label`, and an optional `default`. On insert, QUILL prompts for each param in turn through the shared single-line prompt; cancelling any prompt cancels the whole insertion (nothing is inserted).

**Accessibility requirements.** The gallery is a hardened `wx.Dialog` routed through `_show_modal_dialog`/`apply_modal_ids` for the keyboard contract. The snippet list, preview field, and buttons all carry accessible names; selecting a snippet announces its name and source Quillin; Insert is the default button and Escape cancels. Param prompts are individually labelled and cancellable.

---

### 5.89a Classic-editor power features — Repeat, Restore Deleted Text, Describe Character

**Motivation.** Three keyboard-first conveniences from the lineage of the WordPerfect Editor that have no prior equivalent in QUILL. Each targets the same audience as the rest of the power-tool suite: writers who work entirely from the keyboard with a screen reader, and who value precise, repeatable, inspectable editing. All three ship **unbound** (no default shortcut), registered through the standard command registry so they appear in the Command Palette and Keymap Editor and recirculate into their conventional menus.

#### Repeat Next Command (`edit.repeat_command`, Edit menu)

A numeric repeat prefix for *any* command. The user invokes Repeat Next Command, enters a count in a native, focus-managed prompt, and the very next command dispatched through the command registry runs that many times — move down twenty lines, delete ten words, insert forty dashes, or replay a recorded macro N times — in one gesture.

- **Mechanism.** The count lives on `CommandRegistry` (wx-free): `arm_repeat(count)` clamps the value to `[1, MAX_REPEAT]` (`MAX_REPEAT = 1000`, so a typo can never spin the editor unbounded). `run(command_id)` consumes the pending count at the top of dispatch, resets it to 1, and loops the handler that many times; the run listener fires once per iteration so macro recording captures each step faithfully. The count persists only until the next eligible `run` — it does not leak across a session.
- **Self-protection.** The arming command registers itself as non-repeatable (`register_non_repeatable`), so re-arming while a count is pending never multiplies the prompt.
- **Scope.** Repeats commands dispatched through the registry (the keyboard path screen-reader users rely on). Re-entrant handlers that themselves call `run` see a cleared count and are not double-counted.

#### Restore Deleted Text (`edit.restore_deletion`, Edit menu)

A small kill-ring distinct from both Undo and the Copy Tray. QUILL's structured delete commands (`delete_to_line_start`/`_end`, `delete_to_document_start`/`_end`, `delete_paragraph`) record the removed span into a deletion ring; Restore Deleted Text presents the recent deletions in an accessible list and re-inserts the chosen one at the cursor.

- **Why it is distinct.** Undo reverts the last edit *in place*; the Copy Tray captures *copies*. The deletion ring captures *deletions* and lets the writer place recovered text *anywhere* — the modern form of the WordPerfect Editor "Cancel" buffer.
- **Mechanism.** `quill/core/deletion_ring.py` holds `DeletionRing` (newest-first, capped at three, collapses consecutive identical deletions) and `removed_span(before, after)`, which recovers the contiguous removed run by stripping the common prefix and suffix. `_apply_line_operation` records `removed_span` after every structured delete. The UI presents a `wx.SingleChoiceDialog` of speakable previews (`"Most recent (12 characters): ..."`); a single entry inserts immediately.
- **Read-only and bounds.** Honours the read-only guard; an empty ring announces "No deleted text to restore".

#### Describe Character at Cursor (`power.describe_character`, Tools → Advanced)

The screen-reader descendant of "Reveal Codes". An accessible dialog — rendered in the same read-only `ContextHelpDialog` the F1 help uses, so a screen reader reads the whole description in one pass — names the exact character under the caret.

- **What it reports.** Glyph (or a speakable stand-in for non-printing characters), Unicode name, code point in hexadecimal and decimal, general category in plain language, and a note for invisibles that bite writers: no-break space, narrow no-break space, zero-width space/joiner/non-joiner, BOM, smart quotes, en/em dash, soft hyphen, tab, and line endings. Non-ASCII characters are flagged.
- **Mechanism.** `quill/core/char_describe.py` exposes `describe_character(text, position) -> CharacterDescription` (a `summary` for the status line and a `detail` block for the dialog). Positions at or past end of text report "End of document" rather than raising. The special-character map is keyed by code point (built via `chr`) so invisibles are unambiguous in source.

**Implementation map.** `quill/core/commands.py` (`arm_repeat`, `pending_repeat`, `register_non_repeatable`, repeat loop in `run`), `quill/core/deletion_ring.py`, `quill/core/char_describe.py`, `quill/ui/main_frame_power_tools.py` (`repeat_command`, `restore_deletion`, `describe_character`, `_deletion_preview`), `quill/ui/main_frame.py` (`_deletion_ring` init, non-repeatable registration, `removed_span` capture in `_apply_line_operation`), `quill/ui/main_frame_power_tools_menu.py` (manifest entries), `quill/core/feature_command_map.py`, `quill/core/help/topics.json`. Tests: `tests/unit/core/test_command_repeat.py`, `test_deletion_ring.py`, `test_char_describe.py`.

---

### 5.89b Reveal Codes (WordPerfect-style synchronized code inspector)

QUILL keeps formatting codes hidden so the editing buffer stays clean plain text (see the rich-text hidden-codes model). **Reveal Codes** is the on-demand companion: a second, synchronized view that makes every hidden formatting code and structural/invisible character explicit, navigable, and individually announced — the beloved WordPerfect feature, rebuilt screen-reader-first. The full design rationale is preserved in this PRD (this section is canonical; the former `docs/planning/reveal-codes-design.md` was retired into it on shipment).

**Activation and layout.** `view.reveal_codes_toggle` (**Alt+F3**, the WordPerfect chord; also **View → Reveal Codes**, a checkable item) shows/hides a pane docked **below the editor, above the status bar**. Hidden by default and zero-cost while hidden (the token stream is not built). The visible/view/verbosity state persists in settings.

**The F6 region cycle.** The pane is a first-class focus region. When shown it joins QUILL's existing rotation, so **F6** moves **Editor → Reveal Codes → Status Bar** (with Document Tabs / Preview when present) and **Shift+F6** cycles back — no new keybinding (`_current_focus_region_labels` / `_detect_active_region` / `_focus_region` in `main_frame.py` gained one entry each). `edit.find_all_matches` moved off Alt+F3 to `Ctrl+Shift+F3` to free the chord.

**Synchronized carets — movement-agnostic.** Both views share one logical position via the markup↔visible offset map. Moving in the pane drives the editor caret to the matching markup offset; moving the editor caret re-places the pane caret on the matching cell/token. The editor→pane direction is a throttled idle tick (off the typing hot path) keyed on the editor's caret **offset**, so it follows *any* movement type equally — arrow, Ctrl+arrow word jump, Home/End, Page Up/Down, mouse click, or a programmatic jump from Find/Go To — not just arrow keys. The editor buffer is the markup, so the markup offset is the editor caret space directly.

**The token model (`quill/core/reveal_codes.py`, wx-free).** `build_code_stream(markup) -> list[CodeToken]` linearizes the document, built on `rtf_model.analyze_markdown` (per-character offset map + formatting) and `char_describe` (invisibles). Each `CodeToken` records `kind` (TEXT / FORMAT_ON / FORMAT_OFF / BLOCK / STRUCTURE / INVISIBLE), a display `label` and `spoken` phrase, both `markup_*` and `visible_*` offset ranges, an optional `pair_index` linking an ON code to its matching OFF, and `attrs`. Inline formatting (bold/italic/underline/strike/super/subscript/font/size/color/highlight/link) becomes paired ON/OFF codes; paragraph attributes (heading, bullet, named style, alignment, spacing, indent) become BLOCK codes at the line head; paragraph breaks become `[¶ Hard Return]`; tabs and notable invisibles (no-break/zero-width spaces, smart quotes, dashes) become their own tokens; everything else groups into TEXT runs. `token_at_markup_offset`, `pair_distance`, and `describe_token` support sync, pairing, and verbosity-aware announcements.

**Presentations and verbosity.** `reveal_codes_view` selects **Flowed** (a read-only `wx.TextCtrl` rendering codes inline within the text, `[Bold On]Hello[Bold Off]` — now the interactive surface **and the default**) or **Structured** (a read-only `wx.ListBox`, one labelled/announced item per token — a discrete scanning alternative). `reveal_codes_verbosity` (quiet / balanced / detailed) gates how much each move announces. In-pane commands work in both views: `reveal.next_code` / `reveal.previous_code` (scan code-to-code) and `reveal.go_to_pair` (ON↔OFF).

**Navigation model (`quill/core/reveal_nav.py`, wx-free).** The token stream is deliberately coarse (a whole run is one TEXT token) — the right grain for a *list*, the wrong grain for a *caret*. `build_flow_view(tokens) -> FlowView` flattens it into **navigation cells**: one cell per visible character, and one **atomic** cell per code. Each cell records its `markup_offset` (editor caret space), `visible_offset`, `flow_start`/`flow_end` (offset into the flowed string, kept in lockstep with the rendered text so the visible caret always lands correctly), and a logical `line`. The caret is a single integer index into the cells. Pure movers — `move_char` (Left/Right: one character in text, one whole code atomically), `move_word` (Ctrl+Left/Right, codes are hard stops), `move_line` (Up/Down by logical line, column-preserving), `line_home`/`line_end`, `cell_for_markup_offset` (editor→pane sync), `cell_at_flow_offset` (mouse) — clamp and never raise, so the pane binds them straight to keys. `announce_cell` / `word_at` / `announce_line` produce the spoken phrase for each move (a character, a word, or the whole line read like the editor reads a line move).

**Single-speaker announcements.** In Flowed mode the pane's key handler consumes the navigation key (it does **not** call `event.Skip()`), so the screen reader has no keystroke to pair with the programmatic caret move and stays silent — the pane is the sole voice via `host._announce`, speaking a *bare* phrase with no "Reveal Codes:" prefix (the region name is spoken once on focus entry). This is the fix for two long-standing symptoms: (1) "Reveal Codes" was repeated on every arrow press, and (2) JAWS and NVDA announced the same move differently — both were the result of two channels (the SR narrating the control **and** QUILL's Prism/pyttsx3 status speech) firing on one move and the two readers arbitrating the overlap differently. Structured mode likewise mirrors its rich phrase to the status bar *silently* (`_set_status_quiet`), leaving the SR's own item read as the single channel.

**F2 region editing.** Pressing **F2** on a content token (TEXT / STRUCTURE / INVISIBLE) that sits inside a formatting pair enters an in-place edit restricted to that region. `enclosing_region(tokens, token_index)` finds the **innermost** enclosing `FORMAT_ON`/`FORMAT_OFF` pair and returns the span of the whole inner region (first content token after the ON to last content token before the OFF); `region_source(markup, region)` yields the current buffer text and `splice_region(markup, region, new_text)` writes the edit back as a literal `markup[start:end] = new_text`. The flowed control becomes editable and shows just the region's source; **Enter** commits (splice into the editor buffer, caret placed after the edit, rebuild), **Escape** cancels. Because the span is the whole region, a run carrying a `[Tab]` or a nested code edits as one unit — nested codes appear as their markup and are spliced straight back, losslessly.

**Accessibility.** Character/word/line navigation that mirrors the editor (Flowed) makes the pane feel like a place you edit, not a list you inspect; codes are still conveyed by bracketed text + label and named as atomic units ("bold on"), never colour alone; entering the pane announces the region once and never chatters beyond the chosen verbosity; the single-speaker rule guarantees JAWS and NVDA behave identically. Structured remains a discrete, individually-named list for scanning, and SAPI/braille users get the literal bracketed codes in Flowed mode.

**Scope note.** Read-only navigation, interrogation, movement-agnostic bidirectional sync, and **in-place editing of a formatting-bounded region (F2)** ship now. Region editing is a literal buffer splice over the region's markup span, so it is safe against the zero-width code boundaries `analyze_markdown` exposes. The remaining editing gaps are the defined next step: *deleting* a code to strip its effect, and *inserting/replacing* a code, both of which need the formatting markers' exact character spans (which `analyze_markdown` abstracts away) and so ride on the marker-span work rather than the zero-width boundaries.

**Implementation map.** `quill/core/reveal_codes.py` (token model), `quill/core/reveal_nav.py` (caret/navigation-cell model + region-edit helpers, wx-free), `quill/ui/reveal_codes_pane.py` (the pane: flowed interactive navigation, single-speaker announcements, F2 editing, sync controller), `quill/ui/main_frame_reveal_codes.py` (`RevealCodesMixin`: toggle, idle sync, in-pane command delegates), `quill/ui/main_frame.py` (pane creation in the layout; region-cycle entries; command registration), `quill/ui/main_frame_menu.py` (View item + EVT_MENU) / `main_frame_menu_bindings.py` (EVT_IDLE binding), `quill/core/settings.py` (`reveal_codes_visible` / `reveal_codes_view` default **flowed** / `reveal_codes_verbosity`), `quill/core/keymap.py` + `keymap_packs.py` (Alt+F3, friendly title). Tests: `tests/unit/core/test_reveal_codes.py`, `tests/unit/core/test_reveal_nav.py`, `tests/unit/ui/test_reveal_codes_pane.py`.

---

### 5.89c Story Studio — manuscript and long-form organization

**Goal.** Give a screen-reader user a first-class way to organize a book-length project — manuscript, characters, places, plot threads, research, and brainstorming — without a visual corkboard, while keeping every word in plain, portable text. Optional and additive; inert until opened.

**Model (`quill/core/story`, wx-free, strict-typed).** A *project* is a folder of plain-text files plus an advisory `project.quillstory.json` sidecar recording a title, the ordered manuscript spine (relative POSIX paths), and non-manuscript *elements* (`character`, `location`, `plot`, `research`, `brainstorm`). Loading is corrupt-tolerant in the spirit of the settings-migration delta store (a bad entry is dropped, the rest kept) and the sidecar never duplicates prose: with no sidecar the folder is still a project (its text files become the manuscript spine), so deleting it loses nothing. Persistence uses `write_json_atomic`.

**Binder.** `build_binder(project, read_text)` derives a tree — a Manuscript group whose file nodes carry heading-derived chapter/scene children (nested by level from the files' Markdown ATX headings), then element groups (Characters, Places, Plot threads, Research, Brainstorm). The tree is derived, never stored, so it cannot drift from the prose. File IO is injected as a `read_text` callable, keeping the core pure and unit-tested without a display.

**Front matter and fields.** Each element file may carry an optional leading `---` block of light structured fields, encoded by a small dependency-free codec (`key: value` scalars and `- item` lists; round-trips, preserves field order and unknown keys — deliberately no PyYAML dependency). Per-kind default field sets (`quill/core/story/fields.py`): character → role/goal/motivation/arc; location → significance; plot → status; research → source; plus a universal `tags` list. Empty fields are dropped on save; unknown keys and `type` are preserved.

**Compile.** `compile_manuscript(project, read_text)` concatenates the manuscript spine in order with each file's front matter stripped, producing plain text for the existing export pipeline (`quill/io/export.py`) — no new export engine.

**UI (`quill/ui`).** `Tools → Story Studio...` (command `story.open_studio`, also on the command palette; no default key, assignable in the keymap editor) picks a project folder and opens `StoryStudioDialog`, an accessible `wx.TreeCtrl` binder. Activating a file/heading/element opens it in the editor at the heading offset and closes the binder; activating a group expands/collapses it. **Edit details...** opens `StoryElementFormDialog`, a labelled field-per-row form that writes changes back to the element's front matter. **Compile manuscript...** opens the compiled text as a new untitled document tab for export. Both dialogs route through `_show_modal_dialog`/`apply_modal_ids` and are classified in the dialog inventory.

**Status.** Introduced in the 0.8.1 line. Deferred: richer field types in the details form (choices, dates), saved views and tag filters, and a one-step compile-and-export action.

---

### 5.89d Accessible Vault — linked notes, backlinks, and knowledge navigation

**Goal.** Deliver a linked-notes model — a folder of notes that link to each other, with searchable, bidirectional links — through spoken, keyboard-native surfaces rather than a visual graph. A backlinks *list* read aloud beats a backlinks *picture*. Optional and additive; the editor is unchanged when no vault is open. The Accessible Vault is **feature-complete for 0.9.0**: Phases 0–7 all ship with a wx-free, unit-tested core under `quill/core/vault` and accessible `Tools → Vault` surfaces. The small remaining polish (in-editor `[[`/`#` autocomplete popups, live-preview link/embed resolution, and a Vault Explorer tree) is tracked in [`roadmap.md`](../../planning/roadmap.md) §1.7.

**Model (`quill/core/vault`, wx-free, strict-typed).** A *vault* is a folder of Markdown/plain-text notes plus a `.quill/` cache (regenerable). `scan_vault` parses every note into `NoteInfo` — title (front matter `title`, else H1, else filename), aliases, tags (front-matter `tags:` + inline `#tag`), headings, block ids, and outgoing wikilinks — keeping file-relative offsets so a UI can open at a heading or block. The wikilink codec (`links.py`) parses `[[Note]]`, `[[Note|alias]]`, `[[Note#Heading]]`, `[[Note#^block]]`, and `![[embed]]`, leaving Markdown `[x](y)` and code spans/fences alone. `resolve.py` maps a wikilink to a note + anchor offset, reporting **unresolved** (→ create-on-follow) and **ambiguous** (→ a spoken chooser, never a guess). `index.py` builds forward + reverse adjacency; `backlinks()` quotes each linking line for context, `unlinked_mentions()` finds plain-text name occurrences not yet linked, and `neighborhood()` returns a note's outgoing links (with titles) and backlinks together for "traverse the web by ear." The higher phases add, each wx-free and unit-tested: `search.py` (title-ranked full-text search + a subsequence fuzzy quick switcher), `tags.py` (a nested-tag-aware index — `#area/sub` also answers to `#area` — with counts and `#` suggestions), `render.py` (`[[link]]`→HTML resolution for preview/export, plus `![[embed]]` expansion of a block, heading section, or whole note with cycle detection), `templates.py`/`dailynotes.py` ({{date}}/{{time}}/{{title}}/{{prompt}}/{{cursor}} substitution and daily-note path math), `site_export.py` (a static linked HTML site as `{path: html}`), `sync.py` (commit/pull/push over an injected subprocess runner with conflict detection), `publish.py` (a **gated** single-note publish payload), and `refactor.py` (offset-precise inbound-link edits for a rename). Story Studio (§5.89c) is a curated collection view over this vault and shares the front-matter/heading machinery.

**UI (`Tools → Vault`, all palette-reachable + keymap-assignable with no default keys; dialogs route through `_show_modal_dialog`/`apply_modal_ids` and are classified in the dialog inventory).**

- *Phases 0–2 — links & backlinks.* **Open Vault...** (`vault.open`) indexes a folder and remembers it (`Settings.vault_root`), announcing "Vault X: N notes, M links." **Follow Wikilink** (`vault.follow_link`) opens the note under the caret's `[[link]]` at its heading/block; a missing target offers to create the note, an ambiguous name opens an accessible chooser. **Show Backlinks** (`vault.backlinks`) lists the notes that link here, each read with its linking line (Enter opens the source at the link). **Note Neighborhood** (`vault.neighborhood`) lists outgoing links (→) and backlinks (←) together for traverse-by-ear, and **Unlinked Mentions** (`vault.unlinked_mentions`) lists plain-text occurrences of the note's name not yet linked (Enter opens the source at the mention). **Insert Link to Note...** (`vault.insert_link`) inserts `[[Title]]`. **Rename Note...** (`vault.rename`) renames the current note over `refactor.plan_note_rename` — after a confirm that counts the affected links, it rewrites every inbound `[[link]]`, renames the file, retitles a matching H1, and reopens the note. **Vault Explorer...** (`vault.explorer`) is a keyboard `wx.TreeCtrl` of every note by folder (`explorer.build_note_tree`); **Complete Link or Tag at Cursor** (`vault.complete`) completes an in-progress `[[note`/`#tag` from a spoken filtered list (`autocomplete.py`) — the accessible alternative to a floating popup.
- *Phase 3 — find.* **Go to Note** (`vault.quick_switch`) is a filter-as-you-type switcher (Down moves to the results, Enter opens the top/selected match) and **Search Vault** (`vault.search`) is vault-wide full-text search with Regex / Whole-word toggles, opening a result at its matching line. Both live in `VaultFilterDialog`, which speaks the running count as you type.
- *Phase 4 — tags.* **Show Tags** (`vault.tags`) is the spoken tag pane: filter tags with counts, then list a tag's notes (nesting rolls up).
- *Phase 5 — embeds.* **Speak Embed at Cursor** (`vault.speak_embed`) reads an `![[embed]]` target without changing text; **Resolve Embed Inline** (`vault.resolve_embed`) replaces it with its content as one undoable edit.
- *Phase 6 — notes.* **Insert Template...** (`vault.insert_template`) picks from the vault's `Templates/` folder, speaks each `{{prompt}}`, and lands the caret at `{{cursor}}`; **Open Today's Note** (`vault.today`) and **Previous/Next Daily Note** (`vault.prev_daily`/`vault.next_daily`) create-and-open along the daily pattern.
- *Phase 7 — share.* **Export Vault as Website...** (`vault.export_site`) builds a static, linked HTML site off-thread (links → relative anchors, embeds inlined) using QUILL's own Markdown renderer; **Sync Vault** (`vault.sync`) commits/pulls/pushes over the user's own git remote off-thread (Safe-Mode gated; conflicts listed, never pushed); **Publish Note** (`vault.publish_note`) is registered `feature_id=future.publishing` and stays **hidden** until publishing is unlocked. **Vault Settings...** (`vault.settings`) sets the Templates folder (`Settings.vault_templates_folder`, default `Templates`) and daily-note pattern (`Settings.vault_daily_pattern`, default `Journal/{{date:YYYY-MM-DD}}.md`).

**Preview & freshness.** Previewing a note that lives in the open vault resolves it (`preview.resolve_for_preview`: `[[links]]` as titled inert anchors, `![[embeds]]` inlined) via a defensive passthrough that leaves every other document untouched. Saving an in-vault note re-parses just that note into the cached index (`apply_note_change`), so backlinks, search, tags, and the neighborhood stay fresh without reopening the vault.

**Non-goals.** No visual graph view or canvas (the link *relationships* are delivered as lists and navigation); no *hosted* sync/publish — Sync is the user's own git remote, and Publish is gated off. The Accessible Vault is **complete for 0.9.0** with no open work.

---

### 5.89e Standalone companion apps — Quill Radio and QUILL Cast

**Goal.** Radio and Podcasts are useful without the editor: someone who wants
to listen to internet radio or manage a podcast queue should not have to load
all of QUILL to do it. Quill Radio and QUILL Cast are small standalone
executables — their own window, their own menu bar, their own system tray
icon — that reuse QUILL's feature code *unchanged* rather than forking it.

**Architecture (`quill/ui/app_shell.py` + `quill/apps/`).** The feature
mixins `MainFrame` already uses (`RadioMixin`, `PodcastsMixin`) only ever
touch a small, fixed host protocol on their owner: `self.frame`, `self._wx`,
`self._safe_mode`, `self._task_manager`, `self._announce`,
`self._show_message_box`, `self._set_status`, `self.settings`,
`self.commands`, `self._binding_for`, `self._refresh_statusbar`.
`AppShellFrame` implements exactly that protocol over a plain `wx.Frame`, so
`class RadioAppFrame(AppShellFrame, RadioMixin)` gets the entire feature —
commands, dialogs, favorites, recording, scheduling — with zero changes to
the mixin. Consequences that matter:

- **No fork, ever.** A bug fix or feature added to `quill/core/radio`,
  `quill/core/podcasts`, or the shared dialogs lands in the standalone apps
  automatically — same modules, same imports.
- **One data store.** The apps load the same `core.settings`/`core.keymap`
  and read/write the same favorites, subscription library, and download
  state under `app_data_dir()` — what you subscribe to in QUILL Cast is
  subscribed in QUILL, with no sync layer.
- **Same accessibility contract.** Announcements route through the same
  `AnnouncementEngine`; dialogs keep their existing keyboard/naming
  behavior because they are the same dialog classes.

**Per-app surfaces.**

- **Quill Radio** (`python -m quill.apps.radio`; `run-quill-radio.bat` from
  source). Menu bar: Station (Browse Stations, Add Custom Station, Find
  Streams from a Website, Favorite Stations inline), Playback (disabled
  now-playing status line, Play/Pause, Stop, Mute, Volume Up/Down), Record
  (Record Now/Stop, Schedule Recording, Recording Settings), Help (Open in
  Quill, About). Tray icon: Show/Exit plus the same radio section
  (`_build_radio_tray_menu`) QUILL's own tray shows.
- **QUILL Cast** (`python -m quill.apps.podcasts`; `run-quill-cast.bat`).
  Menu bar: Subscriptions (Open Podcast Manager, Add Podcast, Import/Export
  OPML, Podcast Settings), Episode (now-playing line, Play/Pause, Stop,
  Next/Previous Chapter), Downloads (Pause All / Resume All), Help. Tray
  icon mirrors QUILL's podcast tray section. One behavioral override: "Send
  Show Notes to Editor" copies to the clipboard instead (there is no editor
  buffer standalone), announced as such.

**Open in Quill.** Both apps carry a Help > Open in Quill command that
launches the full editor as a separate process (v1: always a new process; a
focus-existing-instance IPC variant is deliberately deferred — see
`docs/planning/apps.md`).

**Platform notes.** The tray icon follows `MainFrame`'s own rule: on macOS,
`wx.adv.TaskBarIcon` produces a Dock tile rather than a menu-bar extra, so
the apps skip the tray there instead of misrepresenting it. `QUILL_SAFE_MODE`
is honored on launch.

**Installer integration.** The Windows installer creates Start Menu entries
for both companion apps ("Quill Radio", "QUILL Cast") alongside QUILL's own,
launching them via the bundled Python runtime (`-m quill.apps.radio` /
`-m quill.apps.podcasts`), with both launcher variants (bundled runtime and
exe) covered. Desktop icons for the companions are an opt-in installer task
(`companionicons`, unchecked by default) so a default install never adds
desktop clutter unasked. Defined in the `.iss` generator
(`scripts/build_windows_distribution.py`), never the generated script.

**Keyboard-first main panel.** Each app opens on a real, tabbable main
surface — never a bare frame: a live now-playing line, the app's primary
list (favorite stations / subscribed shows) focused on launch with Enter to
act, and its core action buttons, every control named via
`dialog_contract.set_accessible_name`.

**Non-goals (v1).** No single-instance enforcement; no Audio Studio
standalone app yet — the phased plan, including those, lives in
`docs/planning/apps.md`.

---

### 5.90 AI Writing Toolkit: architecture and feature matrix

This section documents the AI writing layer shipped in QUILL 0.6.0, covering provider abstraction, the connection model, per-feature design, and the data-disclosure posture.

#### 5.90.1 Provider abstraction

All AI features route through `quill/core/assistant_ai.py::generate_assistant_response(connection, api_key, prompt, *, max_tokens, timeout_seconds) -> (text | None, error | None)`. This function:

- Accepts an `AssistantConnectionSettings` dataclass (provider, host, model) and a bare API key string.
- Dispatches to the appropriate HTTP client (OpenAI-compatible, Anthropic Messages API, Google Gemini, or Foundation Models on macOS).
- Returns a `(text, None)` tuple on success or `(None, error_string)` on failure.
- Never raises; all exceptions are caught and returned as the error string.
- Is imported at module level (not lazily) in every AI module so that test suites can monkeypatch it.

Supported providers:

| Provider ID | Host type | Auth |
|---|---|---|
| `openai` | OpenAI cloud | API key (Credential Manager) |
| `claude` | Anthropic cloud | API key (Credential Manager) |
| `gemini` | Google cloud | API key (Credential Manager) |
| `openrouter` | OpenRouter cloud | API key (Credential Manager) |
| `ollama` | Local or self-hosted | URL only (no key) |
| `custom` | User-specified endpoint | Optional API key |

#### 5.90.2 Connection settings and AI Hub

Provider, host, and model are stored in `AssistantConnectionSettings` (serialized via `core/storage.py`). The API key is stored separately in the Windows Credential Manager via `platform/windows/credential_manager.py`, keyed per provider.

**AI Hub** (`quill/ui/ai_hub_dialog.py`) is the single entry point for all AI configuration. It is a five-tab `wx.Notebook` dialog:

1. **Provider** — provider choice, API key field, host override, model choice, Test Connection button.
2. **On-Device** — Ollama URL, recommended model list with size and capability notes.
3. **Audio Services** — Deepgram API key (with reveal toggle), max speakers (SpinCtrl, 2–20).
4. **Instructions** — per-task custom system prompt editor. See §5.90.7.
5. **Advanced** — consent summary, settings reset, safe mode documentation.

All writes in `_on_ok()` are atomic: API keys saved via `credential_manager.credential_save`, settings saved via `write_json_atomic`.

#### 5.90.3 Custom instructions

`quill/core/ai/custom_instructions.py` provides a per-task system-prompt layer.

**Data model:**

InstructionSet(task_id, title, default_prompt, user_prompt=““, enabled=True) .active_prompt -> user_prompt if non-empty, else default_prompt .is_customised() -> user_prompt.strip() !=”” and user_prompt != default_prompt .reset_to_default() -> user_prompt = “”


**12 built-in tasks:** `chat`, `spell_check`, `grammar_check`, `rewrite`, `summarize`, `expand`, `toc`, `translate`, `thesaurus`, `document_qa`, `research`, `accessibility_agent`.

**Persistence:** Only user-modified fields (`task_id`, `user_prompt`, `enabled`) are written to `%APPDATA%\Quill\ai_custom_instructions.json`. Built-in defaults always live in code; a QUILL update that improves a default is automatically picked up unless the user has customised that task.

**Application:** `apply_instruction(task_id, base_prompt) -> str` prepends the active system prompt if the task is enabled. It never raises — any failure (missing file, corrupt JSON, unknown task) silently returns `base_prompt` unchanged. This is called at the top of every AI feature's prompt-building path.

#### 5.90.4 AI writing features

| Feature | Shortcut | Module | Dialog |
|---|---|---|---|
| AI Thesaurus | Ctrl+Alt+Shift+H | `core/ai/thesaurus.py` | `ui/ai_thesaurus_dialog.py` |
| AI Spell Check | — | `core/ai/spell_check.py` | `ui/ai_spell_check_dialog.py` |
| AI Grammar Check | — | `core/ai/grammar_check.py` | `ui/ai_grammar_check_dialog.py` |
| Rewrite Selection | — | `core/ai/agent_session.py` | `ui/ai_agent_result_dialog.py` |
| Summarize Selection | — | `core/ai/agent_session.py` | `ui/ai_agent_result_dialog.py` |
| Expand Selection | — | `core/ai/agent_session.py` | `ui/ai_agent_result_dialog.py` |
| Generate TOC | — | `core/ai/agent_session.py` | `ui/ai_agent_result_dialog.py` |
| Document Q&A | — | `core/ai/document_qa.py` | `ui/ai_document_qa_dialog.py` |
| Translate | Ctrl+Shift+T | `core/ai/translation.py` | `ui/ai_translation_dialog.py` |
| Transcribe Audio | — | `core/ai/transcription.py` | `ui/ai_transcribe_dialog.py` |
| Read with AI Voice | — | `core/ai/cloud_tts.py` (dispatch), `core/ai/tts.py` (OpenAI), `core/ai/gemini_tts.py` (Gemini), `core/ai/elevenlabs_tts.py` (ElevenLabs SDK gateway, audio export only — roadmap §4.1), `core/ai/tts_chunk.py` (boundary-safe split) | inline in `main_frame.py` |
| Ask Quill chat | Alt+Q | `core/ai_chat.py` | `ui/ai_chat_dialog.py` |

#### 5.90.5 Agentic task architecture

Four commands (Rewrite, Summarize, Expand, Generate TOC) share a common agent session architecture in `quill/core/ai/agent_session.py`:

- **`AgentPlan`** — profile (from `assistant_agents.py`) + rendered prompt.
- **`AgentContext`** — plan + connection + api_key + `threading.Event` (stop_event) + optional `on_progress` callback.
- **`run_agent(ctx, *, refine=False) -> AgentResult`** — one or two AI calls; step outputs collected into `AgentResult.steps`; optional second refinement pass; refine errors are non-fatal (keeps first draft).
- **`AgentResult`** — `plan_id`, `steps: list[AgentStep]`, `final_output`, `cancelled`, `error`.

All agent runs are launched on daemon threads via `threading.Thread`; UI updates go through `wx.CallAfter`. The stop event is checked between steps and after each AI call.

**Result dialog** (`ui/ai_agent_result_dialog.py`): two-panel layout — step log (ListCtrl, read-only) and final output (TextCtrl, read-only). Buttons: Insert (cursor), Replace (selection), Copy, Re-Run, Close.

#### 5.90.6 AI Thesaurus

`quill/core/ai/thesaurus.py::get_synonyms(word, connection, api_key, context_sentence) -> list[ThesaurusEntry]`:

- Truncates word to 80 chars and context to 400 chars before including in the prompt.
- Calls `apply_instruction("thesaurus", prompt)` then `generate_assistant_response` with `max_tokens=512`, `timeout=30s`.
- Parses the JSON array response via `_parse_response` which strips markdown fences and extracts the first JSON array.
- Returns `list[ThesaurusEntry(synonym, note)]`.

The context sentence is extracted in `main_frame.py::open_ai_thesaurus()` by searching outward from the caret position to the nearest newlines, giving the model enough context to disambiguate polysemous words (e.g. "bank" financial vs. river).

#### 5.90.7 Custom Instructions UI

The Instructions tab in AI Hub presents:

- **Task list** (`wx.ListBox`): shows all 12 tasks; customised tasks show a `*` suffix.
- **Enable checkbox**: per-task toggle; when disabled, `apply_instruction` returns the base prompt unchanged.
- **User prompt editor** (`wx.TextCtrl`, multiline): editable field for the user's override.
- **Default display** (`wx.TextCtrl`, read-only): shows the built-in default for reference.
- **Reset to Default button**: clears `user_prompt`; the `*` marker disappears live.
- **Copy Default to Editor button**: copies the default into the editor as a starting point.

The `*` marker updates live as the user types (via `EVT_TEXT` on the editor). On OK, `save_instructions()` writes only the changed fields.

#### 5.90.8 Data disclosure posture

Every AI feature that transmits data outside the local machine is disclosed in the setup consent screen and in the user guide's AI Privacy Reference table. Summary:

| Data sent | Destination | Feature |
|---|---|---|
| Selected text or document text | Configured AI provider | Spell check, grammar check, rewrite, summarize, expand, TOC, thesaurus, translate, Document Q&A |
| Audio file (up to 25 MB chunks) | Deepgram or OpenAI Whisper | Transcription |
| Selected text or document (TTS, sentence-aware chunks) | OpenAI TTS API, Google Gemini TTS API, or ElevenLabs (SDK gateway, audio export only) per Settings > Read Aloud | Read with AI Voice, Export Document as Audio |
| Document text (up to 80k chars) | Configured AI provider | Document Q&A |
| Nothing (on-device) | Ollama local | All features when Ollama is the configured provider |

Custom instructions, provider settings, and API keys never leave the local machine.

#### 5.90.9 Safety and safe mode

All AI features are gated behind the `future.ai` feature flag. When `QUILL_SAFE_MODE=1` or `--safe-mode` is passed at startup, `generate_assistant_response` returns an error immediately without making any network calls. The Watch Folder `CloudTranscribeAction` also refuses to run in safe mode.

The AI-enabled gating tuple in `main_frame.py` disables every AI menu item when AI is not configured, so no network call can be triggered from the UI in an unconfigured state.

#### 5.90.10 Test coverage

| Module | Test file | Count |
|---|---|---|
| `core/ai/agent_session.py` | `tests/unit/core/ai/test_agent_session.py` | 23 |
| `core/ai/thesaurus.py` | `tests/unit/core/ai/test_ai_thesaurus.py` | 16 |
| `core/ai/custom_instructions.py` | `tests/unit/core/ai/test_custom_instructions.py` | 22 |
| `core/ai/spell_check.py` | `tests/unit/core/ai/test_spell_check.py` | 9 |
| `core/ai/grammar_check.py` | `tests/unit/core/ai/test_grammar_check.py` | 19 |
| `core/ai/translation.py` | `tests/unit/core/ai/test_translation.py` | 18 |
| `core/ai/transcription.py` | `tests/unit/core/ai/test_transcription.py` | 16 |
| `core/ai/document_qa.py` | `tests/unit/core/ai/test_document_qa.py` | 20 |
| `core/ai/tts.py` | `tests/unit/core/ai/test_tts.py` | 18 |

All tests follow the module-level import pattern so `generate_assistant_response` can be monkeypatched in tests without live provider credentials.

#### 5.90.11 Prompt caching

**Goal.** Reduce per-request token cost by sending the stable custom-instruction text as a cacheable system prefix rather than re-sending it inline with every call.

**Design.**

`custom_instructions.split_instruction(task_id, base_prompt) -> (str, str)` is the canonical split point. It returns the active system prompt and the user content as separate strings. Every AI module calls `split_instruction` instead of `apply_instruction` and passes the system_prompt through to `generate_assistant_response`.

`generate_assistant_response` accepts `system_prompt: str = ""` and threads it into `build_chat_body` and `build_chat_headers`. `apply_instruction` is preserved as a legacy convenience wrapper (it calls `split_instruction` internally and joins the parts).

**Provider behaviour.**

| Provider | Caching mechanism | Cost effect |
|---|---|---|
| Anthropic Claude | `cache_control: {"type": "ephemeral"}` block in the `system` array; `anthropic-beta: prompt-caching-2024-07-31` header | ~10% of normal input rate on cache hit; 5-minute TTL |
| OpenAI / OpenRouter | `role=system` message; caching is automatic above 1024 tokens | ~50% of normal input rate on cache hit |
| Gemini | `systemInstruction` field | provider-defined |
| Ollama | `role=system` message in the chat messages array | model-server internal |

**Invariants.**
- When `system_prompt` is empty, the request body is identical to the pre-caching form (no extra fields, no extra headers). No regressions for callers that do not pass a system prompt.
- `apply_instruction` is backward compatible; existing call sites that have not yet migrated continue to work with the combined-string path.
- The Anthropic beta header is only added when the provider is `claude` AND `system_prompt` is non-empty, so the header never appears for other providers.

### 5.91 Startup-speech gating (verbosity shim)

QUILL's `quill.a11y.announce(...)` funnel (§9.5) is the single code path every spoken line passes through. As of 0.7.0, two startup announcements are gated through user-facing settings so the screen reader stays quiet on first run unless the user opts in:

1. The Document Guardian Quillin activation cue (`Document Guardian is now active.` / `is now inactive.`).
2. The screen-reader detection result (`Detected screen reader: <name>. Adaptive hints enabled.`).

#### 5.91.1 Settings and defaults

Two new settings live under the **Accessibility** group:

| Setting | Type | Default | Purpose |
|---|---|---|---|
| `verbosity_speech_enabled` | bool | `True` | Master gate for the spoken output channel. When off, every spoken announcement from built-in startup events and Quillin extensions is suppressed. The status bar still receives the same text. Acts as the shim for the 0.7.1 verbosity rebuild. |
| `announce_screen_reader_detected` | bool | `False` | When on, speaks the `Detected screen reader: <name>. Adaptive hints enabled.` line at startup, but only if `verbosity_speech_enabled` is also on. |

Document Guardian adds its own per-Quillin preference under **Preferences → Document Guardian → Lifecycle Announcements**:

| Setting | Type | Default | Purpose |
|---|---|---|---|
| `enabled_announcements` | bool | `False` | When on, speaks the activation / deactivation cue from `on_enabled` and `on_disabled`. The Quillin always writes its state to the status bar regardless. |

#### 5.91.2 Enforcement layers

Two layers enforce the gates so a regression in either one cannot re-introduce the spoken line:

1. **Per-Quillin check.** The Quillin reads its own setting before calling `api.announce`. `api.get_setting("enabled_announcements", False)` is the per-Quillin value, defaulting to off.
2. **Host dispatcher check.** `quill.core.quillins.host.ApiDispatcher._dispatch` checks `services.is_verbosity_speech_enabled()` before forwarding the `announce` method to `services.announce(...)`. The same gate exists in `quill.plugins.node_quillin_runner._dispatch_action` for the Node.js Quillin runtime. The new `HostServices.is_verbosity_speech_enabled` method is implemented by `_EditorHostServices` (the live-frame adapter) and faked in every test double.

The screen-reader detection result flows through `MainFrame._set_status_quiet` (status bar only) unless both `announce_screen_reader_detected` and `verbosity_speech_enabled` are on, in which case it flows through `MainFrame._set_status` (status bar + spoken). The status bar always receives the text, so sighted and low-vision users get the same information regardless of the speech state.

#### 5.91.3 Why two layers

The per-Quillin check exists because the user's preference for the lifecycle cue is **Quillin-local** — it travels with the extension, not with the global app setting. The host-dispatcher check exists because `verbosity_speech_enabled` is a global master gate that should silence every Quillin announcement, not just one. Together they keep the user in control at both levels.

#### 5.91.4 Verbosity system (shipped)

The full verbosity rebuild (roadmap §1.1; the deferred 2.0 polish backlog is consolidated in roadmap §6) replaces the single `announcement_verbosity` knob with a per-verb, channel-aware, user-customizable announcement system. It has **shipped for 1.0**: the pure-domain core lives in `quill/core/verbosity/` (wx-free, strict-typed, in `mypy` scope) and is live in the app. Beyond the foundation table below, the system now includes the **routing engine** (`engine.py`) reached through the single `VerbosityController.process()` choke-point that `main_frame` routes its announce path through; the **runtime modes** Quiet (`Ctrl+Shift+Q`) and Meeting (`Ctrl+Shift+B`) with status-bar badges, **Quiet Undo** (`Ctrl+Shift+Z`), and the status-query commands **Where am I?**, **What changed?**, and **Speak Status**; **mastery step-down**, **announcement history**, the **"Why did QUILL say that?"** explain trace, **Safe Mode/reset**, **QVP packs + library + preview lab**, **task-aware profiles**, and **import/export**; and the eleven `quill/ui/verbosity_*` UI surfaces (preferences, token editor, data-order editor, chord editor, library, history, preview lab, safe-mode reset, import/export, QVP install). **Announcement anti-spam** (`throttle.py`, §1.1 #408/#409): repetition collapse drops identical consecutive *speech* within a short window and an optional budget caps spoken announcements per rolling window — both applied at `process()`, affecting only the spoken channel (the visual status-bar floor is never throttled), configured by `verbosity_collapse_repeats` and `verbosity_max_announcements_per_window`. The remaining speculative "polish backlog" long-tail items are deferred to 2.0 (roadmap §5). The pure-domain foundation table:

| Module | Responsibility |
|---|---|
| `channels.py` | `Channel(Flag)` — SPEECH, BRAILLE, SOUND, VISUAL. `route_channels()` always folds in the VISUAL floor, which can never be disabled (the accessibility floor). |
| `tokens.py` | `TokenSpec` (name, type, description, derive, per-token filter allowlist) and the **twelve** engine filters (`upper`, `lower`, `title`, `ordinal`, `pad`, `pluralize`, `singular`, `duration_human`, `date_long`, `date_short`, `time`, `truncate`). No custom filters exist — the security boundary that keeps templates and QVP packs data-only. |
| `parser.py` | Strict template parser for `{name}`, `${filter:name}`, `${filter:arg:name}`; returns structured errors, never raises. `validate()` enforces the §13 contract (token allowlist, filter existence, type compatibility, per-token allowlist, argument rules) and produces the spoken summary. |
| `profiles.py` | Built-in `Beginner` / `Normal` / `Expert` / `Quiet` profiles + `CustomProfile` (JSON round-trip). `profile_for_announcement_verbosity()` / `active_profile()` give the legacy `announcement_verbosity` knob (`minimal`→Expert, `normal`→Normal, `verbose`→Beginner) its first real consumer. |
| `verbs.py` / `registry.py` | `VerbSpec` + `Severity` and the initial verb catalog (the 34 verbs enumerated in §15, across `nav.*`, `edit.*`, `doc.*`, `search.*`, `system.*`, and `_legacy`); `VerbRegistry` with duplicate-id protection and id-sorted `all()`. |
| `data_order.py` | Frozen, hashable `DataOrder` (verb id, ordered fields, separator) with move-up/down/reset/render. When both a custom template and a custom data order exist for a verb, the template wins. |
| `schema.py` | Draft-07 JSON schemas for verbosity settings, the custom-profile store, QVP packs (`additionalProperties: false`, required metadata), and profile import/export — the data contracts the later sub-PRs validate against. |

Tested by `tests/unit/core/test_verbosity_*.py` (102 cases; parser coverage 99%).

#### 5.91.5 Verbosity rebuild — engine and runtime modes (sub-PR 1.2)

Sub-PR 1.2 adds the routing layer on top of the foundation, still wx-free and in `mypy` scope. No user-facing surface yet (chords, status-bar badges, and dialogs land with the UI sub-PR), but the full decision layer exists and `VerbosityEngine.speak()` is reachable from the assistant-panel and AI-Hub announce paths through a no-op `speak_legacy_text()` passthrough.

| Module | Responsibility |
|---|---|
| `engine.py` | `VerbosityEngine.speak(verb_id, ctx, *, quiet, meeting, chord, trigger)` returns a `RenderedAnnouncement` (per-channel text, sound event, channels, profile, severity, template source, suppressed flag, and an `ExplanationTrace`). Template precedence: per-chord override → per-verb override → QVP → default. Channels are routed with the always-on visual floor; Quiet drops speech+sound, Meeting drops sound; the profile's `suppress_routine` hides routine confirmations (errors always speak); sound follows the profile's all / errors-only / off policy. |
| `quiet.py` | `QuietMode` (enter/exit/toggle + phrasing) and `VerbosityUndoStack` — the bounded §11 undo stack of reversible verbosity transitions. |
| `meeting.py` | `MeetingMode` controller (hard-mute sound, reduced speech, braille+visual remain). |
| `mastery.py` | `MasteryTracker` — per-verb success counter that signals a step-down offer exactly once at threshold (default 25), then resets so it never nags; per-verb and global disable. |
| `history.py` | `AnnouncementHistory` — bounded ring buffer of `HistoryEntry` records with filter-by verb/profile/severity/warnings; `redact_text` (on by default) keeps raw token values out of the record, storing only the user-facing rendered text. |
| `explain.py` | `ExplanationTrace` — the full "Why did QUILL say that?" account (verb, trigger, profile, channels, template source, per-channel output, suppression reason, mode/override/QVP flags) rendered to plain copyable text. |
| `safe_mode.py` | `VerbositySafeMode` (toggle + `QUILL_SAFE_MODE` / `QUILL_VERBOSITY_SAFE_MODE` env detection) and non-destructive `reset_verb` / `reset_chord` / `restore_builtin` helpers that return a new `CustomProfile`. |
| `feedback_tuning.py` | `FeedbackStore` — local Too Much / Too Little / Just Right tallies per verb with a gentle one-shot suggestion; declined verbs never re-suggest. No telemetry. |
| `task_profiles.py` | `TaskProfileSuggester` — opt-in, **off by default**, per-extension profile suggestions with accept/reject memory. |
| `import_export.py` | `.quill-verbosity-profile.json` import/export. Strictly data: structure is validated by hand and nothing from the file is executed (no `exec`/`eval`/`__import__` path), verified by test. |
| `storage.py` | Atomic `verbosity_custom.json` read/write via `write_json_atomic`; a corrupt file loads as empty defaults plus a load error for a nonblocking warning, never throwing the user out. |

Tested by 11 more `tests/unit/core/test_verbosity_*.py` modules (180 verbosity cases total; engine coverage 96%). The 16 `VerbositySettings` fields and the chord/badge/dialog surfaces are deferred to the call-site-migration and UI sub-PRs.

#### 5.91.6 Verbosity rebuild — QVP packs, library, and preview (sub-PR 1.3)

Sub-PR 1.3 adds the shareable-pack and preview core, still wx-free.

| Module | Responsibility |
|---|---|
| `quill/core/schemas/qvp.json` | The canonical `.qvp.json` schema (§20): nested `pack` metadata, a `templates` array, `kind` fixed to `quill-verbosity-pack`, `additionalProperties:false`. For humans/tools — validation is by hand (no jsonschema runtime dep). |
| `qvp.py` | Loads and validates packs by hand (structured errors, never executing pack content — no `exec`/`eval`/`__import__`, test-asserted) and runs the §21 install flow: JSON → schema → kind → `min_quill_version` gate → metadata → unique template ids → namespace-collision check → dependency check (missing deps warn) → validate each template against its target verb → install → announce. Returns `QVPInstallResult(accepted, rejected_templates, warnings, spoken_sequence, errors)`. |
| `library.py` | `TemplateLibrary` — a flat collection across built-in / user / QVP sources with save / rename / delete CRUD (read-only built-ins and QVP entries) and cross-verb `apply()`, which strips tokens the target verb doesn't track and reports them. |
| `preview.py` | The fourteen built-in Preview Lab scenarios and `preview_scenario` / `preview_all`, which render each through a `VerbosityEngine` and surface per-channel output plus profile, template source, channel mix, and suppressed content. |

Tested by `test_verbosity_qvp.py`, `test_verbosity_library.py`, `test_verbosity_preview.py`, and golden snapshots under `tests/golden/verbosity/` (220 verbosity cases total; qvp coverage 95%). The Library CRUD UI, the QVP install dialog, and the Preview Lab dialog are deferred to the UI sub-PR.

#### 5.91.7 Verbosity rebuild — the wxPython UI (sub-PR 1.4)

Sub-PR 1.4 adds the user-facing surfaces under `quill/ui/`, each A11Y-4 hardened (label-then-control via mnemonics, `SetName`/`SetHint`, `apply_modal_ids`, deterministic focus, no icon-only buttons, registered in the dialog inventory) and wired to the already-tested pure core. The dialogs are not yet menu/chord-reachable — that is sub-PR 1.5.

| Surface | Responsibility |
|---|---|
| `verbosity_prefs.py` | The embeddable `wx.Panel`: profile picker, the four-channel mix (Visual checked + disabled — the always-on floor), validation-mode and mastery boxes, tool buttons (Preview Lab / History / Templates / Safe Mode / Import-Export), and a filterable verb table (master list + detail) whose Edit-announcement / Data-order buttons launch the editors. Initial focus is the filter. |
| `verbosity_token_editor.py` | Simple/Advanced **RadioBox** (not a notebook, §5 decision 6) over one template field; `Ctrl+T` validates and speaks the §13 summary, `Ctrl+Shift+P` previews (via `EVT_CHAR_HOOK`); Save is disabled with a tooltip while blocking errors exist; Insert-token and Speak-current-template. |
| `verbosity_data_order.py` | Move Up / Down / Reset / Preview over a verb's field order. |
| `verbosity_chord_editor.py` | Per-chord template overrides, validated against the chord's verb. |
| `verbosity_library.py` | Template CRUD (Save/Rename/Delete, read-only built-ins & QVP) + Install QVP. |
| `verbosity_qvp_install.py` | Browse → validate → install a `.qvp.json`, reading back the spoken sequence, accepted templates, rejected (with reasons), and dependency warnings; Install is disabled until a valid pack is selected. |
| `verbosity_history.py` | Review / replay / copy / explain recent announcements with a live filter; the explanation pane shows the full trace. |
| `verbosity_preview_lab.py` | The 14 scenarios with per-channel output (speech/braille/visual/sound), profile, template source, channel mix, and suppressed content. |
| `verbosity_safe_mode.py` | Scoped, non-destructive resets (disable custom / reset verb / reset chord / restore built-ins) with export-first. |
| `verbosity_import_export.py` | Data-only `.quill-verbosity-profile.json` import/export. |

The 3-tab About dialog already ships (About Quill, §"About"). Tested by `tests/unit/ui/test_verbosity_ui.py` (62 source-contract cases); the A11Y-4 banned-pattern, dialog-button-contract, escape-z-order, and GATE-11 gates pass, and `dialog_inventory.json` was regenerated with 12 new hardened surfaces.

#### 5.91.8 Verbosity rebuild — live wiring and polish (sub-PRs 1.5 and 1.6)

Sub-PR 1.5 makes the system live. `quill/core/verbosity/controller.py` (`VerbosityController`, wx-free) owns the engine, Quiet/Meeting controllers, the undo stack, announcement history, mastery, and Safe Mode. `MainFrame` gains `VerbosityCommandsMixin` (`quill/ui/main_frame_verbosity.py`) and registers eight `verbosity.*` commands (palette- and Keyboard-Manager-reachable). The announce choke-point (`MainFrame._announce`) routes through `VerbosityController.process` **only once the controller exists** (created on first verbosity use), so the default path is unchanged until then; once live, Quiet/Meeting suppress speech while the status-bar visual floor remains. Default chords avoid all conflicts (`edit.quote_lines` keeps `Ctrl+Shift+Q`): Quiet = QUILL key + Q, Meeting = QUILL key + Shift+Q, Verbosity Undo = `Ctrl+Shift+Z`; the rest are palette/Keyboard-Manager reachable. Eight scalar `verbosity_*` settings persist (mastery enabled/threshold, validation mode, history enabled/limit/clear-on-exit, task-profile suggestions, Safe Mode); collection-typed state stays in `verbosity_custom.json`. Live in-app behavior warrants a manual smoke run before release.

Sub-PR 1.6 (the original "100-item addendum") was **consolidated** into the deduplicated *Polish backlog*, now the authoritative status list in **roadmap §6** (the standalone `verbosity-system.md` archive was retired once verbosity shipped). Rather than 100 separate features: the duplicates and already-built items are folded into the foundation/engine/UI shipped in 1.1–1.5; the themed survivors (per-category verbosity via the verb registry, status-query commands, announcement flow control, safety announcements, friendly names, status-bar surfacing, scope profiles, sound learnability, coaching/discovery, braille polish) are tracked there; and the screen-reader-redundant items — **typing echo, command echo, speech rate/pause, punctuation/symbol profiles** — are recorded as **recommend do not build**, because QUILL speaks alongside the screen reader and must not duplicate or fight the settings (echo, rate, punctuation level) the screen reader already owns.

### 5.92 GLOW — Guided Layout and Output Workflow (shipped, experimental opt-in)

GLOW is QUILL's accessibility review and repair system: **guided confidence, not a
compliance dashboard**. It reviews what is in front of the user, explains each
finding in plain language, and applies only deterministic, reviewable fixes. The
`core.glow` feature flag shipped `locked_off` through 0.8.1 while the engine
deployment was finished; for 0.9.0 it ships as an **experimental opt-in**: the
catalog flag is unlocked, but every GLOW surface is additionally gated by the
Experimental tab (`experimental_acknowledged` + `glow_experimental_enabled`,
both default off) through `MainFrame._feature_enabled`, so GLOW is off by
default and user-flippable with no restart. The flag remains a normal
profile-controllable feature.

#### 5.92.1 Surfaces

All commands live under **Tools > GLOW** and in the command palette:

| Command | Behavior |
|---|---|
| GLOW Audit Current Document | In-editor deterministic audit of the whole buffer (plain text, Markdown, HTML); report opens as a named scratch tab. |
| GLOW Audit Selection / Paragraph | The same audit scoped to the selection, or the paragraph/line at the caret. |
| GLOW Fix Current Document | Deterministic fixes into a *named preview tab* plus an immediate compare session against the original — accept with full knowledge, never a silent rewrite. |
| GLOW Fix Selection / Paragraph | Quick in-place fix of the scoped block; the replacement is selected afterward so it can be reviewed or undone in one step. |
| GLOW Audit File... | **(new with the unlock)** Structured-document audit — DOCX, PPTX, XLSX, PDF, EPUB, Markdown — through the shared engine on the background task pool; report tab carries score, grade, and findings. |
| GLOW Fix File... | **(new with the unlock)** Structured-document fix that writes a repaired copy beside the source (`name-accessible.ext`, numbered on collision). The original is **never** modified; consent names the output path before anything runs. |
| Check for GLOW Updates... (Help) | Opt-in engine update: signed manifest, per-wheel SHA-256, offline `--no-index` install, rollback to the vendored wheels on failure, restart to apply (GLOW-8). |

Handlers for the file-level commands live in the `GlowFileMixin`
(`quill/ui/main_frame_glow.py`); in-editor handlers remain in `main_frame.py`.

#### 5.92.2 Rules (in-editor, always available)

Markdown: heading-marker spacing (auto-fix), heading-level jumps, image alt text,
generic link text. HTML: missing `lang` (auto-fix), heading jumps, `img` alt
(auto-fix), generic link text, tables without header cells. Plain text: tab-indent
hazards. All formats: plain-language phrasing and dense-paragraph warnings, and
trailing-whitespace trim on fix. Implemented wx-free in `quill/core/glow.py`.

#### 5.92.3 The shared engine (structured documents)

Structured audit/fix flows through the `quill_glow_core` contract package
(`audit_by_extension` / `fix_by_extension` / `convert_to_markdown`), which bridges
to the `acb-large-print` backend when installed. Deployment contract (hardened
2026-07-02 after the MCP-era split broke it): QUILL's `glow` extra pins
**`quill-glow-core[glow]>=0.1.1` plus `acb-large-print>=8.0.0`** — 8.0.0 is the
first release that ships the split `acb_large_print_core` dispatch backend the
contract wheel bridges to; older backends satisfy the contract wheel's own loose
floor (>=3.0.0) and leave the engine silently unavailable. Vendored wheels live in
`vendor/wheels` for fully offline install. The seam (`quill/core/glow.py`)
degrades honestly: engine absent means an "engine not installed" report, never a
crash (GLOW-1), and severities map onto QUILL's error/warning/info levels.

#### 5.92.4 Privacy and consent

The default GLOW path is entirely on-device. The engine's optional networked
features — AI alt-text, Presidio PII redaction, WCAG language processing — are
structurally off: the seam forwards no feature-enabling kwargs unless the caller
passes a `GlowNetworkConsent` with a feature explicitly set after per-action
consent (GLOW-7). The engine update check is the only other network touchpoint,
runs only on explicit invocation, and is registered in the network-egress audit
(GATE-9).

#### 5.92.5 Accessibility contract

Reports are plain, heading-structured text in ordinary tabs (searchable, brailled,
speakable); every finding carries rule id, severity, location when known, and a
plain-language suggestion; fix flows announce counts and destinations; file fixes
are non-destructive by construction; background parses never block the UI thread.

### 5.93 Import / Convert Document — free-first conversion and OCR (shipped, all three tiers)

The supported document-rescue tool, now the **canonical spec** (the standalone
OCR planning PRD has been retired into this section; remaining depth items are
tracked in roadmap §5). The routing principle: **free first, local first, and
nothing is ever uploaded without explicit consent** — the paid cloud tier is
reached only when the free local tiers fall short, and only after a per-upload
consent dialog.

#### 5.93.1 The three tiers

| Tier | Engine | Handles | Cost / privacy |
|---|---|---|---|
| 1 | MarkItDown (`quill/io/markitdown_bridge.py`, ships with the `pages` extra) | Born-digital DOCX/DOC, PPTX/PPT, XLSX/XLS, HTML, EPUB, CSV, ODT/ODP/ODS, and PDFs with a text layer | Free, local, no upload |
| 2 | Local Tesseract OCR (`quill/io/tesseract_ocr.py`) | Images (PNG/JPG/TIFF/BMP/GIF/WebP) and image-based PDFs; CPU-only, no GPU | Free, local, no upload |
| 3 | Datalab Chandra cloud OCR (`quill/core/datalab_ocr.py`) | The accuracy escalation: complex tables, forms, handwriting, math, dense layouts, poor scans; returns Markdown/HTML/JSON | Paid (BYOK, per page on the user's Datalab account), cloud, **consent-gated per upload** |

The router (`quill/io/docconvert.py`, wx-free, strict-typed) implements the
free-first flow: born-digital types go to Tier 1; images go straight to
Tier 2; PDFs try Tier 1 and are measured by the **chars-per-page heuristic**
(`text_layer_looks_empty`, threshold 50 chars/page) — a PDF that comes back
looking scanned is flagged `offer_local_ocr`, never silently opened empty.
Tier 2 rasterizes PDF pages via `pypdfium2` (~150 dpi) and recognizes them one
at a time with cancel checks between pages, joining pages with the
screen-reader-searchable `<!-- Page N -->` delimiter. Tesseract's TSV output
supplies per-word confidence; a mean below 60 flags the outcome `looks_weak`
— the Tier 2 -> 3 escalation point. Tier 3 (`convert_with_cloud_ocr`) adapts
the Datalab result into the same `ConversionOutcome`, so every downstream
surface (open-as-tab, review, announcements) is tier-agnostic.

#### 5.93.2 Escalation prompts (never silent, cost/upload named exactly once)

Tier 1 -> 2 (stays free and local): *"QUILL could not find readable text in
<file>. It looks scanned or image-based. Run free on-device OCR (local
Tesseract)? This stays on your computer and does not upload anything."* —
Yes runs OCR, No opens the empty result anyway, Cancel imports nothing.

Tier 2 -> 3 (the only prompt that mentions money or upload; shown only when
the service is configured): *"On-device OCR finished, but the result looks
low-quality or the layout is complex. For higher accuracy you can convert it
with Datalab cloud OCR. This uploads the file to a cloud service and may cost
money."* — accepting leads to the §5.93.2a consent dialog; declining keeps
the local result. When Tier 2 is unavailable (engine not installed) and the
user declines the free install, the same offer applies — free-first order is
structural.

##### 5.93.2a Upload consent (every time, no exceptions)

Before **every** cloud upload, a consent dialog states: the document will be
sent to Datalab; continue only if allowed to upload it; consider
private/medical/legal/educational/financial/employment/confidential content;
Datalab deletes results ~1 hour after processing and QUILL retrieves promptly,
keeping the result only in the opened document. A **filename heuristic**
(`looks_sensitive`: tax/medical/patient/ssn/legal/IEP/... fragments — names
only, content is never inspected) prepends an extra CAUTION line. There is no
"don't ask again": consent is per-action by design.

#### 5.93.2b Cloud client contract (`quill/core/datalab_ocr.py`)

REST against the Datalab Convert API (`POST /api/v1/convert`, multipart, key
in `X-API-Key`; poll `request_check_url` with cancel checks until complete).
BYOK: the key lives in the credential vault
(`QUILL/services/datalab/api_key`) with a `DATALAB_API_KEY` env fallback —
never in settings files. HTTPS enforced; Safe-Mode blocked; injectable opener
keeps tests offline; GATE-9 entry `core/datalab_ocr.py::_request`. Errors map
to the PRD's friendly table (rejected key / billing issue / too large / rate
limited / unreachable / incomplete / empty / timeout), and logging carries
state transitions and page counts only — never content, output, keys, or
bodies. Non-secret knobs persist as settings: `datalab_enabled` (default
off), `datalab_endpoint` (HTTPS-validated), `datalab_mode`
(fast/balanced/accurate), `datalab_output` (markdown/html/json),
`datalab_paginate`.

#### 5.93.2c AI Hub Services tab

**AI Hub > Services** is the customer-facing service manager (opened directly
by **OCR Service Settings...**): a plain-language intro naming the free tiers
first, then the Datalab card with a live status line (Ready / Needs API key /
Not configured), enable checkbox, key field (saved to the vault), endpoint,
default mode/output, page delimiters, an always-on "confirm before upload"
statement, **Test Connection** (key-presence + endpoint sanity only — states
explicitly that no document is uploaded), **Copy Service Summary**
(secret-free, `API key value: Not included`), and six provider link buttons
(website, API keys, pricing, privacy/security, API docs, supported file
types), each announcing that it opens in the browser. `open_ai_hub` folds the
saved Datalab fields back into the live settings object so the next
conversion sees them without a restart.

#### 5.93.2d OCR Review Mode (v1: the spoken checklist)

**Review Last OCR Result...** opens a named tab over the most recent
conversion: source, producing tier/service, page count, mean confidence,
warnings, and every low-confidence line pre-formatted as
`Page N: [NN%] text` (populated by Tier 2 from per-word TSV confidence).
The reviewer walks exactly the flagged lines and jumps into the converted
document by searching the page marker — review by ear, not re-proofreading.
Cloud/Tier-1 results state honestly that no per-line confidence exists.
(The full §12 review workspace — outline tree, per-block accept/re-run,
table review — is tracked as future depth in roadmap §5.)

#### 5.93.2e Temporary files

**Delete OCR Temporary Files** clears leftovers under the app-data
`ocr_jobs` folder (crash residue) and reports what was removed; the local
tiers use per-run auto-cleaned temp directories, so normally there is
nothing to delete. Results live only in the opened (unsaved) document until
the user saves.

#### 5.93.3 Engine acquisition (verified downloadable component)

Tesseract is never bundled. `quill/core/tesseract_install.py` downloads the
**byte-identical official UB-Mannheim 5.4.0 installer** (Apache-2.0) from
QUILL's pinned `assets-v1` release, SHA-256-verified (SEC-6), HTTPS-enforced,
Safe-Mode-blocked, then **launches the installer visibly** for the user to
complete — NSIS has no admin-free extraction analogous to `msiexec /a`, and
QUILL never silently elevates. Discovery
(`quill.io.tesseract_ocr.discover_tesseract_executable`) then finds the engine
with no restart: settings override (`tesseract_path`) -> QUILL-managed folder
-> `PATH` -> the conventional `C:\Program Files\Tesseract-OCR` location. The
download call site is registered in the network-egress audit (GATE-9). On
macOS the managed download is not offered; a Homebrew-installed `tesseract`
on PATH is picked up automatically.

#### 5.93.4 Surfaces

- **File > Import > Import / Convert Document (OCR)...** (leads the submenu)
  and the same command in **Tools > Reading & Dictation**; palette id
  `file.import_convert`.
- **Tools > Reading & Dictation > Install Local OCR Engine (Tesseract)...**
  (`tools.install_local_ocr`) — consent names the size and exactly what will
  happen before any download.
- **Tools > Reading & Dictation > OCR and Conversion Services...**
  (`tools.ocr_services`) — the customer-facing services overview: a friendly
  card per tier (what it does, best for, local-or-cloud, cost, limits, setup)
  plus the live engine install status, in plain language per the OCR PRD's
  Services-tab content requirements. A full AI Hub Services tab ships with the
  first cloud provider.

Handlers live in `quill/ui/main_frame_docconvert.py` (`DocConvertMixin`).
Settings: `ocr_language` (Tesseract three-letter code, default `eng`) and
`tesseract_path` (explicit override). Commands map to `core.ocr` (the install
and services surfaces) so OCR-less profiles stay clean.

---

### 5.94 Math in QUILL — equation input, rendering, structural exploration, and Word export (shipped)

**Goal.** Close the gap the bundled math-equations Quillin always had: an
equation could be typed in, but nothing rendered it, nothing let a
screen-reader user explore its structure, and Word never saw a real
equation object. This section is now the canonical record; the original
planning doc (`docs/planning/math.md`) is retired now that the feature has
shipped in full.

**Canonical model (`quill/core/math/`, wx-free, unit-tested).**
`mathml.py` holds the canonical in-memory representation — MathML, the same
input MathCAT and every screen-reader math engine already expects — with
the original LaTeX source recoverable via a `semantics`/`annotation` pair
(the same interop convention MathJax's own `tex2mathml` uses), so no general
MathML-to-LaTeX parser is needed. `latex_bridge.py` is a `latex2mathml`-backed
LaTeX-to-MathML bridge (the optional `math` extra; the module degrades to a
clear `LatexBridgeUnavailable` rather than failing at import time when it is
absent). `navigator.py` is a lightweight structural navigator — not a
MathCAT-quality math-speech engine — that steps through an equation's parts
(numerator/denominator, base/exponent, a root's radicand) and produces a
plain-English linear reading with a small set of template rules; it is the
"basic algebra, useful today" alternative to real Nemeth/UEB math speech.

**Input (math-equations Quillin).** `Ctrl+Shift+E` / **Insert → Insert
Equation...** takes LaTeX or MathML and wraps LaTeX as `\(...\)` (inline) or
a single-line `$$...$$` (display) — both MathJax's own default-recognized
delimiters, chosen specifically so no rendering template needs a config
change and so a bare `$` in ordinary dollar-amount prose is never
mistaken for math (the earlier `$...$` convention was replaced for exactly
this reason). **Insert → Snippet Gallery...** carries ten static
`$$...$$` formulas (quadratic formula, Pythagorean theorem, slope-intercept
and point-slope forms, slope/distance/midpoint formulas, difference of
squares, circle area/circumference) contributed via the existing
`contributes.snippet_gallery` manifest mechanism — no new UI. 88 Math
AutoCorrect-style abbreviations (seeded from the DAISY-published Word list,
daisy.org/MSMathCodes) are contributed via the existing Insert Automation
abbreviation system; `\delta`/`\Delta` and similar case-differentiated pairs
depend on a fix in `quill/core/abbreviations.py` — `build_contributed_library`
was silently dropping the manifest's `case_sensitive` field.

**Structural exploration.** **Insert → Explore Equation Structure...**
(`Ctrl+Shift+Grave, F`) parses the selection (or a fresh prompt) via
`navigator.py` and drives a sequence of `ui.choices` calls — no new core UI,
entirely within the existing Quillin capability model. Each `ui.choices`
call is a `wx.SingleChoiceDialog` (standard listbox semantics: arrow keys
move the highlight, type-ahead jumps to a match, Enter/OK activates the
highlighted item); **Escape/Cancel at any depth ends the whole session
immediately**, identically to choosing **Done exploring** — it is not "go
up one level," which is the dedicated **Back up one level** choice.
Descending into a numbered child re-announces (`ctx.announce`, speech only)
the new node and reopens the list for its children (role labels: Numerator,
Denominator, Base, Exponent, ...); **Read this part aloud** speaks a full
reading of only the current node via `quill.core.math.speech.speak()`
(below) and reopens the same list at the same position.

**Real math speech (`quill/core/math/mathcat_engine.py`,
`quill/core/math/speech.py`).** **Read this part aloud** is the one part of
Explore Equation Structure that upgrades: `speech.speak(element)` tries the
real MathCAT engine first and falls back to `navigator.read_aloud()` (the
template renderer) on any failure or absence — never raises, always
returns a usable string. The structural walk itself (numerator/denominator/
base/exponent stepping, the ambient per-step labels) is intentionally left
on the lightweight template system even when MathCAT is installed: those
labels are meant to be short list items ("Fraction", "Numerator: Group"),
and MathCAT's fuller natural-language prose would make them unwieldy;
`navigator.py` stays MathCAT-free by design so the mandatory install-nothing
path never depends on it.

MathCAT itself has no pip-installable Python binding, but a maintained,
separate C-interface project (`daisy/MathCATForC`, MIT) publishes prebuilt
Windows DLLs, header, and Rules data as GitHub release assets — no Rust
toolchain or PyO3 wrapper needed at all. `mathcat_engine.py` binds that DLL
via plain `ctypes` against its C-string API (`SetMathML`, `GetSpokenText`,
`SetRulesDir`, ...). The API is process-global mutable state (`SetMathML`
sets "the" current equation for the whole process), so every call is
serialized behind a lock. **Memory safety note, found and fixed the hard
way**: declaring a string-returning function's `restype` as
`ctypes.c_char_p` makes ctypes copy the string into a new Python `bytes`
object and discard the original pointer — freeing "it" afterwards then
frees memory Rust's allocator never allocated (confirmed by hand:
`STATUS_HEAP_CORRUPTION`). Every string-returning function is declared
`c_void_p` instead; a dedicated `_read_and_free` helper casts that exact
address to read it, then frees that exact address.

Distribution follows the existing `quill.core.release_assets` pattern
(whisper.cpp, kokoro, vosk, the braille pack, libmpv, spell dictionaries):
a byte-identical re-publish, pinned by SHA-256, on QUILL's own `assets-v1`
release tag, fetched on demand (**Help → Download Optional Components... →
MathCAT math speech engine**, ~3 MB) with the same already-installed/consent
dialog pair and Safe Mode gate every other component in that list uses.
`quill.core.optional_components._mathcat_installed()` is the availability
check surfaced in that dialog.

Known open item: MathCAT's own speech phrasing has at least one rough edge
observed directly (an implicit-multiplication connector rendered oddly in
one tested equation) that needs a real screen-reader user's ears and
possibly a `SetPreference` tuning pass (verbosity, `ImpliedTimes` style)
before this is the polished default experience — tracked as a follow-up,
not blocking the current shipment. Real Nemeth/UEB math **braille** (the
crate also produces it) is not wired here; that is a further follow-up once
speech quality is validated.

**Rendering.** Browser Preview and HTML export already carried a MathJax
CDN `<script>` tag; the actual fix was recognizing that MathJax's default
`inlineMath` delimiter is `\(...\)`, not bare `$...$` (dollar signs are
opt-in specifically to avoid colliding with prose), so switching the
Quillin's own delimiters to MathJax's defaults made both surfaces render
correctly with **zero template changes**. Verified empirically (headless
browser screenshot), not assumed.

**Word export and round-trip (`quill/io/docx_math.py`).** The native
python-docx writer (`quill/io/docx_writer.py`) had no math model; math spans
in a run's text are now detected and each converted to a real
`<m:oMath>`/`<m:oMathPara>` fragment via a one-equation Pandoc round-trip
(write a tiny Markdown file containing just that equation, convert to docx,
extract the fragment, splice it into the run stream via
`docx.oxml.parse_xml`). Falls back to the literal delimited text — never
silently drops the equation — when Pandoc is unavailable or a specific
conversion fails. `omml_fragment_for_latex` is memoized (`functools.lru_cache`)
so a document repeating the same formula does not spawn a Pandoc subprocess
per occurrence. The docx *read* path (both the default MarkItDown engine and
the Pandoc engine) already round-tripped a native equation back to `\(...\)`
text correctly; regression tests lock that in.

**AI.** A read-only `math-tutor` agent (`default_scope: selection`,
`modify_selection: deny`) explains a selected equation in plain language
without solving anything or touching the document.

**Shipped after all: real MathCAT speech.** An earlier pass through this
section concluded MathCAT integration meant QUILL building its own
PyO3/Rust binding against the `libmathcat` crate and standing up a new
native-build CI pipeline (comparable to `table_uia`) — that conclusion was
wrong, corrected once `daisy/MathCATForC` (a separate, actively maintained,
MIT-licensed C-interface project publishing prebuilt Windows DLLs) was
found. See "Real math speech" above: the binding is a `ctypes` wrapper
against an already-compiled binary, not a new Rust toolchain. Real Nemeth/
UEB math **braille** (MathCAT also produces it) is not wired yet — a
follow-up once the speech path is validated by ear, not a native-build
blocker.

**Explicitly skipped.** DAISY MathML export: `quill/io/daisy.py` is a
DAISY 2.02 *text-only* writer (no OPF/NCX/EPUB3), and no DAISY 2.02 player
has real MathML rendering/speech support, so embedding raw `<math>` there
would be inert markup, not real accessibility.

**Tutorial:** [07-type-math-like-a-pro.md](../tutorials/07-type-math-like-a-pro.md)
teaches the whole surface end to end — gallery first, then typed input, the
structure explorer, Browser Preview, and the Word round trip.

---

## 6. Spell checking deep dive

### 6.1 The TinySpell question

TinySpell is a small, fast Windows spell-checker that watches the clipboard or current input field and alerts on misspellings. It is genuinely useful for many users. So the question is: should Quill integrate tightly with TinySpell, or build its own engine?

We have chosen to **build our own engine**, tightly integrated into Quill, for the following reasons:

1. **Screen-reader fidelity.** TinySpell speaks through its own UI surfaces and conventions. Quill needs total control over how misspellings are announced, navigated, and corrected so that NVDA, JAWS, and Narrator hear the same predictable patterns. A loose integration would create a second source of speech that competes with our own.
2. **Document context.** TinySpell works across applications and therefore cannot use document-level context (heading structure, surrounding paragraph, language metadata, per-document dictionary). Quill knows exactly what document the cursor is in and can do much better.
3. **Per-document and per-paragraph language detection.** Multi-language documents need language switches mid-stream. An external tool will not know.
4. **Suggestion ranking that learns.** TinySpell's suggestion order is fixed. Quill ranks suggestions using the user's own writing and the surrounding sentence, which is the single biggest quality-of-life upgrade in modern spell check.
5. **Offline guarantee.** Quill commits to spell check working with zero network. An external dependency complicates that guarantee.
6. **Distribution.** Bundling TinySpell would create a licence and update story we do not control.
7. **Keymap and palette parity.** Every Quill action is in the palette and reassignable. Spell-check commands must be too.

We will, however, ship a small **TinySpell interop plugin** for v1.1 for users who already rely on TinySpell elsewhere and want a unified personal dictionary. That plugin imports/exports the personal dictionary file and otherwise stays out of the way.

### 6.2 Engine architecture: "Quill Spell"

Quill Spell is a layered local engine:

- **Layer 1: Tokeniser.** Unicode-aware word splitter that understands code (CamelCase, snake_case, kebab-case), contractions, hyphenation, URLs, emoji, file paths, and Markdown/HTML syntax tokens. It hands the next layer a stream of `(word, span, context_kind)` tuples where `context_kind` is one of `prose`, `code`, `identifier`, `url`, `markup`, `path`.
- **Layer 2: Dictionary stack.** A priority-ordered stack of dictionaries: per-document, per-project (if applicable), user personal, language-base (Hunspell), plus optional jargon packs (medical, legal, technical, scientific). The first dictionary that accepts the word wins.
- **Layer 3: Hunspell backend.** Bundled via `cyhunspell`. Dictionaries shipped: en-US, en-GB, en-CA, en-AU, es-ES, es-MX, fr-FR, de-DE, pt-BR, pl-PL, ja-JP (with MeCab). Others available via a one-click downloader.
- **Layer 4: Suggestion engine.** Combines Hunspell's edit-distance suggestions with a character-level n-gram model and a small contextual reranker (a tiny on-device transformer; runs on CPU in <10 ms per call). Top 5 suggestions, ranked by combined score.
- **Layer 5: Learning loop.** When the user accepts a suggestion, the engine increments its frequency. When the user adds a word, it goes to the appropriate dictionary (with a prompt asking which). When the user repeatedly rejects a suggestion in favour of another, the reranker learns the preference. Learning is 100 percent local and resettable.
- **Layer 6: Context awareness.** The tokeniser tells the engine when it is inside a code fence, an inline code span, a URL, or a Markdown link target. Those regions are skipped by default. Settings let the user enable code spell checking with a programming-aware dictionary.

### 6.3 User experience

- **Status indicator.** The status bar shows the active dictionary stack as a short label, for example `en-GB + tech + personal`.
- **`F7` Spelling Review.** *(Implemented 0.7.0 Beta 2 — see §6.4 for the full specification.)* A modal guided dialog. Focus opens in a read-only multiline **Context** field with the misspelled word selected within its sentence. Tab order: Context → Change to → Suggestions → Change → Change All → Ignore Once → Ignore All → Add to Dictionary → Undo Last → Close. All actions available without a mouse. Announcements are configurable (Concise / Balanced / Detailed). Optional letter-by-letter spelling of the wrong word after a configurable pause. Scope detects selection vs. whole document. Case-preserving Change All. Session-scoped Ignore All. Position-aware undo.
- **`Ctrl+F7` / `Ctrl+Shift+F7` next/previous misspelling.** Jumps directly to the next or previous misspelling from the caret without opening the full review dialog.
- **Background pass.** A debounced background tokenise runs as the user types. Results live in a sidecar model. No visual squiggles. The screen reader is never interrupted by background work.
- **Per-document language.** A YAML-style sidecar (`<doc>.quill.yml`) can pin language and dictionaries. Detected automatically from a magic comment on the first line if present.
- **Magic touches.**
  - When you add a word, Quill says "Added <word> to your personal dictionary" or "Added <word> to this document's dictionary" so you always know where it went.
  - When you reject the same suggestion three times for the same misspelling, Quill quietly asks once whether you want to add the rejected word to your personal dictionary.
  - When pasting a large block of text, the spell pass is queued at a lower priority so the paste itself is instant.
  - When entering a code fence in Markdown, the engine switches automatically to identifier mode and stops complaining about variable names.

### 6.4 F7 Spelling Review — Full Specification

> **Status: Implemented in 0.7.0 Beta 2.** This section is the canonical
> specification for the shipped feature; the original standalone planning PRD was
> retired to git history once delivered.

---

#### 6.4.1 Executive Summary

QUILL provides a classic, guided spelling-review experience invoked with **F7**. The feature preserves what made the traditional Microsoft Word spelling dialog effective — one issue at a time, clear suggestions, predictable actions, and an obvious path through the document — while substantially improving that experience for screen-reader and keyboard users.

The defining feature is a focusable **multiline, read-only Context field** that contains the misspelled word in meaningful surrounding text. The current word is selected when the field receives focus so users can hear it in context, review it character by character, move by word or line, copy text, and understand punctuation and sentence structure without leaving the spelling-review dialog.

#### 6.4.2 Product Vision

Pressing F7 in QUILL should feel reassuring and familiar:

1. QUILL finds the next potentially misspelled word.
2. The Spelling Review dialog opens.
3. Focus lands in a read-only multiline Context field.
4. The problem word is selected inside its sentence or paragraph.
5. The screen reader announces the issue, progress, and context without excessive repetition.
6. The user can inspect the text with normal editing-navigation commands.
7. The user can Tab to the replacement field, suggestions, and actions.
8. Every action immediately confirms what happened and advances to the next issue.
9. At completion, QUILL reports a useful summary and returns focus exactly where the user expects.

#### 6.4.3 Goals

**Primary goals:**

- Provide a complete spelling review through F7.
- Present each misspelled word in meaningful, navigable context.
- Make the entire workflow usable without a mouse.
- Make the workflow excellent with NVDA, JAWS, Narrator, and other screen readers using standard Windows accessibility APIs.
- Use standard controls and predictable focus behavior wherever possible.
- Provide concise, useful speech for state changes without duplicating what the screen reader already announces.
- Support Ignore Once, Ignore All, Change, Change All, Add to Dictionary, Undo Last Action, and Close.
- Preserve document integrity, undo history, caret position, selection, viewport, formatting, and accessibility state.
- Keep local document text private by default.
- Scale to large documents without freezing QUILL.

**Secondary goals:**

- Support multiple dictionaries and document languages.
- Provide configurable context size and speech verbosity.
- Provide a reusable review framework that could later support grammar, terminology, style, and accessibility checks.

#### 6.4.4 Non-Goals for Initial Release

- Grammar, style, readability, or inclusive-language review in the same dialog.
- Sending document text to an online service by default.
- Require direct integration with a specific screen reader.
- Automatically rewrite text without explicit user action.
- An always-open proofing sidebar.

#### 6.4.5 Design Principles

- **Familiar, but better.** Retain the strengths of classic sequential spell checking while resolving focus uncertainty, insufficient context, repeated speech, ambiguous button behavior, and loss of position.
- **Context is a first-class control.** The Context field is a standard, focusable, multiline, read-only edit control — not a static label.
- **Standard controls before custom controls.** Use native wxPython controls with reliable name, role, state, value, selection, and keyboard behavior.
- **Speech should inform, not compete.** QUILL announces transitions and results. It does not speak what the screen reader will already announce on focus.
- **No keyboard traps.** Every control is reachable and escapable. Tab and Shift+Tab move predictably. Escape never leaves the user uncertain.
- **User control over every edit.** No spelling correction is applied until the user activates an explicit action.
- **Preserve the document experience.** When the dialog closes, the user returns to the document with caret, selection, and scroll position restored.

#### 6.4.6 Terminology

| Term | Meaning |
|---|---|
| Issue | A token QUILL's spelling provider considers unknown or misspelled |
| Current word | The word under review |
| Context | Surrounding document text shown in the read-only multiline field |
| Replacement | Text that will replace the current word when Change is activated |
| Suggestion | A spelling-provider recommendation |
| Review session | The complete F7 workflow from invocation until completion or cancellation |
| Review scope | Selection or full document, depending on caret/selection state at F7 |
| Session ignore | A word ignored until the current review session ends |
| User dictionary | A persistent, language-specific list of accepted words |

#### 6.4.7 Invocation and Review Scope

- **F7:** Start Spelling Review. Also available from **Tools > Spell Check...**
- **Scope rules:**
  1. If the editor has a nonempty selection, F7 checks the selection only.
  2. If there is no selection, F7 checks the entire document.
  3. If the document is empty, QUILL reports that and takes no action.
- The opening announcement states the scope: *"Spelling review. Checking selected text."* or *"Spelling review. Checking document."*

#### 6.4.8 Main Dialog Specification

**Dialog model:** Modal. Title format: **Spelling Review — Issue 3 of 18**

**Tab order:**
1. Context field
2. Change to field
3. Suggestions list
4. Change button
5. Change All button
6. Ignore Once button
7. Ignore All button
8. Add to Dictionary button
9. Undo Last button
10. Close button

**Issue label:** A bold static text label above Context showing *"Not in dictionary: word"*.

#### 6.4.9 Context Field

- Standard multiline `wx.TextCtrl` with read-only and multiline styles.
- Labeled **Context around word (Alt+W to reselect)**.
- Focusable; included in Tab order.
- Supports character, word, line navigation and Ctrl+C copy.
- Protected from modification, pasting, or deletion.
- On each new issue: populates, computes word offsets, selects the word, moves focus via `wx.CallAfter`.
- **Alt+W** at any time refocuses Context and reselects the current word.

**Context construction** (`quill/core/spelling/context_builder.py`):

- Splits on sentence boundaries using `re.split(r"(?<=[.!?])\s+", ...)`.
- Includes the sentence containing the issue plus adjacent sentences.
- Character ceiling: 900 characters (configurable via `spell_review_context_mode` setting).
- Falls back to a character window when sentence splitting finds no boundaries.
- Preserves original punctuation and Unicode characters.

#### 6.4.10 Change to Field

- Standard single-line `wx.TextCtrl` labeled **Change to** (`Chan&ge to:`).
- Pre-filled with the highest-ranked suggestion, or the original word if no suggestions.
- Full text selected on populate so typing immediately replaces it.
- Enter activates Change.

#### 6.4.11 Suggestions List

- Standard `wx.ListBox` labeled **Suggestions** (`&Suggestions:`).
- First suggestion selected by default.
- Changing selection updates the Change to field.
- Empty when no suggestions; list stays present, Change to pre-filled with original word; announcer says "No suggestions."

#### 6.4.12 Actions

**Change** — Replaces the current occurrence with the Change to field value. Confirms action, advances to the next issue.

**Change All** — Replaces all matching occurrences within scope for the session. Case-preserving: `teh→the`, `Teh→The`, `TEH→THE`. Reports replacement count. Entire operation is undoable.

**Ignore Once** — Skips this occurrence, advances.

**Ignore All** — Ignores all occurrences for the remainder of the session only. Does not persist.

**Add to Dictionary** — Adds the word to the personal dictionary via `core.spellcheck.add_word_to_scope("personal", ...)`. Reversible within the session via Undo Last.

**Undo Last** — Reverses the most recent spell-review action without closing the dialog. Supports: Change, Change All, Ignore Once, Ignore All, Add to Dictionary. Disabled when nothing has been done. Restores prior Context, Change to state, and counters.

**Close** — `Escape` or the Close button. Changes already applied remain in the document's normal undo history.

#### 6.4.13 Keyboard Map

| Key | Action |
|---|---|
| Tab / Shift+Tab | Move forward / backward through controls |
| Alt+W | Focus Context and reselect current word |
| Enter (in Change to) | Change |
| Escape | Close |
| Chan&ge mnemonic (Alt+G) | Change button |
| Change &All (Alt+A) | Change All button |
| &Ignore Once (Alt+I) | Ignore Once button |
| Ignore A&ll (Alt+L) | Ignore All button |
| Add to &Dictionary (Alt+D) | Add to Dictionary button |
| &Undo Last (Alt+U) | Undo Last button |
| &Close (Alt+C) | Close button |

#### 6.4.14 Screen-Reader Experience

**Announcement service** (`quill/core/spelling/announcements.py` — `AccessibilityAnnouncer`):

Three verbosity modes:

| Mode | What is announced |
|---|---|
| **Concise** | Progress numbers and action results only |
| **Balanced** *(default)* | Issue type, current word, progress, and results |
| **Detailed** | Balanced plus control hints and scope reminders |

**Optional spell-word feature:** After announcing the misspelling, QUILL can spell it letter by letter. Enabled by `spell_review_spell_word` setting. Delay before spelling starts is configurable via `spell_review_spell_word_pause_ms` (default 800 ms, range 100–3000). Implemented with `wx.CallLater`.

**Avoiding duplicate speech:** QUILL does not announce control names before moving focus. It announces only what the focused control will not convey: scope, progress, action results, completion summary, errors.

**Opening (balanced):** *"Spelling review. Issue 1 of 12. Not in dictionary: accomodate."*

**After Change:** *"Changed accomodate to accommodate. Issue 2 of 12."*

**After Ignore Once:** *"Ignored once. Issue 3 of 12."*

**Completion:** *"Spelling review complete. 9 changes, 2 ignored once, 1 word ignored for this session, and 1 word added to the dictionary."*

**Focus rules:**
- Focus lands in Context for every new issue.
- Focus never disappears after an action.
- Disabled controls do not receive focus.
- Closing the dialog returns focus to the originating editor.

#### 6.4.15 Session Data Model

Implemented in `quill/core/spelling/` package:

quill/core/spelling/ init.py — exports SpellingIssue, ReviewCounters, ReviewSession, build_context models.py — ActionKind, SpellingIssue, ReviewCounters, ReviewAction context_builder.py — build_context(text, word_start, word_end, max_chars) -> tuple session.py — ReviewSession (owns text copy, rescan logic, undo stack) announcements.py — AccessibilityAnnouncer

quill/ui/spelling_review_dialog.py — SpellingReviewDialog (presentation only)


**ReviewSession** owns a `_text` copy, rescans after every action via `list_misspellings`, tracks ignored-once positions with offset adjustment (`_shift_ignored_positions`), and maintains an undo stack of `_UndoRecord` objects.

**`_UndoRecord`** stores: action kind, prior text, all_ranges (for Change All), prior ignore state, prior counters.

**Case matching** (`_case_match(original, replacement)`): checks `.isupper()` → uppercase, `.istitle()` → capitalize, else lowercase.

#### 6.4.16 Settings

Settings group added in `quill/core/settings_specs.py` and `quill/core/settings.py`:

| Setting key | Type | Default | Description |
|---|---|---|---|
| `spell_review_verbosity` | choice | `"balanced"` | Announcement verbosity: concise / balanced / detailed |
| `spell_review_spell_word` | bool | `True` | Spell the wrong word letter by letter after announcing it |
| `spell_review_spell_word_pause_ms` | int (100–3000) | `800` | Milliseconds before letter-spelling starts |
| `spell_review_wrap_to_beginning` | bool | `True` | After reaching end of document, wrap to beginning |
| `spell_review_context_mode` | choice | `"sentence"` | Context extraction mode: sentence / paragraph |

#### 6.4.17 Acceptance Criteria

The feature is considered complete when:

1. F7 starts Spelling Review from the active editable document.
2. A nonempty selection is checked without modifying text outside the selection.
3. The dialog uses standard accessible controls throughout.
4. Initial focus lands in the multiline read-only Context field.
5. The active word is selected in Context.
6. Users can navigate Context by character, word, and line and can copy text.
7. Users can reselect the active word with Alt+W.
8. Replacement and Suggestions are fully keyboard operable.
9. Change, Change All, Ignore Once, Ignore All, Add to Dictionary, Undo Last, and Close work correctly.
10. Every action results in a concise, understandable state update.
11. Focus never becomes lost or trapped.
12. Closing returns focus to the originating editor.
13. Changes participate correctly in QUILL's undo history.
14. Change All respects scope and capitalisation rules.
15. Missing dictionaries and provider failures produce accessible messages.
16. NVDA, JAWS, and Narrator can complete the workflow without mouse input.
17. The dialog works in high contrast and at 200% scaling without clipping essential controls.
18. Automated tests cover context offsets, session actions, scope boundaries, position-drift after replacements, and completion behavior.
19. User documentation explains F7, review scope, all actions, and keyboard commands.

#### 6.4.18 Future Enhancements

These remain out of scope for the initial release but can reuse the review session framework:

- Grammar and repeated-word detection
- Style and clarity review
- Terminology enforcement
- Custom organisational and domain dictionaries
- "Explain this suggestion" for advanced providers
- Resume an interrupted review session
- Review only comments, headings, or selected structural regions
- Plugin-contributed proofing providers
- Braille-optimised announcement mode
- A compact review mode for experienced users
- Optional modeless review pane after the modal workflow is mature and proven accessible

---

## 7. Command palette deep dive

The command palette is modelled directly on Visual Studio Code's, adapted for screen readers.

### 7.1 Opening and dismissing

- `Ctrl+Shift+P` opens the palette in command mode.
- `F1` does the same.
- Each prefix shortcut (below) opens the palette pre-seeded with that prefix.
- `Esc` closes without action.
- `Enter` runs the selected entry (and remembers it).
- `Tab` from the input moves into the list. `Shift+Tab` returns to the input.

### 7.2 Prefix modes

A single edit field, one list. The first character of the input may be a mode prefix:

| Prefix | Shortcut to open directly | Meaning |
| --- | --- | --- |
| (none) or `>` | `Ctrl+Shift+P` | Run a command |
| `?` | `Ctrl+Shift+?` | Show help for all prefixes |
| `:` | `Ctrl+G` (when no page markers) | Go to line |
| `@` | `Ctrl+R` | Jump to bookmark in current document |
| `#` | `Ctrl+T` | Jump to bookmark across all open documents |
| `!` | `Ctrl+Shift+M` | Show issues (spell check, bookmark, format) |
| `<` | `Ctrl+E` | Open a recent file |
| `~` | `Ctrl+Shift+O` | Switch open document |
| `=` | (palette only) | Open a setting |

These prefixes are themselves reassignable (see [section 8](#8-keymap-and-keystroke-reassignment)).

### 7.3 Matching

- Fuzzy subsequence match across the visible label.
- Each match scored by: subsequence tightness, prefix bonus, word-boundary bonus, recency, frequency.
- Top 100 results displayed, lazily.
- Ties broken alphabetically.

### 7.4 Rows

Each row in command mode shows three regions, all announced as one string by the screen reader (with internal punctuation chosen to read well):

```text
<Command title> — <current keybinding or "unassigned"> — <source>

For example:

Improve Reading Order with AI — unassigned — Quill
Spell Check Document — F7 — Quill
Find Next — F3 — Quill
Pandoc: Convert to DOCX — Ctrl+Alt+P — Plugin: Pandoc Bridge

7.5 Inline keybinding edit

While the palette is open and a command is highlighted:

7.6 Recently used and pinned

7.7 Help

7.8 Accessibility specifics


8. Keymap and keystroke reassignment

Every Quill action is identified by a stable command id (quill.editor.save, quill.palette.open, quill.spell.checkDocument, etc.) and may have zero, one, or many keybindings.

8.1 Where keystrokes live

Three layers, in priority order:

  1. User keymap (%APPDATA%\Quill\keymap.json). User edits win.
  2. Profile keymap (optional, shipped alternatives). For example, “Word-like”, “Vim-friendly”, “Emacs-friendly”. The user picks one as the base.
  3. Default keymap (built into Quill).

The effective keymap is the merge of these three, with user > profile > default.

8.2 Keyboard settings page

Ctrl+, then “Keyboard” (or palette =keyboard).

The page contains:

8.3 Editing a single binding

Three equivalent ways:

  1. From the command palette: highlight a command and press Right Arrow.
  2. From the Keyboard settings page: select the row and press Change (or Enter).
  3. From the menu bar: every menu item has a context-menu entry “Change keybinding”.

In all three, the same small “Press the new shortcut” capture dialog appears. It:

8.4 Conflict handling

When a keystroke is already used:

8.5 “When” contexts

Each command declares which contexts it is valid in:

Contexts can be combined with && in advanced editing.

8.6 Profiles in detail

8.7 Storage format

keymap.json is plain JSON, human readable, version controlled friendly:

{
  "version": 1,
  "profile": "System",
  "bindings": [
    { "command": "quill.editor.save", "key": "Ctrl+S", "when": "editor" },
    { "command": "quill.palette.open", "key": "Ctrl+Shift+P", "when": "global" },
    { "command": "quill.spell.checkDocument", "key": "F7", "when": "editor" },
    { "command": "quill.editor.gotoLine", "key": "Ctrl+G", "when": "editor && !format:pdf" }
  ]
}

8.8 Import, export, sync

8.9 Safety net

8.10 QUILL key chord dispatch

The QUILL key (Ctrl+Shift+Grave by default) operates as a two-layer prefix system managed by QuillKeyMixin in quill/ui/main_frame_quill_key.py.

Layer 1 — Prefix armed. The first press arms a short-lived prefix state (_quill_key_prefix_pending). The following keys are handled as hardcoded mode gates and are not reassignable:

Layer 2 — Chord command dispatch. Any key pressed while the prefix is armed that is not a mode gate is dispatched via a data-driven lookup: _chord_command_for_event scans the live keymap for entries whose binding matches <prefix>, <second-key> and runs the matching command via _run_command. This means every chord command (read aloud, snippets, headings, copy tray, etc.) is fully reassignable in the Keymap Editor without any code change.

Key detection. The QUILL prefix key is recognized by three independent strategies so it works on any keyboard layout and Windows driver: 1. wxPython key code or Unicode key equals ord("“)orord(”~“)2. Windows virtual-keyVK_OEM_3(0xC0) viaGetRawKeyCode()3. Physical scan code0x29(key below Esc / above Tab on PC keyboards) via bits 16–23 ofGetRawKeyFlags()`

Keymap Editor validation. Chord bindings (containing ,) are validated separately from simple bindings: the prefix part must be a known modifier+key combination and the second-key part must be a parseable single key or modifier+key.

8.11 The Ctrl+Alt+ policy (revised 0.7.0)

Quill’s long-standing policy was to forbid Ctrl+Alt+ bindings outright because NVDA, JAWS, and Windows Speech Recognition intercept most of those chords app-globally. The 0.7.0 release (EdSharp port) revises the policy to permit a Ctrl+Alt+ binding when one of two conditions holds:

  1. The command id is in the _CTRL_ALT_DOCUMENTED allowlist in quill/tools/menu_lint.py. Each allowlist entry is paired with a screen-reader-binding justification in docs/keybinding-standard.md. The historical view.send_to_tray and view.toggle_tab_control entries predate the relaxation; the new heading entries (1..6) are documented because they override NVDA’s switch-to-synth-N.
  2. The binding line in keymap.py ends with the inline comment # §edsharp-ok — <justification>. The per-binding escape hatch lets future one-off bindings enter the keymap without a code change in menu_lint.py.

The rename from _CTRL_ALT_ALLOWED to _CTRL_ALT_DOCUMENTED makes the narrower scope explicit: the allowlist is “documented exceptions,” not a free pass. The gate continues to reject every other Ctrl+Alt+ binding, and the regression test in tests/unit/tools/test_menu_lint.py (test_ctrl_alt_uncommented_still_fails) pins the contract.

The full audit lives in the new docs/keybinding-standard.md document. The new EdSharp-port chord pairs (heading 1..6, list 7/8, section-move Alt+Shift+Up/Down) are all documented there with their justification comments and the screen-reader bindings they override.

8.12 The list-toggle chord pair (revised 0.7.0)

Ctrl+Alt+7 and Ctrl+Alt+8 are the EdSharp-port toggle variants of the existing format.insert_bullet_list / format.insert_numbered_list commands. Each chord inspects the caret: if it is on a line that is already a list item, the markers are stripped and the line returns to plain text; otherwise a new list is inserted. This decision is encoded in the pure helper is_caret_inside_list so the toggle behaviour can be tested without spinning up a MainFrame. Plain-text documents announce the chord is unavailable; the action is skipped.

Numbered-list insertion is governed by a new list_auto_fill_numbers setting (SettingsGroup = “editing”, default off) and a per-document five-minute arming flag. The three OR together in should_auto_fill_numbers():

  1. The active document surface is markdown (the default-experience rule — a user who explicitly authored a Markdown file wants filled markers).
  2. The list_auto_fill_numbers setting is on.
  3. The user just toggled a numbered list on the active document — the arming flag is set to time.monotonic() + 300 the first time the chord runs in the document and cleared on document close.

Outside of the three conditions, today’s behaviour of one marker on the first item is preserved, so the change is strictly opt-in. Ctrl+Alt+9 for link insertion is intentionally not added because Ctrl+K already covers that command.

8.13 The Section status bar cell (revised 0.7.0)

A new Section cell appears in the status bar and reads Section: Heading N (ordinal of total) whenever the caret is on a heading in a Markdown or HTML document. The cell is hidden by default so it does not push other useful cells out of the bar for writers who do not work at heading-level granularity; opt in via Preferences -> Status Bar and place the cell where it helps.

The cell dispatches on infer_markup_kind(document.path). Plain-text documents and carets on a non-heading line return an empty string. The cell inherits the same try / except RuntimeError dead-widget guard as the other live-editor cells (line_column, word_count, selection) so a queued caret event after Ctrl+F4 cannot crash the status-bar refresh when the underlying C++ TextCtrl has been deleted.

The HTML path reuses the existing fence-aware parse_heading_blocks and current_section_at from quill/core/markdown_sections.py; both already handle <hN>...</hN> headings in the same way they handle #-prefixed markdown headings.

8.14 QUILL Key branding and menu label clarity (0.7.0)

Two changes ship together so the QUILL key is recognizable on first encounter and so every menu item shows its keybinding.

Branding the chord. The QUILL key chord is presented to the user as QUILL Key + <key> everywhere the editor exposes it: menus, the About > Keyboard Reference page, the QUILL Key Help dialog, the cheat sheet, and the status-bar / announce messages that fire while a chord prefix is pending. The stored binding (Ctrl+Shift+Grave in DEFAULT_KEYMAP, keymap.json, the quill_key_binding setting, the legacy_rebindings comparison table, and any saved keymap/profile_*.json) is unchanged — only the display layer rewrites the prefix. quill.core.keymap_format.format_binding_for_display is the single source of truth; quill.branding.QUILL_KEY_LABEL = "QUILL Key" is the single constant. Test coverage: 18 cases in tests/unit/core/test_keymap_format.py. A second helper, format_quill_key_chord(prefix, second_key), composes a chord without inspecting a stored binding string so power-user status bar code can mention a chord without one in hand.

Binding/label gap detection. quill.tools._check_binding_label_consistency is the 4th menu_lint invariant. The check walks the AST of quill/ui/main_frame_menu.py and flags three regression classes:

  1. _menu_label("", "command.with.binding") — empty title literal routed through the builder for a command that has a keybinding. This is the regression that motivated the gate: a blank title + a bound command produced a menu slot with no readable name.
  2. <name>\t<binding> literal drift — hand-written labels (the wx stock items and the editor’s own Close &Other Documents\tCtrl+Shift+F4, Help on This &Control\tF1, &What Can I Do Here?\tShift+F1, Open User &Guide\tCtrl+F1) whose binding portion disagrees with the DEFAULT_KEYMAP entry or the wx stock binding.
  3. Trailing-tab labels — labels that end with \t and no binding portion. The user would see a menu name with a trailing tab and no accelerator.

The runtime gap-check in MainFrame._menu_label (a one-shot logger.warning per affected item at first menu build) is the safety net for user-customization drift — a user who renames a label through the Customize dialog still gets a custom label; only the silent “blank menu slot” case is reported.

The check is wired into python -m quill.tools.menu_lint and exposed via 12 new test cases in tests/unit/tools/test_binding_label_consistency.py. The gate is part of CI; a regression anywhere in the binding/label chain now fails the build.

Single source of truth for product name. tools/generate_build_info.py, scripts/generate_update_feed.py, and scripts/build_windows_distribution.py import APP_DISPLAY_NAME and APP_ORGANIZATION from quill.branding so a rebrand touches one file. The TOML path still wins when build/version.toml provides a value (the installer and feed can be re-branded per release); the constant is the safety net for older checkouts and dev builds.

8.15 The Page status bar indicator (0.9.0 Beta 2, #872)

Every document shows a Page status bar cell, on by default (unlike most cells, which are opt-in), positioned right after the line/column position cell rather than first. For PDFs, it reports an exact page count and current page, derived from page boundaries preserved as form-feed characters at import (quill/io/pdf.py), reusing quill/core/navigation.py’s previously-dormant page_starts()/page_start_for_number(). For every other format (plain text, Markdown, DOCX), it reports an estimate derived from word count (page_estimate_words_per_page, default 300, clamped 150-600, Preferences > Navigation and QUILL Key) — this is explicitly not an exact science, and the cell’s text always says so: "Page ~N of ~M (estimated)". The tilde and the word “estimated” always appear together, never one without the other, so an estimate is never mistaken for a fact.

BRF/braille documents keep their own richer page system (the "braille" status cell); the generic "page" cell is suppressed whenever that one is active, so the two never compete for the same space.

Go To Page (Ctrl+Shift+G, also reachable by activating the Page cell) is track-aware the same way: an exact jump for form-feed-bearing documents via the existing page_start_for_number, an estimated jump (word-count-derived, via estimate_page_start_for_number) otherwise, with the prompt text stating which kind of jump is about to happen.

DOCX real page breaks are a known gap, not an oversight: DOCX import goes through Pandoc/MarkItDown text conversion (quill/io/structured.py:_read_docx), which does not preserve page-break positions, so DOCX stays on the estimate track pending a follow-up.

8.16 Speech Hub: Offline/Online split and Set as Default (0.9.0 Beta 2, #847)

The Speech Settings dialog splits into four tabs instead of two (quill/ui/speech_hub_dialog.py): Speech (Offline), Speech (Online), Dictation (Offline), Dictation (Online)TAB_SPEECH_OFFLINE/TAB_SPEECH_ONLINE/TAB_DICTATION_OFFLINE/TAB_DICTATION_ONLINE. Before this, one flat “Dictation” tab mixed local engines (whisper.cpp, Faster Whisper, Vosk — install once, no ongoing cost) with any registered cloud provider (a Quillin-based transcription service — an API key, a per-use network cost) in a single radio list (build_engine_descriptors); a “Read Aloud” tab did the same for SAPI5/DECtalk/Piper/Kokoro/ eSpeak alongside ElevenLabs. Local and cloud are different enough resource models that mixing them read as confusing. Both VoiceBrowserDialog (Speech) and SpeechSetupDialog (Dictation) already took their engine/provider list as a caller-supplied parameter, so the split is achieved by constructing two instances of each with a filtered list rather than rewriting either dialog: VoiceBrowserDialog’s engine_options is partitioned into the five offline engines and the one (elevenlabs) online engine at the call site (MainFrame.open_speech_hub); SpeechSetupDialog gained an engine_scope: "all" | "offline" | "online" parameter that filters build_engine_descriptors()’s output. When no cloud dictation provider is registered — the common case, since those arrive only via a transcription Quillin — Dictation (Online) shows a plain explanatory message instead of attempting to build a dialog around an empty engine list (a wx.RadioBox cannot hold zero choices).

“Set as Default” is now explicit and reachable everywhere, not just an implicit side effect of closing the dialog. Read Aloud already applied the OK button’s current engine+voice selection as the default (VoiceBrowserResult(action="select", ...)); it now also has a dedicated Set as Default button and a right-click context menu on the voice list (_do_set_default/_show_voice_context_menu), so the choice can be made without closing the dialog. Dictation previously had no equivalent for the model (only the engine, via the engine radio switching settings.speech_provider as a side effect of any dispatched action) — a new speech_default_model_id setting, a Set as Default button, and a matching context menu (_on_set_default/_show_model_context_menu in SpeechSetupDialog) close that gap. SpeechCommandsMixin._default_model_id (the one function every dictation/transcription/ captions call site funnels through to pick a model) now prefers speech_default_model_id when it names a model that is actually installed, falling back to the catalog’s recommended model otherwise. The guided offline-speech picker (§ above) also writes both speech_provider and speech_default_model_id automatically after a successful install, so downloading an engine + model there is itself sufficient to make it the default — the explicit buttons are for changing that choice later without re-running the picker.

Fixed in the same pass: settings.speech_provider’s valid-value set had never included "vosk", silently resetting it to "" on load — harmless before Vosk was reachable from the guided picker, a real bug once it was.

8.17 Status bar: the Message cell suppresses exact duplicates (0.9.0 Beta 2)

The generic Message cell (_status_message, e.g. “Ready”, “Saved”) is the one status bar cell shown by default regardless of status_bar_order/status_bar_hidden_statusbar_items() (quill/ui/main_frame_statusbar.py) force-inserts it, and falls back to it alone when every other cell is hidden. A user report surfaced a real gap in that always-on guarantee: Message can end up showing the exact same text as another currently-visible cell (observed with the Page cell), which reads as a confusing double-announcement rather than useful information. After the rest of _statusbar_items() resolves the visible list, it now computes Message’s own text (_statusbar_text_for_item("message")) and compares it against every other visible cell’s text; an exact match drops Message from the list for that refresh. any() short-circuits on the first match, so the (rare) no-match case is the only one that pays for computing every other visible cell’s text — and several cells already re-derive from the live document on every refresh (line/ column, page, word-count-family), so this is not a new class of cost, just one more consumer of it. The rule is general (any cell pair), not hard-coded to Page specifically.


9. Accessibility, WCAG 2.2 AA conformance, and certification

Accessibility is not a feature set; it is the product’s contract with its users. This section is the canonical reference for what “accessible” means for Quill, how it is engineered, how it is tested, how regressions are caught, and how a Voluntary Product Accessibility Template (VPAT) Accessibility Conformance Report (ACR) is generated for procurement.

9.1 Compliance posture and target standards

Quill targets the following standards in full at v1.0 release:

Screen-reader coverage across platforms. On Windows, Quill targets NVDA (primary), JAWS, and Narrator; on macOS it targets VoiceOver. Quill routes its own announcements to the active platform screen reader and never speaks over it (on macOS it defers to VoiceOver rather than falling back to a separate speech engine). Embedded web content – the Ask Quill chat, the Markdown/HTML preview, the About box, and the update/consent dialogs – is driven through semantic HTML/ARIA so it reads as a real web document under NVDA, JAWS, and VoiceOver alike (see the accessible-WebView approach in section 10).

9.2 Conformance posture statement (will appear in the ACR)

Quill 1.0 supports WCAG 2.2 Level A and Level AA across all functionality intended for end users. It partially supports Level AAA criteria 1.4.6 (Contrast Enhanced) and 2.4.10 (Section Headings). Quill does not include audio-only or video-only media; criteria specific to such media are not applicable.

9.3 WCAG 2.2 AA conformance matrix

The following matrix lists every Level A and AA Success Criterion, how Quill meets it, and where it is tested. Each row is a contract: a CI test exists for every “Tested by” entry, and a regression fails the build.

Principle 1: Perceivable

SC Title Level How Quill meets it Tested by
1.1.1 Non-text Content A Every icon-bearing button has a wx.Window.SetName/SetHelpText pair; the Accessibility Auditor (5.20) requires alt text on every image in user documents UI a11y harness; auditor unit tests
1.2.x Time-based Media A/AA Not applicable; Quill ships no audio/video n/a
1.3.1 Info and Relationships A Every label is associated with its control via parent-sizer convention plus explicit SetLabel; menu structure is exposed as MSAA tree; status-bar cells expose name+role+value+description UI a11y harness verifies MSAA tree; snapshot tests
1.3.2 Meaningful Sequence A Tab order is explicit and deterministic per dialog; tested with pywinauto walking each dialog Tab-order snapshot tests
1.3.3 Sensory Characteristics A No instruction uses shape, position, sound, or colour alone; all hints are text in tooltips and Where Am I Manual checklist; copy review
1.3.4 Orientation AA Window orientation is user-controlled; no orientation lock n/a (desktop)
1.3.5 Identify Input Purpose AA Settings fields use semantic name attributes mappable to autofill purposes where the OS supports them Settings inventory
1.4.1 Use of Colour A Modified state is text ([modified]), not colour; issue severity uses prefix words (Error:, Warning:); diff hunks use +/- prefixes Theme tests; copy review
1.4.2 Audio Control A Read Aloud has explicit start/pause/stop and a status cell; can be silenced at any time without affecting the SR Manual + UI test
1.4.3 Contrast (Minimum) AA All bundled themes meet 4.5:1 for normal text, 3:1 for large text and UI components; verified by an automated contrast checker run against every theme Contrast CI job
1.4.4 Resize Text AA Editor font 10–48pt via Ctrl++/Ctrl+-/Ctrl+0; dialog text scales with Windows DPI (100–400%) without loss of function or clipping DPI smoke tests at 100/125/150/175/200/300/400
1.4.5 Images of Text AA Quill never renders text as an image; all UI text is real text in stock controls n/a
1.4.10 Reflow AA Window contents reflow to 320 CSS px equivalent (320 device-independent pixels); status-bar cells overflow into a roll-up; menu falls back to compact mode Reflow tests at minimum window size
1.4.11 Non-text Contrast AA All UI components and graphical objects (focus rings, status-bar separators, toggle states) meet 3:1 against adjacent colours Contrast CI job covers component states
1.4.12 Text Spacing AA Editor honours user spacing overrides (line height up to 1.5x, paragraph spacing up to 2x font size, letter spacing up to 0.12em, word spacing up to 0.16em) without truncation or overlap Spacing test fixture
1.4.13 Content on Hover or Focus AA Tooltips are dismissible (Esc), hoverable, and persist until input moves away Hover tests

Principle 2: Operable

SC Title Level How Quill meets it Tested by
2.1.1 Keyboard A Every action is reachable from the keyboard; drag-and-drop is explicitly omitted from required flows Keyboard parity matrix test
2.1.2 No Keyboard Trap A Every dialog has an Escape route; F6 cycles regions; modal dialogs have an explicit Cancel Trap detection in pywinauto suite
2.1.4 Character Key Shortcuts A Single-character shortcuts (like F3) are bound only to commands that re-fire safely; users can rebind all of them; no single-letter shortcuts in editor focus Keymap audit
2.2.1 Timing Adjustable A The only time-bound interaction is read-aloud, which the user controls; no idle timeouts in v1.0 Manual review
2.2.2 Pause, Stop, Hide A Read Aloud has Pause/Stop; the only moving thing in the UI is a progress indicator that the user can cancel Manual review
2.3.1 Three Flashes or Below Threshold A Quill has no flashing UI n/a
2.4.1 Bypass Blocks A F6 region cycling, command palette, outline navigator Navigation tests
2.4.2 Page Titled A Window title always identifies the active document and Quill Title test
2.4.3 Focus Order A Focus order matches reading order; no surprise focus jumps; recent locations history surfaces movement Tab-order snapshot tests
2.4.4 Link Purpose (In Context) A Insert Link dialog enforces meaningful display text; auditor flags generic link text Auditor tests
2.4.5 Multiple Ways AA Outline Navigator, command palette @/#/</~, Find, menu Navigate submenu Navigation tests
2.4.6 Headings and Labels AA Settings tree and dialog sections use descriptive headings; auditor requires the same in user docs Copy review; auditor tests
2.4.7 Focus Visible AA Native Windows focus indicators are preserved (no custom drawn focus); High Contrast theme amplifies the system focus ring Focus visibility audit
2.4.11 Focus Not Obscured (Minimum) AA (new in 2.2) Quill does not float overlays that obscure focus; the palette and modal dialogs receive focus and are larger than the focused control Focus-not-obscured check
2.4.12 Focus Not Obscured (Enhanced) AAA Same as 2.4.11; Quill conforms by construction (auto)
2.4.13 Focus Appearance AAA Native Windows focus ring is honoured; High Contrast theme exceeds the 2px solid / 3:1 contrast target by relying on the OS focus rectangle Manual verification
2.5.1 Pointer Gestures A No multi-point or path-based gestures required n/a
2.5.2 Pointer Cancellation A Buttons activate on up-event; cancel by moving off before release Native button behaviour
2.5.3 Label in Name A Accessible name begins with visible label text for every control UI a11y harness
2.5.4 Motion Actuation A No motion-actuated functions n/a
2.5.7 Dragging Movements AA (new in 2.2) No required drag; every drag-equivalent action has a keyboard alternative Keyboard parity matrix
2.5.8 Target Size (Minimum) AA (new in 2.2) All clickable targets at least 24x24 CSS px including status-bar cells; verified per-theme Layout tests

Principle 3: Understandable

SC Title Level How Quill meets it Tested by
3.1.1 Language of Page A The application’s UI language is declared via wx.Locale and exposed to MSAA Locale tests
3.1.2 Language of Parts AA Per-document language metadata (5.38) and the spell-check language stack indicator (5.1b) expose document language to user and AT Metadata tests
3.2.1 On Focus A Focus changes never trigger unexpected context changes Focus tests
3.2.2 On Input A Form inputs require explicit confirmation; no auto-submit on change Form tests
3.2.3 Consistent Navigation AA Menu structure, palette prefixes, and status-bar layout are identical across every document and dialog Snapshot tests
3.2.4 Consistent Identification AA The same icon and label is used for the same action everywhere Inventory test
3.2.6 Consistent Help A (new in 2.2) Help menu position and Help item names are identical across every window Snapshot tests
3.3.1 Error Identification A Every error message names the field and the rule violated; never just a code Error message inventory
3.3.2 Labels or Instructions A Every input has a visible label; complex fields include placeholder hints that are also exposed as MSAA description Label inventory
3.3.3 Error Suggestion AA Field errors include the value the user typed and a concrete fix suggestion Error message inventory
3.3.4 Error Prevention (Legal, Financial, Data) AA Destructive operations (Replace All > 25 occurrences, Restore Backup, Reset Settings) always show a confirmation; backups exist for restores Confirmation matrix test
3.3.7 Redundant Entry A (new in 2.2) Previously entered values (search history, recent files, settings) are available without re-entry Persistence tests
3.3.8 Accessible Authentication (Minimum) AA (new in 2.2) The only authentication is the AI provider API key, which is paste-and-store via DPAPI; no cognitive-test puzzle n/a

Principle 4: Robust

SC Title Level How Quill meets it Tested by
4.1.1 Parsing A (obsolete in 2.2) (Marked obsolete; included for completeness.) n/a
4.1.2 Name, Role, Value A Every control exposes name, role, value, state, and where applicable description via MSAA and UIA. Custom status-bar cells implement this explicitly via wx.Accessible subclasses UI a11y harness (Inspect.exe parity)
4.1.3 Status Messages AA All asynchronous announcements (saved, replaced N, found on line N, AI progress) are sent via the live-region pattern and through the SR-agnostic announcement channel (9.6) Live region tests

9.4 Screen-reader compatibility matrix

Reader Versions covered Quill support level Notes
NVDA 2024.x, 2025.x, 2026.x stable + current beta First-class (reference platform) All flows fully announced; no required scripts
JAWS 2024, 2025, 2026 latest First-class Small optional script provides live-region parity for non-standard status-bar cell updates; ships in the installer
Narrator Windows 11 24H2 and later First-class Uses UIA path; covered by the standard MSAA wiring
Windows Magnifier Current Supported Focus following works because of stock controls
ZoomText / Fusion Current Supported Tested but not officially certified at v1.0
Dolphin SuperNova Current Best effort Tested at major releases; defects escalated

9.5 Screen-reader announcement architecture

Quill speaks to the user through three independent channels, all of which deliver the same content. This is what “never goes quiet when you needed it to speak” means in code.

  1. Stock control announcements — wx widgets carry their own MSAA/UIA semantics; the SR speaks them automatically on focus and value change.
  2. Live region channel — a hidden wx.StaticText per top-level window acts as an ARIA-like live region. Quill sets its text, fires an MSAA EVENT_OBJECT_LIVEREGIONCHANGED and a UIA Notification event with kind Other and priority Standard or High as appropriate. NVDA and Narrator pick this up natively; JAWS reads it through the bundled optional script.
  3. Direct SR APIs (graceful degradation) — when a detected SR is running, Quill may use its direct API for higher-priority announcements (NVDA’s nvdaControllerClient.dll nvdaController_speakText, JAWS’ jfwapi.dll JFWSayString, Narrator via UIA notification). This is used only when the live region path is too slow for time-sensitive feedback (find result during F3 chaining, spell-check next misspelling). The direct call is preferred only when (a) a registered SR is detected and (b) the user has not opted out in Settings → Reading.

All three channels are funnelled through a single quill.a11y.announce(text, *, priority='normal', interruptible=True) function, so there is exactly one code path to test.

Quill exposes an explicit announcement backend selector in Settings and command surfaces. Supported modes are auto, legacy, prism, and sounds, with auto as default. auto chooses the best backend for the current screen-reader/runtime context and falls back deterministically when a backend is unavailable.

9.5.1 Announcement backend provenance

The prism backend in Quill is derived from the architecture and behavior patterns published in the open-source Prism project:

9.6 Keyboard accessibility gates

9.7 Visual accessibility gates

9.8 Cognitive accessibility gates

9.9 Accessibility testing pipeline

Three layers run on every PR and every release branch.

  1. Static / lint layer (every PR)
  2. Headless UI layer (every PR, runs on a Windows runner)
  3. SR-in-the-loop layer (every release branch and nightly)

No PR merges with any of these failing. A failure can be waived only by the accessibility lead, with an issue link and an expiry date.

9.10 VPAT / ACR generation

9.11 Certification path

9.12 Gated regressions (CI must-pass list)

A build is blocked when any of the following regresses:

9.13 Dialog estate governance (DLG-3)

Every dialog in Quill is the single highest-risk accessibility surface in the UI: controls that do not work, focus landing on the wrong control, and rendering breakage all live in dialogs and are otherwise invisible to a source-only review. DLG-3 is the flagship structural-health refactor that makes the dialog estate correct by construction, source-of-truth, and machine-gated. It folds in and supersedes the earlier batch ambitions (the form-conversion wave and the individual AI-tool conversions).

Surface policy (the non-negotiable standard). Every dialog surface is exactly one of three sanctioned classifications, and no other dialog framework or bespoke modal surface may be introduced:

  1. native — stock wx one-shot dialogs (wx.MessageDialog, wx.RichMessageDialog, wx.MessageBox, wx.SingleChoiceDialog, wx.MultiChoiceDialog, wx.TextEntryDialog, wx.FileDialog, wx.DirDialog, wx.FindReplaceDialog, wx.ProgressDialog, About). Native-first is the default for confirms, choices, simple text input, and file/folder selection.
  2. web — sanctioned accessible web surfaces (show_web_form, the markdown/HTML preview dialog, the accessible chat view) used only where rich rendering, chat interaction, or dynamic multi-field forms are genuinely required, always with a native fallback.
  3. hardened_custom — a wx.Dialog container composed only of stock native controls (wx.ListBox, wx.TextCtrl, wx.SearchCtrl, wx.CheckListBox, wx.Notebook, wx.Button, …). These are “enhanced-native”: real OS widgets in a dialog frame. No custom-drawn or owner-drawn controls are permitted in any dialog.

Source-of-truth inventory. The authoritative dialog inventory is generated from source, not from a checklist. quill/tools/dialog_inventory.py AST-scans all of quill/**/*.py and records every dialog surface under a stable, line-independent key (<module>::<enclosing_qualname>::<kind>) with its sanctioned classification. The committed snapshot is tests/unit/ui/fixtures/dialog_inventory.json, and two gates enforce it: tests/unit/ui/test_dialog_inventory.py fails on any new, moved, removed, or reclassified dialog or unsanctioned surface; and a registry cross-check inside the A11Y-4 banned-pattern gate fails the build on any unregistered or misclassified dialog. Adding a dialog forces a deliberate python -m quill.tools.dialog_inventory --write whose classification is reviewed in the diff. The manual companion checklist dialogs.md maps each shipped dialog to its keyboard command or menu path and is kept in sync, but it is not the inventory authority.

Per-dialog accessibility interaction contract. Every dialog must satisfy all of: deterministic initial focus on open; complete Tab / Shift+Tab traversal; an explicit default action (Enter); Escape/close always exits; preserved modal-result semantics; focus return to the initiating editor/control on close; the screen reader announces the title and every actionable control; and no trap, freeze, or silent state. Modal show is routed through the shared dialog_contract helpers (apply_modal_ids, show_modal_dialog), and raw wx.Dialog(...) always has a Destroy() (or with form) lifecycle and uses wx.EXPAND button sizers, never wx.ALIGN_RIGHT.

Control-surface completeness. For every dialog, every control surface is audited and explicitly dispositioned as keep (already excellent, evidence linked), harden (focus/tab/label/default/announcement improvements), or replace (native or sanctioned-web equivalent). No unlabeled, untabbable, unannounced, or ambiguous control remains.

“Every dialog touched” definition. A dialog counts as touched only when it has all three of: (1) a classification in the registry (native, web, or hardened_custom); (2) explicit conformance evidence (a focused source-contract test or behavior test); and (3) a confirmed dialogs.md mapping, updated if its naming, binding, or path changed. The authoritative inventory is generated from source — including non-quill/ui surfaces such as the startup storage chooser in quill/__main__.py and the nested describe-image source/file pickers in quill/ui/main_frame_image.py — so checklist-only review can never miss a surface. The exit criterion is 100% source coverage, not a fixed legacy count; the count is expected to rise as source-discovered dialogs are folded in.

High-risk clusters (sequenced first). The concentration of lifecycle/focus risk is known and drives wave order: (1) main_frame.py modal sprawl (largest concentration); (2) assistant_tools.py (high interaction complexity, async actions, many custom dialog classes); (3) the startup/onboarding chain (run_startup_wizard, first-run prompts, trust consent, web-preview handoff); (4) sticky notes (historical focus-landing issues); and (5) mixed rendering surfaces (native + web + fallback) where behavior parity must be explicitly pinned. Already in place and relied upon: the shared dialog_contract.py modal helpers, the banned-pattern gate’s existing wx.ALIGN_RIGHT and raw-wx.Dialog destroy-path checks, and the existing behavior tests for preview, web-form, and onboarding navigation.

Execution waves. DLG-3 proceeds as: Phase 0 — authoritative source-generated registry + gates (delivered); Phase 1 — strengthened A11Y-4 static guard (delivered); Phase 2 — native conversion wave for hand-rolled dialogs that are really a single confirm / choice / text prompt, replaced with the stock one-shot equivalent, preserving all user-facing wording and outcomes; Phase 3 — enhanced-native standardization wave converging the genuinely multi-control dialogs onto one focus/default/lifecycle grammar via the shared contract (never flattened into one-shots where that would lose live search, lists, or streaming); Phase 4 — web-surface standardization only where rich rendering or dynamic forms are justified, with native-fallback parity and no raw HTML dumped into document tabs in onboarding/welcome paths; Phase 5 — startup/onboarding hardening (wizard, first-run, trust consent, crash recovery) for deterministic focus across chained modals, preserving the explicit-consent requirements and retiring the screen-reader startup-crash path; Phase 6 — assistant/AI tool dialog consolidation (assistant_tools.py, ai_model_panel.py, train_style_dialog.py, assistant_panel.py) onto the same modal/focus/error contract with safe async/“busy” semantics; Phase 7 — CQ-16 characterization expansion around dialog-launch command paths (return values and side effects) before any CQ-1 decomposition; Phase 8 — manual NVDA baseline, JAWS spot (startup, assistant, sticky notes, watch profiles), and Narrator sanity passes across dialogs.md, each row carrying pass/fail evidence.

Dialog-by-dialog coverage map (no section exempt). Every checklist family in dialogs.md is in scope with a default disposition: file/session dialogs (native-flow normalization + modal/focus hardening); settings/customization/dialog-launch surfaces (enhanced-native consistency — menu editor, settings, command palette); navigation dialogs (keep stock/input surfaces, harden bookmark/list/tree flows); text-analysis dialogs (normalize spell/lookup/thesaurus list workflows); accessibility-tools dialogs (focus/read-order consistency in results dialogs); intake/report dialogs (standardized preview/report modals); read-aloud/OCR dialogs (keep the OCR review contract, harden nested chooser flows); sticky-notes dialogs (retain web-form where justified, harden vault/list/editor transitions); external-tools/format dialogs (enhanced-native standard patterns); compare dialogs (consistent list/option/preview focus); keymap dialogs (stock controls + predictable nested edit flow); appearance/backup/import dialogs (import/export previews and file-picker transitions hardened); watch-folder dialogs (nested editor/browse/preview paths hardened); notifications dialog (standardized close/default semantics); formatting dialogs (preserve show_web_form for Insert Link, harden list/YAML nested flows); macros dialogs (text-entry + management standardization); AI/assistant dialogs (the DLG-2 conversions folded in); BITS speech dialogs (provider/model/status contract hardening); feature/profile dialogs (profile-switch, health, and management consistency); help/startup/support dialogs (wizard/about/diagnostics/report-bug rendering and focus safety); selection-action dialogs (action-chooser semantics hardened); nested/secondary dialogs (explicit coverage for each path launched from a parent); power-tools dialogs (stock prompt/confirm consistency); and startup-only dialogs (crash recovery + untrusted-location remain top-priority hardened native flows).

Conversion decisions (definitive). Keep native: simple confirms and binary prompts, and stable stock file/folder/select/text prompts. Keep sanctioned web: markdown/HTML preview and rich rendered content, explicit multi-field forms where show_web_form is already stable and fallback-backed, and chat-centric surfaces with native fallback. Harden hardened_custom (not a web rewrite by default): complex list/CRUD/picker dialogs where stock controls are appropriate and lower-risk than a web migration, and interaction-heavy assistant panels that are not rich-rendering-centric. No untouched custom paths: any retained custom dialog must carry a written rationale plus a contract test.

Operational mandates (enforcement, not intent). Machine-enforced checks that must pass before merge: the expanded A11Y-4 banned-pattern gate with zero violations; the dialog-registry completeness check (no orphan dialogs); the source-contract tests for touched dialogs; the characterization checks for dialog-launch command paths; and the project-wide source-scan check (no dialog constructor or call-site outside the inventory). “Manual QA later” is never a substitute for these gates. Manual accessibility verification is release-blocking per touched family: a keyboard-only pass (open, traverse, act, cancel, close, focus return), a screen-reader pass (NVDA baseline; JAWS/Narrator spot by risk), a nested-flow pass (child dialogs launched from parents), and a fallback-parity pass (web-backed dialogs validated in native fallback). Every dialog change records its impacted dialog id(s), the control surfaces touched, the classification decision, the tests added/updated, and the manual verification result; missing evidence means the change is incomplete.

Anti-regression controls. No mixed ad-hoc modal patterns may be introduced during the refactor; no dialog merges without a classification and test evidence; and no dialogs.md drift is allowed in a PR. The source-of-truth rule is absolute: before closing any dialog work item, run the inventory scanner across the entire quill package, and if scanner output and the committed registry disagree, the work is incomplete regardless of checklist status.

Definition of done. DLG-3 is done only when: 100% of source-scanned dialogs are touched and classified (not just a legacy checklist count); simple confirm/message/choice/text flows are native one-shots; rich/input flows use only sanctioned web surfaces with fallback parity; every control in every dialog is inventoried and dispositioned (keep/harden/replace); any retained hardened_custom dialog has an explicit rationale and at least one focused source-contract or behavior test; the strengthened A11Y-4 guard blocks contract regressions; the startup wizard and first-run chains are stable and non-crashing with a screen reader active on Windows and macOS; and dialogs.md is synchronized with the final command paths and nested flows.


10. Technical infrastructure

This section is the canonical engineering blueprint for Quill. It defines the module structure, every dependency (pinned, licensed, justified), the per-feature technical specifications, the threading and concurrency model, the data layout, the build and packaging pipeline, supply-chain integrity, observability, the plugin runtime, the update mechanism, the security architecture, the development workflow, and the coding standards.

10.1 Module map and responsibilities

quill/
  core/                       Pure-Python; no UI imports
    document.py               Document model: text buffer, line index, encoding, line endings, dirty state
    history.py                Undo/redo coalescing; persistent-undo serialisation
    bookmarks.py              Bookmark store with text re-anchoring
    locations.py              Recent locations ring + Mark ring
    backups.py                Backup manager, autosave, crash recovery
    settings.py               Settings load/save, schema validation, partitioned reset
    keymap.py                 Three-layer keymap (default · profile · user), chord engine, conflict detection
    commands.py               Command registry (id, title, description, when, default key, handler ref)
    palette.py                Palette scoring, prefix modes, recency/frequency
    sessions.py               Session save/restore, welcome-back snapshot
    fingerprint.py            SHA-256, atomic write helpers
    paths.py                  AppData layout helpers; sandbox roots
    events.py                 Internal event bus (sync; cross-thread queue)
    a11y/
      announce.py             Three-channel announcement funnel
      regions.py              Live region helpers
      transcript.py           In-process transcript capture (test-only)
    spell/
      engine.py               Public API: check_word, suggest, learn
      tokeniser.py            Unicode + code-aware tokeniser
      dictionary_stack.py     Priority stack (per-doc, per-project, personal, language, jargon)
      hunspell_backend.py     cyhunspell wrapper, thread-safe
      reranker.py             v1.1 experimental contextual reranker (interface only in v1.0)
      ignore.py               In-document ignore directives parser
  io/                         Format readers and writers; each module exports `read(path)`/`write(doc, path)`
    txt.py md.py html.py rst.py adoc.py org.py tex.py typst.py
    docx.py pptx.py xlsx.py odt.py odp.py ods.py rtf.py epub.py
    pdf.py                    Tier-1 pipeline + Tesseract fall-back
    subtitles.py              srt vtt sbv ass ttml scc stl cap
    code/                     One module per language family for tokenisation hints
    config/                   json yaml toml ini xml csv tsv
    email.py ics.py vcf.py rss.py atom.py opml.py jsonfeed.py
    daisy.py braille.py       BRF, BRL reading; back-translation deferred
    notebook.py               ipynb linearisation
    sqlite_view.py            Read-only schema + sample rows
    ocr.py                    Tesseract bridge
    detect.py                 Format auto-detect by magic + extension + sniff
    remote_transport.py       ABC + error hierarchy + chunked_copy + merge_headers
    http_transport.py         HTTPS download (verified TLS, _MAX_BYTES cap, progress)
    ftp_transport.py          FTP/FTPS open and save
    sftp_transport.py         SFTP open and save (paramiko, trust-first-use honoured)
    webdav_transport.py       WebDAV open and save (depth-1 PROPFIND, safe XML)
    s3_transport.py           Amazon S3 (and any S3-compatible) open and save
    s3_sigv4.py               Manual AWS SigV4 signer (boto3 fallback)
  ai/
    base.py                   Provider interface
    openai.py azure.py anthropic.py ollama.py
    pipeline.py               PDF reading-order pipeline, document reading-order pipeline
    safety.py                 Per-action consent, size caps, redaction
  ui/                         wxPython shell
    app.py                    wx.App, single-instance, theme loader
    frame.py                  Main wx.Frame; F6 region cycler
    editor.py                 wx.TextCtrl host + line/column tracker + selection helpers
    statusbar.py              Custom-layout wx.StatusBar with focusable cells
    menubar.py                Menu construction; live keybinding labels
    palette.py                Command palette frame
    keymap_editor.py          Settings page + inline rebinding capture dialog
    outline.py                Outline Navigator (wx.TreeCtrl)
    audit.py                  Accessibility Audit panel
    table_mode.py             Markdown/HTML table navigation overlay
    spell_panel.py            Spell-check dialog + quick-fix popup
    settings.py               wx.Treebook settings root
    dialogs/                  Find, Replace, FindAll, GoToLine, Bookmarks, Backups, OpenURL, UnicodeInsert, etc.
    accessible/               wx.Accessible subclasses for custom regions
    themes/                   Bundled theme token maps
    locale/                   gettext .po / .mo bundles
  platform/
    windows/
      shell.py                File associations, Jump List, ICustomDestinationList
      dpapi.py                Windows DPAPI key storage
      sr_detect.py            Active screen-reader detection
      sr_announce.py          NVDA / JAWS / Narrator direct-API bridges
      single_instance.py      Named-mutex + IPC port
      high_contrast.py        Windows High Contrast token translation
      tts.py                  SAPI 5 + OneCore enumeration for Read Aloud
  plugins/                    v1.1 plugin loader, manifest, sandbox; v1.0 ships the loader skeleton only
    api.py                    Stable interface re-exports
    manifest.py               JSON schema validation
  tools/                      Internal CLI tools (not shipped to users)
    gen_keymap_reference.py   Builds the Keyboard Reference markdown
    gen_vpat.py               Builds the VPAT/ACR
    a11y_audit.py             Local accessibility audit runner
  tests/
    unit/  integration/  a11y/  perf/  fixtures/

10.2 Complete dependency manifest

Every direct dependency is listed below with the pinned version range Quill 1.0 will ship against, the license, the reason it is in the tree, and any constraints. Pins are tight in production builds and loose in development.

10.2.1 Runtime, Python

Package Version License Role Notes
python 3.12.x PSF Runtime Frozen with PyInstaller; 3.13 supported as soon as wxPython publishes wheels
wxPython 4.2.2+ wxWindows Library Licence UI toolkit Stock controls only; no wx.lib.agw controls in the writing path
pywin32 308+ PSF-style Windows shell, DPAPI, COM Required for file associations, Jump List, DPAPI
comtypes 1.4+ MIT UIA notifications, SAPI 5 Used by quill.platform.windows.sr_announce and tts
wxasync 0.49+ MIT asyncio + wx integration Runs AI HTTP calls and background OCR without blocking UI
httpx 0.27+ BSD-3 HTTP for AI providers and Open from URL Async; respects per-action consent
charset-normalizer 3.3+ MIT Encoding auto-detect Used by io.detect and editor encoding picker
python-docx 1.1+ MIT DOCX read/write Round-trips heading styles for export
mammoth 1.7+ BSD DOCX → HTML/Markdown extraction Used as fall-back when python-docx output is too raw
python-pptx 1.0+ MIT PPTX read Slides + speaker notes
openpyxl 3.1+ MIT XLSX read (as table) Sheets become sections
odfpy 1.4+ LGPL-2.1+ ODT/ODP/ODS read LGPL is acceptable as a dynamically loaded library
ebooklib 0.18+ AGPL-3 with linking exception EPUB read Only used as library; no AGPL linkage concerns
beautifulsoup4 4.12+ MIT HTML/XHTML/SVG parsing With lxml parser
lxml 5.2+ BSD-3 XML/HTML parsing backend
markdown-it-py 3.0+ MIT Markdown parsing & rendering With mdit-py-plugins for tables, footnotes, deflists
mdit-py-plugins 0.4+ MIT Markdown extensions
docutils 0.21+ Public Domain / BSD reStructuredText parsing & outline
pdfplumber 0.11+ MIT PDF text & layout, tier-1 path A
pypdfium2 4.30+ Apache-2.0 / BSD PDF text, tier-1 path B Native PDFium binary
pdfminer.six 20240706+ MIT PDF text fallback path C Used when A and B both score badly
pikepdf 9.0+ MPL-2.0 PDF metadata + password handling Wraps QPDF
Pillow 10.4+ HPND Image handling for OCR and SVG raster fall-back
cyhunspell 2.0+ MPL-2.0 Hunspell binding for spell check Hunspell dictionaries bundled per language
regex 2024.7+ Apache-2.0 / PSF Unicode regex for find/replace Replaces stdlib re where Unicode classes matter
rapidfuzz 3.9+ MIT Palette and command fuzzy matching
unicodedata2 15.1+ Apache-2.0 Up-to-date Unicode tables Powers Unicode Insert and tokeniser
pyyaml 6.0+ MIT YAML read/write for sidecars and front-matter safe_load only
tomli / tomllib stdlib for read; tomli-w 1.0+ for write MIT TOML
jsonschema 4.23+ MIT Settings, keymap, plugin manifest validation
platformdirs 4.2+ MIT AppData/log directory discovery
keyring 25+ MIT Optional secondary credential storage DPAPI is primary
cryptography 43+ Apache-2.0 / BSD DPAPI fallback + signed manifest verification
pygments 2.18+ BSD Syntax tokenisation for code-aware spell check and tokeniser Tokens only; no styling
chardet 5.2+ LGPL-2.1 Encoding detection secondary charset-normalizer is primary
liblouis (Python bindings) 3.31+ LGPL-2.1+ Braille back-translation — deferred to v1.1 Listed for reference
python-magic (or puremagic) 0.4.27+ MIT Magic-number sniffing for io.detect puremagic preferred (pure-Python)

10.2.2 Native binaries bundled

Binary Version License Use
Tesseract OCR 5.4+ Apache-2.0 OCR for image PDFs and image documents
Hunspell dictionaries (en-US, en-GB, es-ES, fr-FR, de-DE, pt-BR, pl-PL, ja-JP) latest varies (mostly LGPL / Apache / public domain) Spell-check dictionary stack
PDFium bundled with pypdfium2 Apache-2.0 / BSD PDF rendering and text
QPDF bundled with pikepdf Apache-2.0 / Clarified Artistic PDF transformations

LibreOffice, Calibre, Ghostscript, and unrar are not bundled; they are optional plugin dependencies (v1.1).

10.2.3 Build and dev tooling

Tool Version Role
uv 0.4+ Fast dependency resolution; lockfile
ruff 0.6+ Lint + format
mypy 1.11+ Static type-checking (strict in core, gradual in ui)
pytest + pytest-asyncio latest Test runner
pytest-wx / custom fixtures latest wxPython test fixtures
pywinauto 0.6.8+ UI automation tests
Accessibility Insights for Windows (CLI) latest A11y audit in CI
PyInstaller 6.10+ Frozen build
Inno Setup 6.3+ Installer authoring
signtool (Windows SDK) latest EV code signing
pip-audit and osv-scanner latest Supply-chain vulnerability scan
cibuildwheel latest Multi-Python wheel builds for any C-extension we author

10.2.4 License posture

10.3 Per-feature technical specifications

Each feature in section 5 has an engineering spec here. The full table is large; the canonical form lives in docs/QUILL-PRD.md once development begins. The table below is the v1.0 commitment.

Feature Module(s) Key APIs / Libraries Threading Storage A11y wiring
Editor surface (5.2) ui.editor wx.TextCtrl with wx.TE_MULTILINE \| wx.TE_RICH2 \| wx.TE_NOHIDESEL \| wx.TE_AUTO_URL UI thread In-memory document buffer Native stock control; MSAA edit role
Outline Navigator (5.16) ui.outline, io.* outline emitters wx.TreeCtrl, markdown-it-py, beautifulsoup4, docutils, python-docx, pdfplumber outline, ebooklib nav, pikepdf outlines Outline build on worker; wx.CallAfter to populate Per-document outline cache outline_cache/<hash>.json TreeCtrl exposes hierarchical structure to AT
Jump-by-structure (5.17) ui.editor, core.commands Same outline producers UI thread (cheap) n/a Announces via a11y.announce
Line/block ops (5.18) ui.editor, core.history wx editor primitives UI thread Undo via core.history Announcements per action
Statistics (5.19) core.document, core.metrics pure Python; pyphen optional for syllables (deferred) Worker for large docs n/a Dialog with one wx.StaticText per metric
A11y Auditor (5.20) core.audit, io.* audit emitters markdown-it-py AST, beautifulsoup4, python-docx, pikepdf Worker thread Per-doc ignore list in sidecar List dialog; severity prefix in text
Table Mode (5.21) ui.table_mode, io.md table tokens markdown-it-py tables, BS4 for HTML UI thread n/a Announces column header on entry
Editor essentials (5.22) ui.editor, core.document charset-normalizer, stdlib io I/O off-thread Per-doc prefs in file-prefs.json Encoding/EOL cells in status bar
Open from URL (5.27) ui.main_frame.open_url, io.http_transport urllib.request, quill.core.net.verified_ssl_context I/O off-thread; result lands in temp file Per-session temp file Confirms host and Content-Length before download; announces on completion
Remote Sites (5.27a) ui.remote_sites_dialog, core.remote_sites, io.remote_transport, io.{http,ftp,sftp,webdav,s3,s3_sigv4}_transport urllib (HTTPS, WebDAV, S3), stdlib ftplib (FTP/FTPS), paramiko (SFTP), defusedxml (S3 + WebDAV XML), quill.core.safe_xml, quill.core.net.verified_ssl_context I/O off-thread; UI marshals through wx.CallAfter Sites in %APPDATA%\Quill\remote-sites.json; passwords in Windows Credential Manager / DPAPI file / Keychain Native wx.ListCtrl directory pane; announces host and size; read-only by default
Menu bar (5.1a) ui.menubar, core.commands wx.MenuBar UI thread n/a Menu items show live keybindings; mnemonics
Status bar (5.1b) ui.statusbar, ui.accessible.statusbar_cell Custom wx.StatusBar layout with focusable buttons; wx.Accessible subclass UI thread with debounced events n/a Each cell exposes name/role/value/description via MSAA + UIA
Command palette (§7) ui.palette, core.palette wx.SearchCtrl + wx.ListBox; rapidfuzz Match on UI thread (≤30 ms) Recents in palette-recent.json Combobox-style; live region for status
Keymap (§8) core.keymap, ui.keymap_editor Custom chord engine; wx.AcceleratorTable per context UI thread keymap.json; .tmp+rename Capture dialog announces every keystroke
Spell check (§6) core.spell.* cyhunspell, custom tokeniser Background pass on worker; quick-fix on UI personal.dic, <doc>.quill.yml Dedicated panel + quick-fix popup
Find/Replace (5.7) ui.dialogs.find, core.document regex module Worker for very large docs search-history.json Announces structured one-liner per match
Bookmarks (5.10) core.bookmarks pure Python; SHA-1 of path UI thread bookmarks/<hash>.json List dialog; re-anchor on jump
Backups + autosave (5.13, 5.39) core.backups stdlib I/O thread; debounced backups/, autosave/, .tmp+rename Recovery prompt at launch
Read Aloud (5.25) platform.windows.tts, ui.read_aloud SAPI 5 via comtypes; OneCore via Windows.Media.SpeechSynthesis WinRT Background voice thread n/a Uses a secondary voice; never blocks SR
Open from URL (5.27) ui.dialogs.open_url, ai.safety httpx async asyncio via wxasync Temp file Consent dialog announces host + size
AI reading-order (5.5, 5.4 tier 3) ai.pipeline, ai.openai/azure/anthropic/ollama, ai.safety httpx async; per-provider SDK shapes asyncio via wxasync Nothing stored Progress announced every 20 s
OCR (5.4) io.ocr, pytesseract Tesseract binary Worker process per page n/a Progress announced
Diff/Compare (5.22) core.diff stdlib difflib, regex Worker n/a Diff opens as a normal document
Notifications centre (5.35) core.events, ui.notifications Internal event bus UI thread In-memory Cell + dialog
Sessions (5.22, 5.49) core.sessions pure Python I/O thread sessions/last.json Prompt at launch
Templates + snippets (5.22) core.templates pure Python UI thread %APPDATA%\Quill\templates, snippets/ List dialogs
Atomic stores (5.55) core.fingerprint, core.paths stdlib os.replace All write paths All JSON stores Recovery announcements
Plugin loader skeleton (v1.0; full plugin runtime v1.1) plugins.api, plugins.manifest importlib.metadata, jsonschema UI thread on load; isolated thread per task %APPDATA%\Quill\plugins\ Manifest must declare UI surfaces

10.4 Threading and concurrency model

10.5 Data layout (canonical)

%APPDATA%\Quill\
  settings.json             Validated by jsonschema; .bak kept
  keymap.json               Validated; .bak kept; quarantine on corrupt
  recent.json               Last N files; default 10
  recent-locations.json     Per-session ring; ephemeral
  saved-searches.json       Saved search filters
  search-history.json       Last 100 search/replace terms; clearable
  file-prefs.json           Per-path encoding/EOL/wrap/indent/language memory
  sessions\
    last.json               Session restore data
    <name>.json             Named sessions
  bookmarks\
    <sha1-of-path>.json     Per-document bookmark store
  backups\
    <sha1-of-path>\
      <iso-timestamp>.bak   Save backups
  autosave\
    <session-id>\
      <doc-id>.snap         Crash recovery snapshots
      <doc-id>.snap.tmp     In-flight
  undo\
    <sha1-of-path>.undo     Persistent undo (opt-in)
  notes\
    <sha1-of-path>.md       Per-document scratchpad
  remote-sites.json         Saved FTP/SFTP/HTTPS/WebDAV/S3 sites
  credentials\              DPAPI-protected password store fallback
  outline_cache\
    <sha1-of-path>.json     Cached outline; invalidated on document mtime change
  dictionaries\
    personal.dic
    <lang>.aff / <lang>.dic
    jargon\<name>.dic
  spell-learning\
    learning.sqlite         Per-user learning (frequencies, rejection counts)
  templates\<name>.tpl
  snippets\<lang>.json
  plugins\<plugin>\
  logs\quill-YYYY-MM-DD.log Rotating logs, 14-day retention
  diagnostics\               Output dir for Save Diagnostics
  trusted-locations.json
  notifications.json         Pending notifications

All JSON files validate against schemas in quill/core/schemas/. All writes are atomic (tempfile.NamedTemporaryFile in the same directory + os.replace).

10.6 Packaging, signing, supply chain

10.7 Build and dev environment

10.8 Continuous integration and delivery

10.9 Observability

10.10 Plugin runtime (v1.0 surface, v1.1 full)

10.11 Security architecture

10.12 Update mechanism

10.13 Internationalisation infrastructure

10.14 Performance budgets and instrumentation

All performance targets in §11 are enforced by tests/perf/. Each test asserts against a budget; regressions over 15% fail the build. Microbenchmarks live next to the code they measure. Real-world startup time is measured by a stopwatch fixture that uses Windows’ high-resolution timer.

10.15 Coding standards and review

10.16 Module ownership and SOLID boundaries

10.17 Magic, by construction

The engineering choices above add up to the user-visible magic the product promises:


11. Performance targets


12. Privacy and security


13. Internationalisation and localisation


14. Telemetry


15. Success metrics


16. Release plan

v1.0 is locked to features we can build and ship at the highest quality bar with no novel research dependencies and no fragile third-party requirements. Anything that requires unproven libraries, brittle parsers, ML training, or external binaries beyond the bundled set is deferred to the backlog in section 17.

Every v1.0 feature in the list below is Confidence A: a defined library exists, a known engineering path is mapped, accessibility behaviour is predictable, and a working prototype can be produced in a single sprint.

16.1 v0.9 alpha (internal, 6 weeks)

16.2 v1.0 beta (8 weeks after 0.9)

Adds, in order of priority:

16.3 v1.0 release (4 weeks after beta)

Adds and hardens:

16.4 v1.1

Plugin system; TinySpell interop plugin (dictionary sync only); additional AI providers; Pandoc bridge; Whisper transcription importer; LibreOffice headless bridge plugin (raises legacy .doc, .ppt, .xls, .wpd, iWork, OneNote quality from C to A); Calibre bridge plugin (raises azw3, mobi, fb2, lit to A); braille back-translation via liblouis; auto-update; spell-check contextual reranker (opt-in experimental); DjVu, comics, WARC, MSG/PST/MBOX.

16.5 v1.2

Right-to-left UI; additional languages; optional split view (still standard controls); reading-order learning per document; contextual reranker promoted to default; per-paragraph language detection; project workspaces and Find in Folder; chat-export readers (WhatsApp, Telegram, Slack, Discord, Teams, Zoom, Otter, ChatGPT) as plugins.


17. Backlog and deferred items

Plan of record. The authoritative, continuously-updated view of all open work — workstreams (shipped/open), phase outcomes, the release gap list, and the open-issue ledger — lives in docs/planning/roadmap.md. (Shipped workstreams’ specs — offline speech-to-text #617, the braille suite, batch document-to-speech, Structured List Studio, the verbosity system, the ElevenLabs integration, and Table Studio (now PRD §5.4.14) — were retired to git history once delivered; their status lives in the roadmap and user guide, and the verbosity 2.0 backlog is consolidated in roadmap §6.) The current direction is that these items ship (the older 2.0-deferral framing is dropped); the tables below are retained as the format-support reference.

Everything below is intentionally out of v1.0. Each item is either yellow (achievable but requires more engineering than the v1.0 quality bar permits in time) or red (depends on unstable third-party formats, large native dependencies, or research-flavoured uplift we will not promise without measurement).

The organising principle is simple: v1.0 ships only Confidence A. Confidence B and C land in v1.1–1.3 behind opt-in plugins and feature flags, with quality grades shown to the user.

17.1 Format support deferred to plugins (v1.1+)

Format Why deferred Target plugin
Legacy Word .doc, PowerPoint .ppt, Excel .xls Need headless LibreOffice for high quality; LibreOffice is a ~300 MB dependency LibreOffice Bridge
WordPerfect .wpd, Works .wps, Windows Write .wri, StarOffice Python bindings flaky on Windows; covered well by headless LibreOffice LibreOffice Bridge
Apple iWork .pages, .key, .numbers Modern iWork is a proprietary IWA archive; coverage is partial iWork Bridge (best-effort)
OneNote .one Closed format, no stable open library OneNote Bridge via Microsoft Graph (requires sign-in)
Kindle .azw3, .mobi, FB2, LIT, LRF, PRC, PDB, TCR Best handled by Calibre’s conversion engine Calibre Bridge
Kindle .kfx DRM-protected by design; Quill will not break DRM Calibre Bridge (user must already have a DRM-stripped copy)
DjVu Native djvulibre dependency; installer weight DjVu Plugin
Comics cbz, cbr RAR (cbr) requires non-free unrar.dll Comics Plugin
PostScript ps, eps Needs Ghostscript native binary PostScript Plugin
XPS, OXPS Niche; needs additional reader XPS Plugin
MSG, PST, MBOX libpff works but coverage on encrypted/corporate PSTs is uneven Mail Bridge
Web archives WARC, WACZ, .webarchive Format zoo; useful but not ubiquitous Web Archive Plugin
Parquet, Feather Niche analyst use; large native deps Data Files Plugin
EVTX, ETL Native Windows log formats; niche audience Logs Plugin
HEIC, AVIF, JP2 Native binary dependencies (pillow-heif, pillow-avif-plugin) Modern Images Plugin
Chat exports (WhatsApp, Telegram, Slack, Discord, Teams, Zoom, Otter, ChatGPT) Export formats churn 2–4 times per year; requires ongoing maintenance Chat Exports Plugin (independently versioned)
OneNote, Evernote .enex, Bear, Simplenote Mixed formats; per-app quirks Notes Bridge Plugin
Audio + transcript pair playback Needs media playback; out of scope for core editor Audio Transcript Plugin

17.2 Features deferred

17.4 Explicitly out of scope for Quill (any version unless re-evaluated)

Quill is opinionated about what it is not. The following are intentionally and permanently out of scope unless a future PRD revision re-opens the question.

17.3 Confidence grades shown to the user

The Format Support settings page displays a grade per format:

Grade is displayed prominently in the Open dialog when the user picks a file of a non-A format, with one line of context (“Kindle .azw3 requires the Calibre Bridge plugin; current grade B with Calibre installed”) so expectations are set before the user hits Enter.


18. Risks and mitigations

Risk Mitigation
Custom wx behaviour breaks a screen reader on a Windows update Strict policy of stock controls; CI accessibility tests; rapid patch process
PDF extraction quality varies wildly Layered extractors with scoring; manual escalation tiers; opt-in AI repair
AI provider API drift or pricing change Provider-agnostic interface; user supplies key
Plugins introduce accessibility regressions Manifest must declare UI surfaces; review checklist; plugins disabled by default
Custom spell engine underperforms TinySpell/Word Layered architecture; Hunspell baseline; learning loop; published benchmarks
Keymap conflicts with screen-reader shortcuts Explicit detection at capture time; refuse to capture conflicting chords with clear explanation
Localisation lags Ship English first; community translation site; per-language readiness gates
Users expect bold and italic Clear positioning; markdown supports inline emphasis textually; v1.x evaluation only after stable base

19. Open questions

  1. License: MIT for maximum adoption, or GPL to keep derivatives open. Leaning MIT.
  2. Branding: Quill is a placeholder. Final name to be chosen with community input.
  3. Should the contextual reranker be opt-in given memory cost?
  4. JAWS scripts: ship a small one for live-region parity, or rely on wx.Accessible alone?
  5. Hosting model for the community plugin registry.
  6. Two-key chord sequences in default keymap: how many should we ship by default, given screen-reader users often prefer single chords?

20. Why this can be magical

The magic is not in any single feature. It is in the absence of friction:

Quill aims to feel like a very good fountain pen: simple in the hand, faithful on the page, and quietly delightful every time you reach for it.


21. Project delivery TODO (living checklist)

Last updated: 2026-06-02

Todo counts: 160 v1.0 items | 160 completed | 0 remaining (v1.0 scope); post-1.0 foundation work tracked in 21.17+ below.

This is the implementation checklist for v1.0.0 and immediate post-1.0 foundations. It is intentionally granular and is updated in-place as work lands. The current shipping build is 0.7.0 Beta; it implements the full v1.0 checklist (21.1–21.16) plus the post-1.0 foundation work recorded in 21.17 and later. Items still in progress are listed with - [ ].

21.1 Application shell and document lifecycle

21.2 Core editing and formatting

21.4 Metrics, spell, and tools

21.4a Trust and verification

21.5 Persistence, safety, and recovery

21.6 Command palette and keymap system

21.7 Format support and I/O coverage

21.8 Accessibility and UX hardening

21.9 Windows integration

21.10 AI, OCR, and optional network features (deferred to v1.1)

Deferred to v1.1:

21.11 Engineering quality gates and release

21.12 Documentation and readiness

21.13 Profile safety and recovery

Document-intelligence implementation roadmap

This roadmap is the implementation order for the shared document-intelligence work now being folded into Quill.

  1. CSV Mode first. Finish the accessible grid editor, the default-choice prompt, remembered CSV preference, and the ability to return to normal text editing at any time.
  2. Word support next. Complete MarkItDown-first Word intake, semantic diagnostics, and review/fix hooks for .docx and legacy .doc flows.
  3. PowerPoint support next. Finish slide linearisation, speaker-notes extraction, and reading-order diagnostics for .pptx and legacy .ppt flows.
  4. Then the rest of the document families. Extend the same shared path to PDF refinement, EPUB/pages tuning, legacy Office fallbacks, version transparency, and provenance reporting.

The governing rules remain the same throughout the roadmap: local-first processing, no silent network calls, explicit consent for outbound content, and deterministic extraction before higher-level analysis.

21.14 Feature registry and profiles

21.15 Macro and compare expansion

21.16 wx stability and hang resistance

21.17 Watch Profiles automation engine

21.18 Settings home and feature profiles UI

21.19 Update experience enhancements

21.20 Foundation work in progress (post-1.0)

21.21 AI chat, Prompt Library, and Quillin Manager (Phase 2/3)


§22. Startup Wizard — Personalise QUILL

§22.1 Overview

The Startup Wizard is a first-run wizard that lets every user shape QUILL to their work and accessibility needs before the main frame appears. It is also re-runnable at any time via Help > Personalise QUILL. Features the user disables are completely hidden — no menus, no commands, no phantom shortcuts.

The wizard (SetupWizardDialog in quill/ui/setup_wizard_pages.py) is a single wx.Dialog hosting nine wx.Panel pages shown one at a time with Back / Next / Finish navigation. Choices are held in pending state and applied to Settings/FeatureManager atomically only when the user clicks Finish, so backing out changes nothing. Every page heading is a real control with an accessible name; focus on page change lands on the first interactive control.

§22.2 The Nine Pages (as built)

  1. Welcome — introductory text; no configuration.
  2. Keyboard and Sound — keyboard pack and whether QUILL plays sounds for mode changes.
  3. Feature Profile — choose Essential, Writer, Developer and Power Text, Accessibility Professional, or Full QUILL.
  4. Remote Access — enable/disable the FTP / SFTP / WebDAV / S3 open and save features.
  5. AI Assistance — enable/disable AI features (API key added later).
  6. Reading and Accessibility — Read Aloud and the spoken-announcement verbosity (Minimal / Normal / Verbose).
  7. Writing Tools — spell check as you type, word prediction and tag IntelliSense, and curly-quote autoformatting.
  8. Startup Behaviour — start with no document open, check for updates on startup, and system tray icon.
  9. Summary — review every decision; Finish applies them.

Planned wizard additions not yet present (tracked in docs/planning.md): an interface-language selector page, a watch-folder onboarding step, and dedicated Quillins / Power Tools / Notebook / keyboard-profile pages.

§22.3 Feature Gating via FeatureManager

quill/core/feature_flags.py defines a frozen FeatureFlags dataclass and is_enabled(flag: str) -> bool. The wizard writes a feature_flags block into settings.json via write_json_atomic. MainFrame.__init__ loads flags before building menus, the command registry, and keybindings.

The two network-capable gated features are:

Commands with feature_tag = None are always present (core editing). Commands whose tag is disabled are excluded from the command palette search and their keyboard bindings are not registered, so Ctrl+? discovery shows no ghost shortcuts.

§22.4 First-Run Detection and Re-run

setup_wizard_completed in settings.json (type bool, default False) controls first-run detection. When the field is absent or False, MainFrame.__init__ runs run_setup_wizard(parent=None) before any UI is shown, then reloads settings before building menus.

Re-run opens the wizard in update mode with all pages pre-filled. Changed flags trigger _apply_feature_flags(), which rebuilds the affected menu groups, refreshes the command palette, re-applies the keymap profile, and announces the change — no restart required.

§22.5 Settings Additions

Field Type Default Description
setup_wizard_completed bool False Suppresses wizard on next launch
feature_flags_ai bool True Master switch for all AI features
feature_flags_remote bool True Master switch for remote editing
feature_flags_quillins bool True Master switch for Quillins
import_export_recursive bool True Wizard default: include subfolders in batch conversion (### 5.3a.1)
import_export_overwrite choice ask Wizard default: ask, never, or always
import_export_output_layout choice subfolder Wizard default: subfolder or same_folder
import_export_last_folder text "" Wizard-only: remembered last folder; not exposed in Preferences

The four import_export_* fields are documented in full at ### 5.3a.1.6. They are written by the Startup Wizard when the chosen intent profile enables Pandoc import / export, and by the Batch Conversion wizard on every successful Start click. | feature_flags_power_tools | bool | True | Master switch for Power Tools | | feature_flags_notebook | bool | True | Master switch for Notebook workspace | | keymap_profile | str | “default” | Active keyboard profile name |

§22.6 Implementation Status

SHIPPED. Phases 1-3 complete: setup_wizard.py, setup_wizard_pages.py, feature_flags.py, feature_registry.py, and check_feature_tags.py CI gate are all in production. Phase 4 (additional keymap profiles beyond the shipped defaults) is in progress.


§23. Context-Sensitive Help System

§23.1 Overview

QUILL provides three help keystrokes that give the user immediate, contextual assistance without leaving the keyboard:

Key Command Behaviour
F1 Help on This Control Shows a small dialog describing the focused control
Ctrl+F1 Open User Guide Opens docs/html/USER_GUIDE.html in the system browser
Shift+F1 What Can I Do Here? Document-type context report (enhanced)

F1 always returns something useful. There are three fallback levels: (1) named schema topic via ctrl.GetName(), (2) generic description by ctrl.GetClassName(), (3) control name + tooltip + prompt to open the User Guide.

§23.2 Architecture

quill/core/help/topics.json          schema: topic id, title, body, keystrokes, user_guide_section
quill/core/help/renderer.py          render_live(), render_doc(), generate_markdown()
quill/ui/context_help.py             ContextHelpDialog, ContextHelpMixin, describe_focused()
quill/tools/build_docs.py            generates docs/CONTROL_REFERENCE.md from topics.json
quill/tools/check_help_coverage.py   CI gate: stale entries fail; coverage gaps warn

Topic IDs are derived from ctrl.GetName(). Dialog controls are prefixed with the dialog’s accessible name: connect_to_ssh_server.host_or_ip_address. This works because the dialog contract already requires every interactive control to carry a meaningful SetName().

ContextHelpMixin is added to MainFrame’s MRO. It tracks the last-focused control via EVT_CHILD_FOCUS so that navigating to Help > Help on This Control via the menu bar still describes the correct control.

§23.3 ContextHelpDialog Screen-Reader Design

The dialog contains a single read-only wx.TextCtrl (multi-line, no border, dialog background colour) with combined title and body text, plus a Close button. Using one TextCtrl means NVDA reads the dialog title then the entire content line by line on open, without the user tabbing between controls first. Focus lands on the TextCtrl. Closing the dialog restores focus to the described control.

The dialog is registered in dialog_inventory.json. affirmative_id = wx.ID_CLOSE. Escape also closes it.

§23.4 Topics Coverage

The schema (quill/core/help/topics.json) currently contains 134 topics covering:

Full coverage target is 250 topics (all SetName() calls in quill/ui/).

§23.5 CI Gate

quill/tools/check_help_coverage.py enforces two rules:

§23.6 Implementation Status

SHIPPED. Phase A (infrastructure: topics.json, renderer.py, context_help.py, F1/Ctrl+F1/Shift+F1 wiring) complete. Phase B (schema authoring sprint, 109 of 250 topics complete) in progress. Phase D (coverage gate) complete. Phases C (documentation HTML build) and E (user guide restructure) in progress.


§24. Translation and Community Localization

§24.1 Overview

QUILL uses GNU gettext for all user-visible strings, with Babel for POT extraction and Crowdin for community translation management. The design follows the NV Access NVDA translation model, which has produced high-quality, screen-reader-tested translations across 50+ languages.

Speech announcement strings are first-class translation targets. They are marked with #. SPEECH: extracted comments in the POT file so translators can filter them for review and test them with a native screen reader in the target language.

§24.2 Pipeline

quill/core/i18n.py          _(), ngettext(), lazy_gettext(), init_locale()
babel.cfg                   Babel extraction configuration
quill/locale/quill.pot      Master string template (auto-generated by pybabel extract)
quill/locale/{lang}/LC_MESSAGES/quill.po   Per-language translation (community, via Crowdin)
quill/locale/{lang}/LC_MESSAGES/quill.mo   Compiled binary (generated by pybabel compile)
quill/tools/check_translation.py           CI gate

init_locale() is called in MainFrame.__init__ before any UI string. The language setting (BCP 47 tag, default empty = OS locale) controls the active locale. A startup-wizard language selector (shown when more than one .mo file is present) is planned; the shipped wizard does not yet include it.

§24.3 Crowdin Components

Three Crowdin components manage translation content:

  1. UI strings — source quill/locale/quill.pot, target quill/locale/{lang}/LC_MESSAGES/quill.po
  2. Context-sensitive help — source quill/core/help/topics.json, target quill/core/help/topics_{lang}.json
  3. Quillin manifests — source each bundled manifest.json, target manifest_{lang}.json

Auto-PR on approved translation; PRs land on main after the CI gate passes.

§24.4 Four-Tier Role Model

Role Responsibilities
Translator Suggests translations in Crowdin
Proofreader Approves or rejects suggestions for their language
Language Coordinator Manages proofreaders, owns language quality, reviews PRs
Translation Coordinator Project-level; runs translation calls, onboards teams, resolves disputes

The Translation Coordinator is a named maintainer role in MAINTAINERS.md.

§24.5 Language Priority

Completeness thresholds: 90% for established languages, 70% for a language’s first release. Languages below threshold are excluded from that release’s language selector.

§24.6 CI Gate

quill/tools/check_translation.py checks: POT currency (dry-run pybabel extract + diff), completeness threshold per language, mnemonic & preservation, placeholder {n} / %(count)s preservation, and no empty translated strings.

§24.7 Implementation Status

Phase 1 infrastructure complete: quill/core/i18n.py, babel.cfg, check_translation.py gate, language setting, and init_locale() call are all in production. Phase 2 (string marking sprint) is in progress — sweeping all user-visible strings in quill/ui/ and quill/core/ to wrap them with _(). Pilot languages (fr, de, es) are pending community formation and a named Translation Coordinator.


§25. GitHub Remote File Access

§25.1 Overview

QUILL provides first-class GitHub repository browsing, remote file opening, and file commit-back through File > Open from Remote > GitHub Repository… This lets users open files from any public or private GitHub repository without requiring the GitHub CLI, GitHub Desktop, local Git, VS Code, or command-line interaction.

The implementation uses PyGithub behind QUILL’s own RemoteProvider abstraction (quill/core/github/provider.py), which keeps the UI layer stable if the backend changes (e.g. direct REST, GitLab, Bitbucket).

Added to the existing File > Open from Remote submenu:

File > Open from Remote
  ...FTP / SFTP / WebDAV / S3 items (existing)...
  ---
  GitHub Repository...
  GitHub File URL...
  Save to GitHub...
  GitHub Items...
  ---
  Manage Remote Sites...   (existing)
  Manage GitHub Accounts...

All five GitHub commands are also available through the Command Palette. (Repository lifecycle commands — create, fork, rename, and more — live in a separate Tools > GitHub submenu; see §25.13.)

§25.3 Feature Flag

Feature ID: core.github_remote
Category: core
Privacy: network after confirmation
Dependencies: core.remote
Optional dep: pip install "quill[github]" (installs PyGithub >= 2.0)

When the flag is off, all five GitHub menu items are absent. When PyGithub is not installed, QUILL shows a friendly message with the install command.

§25.4 Authentication

First-run consent. The first time the user opens a GitHub feature, a one-time consent dialog explains that QUILL will connect to api.github.com for the user’s chosen repositories. Consent is stored in github_consent.json in the user data directory.

Anonymous access. Public repositories are browsable without a token. Rate limits are lower (60 requests/hour vs 5,000 with auth).

Personal Access Token. The user pastes a token with at minimum public_repo scope (add repo for private repositories). The token is stored in Windows Credential Manager under the target name quill-github-token using DPAPI. Never stored in settings.json or any plaintext file.

Token management. File > Open from Remote > Manage GitHub Accounts… lets the user view the stored identity, add/replace the token, and sign out (deletes the stored token).

§25.5 Repository Browser

A native wx.Dialog with:

Keyboard shortcuts: - Enter on a folder: navigates into it. - Enter on a file: same as Open File. - Backspace: go up one level (when not at root). - F5: refresh.

All controls have accessible names. Long operations (repository load, directory listing, file fetch) run on daemon threads; the dialog remains interactive during loading.

§25.6 GitHub File URL

File > Open from Remote > GitHub File URL… accepts a pasted https://github.com/owner/repo/blob/branch/path URL and opens the file directly without requiring the user to navigate the browser. Useful for sharing links.

§25.7 Save to GitHub

File > Open from Remote > Save to GitHub… is available when the active document was opened from GitHub. QUILL prompts for a commit message, then commits the current document text to the same repository, branch, and path using the GitHub API (update_file). The file SHA is tracked for optimistic concurrency; if the file has changed remotely since it was opened, GitHub returns a 409 and QUILL shows a clear error.

Requirements: the stored token must have repo (write) scope on the target repository.

This command is intentionally not wired to the regular Save shortcut. The user must invoke it explicitly from the menu or Command Palette to avoid accidental commits.

§25.8 Remote Origin Metadata

When a file is opened from GitHub, QUILL stores a RemoteOrigin dataclass keyed by the local temp path:

RemoteOrigin(
    provider="github",
    account_id="github:login",
    repository="owner/repo",
    ref="main",
    path="docs/example.md",
    sha="abc123...",
    url="https://github.com/owner/repo/blob/main/docs/example.md",
    opened_at="2026-06-12T..."
)

The tab’s source_label is set to GitHub: owner/repo (branch) and shown in the title bar.

§25.9 Security Properties

§25.10 Implementation Files

File Purpose
quill/core/github/__init__.py Package marker
quill/core/github/models.py RemoteAccount, RemoteRepository, RemoteRef, RemoteNode, RemoteFile, RemoteOrigin, BrowseResult
quill/core/github/provider.py Abstract RemoteProvider interface
quill/core/github/github_provider.py GitHubRemoteProvider (PyGithub)
quill/core/github/token_store.py Credential Manager token storage
quill/core/github/consent.py One-time consent state
quill/ui/github_dialogs.py Consent, sign-in, manage-accounts, repository browser dialogs
quill/ui/main_frame_github.py GitHubRemoteMixin — orchestration and threading
quill/core/github/items_provider.py GitHubItemsProvider (PyGithub) + read-only item models (#924)
quill/ui/github_items_view.py Wx-free view-model formatting: list cells, details, comment positions (#924)
quill/ui/github_items_dialog.py GitHubItemsDialog — modal list-over-detail viewer (#924)
quill/ui/main_frame_github_items.py GitHubItemsMixin — Safe Mode + consent + token gate (#924)
quill/core/github/saved_items.py Pinned repositories + favorited items store (Unified GitHub Management)
quill/core/github/local_repo.py Local git sync: owner/repo from the document’s own checkout
quill/core/github/repo_admin.py GitHubRepoAdminProvider — repository lifecycle actions (§25.13, Beta 3)
quill/ui/github_repo_admin_dialogs.py Typed-confirm, create-repository, branch-protection wizard dialogs (§25.13)
quill/ui/main_frame_github_admin.py GitHubAdminMixin — Tools > GitHub command handlers (§25.13)

§25.11 Implementation Status

SHIPPED (2026-06-12). All five implementation phases complete: - Phase 1: Core service layer and models. - Phase 2: Authentication (token + anonymous). - Phase 3: Repository browser dialog. - Phase 4: Remote document integration (origin metadata, title, save-back). - Phase 5: Gate compliance (banned patterns, dialog inventory, module size budget, mypy overrides).


§25.12 GitHub Items Viewer (read-only repository browser, #924)

File > Open from Remote > GitHub Items… opens a screen-reader-first browser for a repository’s issues, pull requests, branches, commits, tags, releases, and workflow runs. It is modeled on the GHManage reference viewer (the same field set, list modes, and per-comment navigation), adapted to QUILL’s PyGithub transport and dialog conventions. The anonymous (tokenless) session is fully read-only; a signed-in session additionally has the Batch… menu (below) and the Actions… menu (0.9.0 Beta 3, §25.12a) for new issues/PRs, merging, branch deletion, workflow re-runs, and comment reply/edit/delete — every write is named explicitly in its own confirmation before it runs.

Unified GitHub Management additions (0.9.0 Beta 3, merged from the GHManage + fastgh review):

Tranche 2 (same release):

The review’s remaining items (wiki browser, vault linking, metadata caching, an in-viewer command palette, context sharing) stay deferred; see the roadmap’s “Unified GitHub Management — deferred” list. Branch comparison shipped in §25.18; notifications and security alerts shipped in §25.15; the gh-CLI hybrid engine shipped in §25.16.

Views. A single View switcher selects one of:

View Columns Detail pane
Issues & PRs number, type, state, title, author, updated, labels, comments full issue/PR body + comment thread
Branches name, protected, author, date, commit branch metadata; Enter drills into that branch’s commits
Commits short_sha, author, date, message full sha, author, date, diff stats, message
Tags name, commit_sha tag + commit
Releases tag, name, draft, prerelease, created release name, flags, body (release notes)
Workflows name, state, path workflow definition; Enter dispatches it on a branch (§25.18)
Workflow Runs name, status, conclusion, branch, event, run_number run status + conclusion

The Issues & PRs view is the combined inbox from GHManage: two PyGithub calls (issues + pulls) merged and sorted in one place. It adds three filters the other views do not need: Show (Both / Issues / PRs), State (Open / Closed / All), and Sort (number, title, updated, comments — asc/desc).

List mode (accessibility, GHManage parity). Quick shows compact cells exactly as they appear in the columns. Full spells each non-empty cell as col: value (e.g. number: 208, type: ISSUE, state: OPEN) so a screen reader reads a self-describing line per row instead of bare values with no field names. Toggle with the List mode choice or M in the list.

Detail pane + comment navigation. Selecting an issue/PR shows the metadata and body immediately, then fetches the comment thread off-thread and appends it. Alt+N / Alt+P jump between comments, selecting and scrolling to each; the navigator announces “Comment N of M” and “first/last” at the bounds.

Repository field. Prefilled from the active document’s GitHub origin when the document was opened from GitHub (RemoteOrigin.repository), so a user editing a file from a repo can review that repo in one step. Otherwise the user types owner/repo, pastes an https://github.com/owner/repo URL (with or without a trailing .git, /tree/..., /pull/..., etc.), or pastes a git@github.com:owner/repo.git remote, and clicks Load — parse_repo_reference (§25.18) normalizes any of those to owner/repo.

Keyboard shortcuts.

Threading and accessibility. All fetches (list load, comment thread) run on daemon threads and update the UI via wx.CallAfter (# GATE-40-OK); the UI thread never blocks on the network. Every control has a SetName; the status label announces load state, counts, and the keyboard map. The dialog is shown through show_modal_dialog + apply_modal_ids (never ShowModal).

Gates (same as every GitHub entry point).

Feature flag. core.github_remote (off by default) gates the command via feature_command_map (file.open_github_items). When off, the menu item is absent.

Error mapping. 404 -> “not found”, 401 -> “invalid or expired token”, 403 -> “Access denied (check the token’s repo scope)”; all surfaced as GitHubItemsError(CodedError) with the QUILL-GITHUB-ITEMS-ERROR code so they are greppable and never crash the dialog.

§25.12a Items Viewer Write Actions (0.9.0 Beta 3)

The Actions… button opens a menu whose contents depend on the current view and selection — new issue/PR and comment reply/edit/delete in Issues & PRs, delete branch in Branches, re-run workflow in Workflow Runs — the same shape as the pre-existing Batch… menu, and gated the same way: requires GitHubItemsProvider.is_authenticated (a signed-in token), refuses with a clear announcement otherwise.

All of the above run on a daemon thread with the same wx.CallAfter pattern as every other fetch in this dialog, and reload the list on success so the new/changed item is visible immediately.

§25.13 Repository Administration (0.9.0 Beta 3)

Tools > GitHub is a new submenu (alongside Tools > Sync Folder with GitHub…) with eight commands covering the repository lifecycle actions GitHub’s REST API supports but neither the single-file browser (§25.1-§25.11) nor the items viewer (§25.12) ever touched: creating and forking repositories, renaming, changing visibility and default branch, branch protection, deleting a branch, and committing several files in one atomic commit. See docs/planning/github.md (retired once this section shipped) for the full design rationale — extending the existing PyGithub provider rather than shelling out to the gh CLI, and why repository create/fork/rename never existed before this release (the provider was deliberately built narrow, not blocked by any scope or API limitation).

Every command in this submenu:

  1. Refuses outright in Safe Mode (QUILL_SAFE_MODE=1).
  2. Gates on the shared _ensure_github_ready consent + PyGithub-installed check (identical to every other GitHub entry point).
  3. Requires a signed-in token — there is no anonymous path for any of these eight commands. A missing token offers to sign in on the spot (_gh_admin_ready) rather than just refusing, so starting from Tools > GitHub with no prior GitHub setup still works in one continuous flow.
  4. Prefills the repository field from the current document’s GitHub origin or local git checkout, same as the items viewer.
  5. Runs the actual GitHub API call on a background thread via _run_background_task; the UI thread is never blocked on the network.

Commands:

Command What it does Confirmation
Create Repository… GitHubRepoAdminProvider.create_repository — name, description, private/public, optional org Dialog fields only
Fork Repository… fork_repository, optional target org Plain prompt for the org; no separate confirm
Rename Repository… rename_repository; GitHub redirects the old URL automatically TypedConfirmDialog (retype owner/repo)
Change Repository Visibility… Fetches current visibility, then set_visibility to the opposite TypedConfirmDialog, with an extra warning line when flipping private -> public
Change Default Branch… Fetches the branch list, wx.SingleChoiceDialog, then set_default_branch Choice dialog only
Configure Branch Protection… BranchProtectionDialog wizard (branch choice, required approving reviews, required status checks, enforce-admins, or a “remove all protection instead” toggle), then set_branch_protection / remove_branch_protection Plain Yes/No naming the exact rule change
Delete Branch… Fetches branches excluding the repository’s default branch (never offered as a delete target), wx.SingleChoiceDialog, then delete_branch TypedConfirmDialog (retype the branch name)
Commit Multiple Files… wx.FileDialog (multi-select) reads local files as UTF-8 text, prompts for branch + commit message, then GitHubRepoAdminProvider.commit_files builds one atomic git tree/commit and fast-forwards the branch Plain Yes/No listing the file names

Create-repository and fork both end by offering QUILL Sync. On success, a Yes/No asks “Set up a local folder to sync with it now?” — accepting reuses the existing folder picker and init_repo_with_remote from quill.core.git_sync (§5.59b) to clone the fresh repository (or fork) into a local folder and run the first sync, all in one continuous flow. This is the “create a project without leaving QUILL” path described in docs/planning/github.md section 4: previously, starting a new GitHub-backed project meant leaving QUILL, creating the repo in a browser, then coming back to set up local sync as two disconnected steps.

Multi-file commits vs. Save to GitHub (§25.7). Save to GitHub commits exactly one already-open document to its own tracked origin. Commit Multiple Files is a separate, standalone command: it reads arbitrary local files (not necessarily open in QUILL) chosen via a file picker and commits all of them to a chosen repository/branch in a single atomic git commit (create_git_tree + create_git_commit + a fast-forward-only ref update — refused, not force-pushed, if the branch has moved since it was read). Binary files are rejected with a clear error; this call is UTF-8 text only.

Command palette + hotkeys. All eight commands are registered via commands.try_register (so they appear in the Command Palette) and ship default Ctrl+Shift+ + backtick, second-key` chords (the QUILL Key leader): Create = Shift+K, Fork = Shift+F, Rename = Shift+E, Change Visibility = Shift+V, Change Default Branch = Shift+B, Configure Branch Protection = Shift+L, Delete Branch = Shift+X, Commit Multiple Files = Shift+U. The five pre-existing read-only GitHub commands (§25.2), previously unbound, also gained default chords in the same release: GitHub Repository = Shift+Y, GitHub File URL = Shift+W, Save to GitHub = Shift+Q, Manage GitHub Accounts = Shift+Z, GitHub Items = Shift+I. All are freely reassignable in Preferences > Keyboard Shortcuts, like every other QUILL command.

Implementation files:

File Purpose
quill/core/github/repo_admin.py GitHubRepoAdminProvider — create/fork/rename/visibility/default-branch/protection/delete-branch/commit-files, PyGithub-backed
quill/ui/github_repo_admin_dialogs.py TypedConfirmDialog, CreateRepositoryDialog, BranchProtectionDialog
quill/ui/main_frame_github_admin.py GitHubAdminMixin — the eight command handlers, gating, and command-palette registration

Feature flag. All eight commands map to core.github_remote (the same flag as every other GitHub command) in feature_command_map.py; when the flag is off, none of them are reachable.

Network egress. Every PyGithub call this section makes is documented in quill/tools/network_egress_audit.py’s PyGithub section (not AST-scannable, so it is maintained by hand alongside every other GitHub entry point).

§25.14 Local Git Accessibility (0.9.0 Beta 3)

Tools > Local Git is the crown-jewel work from the git/gh integration PRD (docs/planning/github.md section 4): a genuinely accessible local git experience, built specifically because interactive rebase and merge-conflict resolution are among the most screen-reader-hostile workflows in mainstream software, and no existing tool — not the git CLI, not a GUI client — was built with that problem in mind. This is entirely local; it never talks to GitHub’s API (that stays in quill/core/github/*) and never pushes or pulls a remote (that stays in git_sync.py/vault/sync.py) — it only operates on the working copy and its own history.

Repository resolution. Every command resolves a repository root the same way: the nearest enclosing .git above the current document (walking up, following the same logic quill.core.github.local_repo’s detector uses), or a folder picker when no document is open or none is found.

Ten commands:

The merge-conflict walker. <<<<<<</=======/>>>>>>> markers read as line noise with a screen reader; QUILL instead parses a conflicted file into structured hunks (quill.core.local_git.parse_conflict_hunks) and presents each one as “Conflict 1 of 3: your version says X, their version says Y” with your version / their version / both / edit manually as the resolution choice, writing the resolved content back and staging it (resolve_conflict_hunks + mark_conflict_resolved). Reached directly from Resolve Conflicts…, and automatically offered when a rebase stops for conflicts (below) or when a conflict surfaces from the already-shipped Sync Folder with GitHub command — the same walker resolves a conflict regardless of which command produced it.

Interactive rebase, spoken. git rebase -i opens an editor buffer with a pick/squash/reword/drop todo list meant to be reordered by eye. QUILL instead builds the same commit list as a real InteractiveRebaseDialog list — one commit per row, an action per row via a Choice control, and Move Up/Move Down to reorder — then substitutes the result for git’s own generated list. Mechanically: git invokes whatever sequence.editor names with the todo file’s path as its argument; QUILL writes the chosen todo content to a temp file and points sequence.editor at a small generated driver script that copies it over the path git provides (quill.core.local_git.default_sequence_editor_command) — the same trick every GUI git client uses to give interactive rebase a UI instead of a text buffer. A rebase that stops for conflicts hands off directly to the merge-conflict walker above, resolving each conflicted file in turn before calling git rebase --continue; Abort Rebase restores the original branch state.

Testing. quill/core/local_git.py is tested against real temporary git repositories (not fakes) — including a genuine merge conflict produced by two diverging branches and a genuine interactive rebase exercising both the clean path and the conflict-stop/continue path — because subprocess orchestration against real git behavior is exactly what needed verifying, not assumptions about it.

Implementation files:

File Purpose
quill/core/git_binaries.py resolve_git()/resolve_gh() executable resolution (system PATH first, QUILL-managed vendor copy second) and the narrow git/gh subprocess allowlist
quill/core/local_git.py The wx-free local git engine: status/diff/stage, branches, stash, blame, bisect, conflict parsing/resolution, interactive rebase
quill/ui/local_git_dialogs.py UncommittedChangesDialog, MergeConflictDialog, InteractiveRebaseDialog
quill/ui/main_frame_local_git.py LocalGitMixin — the ten command handlers and command-palette registration

Feature flag. None — local git commands need no GitHub account and touch no network, so they are not gated by core.github_remote; they are gated only by Safe Mode and by git being available (system install or the QUILL-managed vendor copy from §2 of docs/planning/github.md).

Keybindings. None by default — the single-letter QUILL Key leader-chord space is fully claimed by existing commands (see §25.13’s chord table). Reachable via Tools > Local Git and the Command Palette; freely assignable in Preferences > Keyboard Shortcuts.

§25.15 GitHub Tier 2: Organizations, Releases, Workflow Dispatch, Notifications, Security Alerts (0.9.0 Beta 3)

The rest of the GitHub API surface from docs/planning/github.md section 5 — extended the same way as §25.13, using the existing PyGithub provider pattern rather than a gh-CLI shell-out. Every method’s PyGithub call shape was confirmed by direct introspection of the installed github package (not assumed), specifically to catch the cases below where PyGithub’s actual surface didn’t match what the plan first hoped for.

Five new commands (Tools > GitHub):

What was deliberately not built, and why. The original plan (docs/planning/github.md section 5) also named Discussions, Projects (v2), Packages, and repository transfer. Introspecting PyGithub directly (not guessing) before writing any code against it settled each one:

All four would require either a hand-rolled GraphQL client or a raw REST call PyGithub doesn’t provide a typed wrapper for — exactly the gh api passthrough / GraphQL surface docs/planning/github.md section 1 already ruled out of scope for this batch. They remain real candidates for a future batch, specifically once there’s a path to verify them against a live repository rather than shipping speculative field-name guesses.

Implementation files:

File Purpose
quill/core/github/repo_admin.py list_organizations, list_org_repositories, generate_release_notes, create_release added to GitHubRepoAdminProvider
quill/core/github/items_provider.py GitHubWorkflowJob/GitHubNotification/GitHubSecurityAlert models plus fetch_workflow_jobs, dispatch_workflow, fetch_notifications, mark_notification_read, fetch_security_alerts
quill/ui/main_frame_github_extras.py GitHubExtrasMixin — the five command handlers, split into its own module rather than growing main_frame_github_admin.py a second time past its size budget; reuses that mixin’s shared gating helpers (_gh_admin_ready, _gh_admin_prompt_repo, etc.) via self, the same sibling-mixin pattern GitHubItemsMixin already uses with GitHubRemoteMixin

Feature flag. All five commands map to core.github_remote, same as every other GitHub command.

§25.16 GitHub Tier 3: Codespaces and Copilot CLI (0.9.0 Beta 3, needs live-device verification)

The narrow gh-CLI bridge docs/planning/github.md section 1 scoped — deliberately not a general gh api passthrough, just Codespaces lifecycle management and gh copilot’s two read-only helper commands. Unlike every other GitHub command in this PRD (all built on PyGithub against the REST API), this tier shells out to the user’s own gh CLI, because Codespaces and Copilot CLI have no REST/PyGithub equivalent — gh is the only supported client.

Four new commands (Tools > GitHub):

Architecture. quill/core/github/gh_bridge.py (wx-free) wraps each gh subcommand through the same Runner = Callable[..., object] injection pattern local_git.py and git_sync.py already use (production: run_subprocess_safely; tests: a fake runner) — one subprocess-injection idiom across every git/gh-adjacent module in the codebase, not a new one for this tier. quill/ui/main_frame_gh_bridge.py (GhBridgeMixin) is the thin command layer: gates on Safe Mode and git_binaries.resolve_gh() finding a gh executable (system PATH first, the bundled copy second — see §25.17), then dispatches to gh_bridge.py on a background task.

Needs live-device verification. Both modules carry an explicit docstring flag: every command here is wired and unit-tested with a fake gh runner (argument-building and JSON-parsing correctness are covered), but none of it has been exercised against a real gh installation, a real Codespaces-enabled repository, or real Copilot CLI access. Treat this tier as needing a real-device pass before promoting it out of that status — matching QUILL’s own precedent for other hardware/environment-dependent features shipped with the same caveat (e.g. Narrator’s direct UIA notification channel in §… — see the Beta 3 release notes).

Feature mapping. All four commands map to core.github_remote, same as every other GitHub command; no default keybindings (the leader-chord space is fully claimed), reachable via the Tools > GitHub menu and the Command Palette.

§25.17 Packaging: self-hosted git and gh binaries (0.9.0 Beta 3)

docs/planning/github.md section 2’s packaging plan, shipped: QUILL can bundle portable copies of Git (MinGit) and the GitHub CLI so local-git accessibility (§25.14) and the Tier 3 gh bridge (§25.16) work for a user who has never installed either, without QUILL depending on either being present.

Detection and the subprocess allowlist. quill/core/git_binaries.py resolves git/gh by checking the system PATH first (shutil.which), falling back to a QUILL-managed vendor copy (app_data_dir() / "vendor" / "git", shared by both tools) second — the system copy is always preferred, so an existing install is never duplicated. validate_executable() enforces a narrow basename allowlist (git, git.exe, gh, gh.exe) before any resolved path reaches a subprocess call, the same defensive pattern external_engine.py’s _ENGINE_EXECUTABLE_BASENAMES already uses — a tampered settings file or a writable vendor directory can never turn a git-integration feature into an arbitrary-executable launcher.

The real, self-hosted, pinned assets. Three release_assets.py ASSETS entries, uploaded to the assets-v1 release tag and pinned by real SHA-256 (not placeholders — is_pinned() fails closed on anything that isn’t a genuine 64-hex hash):

Component Source Notes
git-windows Git for Windows MinGit (GPL-2.0), portable/no-installer Repackaged — see below
gh-windows GitHub CLI (MIT, cli/cli) Byte-identical re-publish
gh-macos GitHub CLI (MIT, cli/cli) Byte-identical re-publish

MinGit’s repackaging problem, and the fix. release_assets.fetch_component() extracts the whole zip to a temp directory, then copies only expect_member’s immediate parent folder into the install target — this works for a single-folder bundle (e.g. Piper’s piper/piper.exe, where the whole piper/ folder is what gets copied), but MinGit’s official zip spreads cmd/, mingw64/, etc/, usr/ across separate top-level folders with no common parent. Naively pointing expect_member at cmd/git.exe would have silently copied only cmd/ and dropped mingw64//etc//usr/, which git.exe needs at runtime — a real, non-obvious packaging bug caught before it shipped, not a hypothetical. Fixed by re-zipping MinGit’s full extracted tree under one new top-level wrapper folder (git/), with expect_member="git/LICENSE.txt" — a file that sits directly beside cmd//etc//mingw64//usr/ as a sibling inside the wrapper, not nested inside one of them. Anchoring on git.exe itself instead would have resolved to the cmd/ folder and reproduced the exact bug this repackaging exists to avoid. The installed layout is therefore <vendor_dir>/cmd/git.exe with etc/, mingw64/, usr/ as siblings — git_binaries.py’s _vendor_candidate("git") expects exactly this nested path. gh-windows/gh-macos needed no such restructuring: their expect_member’s parent folder (bin/) already contains nothing else, so the existing single-folder copy semantics work unmodified.

Download Optional Components wiring. Two new rows in Help > Download Optional Components (quill/core/optional_components.py): Git (Windows only — no self-hosted asset for other platforms, which typically already have a system git) and GitHub CLI (Windows + macOS). Each has its own _git_installed/_gh_installed detector (delegating to git_binaries.git_available()/gh_available()), a verify-dispatch entry, and dedicated installers — LocalGitMixin.download_git and GhBridgeMixin.download_gh (the latter resolves gh-windows vs gh-macos by sys.platform) — following the same fetch_component + _run_background_task pattern as every other on-demand component (e.g. download_mathcat, download_libmpv).

Removal is deliberately careful. Because git and gh share one vendor directory, a naive “delete the vendor dir” Remove for either component would destroy the other’s files too. git’s remove path deletes only MinGit’s own top-level entries (cmd/, etc/, mingw64/, usr/, LICENSE.txt) individually; gh’s remove path deletes only its own flat executable file. Removing one never touches the other’s installed files.

§25.18 GitHub Items: GHManage Parity Completion (0.9.0 Beta 3)

Attribution. This section closes out the GHManage/fastgh unification review named throughout §25.12–§25.16: every remaining feature GHManage (Kelly Ford, github.com/kellylford/GHManage) has that QUILL’s Items viewer did not yet match. As with the rest of the GHManage-derived work in this PRD, each feature is not a straight port — it is extended to fit QUILL’s own architecture and to go further where QUILL already has the infrastructure to do so (most visibly: Compare Branches routes its file diffs through QUILL’s own compare engine instead of a raw patch, and Quick Filter is deliberately quieter than GHManage’s own version so it does not fight a screen reader’s character echo — see below). GHManage remains the reference viewer this entire integration is measured against.

Compare Branches (quill/core/github/branch_compare.py, quill/ui/github_compare_branches_dialog.py). In the Branches view, select a row and press Compare… or Ctrl+Shift+B. Two prompts (base branch, then head branch — the selected row prefills the second) call GitHubItemsProvider.compare_branches, which wraps PyGithub’s Repository.compare(base, head) and maps ahead_by/behind_by/status/ total_commits, the commits between the two refs, and the changed files. The result dialog has two tabs: Commits (a plain list) and Changed Files — selecting a file fetches both sides at the two branch names as refs (reusing fetch_file_text) and renders the difference through quill.core.compare_service, the same spoken difference walk §25.12’s Tranche 2 PR diff viewer already gives (“Difference 2 of 5. Text changed at line 41…”), not a raw unified patch; binary/oversized files degrade to their +/- counts and GitHub’s patch text, matching the PR diff viewer’s own fallback. This is read-only — unlike Batch and Actions, it requires no authenticated session, since Repository.compare never writes to GitHub.

Workflows view (quill/core/github/workflows.py, extending the Views table above). A GitHubWorkflow model (id, name, path, state, url, badge_url) lists workflow definitions via fetch_workflows (PyGithub’s Repository.get_workflows()) — distinct from the pre-existing Workflow Runs view (§25.12’s Views table), which shows run history. Enter on a row, or Actions… > Run <name> on Branch…, calls GitHubWorkflowsMixin._run_selected_workflow: prompts for a branch, confirms, and dispatches through the already-shipped GitHubItemsProvider.dispatch_workflow (§25.15’s sibling Tools > GitHub > Dispatch Workflow… command uses the same method). Requires a signed-in account; GitHub’s own refusal for a workflow that doesn’t accept manual (workflow_dispatch) runs is reported verbatim rather than QUILL guessing.

Quick Filter (quill/ui/github_items_dialog_filter.py, GitHubQuickFilterMixin, Ctrl+Shift+F). A second, different kind of narrowing from the existing Search (Ctrl+F, full GitHub search syntax, a network call): Quick Filter narrows the rows already sitting in self._unfiltered_rows (the last fetch) live as you type, entirely locally — it matches the query, case-insensitively, against the same rendered cell text row_cells produces for the current view’s visible columns, so it always matches what’s actually on screen. Two accessibility properties extend GHManage’s own quick-filter idea rather than copying it outright: a keystroke never moves keyboard focus into the list (move_focus=False), and never triggers a status announcement (announce=False) — re-speaking “N items” on every character would fight the screen reader’s own typing echo. A completed fetch, and Escape-to-clear (only when the filter box has focus, and only when there is something to clear), still announce, since both are discrete actions rather than per-keystroke noise. Switching views or loading a different repository resets the filter, matching the existing reset behavior for a stale Search query.

Column persistence (quill/core/github/saved_items.py, GitHubSavedItems.columns). The pre-existing Columns… menu (per-view column visibility, at least one column always stays visible) now persists its choice to the same atomic-JSON store as Pinned repositories and Favorites, keyed by view, and is re-seeded from that store the next time the dialog opens — a session-only preference in earlier betas, matching GHManage’s own column toggling, now durable the way QUILL’s other GitHub preferences already are.

Backspace navigation. In the Commits view (reached by pressing Enter on a branch row, §25.12), Backspace steps back to the Branches list and clears the drill target — list-scoped (bound in the ListCtrl’s own key handler, not the dialog-wide char hook), so it never eats Backspace inside the repository or search text fields.

Repository field URL/remote parsing. See the Repository field paragraph above (§25.12); parse_repo_reference (quill/ui/github_items_view.py) lives in the wx-free view-model module so it is unit-testable without a display, matching every other formatting helper in that module.


§26. Braille Mode (BRF/BRL/PEF/UEB)

Braille Mode makes QUILL a first-class proofreading environment for formatted braille text files. The full plan lives in docs/planning/planning.md under “Feature: Braille Mode”; this section captures the shipped requirements. The detailed engine design and the liblouis deployment/packaging plan are also in that planning section.

Requirements

Non-goals (this phase)

The proofing/sidecar workflow (Phase 3) and the bundled translation pack install path remain tracked separately (BR-015 and later). The translation install path is a no-op stub until the signed, audited download lands; see the deployment plan in docs/planning/planning.md (Feature: Braille Mode). Six-key entry and an embedded forward-translation editor (as in NLS-BRT’s “contracted braille typed manually”) remain out of scope.

§27. Markdown Profiles and Table of Contents (#257)

§27.1 Overview

A community PRD (issue #257, “Support for Markdown_py-style extensions such as nl2br and toc”) asked for plain-language Markdown profiles and a deterministic table of contents. Before this work, QUILL’s only table-of-contents generator was the AI Generate Table of Contents agent task (assistant_agents.py, agent_id="toc") — useful, but it requires a configured AI provider, a network round trip, and produces a result that is only as faithful as the model’s read of the document. A screen-reader user with AI disabled, working offline, or who simply wants a result guaranteed to match the heading text exactly had no alternative.

§27 ships the non-AI alternative and the friendly-naming layer the PRD asked for, as Phase 1 of that PRD’s four-phase rollout (the PRD’s own §23 “Implementation Phases”). Phases 2-4 (a full Extension Customization dialog, EPUB-ready export, and a curated third-party extension ecosystem) remain backlog.

§27.2 What shipped

§27.3 What already existed and was not duplicated

Tables, fenced code blocks, footnotes, and task lists are already rendered by quill.core.browser_preview and already have Insert commands (format.insert_table, format.insert_code_block, format.insert_footnote, format.insert_task_list). §27 catalogs them as named extensions (so a user can ask “what does the Documentation profile turn on?” and get a real answer) without re-implementing a parser QUILL already has.

§27.4 Persona profiles (Casual Writer, Author or Student)

While closing #257, the Writer persona profile was renamed Casual Writer (id unchanged — "writer" — so saved settings and the onboarding wizard’s technical_profile="writer" mapping are unaffected) to make room for a more precise sibling: Author or Student, a new ninth persona profile for long-form writing with a table of contents, footnotes, and citations — papers, theses, and class assignments. It turns on core.markdown_profiles and core.text_encoding (§28) by default and keeps core.macros / future.regex_library quiet or off, since that audience wants structure and citations, not regex tooling. See §5.1g’s shipped-profile list for the full nine-persona-plus-Full-Quill catalog.

§27.5 Non-goals (this phase)

A dedicated Extension Customization dialog (PRD §13.2), per-document extension overrides, EPUB export, and curated third-party extensions are PRD §257 Phases 2-4 and are not part of this drop. The eight profiles are catalog data today (used for describe_profile and for gating which extensions a persona profile favors); a future phase wires per-profile extension toggles into preview/export rendering directly.

§28. HTML Entity Conversion and Minimum Required Encoding (#256)

§28.1 Overview

A community PRD (issue #256, “Feature: Convert from HTML entities to characters”) asked for two things: decode HTML entities (&eacute;é) and then save the result in the smallest encoding that can represent it losslessly, rather than forcing UTF-8. Most of the surrounding machinery already existed from issue #197 (quill/core/encoding_tools.py: find non-ASCII characters, encode them to entities, re-encode to a chosen charset) and from the EDS-21 text-transform wave (quill/core/format_ops.py::decode_html_entities, already wired as Format > HTML & Encoding > Decode HTML Entities). §256’s actual gap was narrow: nothing picked the minimum encoding automatically.

§28.2 What shipped

§28.3 Non-goals (this phase)

A live preview/details dialog before conversion (PRD §9.4/§9.5), a per-nbsp handling preference, and an “Always warn before changing file encoding” preference toggle are PRD §256 Phases 2-4. decode_html_entities already satisfies the “never silently destroy unknown entities” requirement for free: html.unescape leaves unrecognized named entities untouched.


§29. Emmet-Style Abbreviation Expansion (MVP)

§29.1 Overview

A community proposal asked for deep Emmet-compatible abbreviation support: type a compact shorthand such as ul>li.item$*3>a[href]{Item $} and expand it into full markup, with the full Emmet operator grammar, CSS shorthand, and accessibility-aware boilerplate snippets. The full proposal scoped a multi-phase product (placeholder/tab-stop navigation after expansion, a snippet manager, suggestions, Quillin extension points, a Markdown abbreviation pack, per-language mappings). This release ships the MVP: a real, tested expansion engine and three commands, with the rest tracked as backlog below rather than half-built.

§29.2 What shipped

§29.3 Non-goals (this phase — Phase 2/3 backlog)


§30.1 Public product name

The product ships under the public name “QUILL for All”. The legacy short name “Quill” remains valid as a programmer-friendly identifier (package name, __init__ symbol, command-line tool name) but is no longer presented to end users in product surfaces.

All product-facing strings — window titles, About dialog, support info, installer metadata, README, release notes, GitHub release titles — read QUILL for All. The single source of truth is quill/branding.py:

§30.2 Independence notice

INDEPENDENCE_NOTICE is surfaced in:

The notice states that QUILL for All is developed independently of any assistive-technology vendor and that vendor and screen-reader names appear for compatibility purposes only. This is non-negotiable.

The repository ships four canonical legal documents. Their content is governed by a CI check that asserts each file contains the required notice strings.

File Audience Purpose
LICENSE All Full license text (project license)
NOTICE All Attribution notices and the independence statement
TRADEMARKS.md End users, contributors Trademark acknowledgements and usage rules
docs/legal/legal-notices.md Embedded in About Short, accessible summary shown in the Legal tab
docs/legal/trademark-notices.md Detailed reference Full vendor acknowledgements with links

The tests/unit/tools/test_legal_docs.py gate ensures:

  1. The independence notice appears verbatim in each surface it is required in.
  2. TRADEMARKS.md lists every third-party trademark referenced in the About dialog Dependencies tab.
  3. The product name “QUILL for All” appears consistently across all five files.

§30.4 Module policy


§31. Build-number system and single source of truth (0.7.0+)

§31.1 The drift problem

Prior to 0.7.0, the version string lived in quill/__init__.py (__version__) and was copied — by hand — into installer/quill.iss, the CHANGELOG, the autoupdate manifest, README, and the GitHub release title. Each of those copies was a drift hazard: a version bump in the codebase could leave installer metadata, the update feed, and the docs all out of sync.

§31.2 build/version.toml — the canonical source

The single source of truth is build/version.toml:

[build]
base_version = "0.7.0"
channel = "beta"           # dev | alpha | beta | rc | stable
prerelease_number = 1
product_name = "QUILL for All"
publisher = "Community Access"
website = "https://community-access.github.io/quill/"

The display version is derived as:

§31.3 Generated artefacts

tools/generate_build_info.py reads build/version.toml and emits two artefacts:

  1. quill/_build_info.py — a small Python module of frozen constants (BUILD_BASE_VERSION, BUILD_CHANNEL, BUILD_PRERELEASE_NUMBER, BUILD_PRODUCT_NAME, BUILD_PUBLISHER, BUILD_WEBSITE, BUILD_DISPLAY_VERSION, BUILD_PEP440_VERSION, BUILD_IS_RELEASE_BUILD). This file is regenerated at packaging time and is the authoritative source read by the About dialog, support info, crash reports, and InnoSetup.
  2. build/build-info.txt — a plain-text summary used by InnoSetup’s pre-build step to substitute AppName, AppPublisher, AppVersion, and OutputBaseFilename.

§31.4 Safe read wrapper

quill/build_info.py is a safe read wrapper around quill/_build_info.py. When the generated module is present it re-exports its constants. When it is absent (e.g. during a fresh clone before packaging) it falls back to safe dev-build defaults derived from quill/__version__:

quill/__init__.py re-exports both modules and the public helpers so that from quill import build_info and from quill import APP_DISPLAY_NAME work without further imports.

§31.5 The version consistency gate (GATE-VC)

quill/tools/check_version_consistency.py is a CI gate. It asserts:

The gate is run from scripts/verify_release_corpus.py and is included in windows-release.yml.

§31.6 The release procedure for version changes

To bump the version:

  1. Edit build/version.toml.
  2. Run python -m tools.generate_build_info to refresh the generated artefacts.
  3. Add a ## <new version> heading to CHANGELOG.md.
  4. Run pytest -q and python -m quill.tools.check_version_consistency.
  5. Commit, push, tag.

No other file (installer script, About dialog, manifest generator) needs editing — they all read from the canonical source.


§32. About dialog (0.7.0+)

The About dialog is a 4-tab wx.Notebook:

  1. Overview — product name, version, copyright, short description.
  2. Legal — license name, independence notice, and short legal summary (mirrors docs/legal/legal-notices.md).
  3. Dependencies — list of third-party packages, license, and version (mirrors the Dependencies section of TRADEMARKS.md).
  4. Links — website, source, issue tracker, support.

Title is dynamic: f"About {about_info.product_name}".

The dialog also exposes a Copy Support Info button that copies about_info.support_info() (a single multi-line string containing product name, version, install path, build identity, and platform info) to the clipboard for inclusion in support emails and bug reports.

quill/core/about_info.py is the single source of truth for every field the dialog renders. The dialog does no string formatting of its own.


§33. Autoupdate manifest contract (0.7.0+)

The autoupdate manifest at docs/site/updates/.quill-update-feed-v1.json is published by the windows-release.yml workflow on every tagged release. The manifest schema:

{
  "version": "0.7.0 Beta 2",
  "download_url": "https://github.com/Community-Access/quill/releases/download/v0.7.0-beta2/Quill-for-All-Setup-0.7.0 Beta 2.exe",
  "published_at": "2026-06-19T12:34:56Z",
  "notes": "Release notes excerpt.",
  "signature": "<HMAC-SHA256>"
}

§33.1 Invariants

  1. manifest.version matches the display version of the release (e.g. 0.7.0 Beta 2), not the PEP 440 form. The display form is what quill/build_info.get_display_version() returns and what the running build uses for version comparison.
  2. manifest.download_url matches the actual installer filename the InnoSetup script produces (Quill-for-All-Setup-<display_version>.exe). A rename in the installer script without updating the manifest is a gate failure.
  3. manifest.signature is HMAC-SHA256 over the canonical JSON form of the payload, signed with the deployment key in QUILL_UPDATE_MANIFEST_KEY. The running build rejects manifests whose signature does not verify.

§33.2 Version comparison

The running build compares its own display version against the manifest version using quill.core.updates._version_tuple(), which accepts both the display form (0.7.0 Beta 1) and the PEP 440 form (0.7.0-beta1) and orders them consistently:

A 0.7.0 beta 1 build sees beta 2 as newer. A 0.7.0 stable build sees beta 1 as older (so users on stable are not nagged about pre-releases).

§33.3 Publisher contract

scripts/generate_update_feed.py is the only producer of the manifest. It reads build/version.toml for the version, reads the installer filename from installer/quill.iss for the download URL, and signs the payload. The windows-release.yml workflow runs it on every tagged release and commits the resulting JSON back to docs/site/updates/.

§33.4 Cross-platform asset safety (GitHub Releases path, 0.9.0 Beta 3)

The manifest above only ever carries the Windows installer URL, so non-portable, non-Windows clients — and every portable client, which skips the manifest by design (§ portable updates use the releases path so the prompt offers the .zip, not the installer) — rely on fetch_releases() / fetch_latest_release() in quill/core/updates.py, which reads every asset GitHub attached to a tag and picks one via _pick_asset().

That picker used to fall back to any remaining asset when nothing matched the running platform’s extension. Windows and macOS release workflows both tag-push to the same v* tag and can land in the same GitHub release; when the Windows Beta 3 build failed in CI and only the macOS build published, the release’s sole asset was a .dmg — and the old fallback handed that .dmg to Windows clients. Fixed two ways, both in quill/core/updates.py:

A release with zero real assets at all (only provenance/checksum artifacts, or none) is still treated as usable and falls back to the release’s html_url — that case has nothing platform-specific to get wrong.


§34. Accessible Emoji Picker (0.9.0 Beta 3)

§34.1 Overview

QUILL already ships Insert Special Character for “I know the code point, insert this exact character.” Emoji need the opposite workflow: “I don’t know the code point, I might not even know the name, help me find and understand one.” Insert > Emoji… (Alt+.) is that workflow — browse by Unicode’s own category tree or search across every field a catalog entry carries (the symbol itself, a legacy ASCII emoticon like :) or <3, the official name, a CLDR keyword, or a phrase that only appears in the entry’s generated visual description), with a description pane that reads out what an emoji actually looks like, not just its Unicode name.

First-party rather than a Quillin: it needs a bundled, structured dataset and a richer three-pane dialog than the Quillin capability model (ui.prompt, ui.announce) supports.

§34.2 What shipped

Favorites and Recent. Two additional entries at the top of the category list, before Unicode’s own nine groupings: Favorites (emoji the user has explicitly starred) and Recent (the last 30 emoji actually inserted, most-recent-first, deduplicated on re-use). Backed by a new quill/core/emoji_usage.py (wx-free), structurally identical to quill.core.github.saved_items.GitHubSavedItems — atomic JSON writes under the app data dir (emoji_usage.json), a corrupt file degrades to empty rather than crashing. EmojiUsage.record_used(char) fires once per successful Insert; a dedicated Add to Favorites / Remove from Favorites button (label reflects current state, reusing quill.core.emoji_usage.EmojiUsage.toggle_favorite) acts on the currently selected result regardless of which category or search view it came from. Deliberately a separate module from the read-only, shared emoji_data.py catalog: un-favoriting or clearing Recent is bookkeeping on this small per-user store only and can never remove an emoji from the catalog itself — it stays exactly as findable by search or its Unicode category either way. emoji_data.entry_by_char/entries_by_chars resolve the stored characters back into full EmojiEntry rows for display, silently dropping (not crashing on) a character that no longer exists in the current catalog version.

§34.3 Search and browse design

search(query) matches in four tiers, most-confident first, each deduplicated against entries an earlier tier already matched so a result never appears twice and stronger matches always sort first:

  1. Direct symbol lookupquery is itself one or more emoji characters someone pasted or typed in.
  2. Legacy ASCII emoticon alias — an exact match against :), <3, :-D, and similar de facto conventions Unicode itself does not define.
  3. Name or keyword substring — the official Unicode name or a CLDR annotation keyword.
  4. Description substring — the widest, lowest-precision net: a half-remembered phrase that names neither the emoji nor one of its keywords, but does appear in its generated visual description (e.g. “puddle” finds the melting face even though “puddle” is not a CLDR keyword for it).

An empty or all-whitespace query returns no results — the dialog shows the category browser instead. Category browsing and search do not fight each other: typing in the search box takes over the results list; clearing it reverts to whichever category was last selected.

§34.4 The catalog: data sources and generation

QUILL never fetches emoji data at runtime (network_egress_audit.py’s “no surprise network calls” rule; the app must work fully offline and in Safe Mode). The committed catalog, quill/data/emoji_catalog.json (added to pyproject.toml’s wheel force-include, the same convention as the other quill/data/* bundled files), is generated offline, ahead of time, by quill/tools/generate_emoji_catalog.py — a real, permanent, re-runnable maintainer tool, not a one-off script — from three tiers of source data, most to least authoritative:

  1. Unicode Consortium’s emoji-test.txt (pinned to a specific Unicode emoji version, e.g. 16.0, not latest, for reproducible builds) — the category tree, the official short name, and which code points count as standard emoji at all. Only fully-qualified lines are kept: unqualified/minimally-qualified are legacy/alternate encodings of the same emoji, and component lines (bare skin-tone modifiers, the ZWJ joiner) are not standalone emoji.
  2. Unicode CLDR’s English annotations (common/annotations/en.xml) — search keywords per emoji. Coverage is real but partial (roughly a third of entries in the Unicode 16.0 catalog) — CLDR does not annotate every modifier/variant combination, and an entry with no CLDR keywords is still findable by its official name or (per §34.3 tier 4) its description.
  3. iamcal/emoji-data (MIT license) — legacy ASCII emoticon aliases (:), <3, :-D, …) that Unicode does not define at all; every major emoji picker (Windows, Slack, Teams, GitHub) maintains its own small hand-curated table of these, and this is the widely-reused reference one.

The rich visual description has no authoritative source at all. Scraping a picker site like Emojipedia was considered and explicitly rejected: it is not an open dataset, and scraping it carries real licensing/ToS risk that would violate QUILL’s own network-egress and licensing discipline. Instead, the generator asks an LLM (OpenAI gpt-4o-mini, in batches of ~40 emoji per request, structured JSON output) to write an original description per emoji from its Unicode name/category/keywords — “generate, don’t scrape,” chosen specifically so the description text is QUILL’s own to redistribute, not lifted from anyone else’s picker. A maintainer who omits the API key (or has none) still gets a usable, if blander, catalog: every entry falls back to a mechanical synthesis ("{category} > {subgroup}. {name}." plus up to five keywords) built with zero network calls.

The generator is resilient by design, not just “best effort”:

§34.5 Maintenance: updating for a new Unicode emoji version

Unicode publishes a new emoji version roughly annually. When it does:

  1. Re-run the generator against the new version: python -m quill.tools.generate_emoji_catalog --out quill/data/emoji_catalog.json (set OPENAI_API_KEY in the environment, or pass --api-key, to get real descriptions rather than the mechanical fallback for newly-added emoji). Bump _EMOJI_VERSION at the top of the script first if the new version has a different published path under unicode.org/Public/emoji/<version>/.
  2. Review the diff. New emoji only — categories, names, and keywords for existing entries essentially never change between Unicode versions, so a diff should be almost entirely additions.
  3. If a prior run left some entries on the mechanical fallback (check the generator’s own end-of-run validation report, or search the catalog for a description matching the "{category} > {subgroup}. {name}." pattern), repair just those without regenerating everything: python -m quill.tools.generate_emoji_catalog --out quill/data/emoji_catalog.json --fix-missing.
  4. Commit the regenerated quill/data/emoji_catalog.json like any other data update. No application code changes are needed — emoji_data.py reads whatever the catalog contains.
  5. Regenerate the emoji reference table in the user guide (the table is a mechanical dump of the catalog, grouped by category — see the user guide’s Accessible Emoji Picker section for the exact generation step) and its .html/.epub artifacts.
  6. If the run adds a meaningful number of new emoji, mention it in that Beta/point release’s CHANGELOG and release notes, matching the pattern this feature’s own launch used.

Adding a one-off hand-curated description (e.g. touching up a description that reads oddly, or overriding the fallback for a specific emoji without a full LLM re-run) is a direct edit to the relevant entry’s "description" field in quill/data/emoji_catalog.json — the catalog is plain, readable JSON, not a binary format, and emoji_data.py has no separate “curated override” layer to keep in sync; the committed file is the source of truth the running app reads.

§34.6 Non-goals (this phase)


§35. The QuillVille family: apps, build-time sharing, and the data contract (2026-07-20)

QUILL is no longer a single application. It is the source of truth for a family of screen-reader-first apps that share one codebase, one accessible design system, and one on-disk data contract, while each ships and versions on its own cadence. This section states the family architecture as product requirements. It supersedes the ad-hoc per-app arrangements catalogued in docs/design/2026-07-17-audio-studio-migration-audit.md (whose sections 20-25 decided this direction); that audit is the working record, this is the contract.

§35.1 The apps

All app front-ends live in quill/apps/ as small frames on the shared quill.ui.app_shell.AppShellFrame, reusing the same feature mixins QUILL uses:

Each standalone app is a thin wrapper repo (launcher + PyInstaller spec + installer + docs) that pins quill @ <tag> and calls quill.apps.<app>:main. Quill Radio and Quill Cast already follow this shape; Audio Studio adopts it as its reverse-vendor completes.

§35.2 Code sharing model: build-time, not a runtime shared library

Requirement: apps share code at build time, not as a single shared runtime on the user’s disk. Each app ships its own frozen copy of quill/ built against a pinned tag. Rationale:

A single shared Python runtime on disk (so Radio and Cast would not each carry Python) is therefore explicitly not the near-term model. If it is ever pursued, it must be side-by-side versioned (per-app compat floors, GC of old versions) so hotfixing one app cannot break another — that is the only form that preserves §35.2’s guarantees, and it is a deliberate future project, not a default. The near-term win for “don’t install Python twice” is instead an optional shared-runtime installer variant layered on the same build-time codebase, evaluated only after the reverse-vendors and packaging toolkit land.

§35.3 The real shared component: the data contract and core seams

What makes the apps a family is not a shared binary; it is:

Requirement (Tier 1, mandatory before more apps pin/vendor): harden the shared store so a newer app cannot silently downgrade an older app’s data — preserve unknown fields on rewrite, rewrite-on-load only when genuinely legacy, and stamp last_written_by (app + version). And publish FAMILY-DATA.md: a per-file ownership map (every %APPDATA%\Quill file, its schema owner, and its readers). Shared core state stays shared; each app gets namespaced keys for its private state. SQLite is permitted for app-private indexes (Beacon’s engine, a future Audio Studio library index) in per-app namespaces; the shared contract files stay JSON under the hardened versioned-store rules.

§35.4 Suite versioning

Each app carries its own version; the QuillVille suite carries a major version above them as the compatibility anchor. Every app released under suite major N honors the same FAMILY-DATA.md schemas and the same core API line; apps version independently within a suite major (Audio Studio 1.0.0, Radio 2.1.1, Cast 1.0.0 can all be QuillVille 1). A suite-major bump is the only place breaking changes to shared spaces are allowed, coordinated across all apps. About boxes and diagnostics show both (“Quill Radio 2.1.1 — QuillVille 1”) so support knows the contract vintage at a glance.

§35.5 Consolidation roadmap

  1. Tier 0 hygiene — commit stray working trees; resolve the pr.md/pr.html press releases; keep the GATE suite green in each repo. (Largely done.)
  2. Tier 1 data-store hardening + FAMILY-DATA.md — the prerequisite for everything below.
  3. Audio Studio reverse-vendorquill/apps/studio.py (Phase A landed under the GATE suite), then the thin-wrapper cutover and closure deletion (Phase B/C, validated build).
  4. Cast 1.0.0 — Cast has never shipped: reset the version chaos to a single 1.0.0 (pyproject, apps/podcasts.py _VERSION, installer AppVersion + VersionInfoVersion, changelog), add the missing libmpv staging, ship one clean release.
  5. Packaging toolkit — one parameterized spec/installer/build script consumed by QUILL, Radio, Cast, and Audio Studio, retiring the four forks; PyInstaller onedir for Radio/Cast/Beacon, embeddable-CPython for apps with on-demand engine installs (Audio Studio, QUILL).
  6. Beacon convergence — after Beacon’s data-loss and security fixes, fold it into quill/apps/beacon.py on the shared shell; QuillSync becomes the family handshake (adapter order: Beacon, then radio favorites, then settings/keymaps).

§35.6 One update path and one feedback path

Every app resolves updates from its own GitHub Releases (Radio/Cast/Studio read api.github.com/repos/<repo>/releases; QUILL uses the signed manifest feed). The target is one shared, signed-manifest update module (Ed25519) with per-app repo slugs, adopted as each app moves onto the pinned-tag model, and one shared accessible feedback path (the bundled issues-token flow) used by all apps.


Appendix: Engineering documentation

Folded in from the former docs/QUILL-PRD.md on 2026-06-13.

QUILL engineering documentation

Consolidated from the former docs/engineering/ folder on 2026-06-13. Each section preserves the original document in full.


Thread-safety invariants

This note documents the concurrency invariants for Quill’s module-level and shared caches (CQ-17). It is the canonical reference for how the lazily-loaded caches stay correct when several threads touch them at once — the writing thread, the file-I/O and compute pools, and watch-folder worker threads.

Concurrency model recap

Because a lazily-loaded cache can be touched from more than one of these threads on first use, each such cache is guarded by a lock. There are no unguarded module-level mutable caches in quill/core.

Pattern 1 — module-level lazy caches (double-checked locking)

Read-mostly data that is expensive to build once and then never changes uses a module-level threading.Lock plus double-checked locking: an unlocked fast-path read of the cached value, then the lock, a re-check, and population under the lock. The cached value is always an immutable snapshot (a frozenset, or a dict that is never mutated after publication), so readers that win the fast path never observe a half-built structure.

Cache Module Lock Cached state
Word-list fallback quill/core/spellcheck.py _BACKEND_LOCK _WORDLIST_CACHE (frozenset)
Enchant dictionary handle quill/core/spellcheck.py _BACKEND_LOCK _ENCHANT_DICT, _ENCHANT_TRIED
Thesaurus index quill/core/thesaurus.py _LOAD_LOCK _INDEX (dict, never mutated after build), _LOAD_ERROR

Invariants for this pattern:

  1. The cache slot is only ever written while holding the lock.
  2. Once published, the cached object is treated as immutable. To refresh it, replace the whole slot under the lock; never mutate in place.
  3. The fast-path read outside the lock is safe because it reads a single reference that is either None (not yet built) or a fully-built snapshot.
  4. A failed load still publishes a definitive result (an empty cache plus an error string) so the expensive attempt is not retried on every call.

Pattern 2 — per-instance mutable-set caches

Caches that are genuinely mutated over time hold a per-instance threading.Lock and take it around every read and write of the shared mutable state.

Cache Module Lock Shared state
Watch-folder seen-set quill/core/watch_folder.py self._lock self._seen_files (set[str])

Invariants for this pattern:

  1. Every access to the mutable set — clear, membership test, and add — is performed inside with self._lock.
  2. The lock is held only for the brief set operation, never across slow work such as file I/O or dispatching an action, so worker threads do not serialise behind each other.

Stability helpers

The stability layer follows the same per-instance discipline: the task manager (quill/stability/task_manager.py), the wx dispatch queue (quill/stability/wx_dispatch.py), and the heartbeat (quill/stability/wx_heartbeat.py) each own a threading.Lock and take it around their shared bookkeeping.

Rule for new caches

Any new module-level or shared cache must adopt one of the two patterns above: a double-checked Lock with an immutable published snapshot for read-mostly data, or a per-instance Lock taken around every access for mutable state. Do not add an unguarded module-level mutable cache to quill/core.


Docs-artifact regeneration pipeline

This note documents how Quill keeps each docs/**/*.md source in sync with its rendered .html and .epub artifacts, and how the GitHub Actions workflows that enforce and automate that stay correct. It is the canonical reference for the Docs artifacts workflow, the docs-parity guard (scripts/check_docs_artifacts.py), and the workflow-lint gate in PR CI.

What the pipeline guarantees

Every Markdown file under docs/ ships alongside a matching HTML and EPUB build, so readers who consume the published artifacts never see a stale rendering of a source that has since changed. Two mechanisms cooperate:

The parity guard

scripts/check_docs_artifacts.py diffs a base and head ref, and for every changed docs/**/*.md source that still exists, requires that both its .html and .epub siblings also changed in that range. It recurses into subdirectories (docs/planning, docs/qa, …), not just the top level. The guard runs as the “Verify docs artifacts are regenerated” step in Accessibility CI and is a required check.

The auto-regeneration workflow

Docs artifacts triggers only when a docs/**.md source changes. It has two design constraints that drove its current shape, both learned from real failures.

Constraint 1 — EPUB output is non-deterministic

Pandoc embeds a fresh UUID and build timestamp in every EPUB, so regenerating an unchanged source produces different bytes each run. HTML output, by contrast, is deterministic. A naive “regenerate everything and commit the diff” approach therefore reports false staleness on every run and fails forever.

The workflow handles this by:

  1. Scoping regeneration to only the Markdown that changed in the push/PR range (mirroring the parity guard), not the whole tree.
  2. Always rebuilding the deterministic HTML, but generating an EPUB only when it is missing — never rewriting an existing one, so the random-UUID churn cannot manifest.
  3. Basing the “is this stale?” decision on deterministic HTML drift plus genuinely untracked new files, never on EPUB byte differences.

Constraint 2 — main is a protected branch

The workflow cannot push regenerated artifacts to main: branch protection requires changes to arrive through a pull request, so a direct push is rejected (GH006: Protected branch update failed). Attempting it was the original chronic failure. The reconcile step now branches on context:

Regenerating locally

When the workflow or the parity guard reports a stale artifact, regenerate it with the same Pandoc invocations CI uses:

pandoc <source>.md -f gfm -t html5 -s -o <source>.html
pandoc <source>.md -f gfm -t epub3      -o <source>.epub

Commit both artifacts with the source (through a pull request for main).

Workflow linting (workflow-lint)

The workflow-lint job in PR CI runs actionlint (pinned to a specific image) over every workflow on each push and pull request. It statically validates YAML structure, action input contracts, and shell-safety patterns.

This gate exists because a real script-injection vector slipped into the docs workflow: an untrusted ${{ github.head_ref }} was interpolated directly into an inline git push script, where a crafted branch name could execute arbitrary shell. The fix — and the rule the gate now enforces automatically — is to pass workflow context through the step env: block and reference it as a shell variable, never to expand untrusted ${{ github.* }} values inline.

Verifying the workflow logic

Because the reconcile logic is shell rather than Python, it is validated two ways:

  1. Static analysis with actionlint, now wired into CI as workflow-lint.
  2. Behavioral simulation against a throwaway git repository with a bare remote whose pre-receive hook rejects writes to main (simulating branch protection) and a mock pandoc that mirrors the measured behavior (deterministic HTML, random-bytes EPUB). The simulation exercises the in-sync, fail-loud, recursive-subdirectory, missing-EPUB, no-churn, and PR-push-back paths.

When changing the reconcile logic, re-run actionlint locally and re-run the behavioral simulation before relying on CI.


Building Quill for macOS

The macOS build reuses the cross-platform wxPython core plus the quill/platform/macos/ adapters. See issue #42 for the full plan.

Prerequisites

Build

python scripts/setup_macos.py py2app   # -> dist/Quill.app

Sign, notarize, package

export IDENTITY="Developer ID Application: Your Name (TEAMID)"
export NOTARY_PROFILE="quill-notary"
./scripts/build_macos.sh              # -> dist/Quill.dmg (signed, notarized, stapled)

Platform adapters (macOS)

Remaining integration (tracked in #42)

macOS platform review (2026-07-09)

A full-codebase audit of QUILL’s macOS support landed a batch of fixes that close the highest-traffic macOS gaps without rebinding the still-unvalidated bare-F-key chords. The live backlog of remaining work is tracked in docs/planning/macos-review-backlog.md. Completed this pass:


Security Advisory Workflow

This runbook defines how QUILL handles private vulnerability coordination.

Intake

  1. Receive report through SECURITY.md private channel.
  2. Acknowledge receipt and open a private advisory in GitHub Security Advisories.
  3. Classify severity and affected versions.

Triage

  1. Reproduce issue in a controlled environment.
  2. Assess exploitability, user impact, and affected surfaces.
  3. Record mitigation options and patch strategy.

Patch and validation

  1. Implement fix on a private branch when needed.
  2. Validate with standard checks:
  3. Add regression tests where appropriate.

Disclosure

  1. Coordinate disclosure date with reporter when possible.
  2. Publish patched release and advisory details.
  3. Include clear upgrade/mitigation guidance for users.

Post-incident actions

  1. Add follow-up hardening tasks.
  2. Update security checks/process docs if gaps were found.

QUILL Dialog Estate Report (DLG-3)

Last updated: 2026-06-04

This report is the human-readable companion to the machine-enforced dialog governance described in PRD §9.13 (Dialog estate governance) and tracked in docs/planning/ROADMAP.md as DLG-3.0 through DLG-3.8. It records where the dialog-unification work stands after the triage and Phase 3 hardening pass.

Headline

How the estate is governed

Mechanism File Role
Source-generated inventory quill/tools/dialog_inventory.py AST-scans every wx.Dialog(...), stock wx dialog, and show_web_form(...); assigns a stable key <module>::<enclosing_qualname>::<kind> and a sanctioned classification.
Committed snapshot tests/unit/ui/fixtures/dialog_inventory.json The source of truth; CI fails if the live scan disagrees.
Inventory gate tests/unit/ui/test_dialog_inventory.py Fails on any new, moved, removed, or reclassified surface.
Banned-pattern gate quill/tools/check_banned_patterns.py Cross-checks every surface against the snapshot; fails on unregistered/misclassified dialogs (Security CI).
Shared contract helpers quill/ui/dialog_contract.py apply_modal_ids(...) (affirmative/escape IDs) and show_modal_dialog(...) (region + announcement hooks).
Hardening guard (new) tests/unit/ui/test_dialog_hardening_contract.py AST-asserts every hardened_custom surface wires apply_modal_ids + an accessible show/ShowModal/Show path. Blocks future drift at author time.

Triage outcome

A live audit on the Windows wx 4.2.5 (Phoenix, wxWidgets 3.2.9) runtime, plus a source scan for custom-drawn paint code (EVT_PAINT / wx.lib.agw / OnPaint / wx.PaintDC / owner-draw / wx.html2), returned zero custom-drawn dialogs. Every hardened_custom surface is already a stock-widget wx.Dialog container (ListBox, TextCtrl, SearchCtrl, TreeCtrl, multi-action button rows). “Convert to native” is therefore, in the literal wx sense, already true — the genuine work is contract hardening, not rewrites.

Bucket Count Meaning
Convert (flatten to a stock one-shot) 0 No dialog is a lossless single confirm/choice/text entry; flattening any would drop live search, lists, previews, or multi-action rows.
Harden (enhanced-native onto one contract) 48 Genuinely multi-control native dialogs that converge on one focus/default/lifecycle grammar via dialog_contract.
Keep web 1 AskQuillChatDialog (rich streaming chat surface) stays on the sanctioned web surface.

Phase status

Phase Item Status Notes
0 Source-generated registry + gates Done Inventory engine + snapshot + two gates shipped.
1 Strengthened A11Y-4 dialog-contract guard Done _check_dialog_registry + Dialog Excellence Mandates.
T Triage all 49 hardened_custom dialogs Done 0 convert / 48 harden / 1 keep-web.
2 Native conversion wave (flatten to one-shot) Done (no applicable work) Triage found zero lossless conversion candidates; honest no-op.
3 Enhanced-native contract standardization Done All 49 wire the shared contract; new AST guard prevents drift.
4 Web-surface standardization Todo Confirm the 5 web surfaces have native-fallback parity and no raw HTML in onboarding tabs.
5 Startup/onboarding hardening Todo Deterministic focus across chained startup modals; consent preserved.
6 Assistant/AI dialog consolidation (folds DLG-2) Todo assistant_tools.py/ai_model_panel.py/train_style_dialog.py/assistant_panel.py async/“busy” semantics.
7 CQ-16 characterization around dialog-launch paths Todo Return-value/side-effect regression tests before any CQ-1 split.
8 Manual NVDA/JAWS/Narrator SR pass Todo Requires a live Windows screen-reader runtime; cannot be machine-verified.

Phase 3 — what changed

A machine-derived AST audit of every hardened_custom scope found 5 surfaces that did not fully wire the shared contract. Four were brought onto it; the fifth was already correct.

Dialog Module Action
_present_quill_key_help quill/ui/main_frame.py Added apply_modal_ids(ID_OK, ID_OK); routed direct ShowModal() through _show_modal_dialog (adds region/announce hooks).
_offer_crash_recovery quill/ui/main_frame.py Added apply_modal_ids(ID_YES, ID_NO) alongside existing SetDefaultItem/SetEscapeId.
_present_quick_nav quill/ui/main_frame.py Added apply_modal_ids(ID_OK, ID_CANCEL); routed direct ShowModal() through _show_modal_dialog.
_choose_searchable_option quill/ui/main_frame.py Added apply_modal_ids(ID_OK, ID_CANCEL) + deterministic search.SetFocus().
show_watch_folder_status quill/ui/main_frame.py No change — correctly-hardened modeless monitor (Show() + EVT_CLOSEDestroy); the contract audit’s “missing show” was a false positive for modeless windows.

No dialog was flattened; live search, lists, and preview panes are preserved.

Tests and validation

Honest remaining work


Windows installer evaluation and rethink

This document evaluates the QUILL Windows installer (Inno Setup), grounded in what QUILL actually ships, and records the changes made plus a forward-looking vision. The .iss is generated by build_inno_setup_script() in scripts/build_windows_distribution.py; edit the generator, never the emitted installer/quill.iss (a test enforces they stay in sync).

What QUILL actually installs

From scripts/build_windows_distribution.py and the portable bundle layout:

The installer mirrors this with opt-in [Components] and skipifsourcedoesntexist so a build that omits a tool still produces a valid installer.

What the installer already does well

Gaps found

  1. No architecture directives. The bundle is amd64, but the script never set ArchitecturesAllowed / ArchitecturesInstallIn64BitMode, so on a 64-bit OS it installed into the 32-bit Program Files (x86) and would even attempt to run on a 32-bit Windows it can never support.
  2. No minimum OS. The zero-install OCR backend (Windows.Media.Ocr), the winget Node bootstrap, and modern wxPython all require Windows 10+, but the installer would run on Windows 7/8.1 and then fail at runtime.
  3. No association-cache refresh. The fileassoc/shellverbs tasks write Explorer keys, but ChangesAssociations was not set, so Explorer would not refresh icons/Open-With menus until the next shell restart.
  4. A dead component. aiassistant (“Install the Writing Assistant setup guide and AI connection shortcut”) gated no [Files] payload at all – it installed nothing. Showing it implies a choice that does not exist.
  5. Add/Remove Programs icon. UninstallDisplayIcon pointed at run-quill.cmd, which has no icon, so ARP showed a blank/generic glyph.

Changes made in this pass

All in build_inno_setup_script() (and installer/quill.iss regenerated, test updated):

These are correctness and honesty fixes that do not change the bundle contents or the opt-in model.

Forward-looking vision (not yet implemented)

Deliberately deferred because each needs an asset, a signing identity, or a product decision:

Build and verify

# Regenerate the committed installer script after editing the generator:
python -c "from scripts.build_windows_distribution import build_inno_setup_script; from pathlib import Path; Path('installer/quill.iss').write_text(build_inno_setup_script('0.5.0'), encoding='utf-8')"

# Full distribution build (bundled Python + optional tools), then compile:
python scripts/build_windows_distribution.py --bundle-python --compile-installer

# Guard tests:
python -m pytest tests/unit/scripts/test_build_windows_distribution.py -q

Blocked-items completion guide — the exact path to Done on the environment-gated 1.0 features

Status as of 2026-06-03. A small set of QUILL 1.0 items are honestly “In progress” or “Todo” in docs/planning/ROADMAP.md because they are genuinely blocked on something that cannot be produced or verified from a non-live development environment: there is no live AI provider endpoint and no Windows 11 packaged-install cycle available here. None is faked Done.

This document is the precise, file-by-file runbook for what a maintainer (on a real Windows 11 machine with live provider credentials) must do to drive each remaining item to verified Done. Nothing here is hand-waving: every step names the file, function, and acceptance test. It is the operational companion to the docs/planning/ROADMAP.md tracker — the tracker records what remains; this guide records exactly how to finish it.

This guide was previously the working file zfix2.md. It is preserved here under a descriptive name so the completion steps survive scratch-file cleanup.


Summary table

ID Title State now What’s already built & tested What only you can finish Effort
AI-19 Accessible subscription sign-in (OAuth device flow) In progress The full RFC 8628 device-flow state machine (device_login.py), fully unit-tested with an injected poster Real HTTPS poster, consent dialog, DPAPI token storage, AIBackend wiring, live end-to-end run M
SHELL-2 Structured-Markdown OCR verb (AI pass) In progress The assistant structure operation + _apply_ocr_structuring worker wiring, unit + contract tested One live-key run to verify quality/threading on real OCR output; quality tuning S
SHELL-3 Windows 11 modern context menu (IExplorerCommand) Todo The classic Explorer verb path (SHELL-1) ships and is verifiable A compiled IExplorerCommand COM handler + sparse MSIX package + installer registration + real install/uninstall verification M–L

When all three reach Done, Tier 2 is golden (60/60) and the QUILL 1.0 subtotal drops by three remaining.


AI-19 — Accessible subscription sign-in (no pasted API key)

What already exists (do not rebuild)

Exact remaining work (Windows + live provider only)

  1. Real HTTPS poster. Add a urlopen-based poster (TLS-verified) that satisfies the Poster protocol in device_login.py. Put it in a new quill/platform/windows/ or quill/core/ai/ module (keep device_login.py itself poster-free so the GATE-9 egress inventory stays explicit — register the new urlopen site in the egress audit).

  2. Consent / progress dialog DeviceLoginDialog in quill/ui/.

  3. DPAPI token storage. On authorized, persist the returned token via save_generic_credential(target_name="Quill/<provider>/oauth", ...). Never log the token; never write it to the JSON connection file in plaintext.

  4. AIBackend wiring. Teach assistant_ai.py / the provider backend to resolve credentials as: device-login token (if present in DPAPI) → else pasted api_key. The provider must then send the device-login token in _build_auth_headers.

  5. Surface the entry point. Add a “Sign in with your account” button to the AI provider configuration surface (the assistant setup) that launches the flow. Gate behind FeatureManager like every other AI surface.

  6. Live end-to-end verification (the actual blocker). On a real Windows machine with a provider that genuinely offers an OAuth device authorization grant for API access:

Done definition for AI-19

A blind user signs in with an existing subscription via the device flow, with no visible 51-character secret, the token is stored in DPAPI, and at least one real provider serves AI responses using that token — all keyboard/screen-reader accessible and registered in the GATE-9 egress audit.


SHELL-2 — Structured-Markdown OCR verb (AI structuring pass)

What already exists (built and tested this session)

Exact remaining work (needs a live AI key)

  1. One live run. On Windows with a configured, available assistant backend: right-click an image/PDF → “OCR with Quill (structured Markdown)”. Confirm the recognized text comes back as structured Markdown (headings/lists/paragraphs), inserted into the editor, with the “Structured OCR text inserted” status.
  2. Quality tuning. Inspect the output on harder inputs — multi-column PDFs, tables, headers/footers, page numbers. If the model summarizes or drops content, tighten the structure prompt in _OPERATION_PROMPTS (it is the single source of truth) and re-run. No code path changes needed — only the prompt string.
  3. Threading/latency check. Confirm the off-thread assistant.transform(...) call is thread-safe with your backend and that the progress dialog stays responsive (the worker already runs off the UI thread; verify no backend requires UI-thread affinity). If a backend needs main-thread marshaling, route the structuring call through wx.CallAfter-bounded handoff instead.

Done definition for SHELL-2

The structured verb produces faithful structured Markdown from real OCR output on a live backend, with no content loss, responsive UI, and accessible status announcements. Then flip SHELL-2 to Done in ROADMAP.md (tracker + both living lists + a dated activity-log entry) and regenerate docs/planning/ROADMAP.html and docs/planning/ROADMAP.epub.


SHELL-3 — Windows 11 modern context menu (IExplorerCommand) + installer

What already exists

Why this can’t be finished from here

The Windows 11 primary context menu (the non-“Show more options” menu) only shows verbs provided by a registered IExplorerCommand COM handler packaged in a sparse/MSIX package. That requires a compiled in-proc COM component and a real package install — none of which a pure-Python repo can produce or verify in this environment.

Exact remaining work (real Windows 11 + packaging toolchain)

  1. Build the IExplorerCommand handler. A compiled in-proc COM server (C++/WinRT or Rust, or a packaged .NET COM component) that implements IExplorerCommand (and IExplorerCommandState / IEnumExplorerCommand for the submenu). It must surface the same verbs as the core registry — drive its labels/actions from quill/core/shell_verbs.py (default_shell_verbs(), verb_for_action, verbs_for_extension) so there is exactly one source of truth. Each invoked verb launches quill --action <verb> "<path>" (reuse verb_launcher_command(action) from shell_integration.py).

  2. Sparse package + registration. Author a sparse MSIX package manifest that declares the IExplorerCommand handler under the relevant windows.fileTypeAssociation / desktop4:FileExplorerContextMenus extension, and register/unregister it on install/uninstall.

  3. Installer wiring. Wire the package register/unregister into the QUILL installer (installer/quill.iss and/or scripts/build_windows_distribution.py) so a normal install adds the modern menu and uninstall removes it cleanly. Keep the classic-menu fallback for non-packaged/portable installs.

  4. Live install/uninstall verification. On Windows 11: install → confirm the verbs appear in the primary right-click menu (not just “Show more options”) for the correct file types → run each verb → confirm uninstall removes them with no orphaned registry/package state. Confirm keyboard and Narrator accessibility of the menu entries.

Done definition for SHELL-3

QUILL’s “Send to Quill” verbs appear in the Windows 11 primary context menu via a registered IExplorerCommand, driven by the same shell_verbs.py registry, installed and removed cleanly by the installer, and verified on a real Win11 box. Then flip SHELL-3 to Done in docs/planning/ROADMAP.md and regenerate docs/planning/ROADMAP.html and docs/planning/ROADMAP.epub.


Per-change discipline reminder (applies to all three)


Appendix A (merged from former zfix4) — SHELL-3 live verification & issue status

This appendix preserves the full SHELL-3 live-verification checklist and intake issue status that previously lived in zfix4.md.

Part 1 — SHELL-3 verification steps

What already shipped (commit 1d3bfc4, on main)

These are all green (ruff + strict mypy + pytest). What remains is purely a live Windows install/uninstall pass — it cannot be done in a non-live environment and is the only thing standing between SHELL-3 and Done.

Prerequisite: install the Inno Setup 6 compiler (ISCC)

The build box does not currently have ISCC. Install it once:

winget install --id JRSoftware.InnoSetup --source winget

Confirm it resolves (either of these should print a path):

Get-Command ISCC.exe -ErrorAction SilentlyContinue
@("C:\Program Files (x86)\Inno Setup 6\ISCC.exe",
  "C:\Program Files\Inno Setup 6\ISCC.exe") | Where-Object { Test-Path $_ }

Step 1 — Build the portable bundle + installer

From the repo root, with the venv active:

# Build the portable tree, regenerate installer/quill.iss, and compile the
# installer in one pass. --bundle-python makes the result self-contained.
python -m scripts.build_windows_distribution --bundle-python --compile-installer

Expected: windows-distribution\installer\Quill-Setup-<version>.exe (and/or ...\Output\Quill-Setup-<version>.exe) is produced. If --compile-installer is not wired as a flag in your build, compile manually:

& "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" `
  windows-distribution\installer\quill.iss

Step 2 — Install with the “Send to Quill” verbs enabled

  1. Run Quill-Setup-<version>.exe.
  2. On the Select Additional Tasks page, check “Add ‘Send to Quill’ actions (OCR, Open, Read aloud) to the file right-click menu” (it is unchecked by default, by design).
  3. Finish the install.

Accessibility check while you are here: the task checkbox label must be read clearly by your screen reader, and the wizard must not auto-close before the final status is announced (it is configured CloseApplications=force, RestartApplications=no).

Step 3 — Verify the verbs appear and run (the core test)

The classic registry verbs surface under “Show more options” on Windows 11, and directly in the Shift+F10 keyboard context menu.

  1. In File Explorer, navigate to a test .png (or .jpg, .tif).
  2. Give it focus and press Shift+F10 (keyboard context menu).
  3. Confirm these items are present and screen-reader friendly:
  4. Activate OCR with Quill.
  5. Confirm: a running QUILL instance is reused (or one launches), focus lands on the OCR review dialog, and an announcement states what happened.
  6. Repeat once for a .pdf to confirm document handling.

If a verb does not appear, the most likely causes are: the shellverbs task was left unchecked at install, or another app owns an overriding per-extension association. Re-run the installer and confirm the task is ticked. The keys are written to HKCU (never HKLM), so no elevation is needed and no other user is affected.

Step 4 — Confirm the registry keys exist (optional, precise)

# Should list a "Quill.ocr" subkey with a (default) value of "OCR with Quill"
reg query "HKCU\Software\Classes\SystemFileAssociations\.png\shell\Quill.ocr" /ve
reg query "HKCU\Software\Classes\SystemFileAssociations\.png\shell\Quill.ocr\command" /ve

The \command default value should be: "<install>\quill.exe" -m quill --action ocr "%1".

Step 5 — Uninstall and confirm clean removal

  1. Uninstall QUILL (Settings → Apps, or the Start-menu uninstaller).
  2. When prompted about removing personal data, either choice is fine for this test (that prompt covers %APPDATA%\Quill, not the shell verbs).
  3. Re-run the reg query commands from Step 4 — both must now return “ERROR: The system was unable to find the specified registry key”, proving uninsdeletekey cleaned up the verbs.
  4. Re-check Shift+F10 on the test .png: the Quill verbs must be gone.

Step 6 — Flip SHELL-3 to Done

Once Steps 1–5 pass on real hardware:

Part 2 — What’s left, issue by issue

Umbrella #113 — Open & OCR from the file manager

Status: substantially delivered on Windows; keep open as the umbrella. The shared groundwork it asked for is done: the action-bearing entry point (quill --action <verb> "<path>"), routing through the existing single-instance IPC, the qualifying file-type sets, and the initial action set (Open, OCR, Read aloud) all ship via SHELL-1. Close this only when its three sub-issues are resolved.

#114 — Windows Explorer context menu

Status: classic/Show-more-options path is code-complete and tested; needs the Part 1 live pass to call done. One sub-item is intentionally deferred.

Sub-item from #114 Status
Classic registry verbs (SystemFileAssociations\<ext>\shell\…) per image ext + .pdf Done in code (SHELL-1 runtime writer + SHELL-3 installer registration)
Verb invokes shared --action entry point over single-instance IPC Done (SHELL-1)
Installer registration + clean uninstall Done in code (SHELL-3); needs live verify (Part 1)
Reachable via Shift+F10; clear labels; focus + announcement after invoke Done in code; confirmed by the Part 1 manual pass
Modern Win11 menu via IExplorerCommand (packaged COM) Deferred to a future major release — the OS gates the primary menu behind compiled COM + package identity (sparse/MSIX). Out of scope for 1.0.

To finish #114 for 1.0: run Part 1, then post a status comment noting the modern-menu IExplorerCommand piece is tracked as a 2.0 follow-up, and close.

#115 — macOS Finder integration

Status: Blocked, correctly. Not a 1.0 item. Depends on the macOS port (#42), which is not done. The OCR engine work it describes (Apple Vision backend) also lands with the macOS port. No action now.

#116 — Structured OCR (AI-gated)

Status: functionally delivered (SHELL-2), pending one live-AI verification; the geometry/bounding-box enhancement is an optional follow-up.

Sub-item from #116 Status
Gate behind AI + explicit ocr_structured opt-in setting Done (SHELL-1/SHELL-2)
Dedicated transform-style op returning structured Markdown Done — assistant structure operation
Feed structured result into the OCR review dialog Done_apply_ocr_structuring in the OCR worker
Plain-text behavior unchanged when AI off Done — degrades safely with a status note
Capture layout geometry (bounding boxes) for tables/columns Not started — optional quality enhancement; OcrLine has no boxes yet. Nice-to-have, can be a 2.0 follow-up.

To finish #116 for 1.0: one live-key end-to-end run + structuring-quality tuning on real multi-column / table OCR output (this is the SHELL-2 remainder), then close noting the geometry enhancement is a future improvement.


Open Tier 2 roadmap items (the honest blockers)

After SHELL-1 (Done) and this SHELL-3 work, Tier 2 stands at 57 of 60. The three open items and what each genuinely needs:

ID What’s left Blocker class
SHELL-3 The Part 1 live install → right-click → run → uninstall pass Windows runtime + Inno Setup install cycle
SHELL-2 One live-AI run + prompt-quality tuning on real OCR; then flip to Done Configured/available AI backend
AI-19 Real HTTPS device-login poster, DeviceLoginDialog, DPAPI token storage, AIBackend wiring, live sign-in (RFC 8628 state machine already built + tested) Live provider OAuth device endpoint + Windows runtime

Closest to Done: SHELL-3 and SHELL-2 — each needs a single live pass with no further code. AI-19 still needs real code plus a live provider.

Explicitly out of scope for 1.0 (do not work on)


Appendix: Accessibility documentation

Folded in from the former docs/QUILL-PRD.md on 2026-06-13 (ACR/VPAT conformance report + announcement-grammar style guide).

QUILL accessibility documentation

Consolidated from the former docs/accessibility/ folder on 2026-06-13. Each section preserves the original document in full.


Accessibility Conformance Report (ACR)

Report details

Standards and guidelines

VPAT summary table

Criteria Conformance Level Remarks
1.1.1 Non-text Content Supports / Partially Supports / Does Not Support Fill in evidence
1.3.1 Info and Relationships Supports / Partially Supports / Does Not Support Fill in evidence
2.1.1 Keyboard Supports / Partially Supports / Does Not Support Fill in evidence
2.4.3 Focus Order Supports / Partially Supports / Does Not Support Fill in evidence
4.1.2 Name, Role, Value Supports / Partially Supports / Does Not Support Fill in evidence

Assessment notes


Announcement style guide

This guide defines the shared grammar for every status message and screen-reader announcement in Quill. A single predictable shape lets users of NVDA, JAWS, and Narrator parse an outcome in one pass and builds trust that the app always reports what it just did.

The grammar is implemented in quill/core/announcements.py (format_announcement, format_progress, pluralize). Use those helpers rather than hand-building strings, and keep this document in sync with them.

The grammar

<Verb> <object>[, <count> <unit>(s)][, <detail>].

Examples

Situation Announcement
Rewrote the paragraph at the cursor Rewrote paragraph, 42 words.
Summarized the whole document Summarized document, 1,200 words.
Saved the file Saved document.
Copied with no measurable object Copied.
Replaced a one-word selection Replaced selection, 1 word.
Nothing was selectable to act on Nothing to rewrite.
Starting a slow rewrite Rewriting paragraph, 42 words.

Rules

  1. Always report the outcome. Every action that changes the document or state announces what happened, including the scope it chose when there was no selection (paragraph at the cursor, or the whole document).
  2. State the scope and count for content actions. When an action operates on a body of text, include the object and the word count so the user knows the size of what changed.
  3. Say so when nothing happened. If there is nothing to act on, announce it (“Nothing to rewrite.”) instead of staying silent or sending an empty request.
  4. Keep it one short sentence. No nested clauses beyond a single optional detail segment. Screen-reader users should not have to wait through a paragraph.
  5. No raw punctuation tricks. Do not pad with parentheses or trailing ellipses; the helpers produce a clean sentence.
  6. Reuse the helpers. Do not duplicate phrasing logic. Import format_announcement / format_progress from quill.core.announcements.

Verb reference (common actions)

Action Progress verb Outcome verb
Rewrite Rewriting Rewrote
Summarize Summarizing Summarized
Fix grammar Checking grammar in Fixed grammar in
Continue writing Continuing Continued
Save Saving Saved
Replace Replacing Replaced
Copy Copying Copied

Appendix: QA test plan

Folded in from the former docs/QUILL-PRD.md on 2026-06-13.

QUILL QA documentation

Consolidated from the former docs/qa/ folder on 2026-06-13. Each section preserves the original document in full.


QUILL 1.0 final-QA test plan

Status: living document — execution owned by the maintainer for the Tier 6 release gate (DLG-3.8). This is the authoritative manual and exploratory test plan for the QUILL 1.0 release. It complements, and does not replace, the machine-enforced gate ladder (PR CI, Security CI, Accessibility CI) and the dialogs.md manual dialog regression checklist.

1. Purpose and scope

This plan defines the final, human-executed quality pass required before QUILL 1.0 ships. Its job is to verify, on a real Windows runtime with real assistive technology, the behaviours that static analysis and headless unit tests cannot prove: screen-reader announcements, focus journeys, audio output, OCR against live images, the device-login network round trip, and the felt quality of the writing experience.

In scope:

Out of scope for 1.0 (deferred to 2.0, tracked in ROADMAP.md): axe-core / Nu Html Checker validation, BITS Whisperer, the GLOW watch-action binding (WATCH-8), and the Accessibility Agents workstream.

2. Test environments

Run the full pass on at least one machine from each row. Record the exact build identifier (version + commit) on every result.

Environment OS Screen reader Notes
Primary Windows 11 (latest) NVDA (latest stable) Baseline for every case
Secondary Windows 11 (latest) JAWS (latest) Spot-check the high-traffic surfaces
Sanity Windows 10 + Windows 11 Narrator Confirm no hard breakage
Clean-install Fresh Windows VM, no Python (NVDA) Installer + first-run only
High contrast Windows 11, High Contrast on NVDA Visual + announcement parity

Build under test: record version from About Quill and the commit SHA. A pass is only valid against a single, named build.

3. Entry and exit criteria

Entry criteria (all must hold before a final-QA pass begins):

Exit criteria (all must hold to sign off 1.0):

4. Severity model

Severity Definition Release impact
Critical Data loss, crash, silent network egress, or a screen-reader user cannot complete a core task Blocks release
High A core task is severely degraded, or an announcement/focus contract is broken Blocks release unless explicitly waived
Medium A non-core task is degraded, or an announcement is imprecise Fix or document before release
Low Cosmetic, or a minor wording nit Track for a follow-up

5. Evidence capture

For every test case record: build id, environment row, screen reader + version, pass/fail, and a one-line observation. For dialog cases, capture the exact announced title and the announced text of the default and cancel actions. For failures, capture: reproduction steps, expected vs actual announcement/focus, severity, and a linked issue.

Store the completed evidence alongside this plan (a dated copy of the filled dialogs.md plus a results sheet) so each release has a durable record.

5.1 Evidence artifacts to record alongside the build id

For every pass, record the following alongside the build identifier in the results sheet. They are the machine-checked receipts that the build under test actually agrees with the in-repo enforcement gates.

If any of the five artifacts above changed during the pass, the diff must be present in the commit history of the release branch; a release tag without these diffs is not a clean 1.0.

6. Dialog estate pass (covers DLG-2, DLG-3.6, DLG-3.8)

The authoritative list is dialogs.md (sections A–X). Do not duplicate it here; execute it directly. The contract every dialog must satisfy is the A11Y-4 contract restated at the top of dialogs.md:

  1. It opens from the listed command or menu path.
  2. Tab and Shift+Tab reach every control in a sensible order.
  3. The screen reader announces the dialog title and each control.
  4. Enter activates the default action; Escape and the close button cancel.
  5. On close, focus returns to the editor.
  6. The dialog never traps, freezes, or goes silent.

Screen-reader coverage matrix for the dialog estate:

Surface group (dialogs.md) NVDA JAWS Narrator
A. File / session Full Spot Sanity
B. Settings, palette, menu editor Full Spot Sanity
C. Navigate (Go To, Outline, bookmarks) Full Sanity
D–F. Text analysis, accessibility, intake Full Sanity
G. Read aloud + OCR (incl. OCR Review) Full Spot Sanity
H. Sticky notes Full Spot Sanity
I–P. Formats, compare, keyboard, macros Full Sanity
Q. AI and assistant (DLG-2 / DLG-3.6) Full Spot Sanity
R. BITS Whisperer Out of scope for 1.0
S–T. Help, features, startup, support Full Spot Sanity
U. Selection and QUILL key Full Sanity
V. Nested and secondary dialogs Full Sanity
W. Power Tools Full Sanity
X. Startup-only (crash recovery, trust) Full Spot Sanity

“Full” = exercise the complete A11Y-4 contract. “Spot” = open, confirm title + default + Escape + focus return. “Sanity” = open and confirm it is not silent or trapped. JAWS spot priority: startup, the assistant/AI tools, sticky notes, and watch profiles (the surfaces with live lists, async work, and chained modals).

Special attention for the AI/assistant tools (Q) — the DLG-2 / DLG-3.6 acceptance the maintainer is signing off:

7. Keyboard and focus pass

8. File, session, and document lifecycle

10. OCR pass (OCR-1, OCR-3)

Requires a live display, clipboard, and OCR engine, so it cannot be verified headless.

11. Startup, onboarding, and recovery pass

12. Read-aloud and speech pass

13. Installer and first-run pass (clean machine)

14. Performance and stability pass

15. High-contrast and visual pass

16. Localization sanity

17. Sign-off

Final sign-off is recorded by the maintainer against a single named build when every section has evidence, dialogs.md is fully ticked for that build, and no open Critical or High defect remains. This sign-off closes DLG-3.8 and, with it, the deferred SR-verification criteria of DLG-2 and DLG-3.6.

Role Name Build Date Result
Maintainer (SR sign-off)

Appendix: Deployment

Folded in from the former docs/QUILL-PRD.md on 2026-06-13.

QUILL Deployment Guide

Covers distribution, update packaging, pack distribution, and release best practices.

Table of Contents


Release types

Type Description Tag pattern
Beta Pre-release for wider testing v0.5.0-beta.1
Stable Production release v1.0.0
Hotfix Critical-only patch v1.0.1

All three go through the same windows-release workflow. Mark betas as prerelease: true in the GitHub release (already the default in the workflow).


Building a Windows release

The build workflow (windows-release.yml) runs on push to a v* tag. To trigger it manually:

git tag v0.5.0-beta.1
git push origin v0.5.0-beta.1

The workflow produces three artifacts:

To build locally (requires Inno Setup 6 and Python 3.12):

pip install -e .[ui,spellcheck,dev]
python scripts/build_windows_distribution.py `
    --bundle-python `
    --compile-installer `
    --iscc-path "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" `
    --output-dir windows-distribution

Update mechanism overview

QUILL uses the AccessibleApps autoupdate library (vendored at quill/_vendor/autoupdate/) for incremental over-the-air updates.

The flow:

  1. QuillUpdateManager.check_for_updates() is called (on startup or from Help > Check for Updates).
  2. The manager fetches the update feed JSON from a hosted endpoint.
  3. If the feed version is newer than the running version, the user is prompted via _on_update_available.
  4. If accepted, the update ZIP is downloaded with progress announcements.
  5. The ZIP is extracted to a temp directory.
  6. The platform bootstrapper (bootstrap.exe on Windows) is moved out of the extracted tree and executed.
  7. The bootstrapper waits for QUILL to exit, then copies the new files over the installation and restarts the app.

The manager announces all state changes through the screen-reader bridge (prism_bridge.announce) so users with NVDA/JAWS/Narrator hear every step without watching the UI.


Update feed format

The feed is a single JSON file served over HTTPS. Example:

{
  "current_version": "0.5.0",
  "description": "Accessibility improvements and bug fixes. See the release notes for details.",
  "published_at": "2026-06-12T14:00:00Z",
  "downloads": {
    "Windows": "https://releases.example.com/quill-0.5.0-windows.zip",
    "Darwin":  "https://releases.example.com/quill-0.5.0-macos.zip",
    "Linux":   "https://releases.example.com/quill-0.5.0-linux.zip"
  }
}

Field notes:

Generate the feed with the bundled script:

python scripts/generate_app_updater_feed.py `
    --version "0.5.0" `
    --windows-url "https://github.com/Community-Access/quill/releases/download/v0.5.0/quill-0.5.0-windows.zip" `
    --macos-url   "https://github.com/Community-Access/quill/releases/download/v0.5.0/quill-0.5.0-macos.zip" `
    --linux-url   "https://github.com/Community-Access/quill/releases/download/v0.5.0/quill-0.5.0-linux.zip" `
    --description "Accessibility improvements and bug fixes." `
    --output docs/site/updates/.quill-app-updater-v1.json

Commit and push; the GitHub Pages workflow publishes it automatically.


Building an update archive

The update ZIP must contain:

quill/                   all application Python files
quill-data/              data files (words_alpha, thesaurus, schemas, quillins)
bootstrap.exe            Windows bootstrapper (see below)

The bootstrapper must be at the root of the ZIP (not in a subdirectory) so move_bootstrap can find it. The rest of the layout must match the structure that bootstrap.exe expects when copying files over the existing installation.

Use scripts/build_update_zip.py to produce the update ZIP automatically. It handles delta computation, bootstrapper placement, and UPDATE_MANIFEST.json generation. See Update ZIP scripts for full usage.

The bootstrapper binaries are not checked into this repo because they are native executables maintained upstream. Run scripts/fetch_bootstrappers.py to download them, or see quill/_vendor/autoupdate/bootstrappers/README.txt for manual instructions.


Bootstrapper binaries

The bootstrapper (bootstrap.exe on Windows, bootstrap-mac.sh on macOS, bootstrap-lin.sh on Linux) is built and maintained by the AccessibleApps project:

https://github.com/accessibleapps/app_updater

To obtain the binaries for a release build, use the bundled fetch script:

python scripts/fetch_bootstrappers.py

This downloads all three bootstrappers from the upstream repo, verifies their SHA-256 hashes, and records checksums in quill/_vendor/autoupdate/bootstrappers/checksums.sha256. Re-running is idempotent — files with matching checksums are skipped.

The binaries are not required for development or running tests. They are only needed when building a distributable package that supports in-place updates.

Add the bootstrappers/ directory to the Windows installer via the Inno Setup script generated by build_windows_distribution.py. The script already includes a [Files] directive for the bootstrapper; verify it points to the correct path before compiling.


Update ZIP scripts

Three scripts in scripts/ automate the update ZIP pipeline. All three are stdlib-only and require no extra dependencies beyond what pip install -e . already installs.

scripts/fetch_bootstrappers.py

Downloads bootstrap.exe, bootstrap-lin.sh, and bootstrap-mac.sh from the AccessibleApps upstream repo and writes a checksums.sha256 file for verification. Binaries land in quill/_vendor/autoupdate/bootstrappers/ and are excluded from git.

# Download (idempotent — skips files whose SHA-256 matches the recorded checksum)
python scripts/fetch_bootstrappers.py

# Force re-download regardless of checksum
python scripts/fetch_bootstrappers.py --force

# Verify existing files without downloading
python scripts/fetch_bootstrappers.py --verify-only

The CI build job runs this automatically before compiling the installer.

scripts/generate_file_manifest.py

Walks quill/ and records the SHA-256 hash, size, and install path of every distributable file. Output is docs/site/updates/manifests/manifest-{version}.json. The manifest feeds build_update_zip.py to compute deltas.

# Generate manifest for the current source tree
python scripts/generate_file_manifest.py --version 0.5.0

# Generate and compare against a previous release
python scripts/generate_file_manifest.py --version 0.5.0 --compare-to 0.4.9

Excluded from the manifest: quill/tools/, __pycache__/, *.pyc, *.pyo. The --compare-to flag prints changed, added, and deleted file counts and a net size delta — useful for sanity-checking before cutting a release.

scripts/build_update_zip.py

Produces the update ZIP consumed by the autoupdate bootstrapper. By default it runs in delta mode: only files whose SHA-256 changed since the previous manifest are included, keeping update ZIPs lean.

# Delta update (auto-detects base version from available manifests)
python scripts/build_update_zip.py --version 0.5.0

# Force full update
python scripts/build_update_zip.py --version 0.5.0 --mode full

# Specify base version explicitly
python scripts/build_update_zip.py --version 0.5.0 --mode delta --base-version 0.4.9

# Target a non-Windows platform
python scripts/build_update_zip.py --version 0.5.0 --platform macos

The ZIP always contains:

If no previous manifest exists, the script falls back to full mode automatically.

Running the full pipeline

# 1. Fetch bootstrappers (once per release machine)
python scripts/fetch_bootstrappers.py

# 2. Generate the manifest for this version
python scripts/generate_file_manifest.py --version 0.5.0

# 3. Build the delta update ZIP
python scripts/build_update_zip.py --version 0.5.0

# Output: release-artifacts/quill-0.5.0-update-windows.zip

The CI build job runs all three steps automatically on each v* tag push.


Hosting the feed

GitHub Pages (default)

The update feed lives at:

docs/site/updates/.quill-app-updater-v1.json

The github-pages workflow publishes the entire docs/site/ tree on every push to main. The feed URL used by QuillUpdateManager should be:

https://community-access.github.io/quill/updates/.quill-app-updater-v1.json

Update UPDATE_FEED_ENDPOINT in quill/core/settings_specs.py (or wherever the URL is configured) before the first beta that ships the update check.

Keeping the feed up to date

Workflow: after every release, regenerate the feed, commit it, and push. The Pages deployment runs automatically:

python scripts/generate_app_updater_feed.py --version "0.5.1" ...
git add docs/site/updates/.quill-app-updater-v1.json
git commit -m "chore: update feed to 0.5.1"
git push

Do not update the feed until all platform ZIPs are uploaded and their URLs are stable. Clients check the feed on startup; a partial update (feed updated before ZIPs are live) will offer a broken download to users.


Publishing a release

  1. Bump the version in pyproject.toml and commit.
  2. Tag the commit: git tag v0.5.0 && git push origin v0.5.0
  3. The windows-release workflow runs automatically:
  4. Download the portable ZIP artifact, add the bootstrapper at the root, re-upload as the final update ZIP to the GitHub release.
  5. Generate the update feed pointing at the final ZIP URLs.
  6. Commit and push the feed; Pages deploys within minutes.
  7. Announce the release (release notes, mailing list, etc.).

Pack distribution

Packs (Quillins) are distributed as .zip archives containing a manifest.json validated against quill/core/schemas/extension.json.

Creating a pack

# Directory structure:
my-pack/
  manifest.json
  README.md
  LICENSE
  <script-or-data files>

# Validate before distributing:
python -m quill.tools.quillin_lint my-pack --strict

# Package:
Compress-Archive -Path my-pack -DestinationPath my-pack-1.0.zip

Distributing a pack

Packs can be distributed through any channel — GitHub Releases, a personal website, email. Users install them via Tools > Quillin Manager > Install from File.

For packs that should ship bundled with QUILL, place them in quill/quillins_bundled/ and submit a pull request. Bundled Quillins are linted in CI with --strict and require a README.md, a LICENSE, and a manifest.json with a justification for each capability requested.

Pack versioning

Pack versions are free-form strings in manifest.json. There is no automatic update mechanism for packs; users re-install a newer .zip to upgrade.


Version numbering

QUILL uses semantic versioning (MAJOR.MINOR.PATCH). The vendored autoupdate library (quill/_vendor/autoupdate/autoupdate.py) compares versions with a tuple-based integer comparison:

tuple(int(x) for x in str(v).split("."))

This correctly orders 1.9 before 1.10. The upstream library used a plain string comparison ("1.9" > "1.10" evaluates True — wrong). The fix is in _version_tuple() in the vendored copy; the upstream issue should be tracked at https://github.com/accessibleapps/app_updater if a PR is contributed.


Testing updates locally

To test the full update pipeline without publishing a real release:

  1. Start a local HTTP server:

    python -m http.server 8765 --directory /path/to/test-assets
  2. Create a test feed at /path/to/test-assets/feed.json:

    {
      "current_version": "99.0.0",
      "description": "Local test update",
      "downloads": { "Windows": "http://localhost:8765/test-update.zip" }
    }
  3. Create test-update.zip containing the app files and bootstrap.exe.

  4. Override the feed URL in a dev settings file (do not commit):

    # quill/core/settings.py — temporary, revert before committing
    update_feed_endpoint = "http://localhost:8765/feed.json"
  5. Run QUILL and trigger Help > Check for Updates.

The screen reader should announce “Quill 99.0.0 is available”, then progress during download, then “Update ready. Quill will restart…”.

For CI-safe automated tests, see tests/unit/ui/test_update_manager.py. All HTTP is mocked; the bootstrapper execution step is always stubbed.


Rollback

To roll back to a previous version after a bad release:

  1. Update the feed to point at the previous version’s ZIP:

    python scripts/generate_app_updater_feed.py `
        --version "0.4.9" `
        --windows-url "https://.../quill-0.4.9-windows.zip" ...
  2. Commit and push. Clients on 0.5.0 will be offered the 0.4.9 update on their next startup check.

    Note: "0.4.9" > "0.5.0" is false under string comparison, so the rollback feed will not be offered automatically. You must either:

    This is a known limitation of string-comparison versioning. Plan hotfix releases as forward increments.


Best practices

Do not update the feed before the ZIPs are live. Users who start QUILL in the window between feed publication and ZIP upload will get a failed download.

Keep description text short. The description is read aloud by the screen reader when the update dialog opens. Two sentences maximum; link to the full release notes instead of embedding them.

Sign the ZIPs. The autoupdate library downloads and extracts ZIPs without verifying a signature. For the beta, this is acceptable. Before 1.0, add SHA-256 checksums to the feed and verify them in QuillUpdateManager before calling extract_update.

Test the bootstrapper on a clean machine before each major release. The bootstrapper is a native binary that replaces files on disk. Run it manually on a clean install once per release cycle to confirm the file-copy logic matches the new distribution layout.

Announce update availability through the screen reader, not a modal. The current _on_update_available implementation returns True without showing a dialog. Before 1.0, replace this with a non-modal notification bar (or a simple Yes/No dialog that is immediately reachable with Tab). Never block QUILL startup on an update prompt.

Staged rollout. For major versions, serve the new feed to a fraction of users first. This is not natively supported by autoupdate; implement it by hosting multiple feed URLs (e.g., /updates/beta.json and /updates/stable.json) and shipping beta builds that point at the beta feed.


Appendix: AccessibleApps integration

Folded in from the former docs/QUILL-PRD.md on 2026-06-13.

AccessibleApps Integration Strategy for Quill

Overview

Quill now integrates multiple libraries from AccessibleApps (MIT-licensed) to accelerate accessibility improvements, enable incremental updates, and provide battle-tested UI components across Windows, macOS, and Linux.

Components Integrated

1. app_updater (Incremental Updates)

Status: Implemented (production-ready)
Files: - scripts/generate_app_updater_feed.py — generates JSON feed in autoupdate format - quill/ui/update_manager.py — callback integration with screen-reader announcements - pyproject.toml — autoupdate dependency added to core

What it enables: - Micro-updates: Ship patches as small ZIPs containing only changed files, not full reinstalls. - Automatic bootstrapper: Platform-specific scripts (bootstrap.exe, bootstrap-mac.sh, bootstrap-lin.sh) apply updates atomically after app exit and restart. - Accessible UX: Update checks and progress announced via screen reader with explicit user consent. - Security: Feeds can be cryptographically signed (Ed25519/RSA) and verified client-side.

Developer notes: - Call UpdateManager.check_for_updates() from Help menu or startup checks. - Callbacks route through prism_bridge.announce() for screen-reader compatibility. - CI must produce ZIP update packages in release pipeline (full installer flow unchanged). - Test bootstrapper atomicity on Windows (NVDA/Narrator) and macOS (VoiceOver).


2. smart_list (Virtual Lists)

Status: Optional (integration in progress)
Files: - pyproject.toml — smart_list added to [project.optional-dependencies.ui] - Prototype: quill/ui/smartlist_adapter.py (to be created)

Developer notes: - Keep smart_list use confined to quill/ui (it imports wx). - Prototype: wrap SmartList/VirtualSmartList in an adapter that matches Quill’s existing list interface. - Test with NVDA/Narrator (Windows) and VoiceOver (macOS) for focus and announcement behavior. - Intended for outline navigator and large-document list views.


3.5. html_to_text (HTML Paste Cleaning)

Status: Fully integrated (production-ready)
Files: - quill/ui/html_paste_cleaner.py — HTML detection and cleaning logic - quill/ui/main_frame.py — integrated into magic_paste() function - tests/unit/ui/test_html_paste_cleaner.py — 21 comprehensive tests - quill/core/settings.pyauto_clean_html_paste setting (currently defaults to False) - pyproject.toml — html_to_text added to [project.optional-dependencies.ui]

What it enables: - Automatic HTML detection from web browser pastes (blog posts, emails, articles). - One-click conversion to clean plain text while preserving structure (headings, links, lists). - Optional auto-clean mode via Settings → Paste (currently opt-in for user safety). - Fallback regex-based cleaning if html_to_text library is unavailable. - No silent stripping; user controls every paste action via Magic Paste picker.

Workflow: 1. User copies HTML from web browser or email client. 2. User presses Ctrl+Shift+V (Magic Paste). 3. Quill detects HTML and shows picker: - “Paste HTML as clean text” (recommended) - “Paste HTML as-is” (original raw HTML) - “Paste as plain text” (fallback) 4. User chooses; clean version is inserted immediately.

Developer notes: - analyze_paste(text) → HtmlPasteContext: Fast heuristic detection (doesn’t require html_to_text import to detect). - clean_html(html) → str: Uses html_to_text if available; falls back to regex stripping. - Heuristic detects HTML tags but avoids false positives (code samples, email footers). - All 21 tests passing: detection, cleaning, fallback, edge cases, integration scenarios. - Screen-reader integration: Status bar announces cleanup (e.g., “Pasted cleaned HTML (350 chars)”).


4. accessible_output2 (Braille + Speech Fallback)

Status: Optional (recommended for later)
Files: None yet — planned as fallback in quill/platform/windows/prism_bridge.py

What it enables: - Braille output when Prism backend doesn’t support it. - Fallback speech when Prism runtime is unavailable (graceful degradation). - Mature support for NVDA, JAWS, Narrator via multiple speech backends.

Developer notes: - Do NOT add as a hard dependency; keep Prism as the primary backend. - Create an optional adapter quill/platform/windows/accessible_output_adapter.py that: - Detects accessible_output2 availability at import time. - Exposes speak(message, interrupt) matching Prism’s interface. - Falls back to accessible_output2 if Prism probe fails or braille is needed. - This is a future enhancement after Prism stabilizes and user demand for braille surfaces.


4. app_elements (Common Dialogs)

Status: Candidate for selective adoption
Files: None yet — audit and wrap per-component

What it enables: - Pre-built about boxes, standard dialogs, and reusable UI elements. - Reduces boilerplate for non-editor UI.

Developer notes: - Do NOT wholesale-import; audit each component for Quill’s dialog contracts (default buttons, focus return, accessible names). - Create wrappers in quill/ui/app_elements_compat.py that enforce Quill dialog rules. - Use selectively for About dialog, license viewer, and settings panels.


5. platform_utils (Clipboard, Paths, Stdout)

Status: Candidate for later
Files: None yet

What it enables: - Cross-platform clipboard, path normalization, stdout capture — small utilities to reduce boilerplate.

Developer notes: - Low-risk adoption for small utilities. - Can be integrated incrementally if Quill’s platform layer needs it.


Update Process: How It Works

1. Release Pipeline

quill/version.py → scripts/generate_app_updater_feed.py
                  ↓
                  Produces: docs/site/updates/.quill-app-updater-v1.json
                  Format: { "current_version", "description", "downloads": { "Windows": "...", "Darwin": "...", "Linux": "..." } }

2. User Workflow (Automatic or Manual)

User: Help → Check for Updates
       ↓
quill/ui/update_manager.py: UpdateManager.check_for_updates()
       ↓
autoupdate library:
  - Downloads update ZIP to staging directory
  - Announces progress via quill.platform.windows.prism_bridge.announce()
  - Calls update_complete_callback() when ready
       ↓
Platform bootstrapper (after app exit):
  - Waits for Quill process to exit
  - Replaces old files with new files from ZIP
  - Restarts Quill

3. Micro-Update Example

Release 1.0.0: Full installer (30 MB)
Release 1.0.1: Micro-update ZIP (2 MB)
  - One file changed (bug fix in core)
  - User downloads 2 MB instead of 30 MB
  - Update applied atomically on next startup

Attribution & Licensing

All AccessibleApps libraries are MIT-licensed. Attribution is documented in: - CONTRIBUTING.md — “Acknowledgments and Attribution” section - Individual module docstrings (e.g., quill/ui/update_manager.py) - Release notes (when a library is integrated into a shipped version)


Next Steps

Immediate (v1.0)

  1. ✅ Integrate app_updater feed generator and update callbacks.
  2. ✅ Document update process in user guide and PRD.
  3. ✅ Add AccessibleApps attribution to CONTRIBUTING.md.
  4. ✅ Fully integrate html_to_text with Magic Paste HTML detection and cleaning (21 tests passing).
  5. Test app_updater flow locally on Windows (bootstrapper atomicity).
  6. Set up CI to produce ZIP update packages alongside full installers.

Short-term (v1.1 – v1.5)

  1. Wire html_to_text auto-clean setting into UI (Settings → Paste → Auto-clean HTML).
  2. Prototype and integrate smart_list for outlines and large lists.
  3. Test smart_list on macOS (DataView) and Windows (ListView) with screen readers.
  4. Consider selective app_elements adoption for dialogs.
  5. Evaluate accessible_output2 braille support if user demand arises.

Future


Testing Checklist

Before shipping any AccessibleApps-integrated feature:


Contact & Contributions

Questions about AccessibleApps libraries? Open an issue or discussion in this repo. Pull requests that improve integration, fix bugs, or add tests are welcome.

Links: - AccessibleApps: https://github.com/accessibleapps/ - app_updater: https://github.com/accessibleapps/app_updater (MIT) - smart_list: https://github.com/accessibleapps/smart_list (MIT) - accessible_output2: https://github.com/accessibleapps/accessible_output2 (MIT)


Appendix: Native RTF editing and Ulysses study (design)

Folded in from the former docs/QUILL-PRD.md on 2026-06-13.

QUILL: Native RTF Editing and a Ulysses Competitive Study

Two forward-looking proposals in one place. Part One asks what it would take for QUILL to host a real rich-text editing surface so a writer can choose between plain-text-first writing and live rich editing. Part Two studies Ulysses, the Apple Design Award winning writing app for Mac, iPad, and iPhone, and recommends which of its ideas QUILL should adopt without compromising its accessibility-first soul.

Both parts are written to be ambitious. They are also written honestly: where an idea collides with a non-negotiable QUILL principle, the collision is named and a safer path is offered.


Part One: Native RTF Editing as an Optional Surface

What shipped: hidden-codes first (0.8.1 Beta 1), then real editable rich mode (0.9.0 Beta 3) — the WYSIWYG surface this Part called out as deferred has now shipped. The plan of record for the first phase became hidden-codes formatting rather than a WYSIWYG editing surface (the planning docs rtf.md and rich-text-formatting-hidden-codes-design.md are retired; this appendix and “One Editor, Every Format” above are the canonical record, and the deferred P5 clean-on-disk overlay stays a deliberate backlog item, revisited only on field demand). Hidden-codes delivered real document formatting (font family/size, colour, highlight, underline, strikethrough, super/subscript; paragraph alignment, line spacing, indent, named styles, page breaks) applied from the Format menu and the accessible Font… dialog, stored as invisible codes over the clean plain-text buffer, interrogated on demand with Describe Formatting at Cursor (and an optional announce-on-move), and materialised at export to Word (.docx, native writer + Pandoc fallback), RTF, and HTML — with honest-fidelity warnings before any lossy save. The plain-text buffer stayed the single editing surface, so undo, search, the outline, metrics, read-aloud, and AI kept working unchanged.

0.9.0 Beta 3 retired the read-only rich-text lens entirely (the core.rich_text_lens feature flag, view.switch_editing_lens, and quill/ui/rich_text_surface.py are all gone from the codebase — closing #893, whose whole premise was making that now-retired lens discoverable) and replaced it with the real thing: quill/ui/main_frame_rich_mode.py makes QuillRichEdit itself the single, always-present editing surface, with genuine in-place bold/italic/headings/alignment/color, native .rtf/.docx open-and-save, and the Document Format switcher (below) as the discoverable, in-context way to move a document into rich mode — exactly the affordance #893 asked for, just built directly into the one editor instead of as a separate lens to find. See “One Editor, Every Format” above for the complete, current design; the vision narrative below is retained for historical record only.

The idea in one sentence

Let a writer open Preferences and choose their editing surface: keep the current plain-text-first editor (a wx.TextCtrl over Markdown-style markup), or switch to a native rich-text surface (a hosted wx.RichTextCtrl) where bold, italic, headings, lists, and links are shown as formatted text rather than as markup characters, and .rtf files open and save with no markup translation at all.

Why this is different from what exists today

QUILL already supports RTF as a file format. The io-layer round-trip delivered under EDS-21 reads RTF into Markdown-style markup and writes that markup back out to valid RTF. See quill/io/rtf.py. That work is real and it stays valuable. What it deliberately does not do is change the editing surface: when you open an .rtf today, you edit markup in a plain-text control, and the formatting is a translation, not a live object.

This proposal is about the surface itself. It introduces a second, opt-in editor control where formatting is native and visible, and where RTF is the document’s true in-memory representation rather than a serialized export of markup.

The headline tension, stated plainly

QUILL’s founding principle is screen-reader-first, plain-text-first writing on stock controls. The PRD and the repository conventions are explicit that the writing path should use wx.TextCtrl and avoid custom-drawn or heavily rich-formatted editor controls, because plain stock controls give the most predictable, best-tested screen-reader experience across NVDA, JAWS, and Narrator.

A wx.RichTextCtrl is a richer, more complex control. On Windows its accessibility exposure is good but not identical to a plain edit control, and on macOS and Linux the wx rich-text implementation behaves differently again. So the core design question is not “can we host an RTF control” (we can) but “can we host it without weakening the accessibility guarantee that defines QUILL.” The answer this proposal defends: yes, but only as an explicit, clearly-announced, non-default choice, with the plain-text surface remaining the supported default and the rich surface treated as a power-user mode with its own honest accessibility disclosure.

What “magical” looks like here

The magic is not the formatting. Every word processor has formatting. The magic is making a rich surface feel as calm, legible, and screen-reader-honest as the plain surface, and letting a writer move between the two without ever losing a word.

Architecture: where this lives

QUILL’s layering rules are strict. quill/core and quill/io must stay free of wx. All widget code lives in quill/ui and quill/platform. This proposal respects that boundary completely.

This is the single most important design decision: introduce a surface protocol so the two controls are interchangeable from the application’s point of view. Without it, every one of the command surfaces below would need a branch for “is this the rich control or the plain control.” With it, each command asks the surface to do the work and the surface knows how.

Impact across every command surface

This is the heart of the proposal. The instruction to “keep in mind all command surfaces that could be impacted” is taken literally. The table below inventories the real call sites discovered in the codebase and rates the work each one needs to support a rich surface. The plain-text surface keeps working unchanged in every row; the rating describes the rich-surface effort.

Effort key: Low means the existing logic works through the surface protocol with little change. Medium means the command needs a rich-aware path. High means the command needs genuine new design because formatting changes its meaning.

Command surface Representative call sites Rich-surface effort Why
Editor creation and event binding _create_document_tab, _bind_editor_events, _on_editor_char_hook in quill/ui/main_frame.py High The control is constructed here; this is where the surface protocol and the two implementations plug in. Smart-quote and dash autoformat must be re-expressed against the rich surface.
Dirty-state and document buffer _on_text_changed, document.set_text(...), quill/core/document.py Medium The buffer is no longer just a string. The Document model needs a representation that can hold formatting (or a paired markup-plus-format view) without core importing wx.
Selection (expand, shrink, select all) selection helpers in quill/ui/main_frame.py, quill/core/selection.py, quill/core/set_ops.py Medium Offsets differ between a markup string and a formatted run model; selection math must move behind the surface protocol.
Clipboard (cut, copy, paste) and context menu editor context-menu wiring, _copy_text_to_clipboard, copy_with_source, quill/platform/windows/clipboard.py, quill/ui/main_frame_power_tools.py High Rich paste must decide between formatted paste and paste-as-plain, and copy should offer RTF and plain flavors. This is also a security surface: pasted RTF must be sanitized.
Navigation (go to line, bookmarks, headings, outline) go_to_line, _navigate_heading, open_heading_organizer, bookmark commands, quill/core/outline.py, quill/core/structure_nav.py Medium Headings are styles in a rich document, not # prefixes. Outline and heading navigation must read paragraph styles instead of scanning markup.
Find and replace find_text, _open_find_replace, replace-all and replace-in-files, quill/core/search.py, quill/core/regex_ops.py Medium Search runs over plain text extracted from the rich model; match offsets map back to formatted runs. Replace must preserve surrounding formatting.
Spellcheck open_spell_check_dialog, as-you-type toggle, quill/core/spellcheck.py Medium Word extraction and squiggle placement work on runs rather than a flat string; correction must not drop a run’s formatting.
Markup and formatting commands format_bold, format_italic, format_heading, heading-level commands, quill/core/format_ops.py, quill/core/autoformat.py High On the plain surface these insert characters; on the rich surface they toggle native attributes. This is the clearest case for the surface protocol to own two implementations of one command.
Autosave _maybe_autosave, quill/core/autosave.py Low Autosave serializes the document; it needs the rich serializer but the trigger logic is unchanged.
Backups and recovery restore_backup, quill/core/backups.py, quill/core/recovery.py Medium Backups must store enough to restore formatting; recovery must round-trip the rich representation atomically.
Undo and redo, including persistent undo undo, redo, persistent-undo load and flush, quill/core/undo_store.py High The rich control has its own undo stack. Reconciling it with QUILL’s persistent, cross-session undo is the subtlest engineering problem in the whole proposal.
Word count and statistics show_word_count, quill/core/metrics.py Low Metrics run on extracted plain text.
Read-aloud and text-to-speech read-aloud commands and progress handlers, quill/core/read_aloud.py Medium Read-aloud reads plain text; an opportunity is to announce formatting boundaries (entering and leaving bold) as a spoken cue.
Dictation dictation commands and handlers, quill/core/dictation.py, quill/platform/windows/dictation.py Medium Inserted dictated text must land in the rich model with the caret’s current formatting.
Screen-reader announcements quill/core/announcements.py, quill/core/a11y_regions.py, quill/platform/sr_announce.py High The defining work. The announcement grammar must grow a vocabulary for formatting so the rich surface is as legible by ear as the plain surface.
Soft wrap soft-wrap toggle and apply, quill/core/wrap_ops.py Low The rich control wraps natively; the toggle maps to a control style.
QUILL-key command system quill/ui/main_frame_quill_key.py, quill/core/commands.py, quill/core/keymap.py Medium Every editor command dispatched through the QUILL key must resolve against the active surface; the dispatch indirection already exists, so this is wiring, not redesign.
Dialog estate Preferences hub, the new surface picker, dialogs.md, tests/unit/ui/fixtures/dialog_inventory.json Medium A surface-choice control and any fidelity-warning dialogs must be registered and classified under the DLG-3 dialog inventory gate.

Cross-cutting impacts beyond individual commands

A staged, honest delivery plan

  1. Surface protocol first. Refactor main_frame so all editor access goes through an editor-surface protocol, with the existing wx.TextCtrl as the only implementation. No user-visible change. This de-risks everything that follows and is independently valuable.
  2. Rich model in quill/io. Extend the RTF model so a formatted document can be represented, converted, and tested without wx.
  3. Read-only rich preview. Ship a rich view of a document before a rich editor. Lower risk, immediately useful, and it exercises the announcement grammar for formatting.
  4. Opt-in rich editor. Introduce the wx.RichTextCtrl surface behind a feature flag and the new setting, defaulting off, with a frank accessibility note in the picker.
  5. Fidelity and safety polish. Lossy-conversion warnings, sidecar preservation, RTF sanitization, and the spoken formatting vocabulary.

Opening files: the moment the design lives or dies

Everything above is plumbing. The place a writer actually meets this design is the instant they open a file. If that moment is confusing (“why is this file showing markup characters?” or “why did my headings turn into hash marks?”), the feature fails no matter how elegant the internals are. So the open and save experience deserves its own design, and it can be genuinely magical.

The one principle that prevents all confusion

The file chooses the surface, the writer can override, and QUILL always says which lens you are in. Stated as three promises:

That last promise is the accessibility heart of it. The status bar shows the lens (“Plain” or “Rich”), and the announcement grammar speaks it: “Opened report.rtf, Rich text lens.”

How each kind of file behaves on open

There is no separate “open as plain” versus “open as rich” file picker. There is one Open command. QUILL inspects what you opened and does the obvious right thing, then tells you what it did.

You open Default surface What QUILL says and offers
A plain text file (.txt, .md, .markdown, code) Plain Opens exactly as today. No change whatsoever for existing users.
An .rtf file, with the rich surface available Rich “Opened in Rich text lens.” A single command flips to Plain to see or edit the underlying markup.
An .rtf file, with the rich surface turned off Plain (markup) Behaves like today’s EDS-21 round-trip, and offers a one-key “Open in Rich text lens” if the writer wants formatting.
A rich format QUILL can import (.docx, .odt, .pages) Plain by default, Rich if opted in QUILL says how it imported and whether formatting was simplified, with a link to view details.
A non-text file (.pdf, image for OCR, spreadsheet) Plain (extracted text) Unchanged; these are read or extracted into text and never pretend to be rich documents.

The key insight: the writer’s default-surface preference is a fallback, not a mandate. An .rtf is inherently a rich document, so it opens rich when rich is available, even if the writer’s default is plain, because that is the least surprising thing. The preference only decides the ambiguous cases.

Making the choice effortless, not a chore

A few touches turn a potential annoyance into something that feels considerate:

Saving: the mirror image, equally calm

Saving follows the same “least surprise, always announced” rule.

Why this is magical and not just tolerable

The ordinary version of this feature forces a mode decision on the user and then punishes wrong guesses with lost formatting. The QUILL version removes the decision in the common cases, makes it one reversible keystroke in the rare cases, keeps both views of the document alive at once so nothing is ever lost, and speaks every state and consequence aloud so a blind writer navigates it with exactly the same confidence as a sighted one. A writer opens their file and it simply looks right, sounds right, and saves right. That is the magic: the two surfaces feel like one calm editor that happens to know when to show formatting.

Recommendation

Pursue it, but as a protocol-first refactor that delivers value at every stage, and keep the plain-text surface the default and the fully-supported path. The rich surface should be a celebrated power-user choice, not a replacement, and its accessibility story should be told honestly at the moment of opt-in. Done this way, QUILL becomes the rare app that offers live rich editing and still treats plain-text, screen-reader-first writing as first class. That is the magic: not catching up to word processors, but giving blind and low-vision writers a rich surface that finally speaks formatting out loud.

Illumination: formatting beside a plain-text file (shipped 0.8.1 Beta 1)

A plain .txt file has nowhere to store fonts, colour, or alignment, so the hidden-codes model’s plain-text writer strips formatting on save (honest fidelity). For writers who want a clean text file that still round-trips their formatting in QUILL, Beta 2 adds the Illumination — named for the decorative layer a scribe paints over a manuscript: the clean text is the manuscript, the formatting is its illumination, stored as a companion file.

Model (quill/io/illumination.py, wx-free). An Illumination is a small JSON sidecar written next to the document as <name>.illumination:

Drift safety. On open, QUILL re-applies the Illumination only when the .txt on disk still hashes to the recorded text_sha256. If the file was edited in another program, the overlay would land on the wrong words, so QUILL declines and opens the file as plain text instead. This makes the sidecar safe to keep beside files that travel.

Policy (configurable). The plain_text_with_formatting setting (Editing group) governs what happens when a formatted document is saved as plain text:

Honest limits. The Illumination is a separate file: copy or e-mail only the .txt and the formatting does not travel — the UI and docs say so, and steer writers who want one self-contained file to Markdown (inline codes) or Word/RTF (native formatting). The fully out-of-band, clean-on-disk-everywhere variant (no visible codes even in a saved .md) remains the deferred “Option B” end-state — a deliberate backlog item, revisited only on field demand (the rtf.md planning doc retired with One Editor, Every Format); Illumination delivers the clean-text round-trip now without that larger overlay rebuild.

Spoken Echo: re-read the last announcement (shipped 0.8.1 Beta 1)

Problem. QUILL speaks a great deal of transient information — indent depth, formatting at the caret, save and search results, “no matches” — and speech is gone the instant it is spoken. A screen-reader user who missed or wants to copy a spoken line had no way to recover it. Screen readers solve this with a “speak, double-tap to virtualise” convention; QUILL needed an equivalent that fits its architecture.

Model (quill/core/spoken_echo.py, wx-free). Every spoken line already flows through a single choke point in the shell — _announce and _set_status both update the status bar and speak. Those two methods now also call _record_spoken, which appends to a bounded history (SPOKEN_ECHO_LIMIT = 20) via record_spoken, dropping empty and consecutive-duplicate lines. format_spoken_echo renders the history newest-first for display. Keeping the history and formatting in core makes them unit-testable without a UI; the deque lives on the frame.

Surfaces. view.spoken_echo (“Show Spoken Echo”) opens a read-only, selectable, copyable dialog (modelled on the QUILL-key help dialog, registered in the dialog inventory). It is bound to Alt+Shift+E in DEFAULT_KEYMAP and both shipped profiles, and appears in the Help menu. This dedicated key is the universal trigger: it works after any announcement, including those produced by text-editing keys (Tab’s indent depth), with no risk to typing.

Double-press layer. For the familiar gesture, _run_command detects a second press of the same command within _ECHO_DOUBLE_PRESS_WINDOW (0.5 s) and opens the Echo instead of re-running — but only for a curated set of informational, side-effect-free commands (_ECHO_DOUBLE_PRESS_COMMANDS: describe formatting, document summary, context help, announce contrast), where re-running is harmless. It is deliberately not wired to text-editing keys, and is gated by the spoken_echo_on_double_press setting (default on). Because accelerator-bound commands dispatch through wx menu events rather than _run_command, double-press currently covers the command-palette and QUILL-key paths; the dedicated key covers everything. Extending double-press to specific accelerator keys is a possible later enhancement.

Keyboard Manager: search, record, conflicts, diagnostics (shipped 0.8.1 Beta 1)

Problem. The original Keymap Editor was a flat list with a plain text box: you typed an exact binding string and hoped. There was no way to ask “what is this key bound to?”, conflict detection was a brittle .upper() string compare that missed re-ordered spellings (Shift+Ctrl+K vs Ctrl+Shift+K), conflicts were reported by raw command id, and there was nothing to catch duplicates or assigned-but-inert keys. The goal was a VSCode-class, screen-reader-first experience.

Core grammar (quill/core/keymap_query.py, wx-free). One module owns the tolerant parse and canonicalisation: parse_binding accepts alias modifiers (control/ctrl/ctl, option/opt), any modifier order, any case, named keys, function keys, the QUILL-key chord grammar, and the quill prefix alias; it rejects genuine garbage. canonical_binding collapses an accepted binding to one deterministic string (modifier order Ctrl, Alt, Shift to match DEFAULT_KEYMAP; macOS Cmd is a distinct token, never folded into Ctrl). On top sit find_keymap_conflicts (all owners of a key, canonical-compared), commands_for_keystroke (reverse lookup for search), duplicate_bindings, and diagnose_keymap (duplicates, invalid strings, unknown commands, and “missing dispatch” — bound but not firable). keymap.find_keymap_conflict now delegates here, so merge_keymaps correctly drops a saved override that collides with a default under a different spelling.

Editor (quill/ui/keymap_editor.py, KeymapEditorMixin, extracted from main_frame). A persistent dialog with: a single smart search box (command-name substring, or — when the text parses as a binding — a reverse lookup with live “assigned to X / unassigned and available” feedback); a Record Keys capture dialog that turns a pressed chord into a binding string; alias/any-order tolerant assignment that stores the canonical form and refuses keys the dispatch layer cannot fire (no inert bindings); friendly conflict resolution that names the owning command by title and offers a one-step reassign (move here, free there); and a Diagnostics report with a conservative Heal that removes invalid/orphaned entries and re-applies the keymap via _reload_shortcuts_from_keymap (duplicates and inert keys are reported for manual resolution rather than guessed at).

Go to Document by position (shipped 0.8.1 Beta 1)

With several documents open, cycling Next/Previous (Ctrl+Tab) is slow. Alt+1 through Alt+9, plus Alt+0 for the tenth, jump straight to a document by its position. Ten window.go_to_document_N commands back a single go_to_document(position) method (built on the existing _select_tab); out-of-range positions announce rather than switch. The bindings live in DEFAULT_KEYMAP (so they flow to every profile and are remappable in the Keyboard Manager) and are applied through the frame accelerator table via accelerator-only menu ids. The Window menu’s dynamic open-document list shows each shortcut inline (&1: Notes (Alt+1)) for discoverability; Alt+digit is free default key-space and, unlike Ctrl+Alt+ chords, is not screen-reader-hostile.

Braille editor control type (shipped 0.8.1 Beta 1; cell-two offset unresolved)

Some braille displays render the first character of every line in cell two for a rich-text control — the long-standing word-processor quirk. Because QUILL’s editor must be a RichEdit for correct accessible-value reporting (#616, a read-only constraint), Settings → Accessibility → Editor control type (braille) lets a braille user pick the native control: rich2 (RichEdit 3.0, default), rich (RichEdit 2.0), or plain (a Notepad-style EDIT control — editable, so it still reports its value to JAWS/NVDA). It applies at editor construction (new documents/restart). Status (2026-07-02): the control-type switch has not resolved the reported offset — the issue persists on affected displays, and an outcome is still being considered. The options are retained deliberately as a troubleshooting/experimentation surface (users A/B against their own display; where the offset reproduces in Notepad itself, the cause is the braille display / screen-reader configuration, e.g. left status cells, not the control), and shipping docs must not claim the offset is fixed.

QuillRichEdit: a native Rich Edit wrapper for the cell-two offset and selection-dots investigation (shipped 0.9.0 Beta 2; braille testing pending)

The control-type switch above changes which native control QUILL uses but gives no leverage inside the chosen control. QuillRichEdit (quill/ui/richedit_rtf_surface.py) is a thin wrapper over the same RICHEDIT50W control QUILL already ships as its default editor — same Win32 window class, same wx.TextCtrl(TE_RICH2 | TE_NOHIDESEL), so the full editor contract (value/caret/selection/undo/events) is inherited unchanged and no existing surface is affected. What it adds is a controlled handle on the control’s Text Object Model (ITextDocument/ITextSelection, reached via EM_GETOLEINTERFACEQueryInterface(ITextDocument), no Python ctypes callback in the hot path — a callback-driven EM_STREAMIN/EM_STREAMOUT attempt was tried first and hard-crashes msftedit; see docs/engineering/editor-surface-history.md for the post-mortem) and its low-level edit-style messages (EM_SETEDITSTYLE/EM_GETEDITSTYLE).

Three phases shipped:

Status: CONFIRMED and promoted (0.9.0 Beta 3). Live JAWS + braille hardware testing verified the fix — text renders from cell 1, selections show dots 7-8, and JAWS reads the editor’s value correctly — with one addition the testing surfaced: the visible editor border itself pushes braille output out of cell 1, so a borderless frame is part of the fix. See One Editor, Every Format below for the shipped design; the experimental gating this section originally specified is retired, and the surface-experiment record lives in docs/engineering/editor-surface-history.md.

One Editor, Every Format (shipped 0.9.0 Beta 3)

The QuillRichEdit promotion: every document tab on Windows is built through create_richedit_rtf(...) (quill/ui/richedit_rtf_surface.py) — no surface choice anywhere — and the braille fix ships on by default as two plain checkboxes on Preferences > Braille, forced on for upgraders (the settings delta store drops the retired surface-override keys on load, with a one-time migration notice):

Two document modes, one control (quill/ui/main_frame_rich_mode.py, _DocumentTab.editor_mode):

Mode-polymorphic commands: one command id, one shortcut — the effect follows the document format (rich → TOM; markdown/html → today’s insertion; plain text → a once-per-document transition prompt). Native Ctrl+B/I/U are intercepted in rich mode and routed through QUILL’s commands so formatting is announced, dirty-marked, and remappable. Describe Formatting at Cursor reads the live TOM in rich mode.

The Document Format switcher (format.switch_document_format, Ctrl+Shift+Grave, K): Format menu, command palette, keyboard chord, and the interactive document_format status bar cell all dispatch one handler — a native popup radio menu (screen readers get the items and checked state for free). Transitions run through the RichDocument bridge with scan_rtf_features honest-fidelity warnings before anything lossy, and a switched document retargets its file type on the next Save As (never a silent in-place rewrite).

Editable rich Word (.docx) is reconstructive — no control hosts docx natively — so safety is procedural: quill/io/docx_reader.py reads exactly the vocabulary docx_writer.py emits (symmetric by construction through RichDocument); scan_docx_features inventories out-of-vocabulary features (tables, images, comments, tracked changes, headers/footers, footnotes) and drives a three-way open choice (read-extract default / rich with named losses / edit a copy); the first rich save over a flagged original writes a timestamped backup alongside. python-docx stays a soft dependency — absent, docx opens read-extract exactly as before. Tables are preserved on the rich read path: read_docx_rich walks the body blocks in document order (via _iter_body_blocks, which reunites python-docx’s separate paragraph/table lists) and renders each table inline as GFM Markdown-table paragraphs — so table content that was previously dropped entirely now survives, reads correctly, and is reachable by single-key table navigation (T). scan_docx_features still lists tables so the format change is disclosed before a rich edit.

EPUB reading. core/epub.py renders each chapter’s headings inline (as Markdown # lines two levels beneath the chapter) so single-key heading navigation traverses them, not just chapter boundaries. When a chapter has no real <h1><h6> markup, _infer_heading_spans guesses headings from structure — a <p>/<div> whose class names it a title/chapter/section/heading, or a short standalone bold line — while leaving body text and long bold emphasis alone. The EPUB Navigator tree and inline navigation both benefit; EpubChapter.headings_inferred records when headings were guessed rather than read.

macOS: quill/ui/nstextview_rtf_surface.py (QuillMacRichText) mirrors the Windows wrapper method-for-method over Cocoa’s text model (the editor is an NSTextView underneath). The Mac .app bundles the PyObjC bridge: the [macos] build extra installs the full pyobjc, and scripts/setup_macos.py lists objc/AppKit/Foundation in the py2app includes because every AppKit import in the codebase is lazy and the import tracer misses them (the same fix makes the #616 VoiceOver editor-role pin actually ship in the .app — it was a silent no-op when AppKit was absent from the bundle). Source installs use the mac extra (pyobjc-framework-Cocoa). The code path still degrades to converted rich if the bridge is ever absent — the floor and the fallback are permanent — and on-device VoiceOver verification remains the promotion gate for the native path, mirroring the Windows braille A/B; the Beta 3 release notes ask Mac users to report results via Help > Report a Bug, positive or negative.

The Fragment spine, and five features built on it (shipped 0.9.0 Beta 2)

quill/core/fragment.py introduces one small object, Fragment (markup, title, source, source_url, kind, created_at), and one pure function, render_fragment(frag, fmt), rendering it as TEXT (via the existing io.export.markdown_to_plain_text), MARKDOWN (verbatim), or HTML (via browser_preview.render_preview_body). A new setting, content_handoff_format (Preferences > Editing > “Kept and sent content format”; text/markdown/html), is the one format choice every consumer below reads, so “interchangeable” is true in practice, not just in name.

Mandatory alt text at insertion, and inline image descriptions (#899, shipped 0.9.0 Beta 2)

GLOW (quill/core/glow.py) already audits missing alt text, auto-fixes it, and can generate it via opt-in cloud AI — an accessible image object model already existed, but only as an after-the-fact repair pass. #899 asked for the proactive half: a document should not be able to accrue an un-alt-texted image in the first place, and a screen reader should announce when one is missing as the caret passes it, not just when an audit is run.

quill/core/inline_image_alt.py is the pure core: image_at_position(text, pos) finds the Markdown (![alt](src)) or HTML (<img src=... alt=...>) image reference the caret is inside or touching — the same two patterns link_inventory.py already parses for GLOW’s audit — and describe_image(record) renders “Image: {filename}, alt text: {alt}” or, just as loudly, “Image: {filename}, alt text MISSING”. build_image_markdown(path, alt, decorative=) builds the Markdown for a newly inserted image; a decorative image (the correct accessible pattern for one with no informational content) gets deliberately empty alt text, distinct from an image nobody ever described.

InsertImageDialog (quill/ui/insert_image_dialog.py) is QUILL’s first dedicated image-insertion flow (Insert > Image…): a file picker, an alt text field, and a “this image is decorative” checkbox that disables the alt text field when checked. Insert is refused — with a status message, not a silent no-op — unless real alt text is present or decorative is explicitly checked. ImageAltMixin (quill/ui/main_frame_image_alt.py) wires this plus Tools > Describe Image at Cursor, which answers the “what does this image say” question for any image already in the document, however it got there (typed by hand, pasted, imported).

The dialog is format-aware and AI-assisted. insert_image resolves the target markup (_resolve_structured_markup) before opening the dialog, and builds Markdown (build_image_markdown) or rich HTML (build_image_html) accordingly. In HTML mode the dialog collects width/height (intrinsic size to prevent layout shift), a responsive cap (max-width:100%;height:auto), and an optional caption (emitted as <figure>/<figcaption>); decorative images emit empty alt plus role="presentation". When an AI vision model is connected and Safe Mode is off, a Suggest alt text with AI button calls the existing cloud vision pipeline (quill.core.ai.vision.describe_image with the accessibility prompt) on a worker thread behind a progress indicator and drops the draft into the alt field for human review. The gate is AI-enabled AND provider≠“off” AND an unlocked API key AND not Safe Mode — the vision path does not itself block Safe Mode, so ImageAltMixin adds that check (_ai_alt_text_available / _ai_alt_text_for_path).

Deliberately out of scope for this pass: non-image embeds (page breaks, equations, removed objects) as accessible placeholders. add.md’s own spike note said to investigate a shared placeholder model before designing one — this covers the object model that already exists (images), not a new one.

Real print plumbing already existed – _print_data (wx.PrintData), a Page Setup item on wx.PageSetupDialogData, and print_document() driving wx.Printer. Two things were missing: any preview surface at all, and any odd/even/reverse/different-first-page options. The existing printout was also single-page-only (HasPage always page == 1) – it drew whatever fit on one page and silently dropped the rest of the document.

quill/core/print_pagination.py is the pure core: paginate_lines(lines, lines_per_page) splits a document into pages; select_pages(page_count, page_set=, reverse=, skip_first_page=) turns a page count and the chosen options into the concrete, ordered list of page numbers to print – skip_first_page is computed on the original page numbers before odd/even filtering, so “odd pages, skip first” on a 7-page document is 3/5/7, not 1/3/5. paper_name/margins_text/describe_preview build the spoken/textual preview – “3 pages, Letter, default margins” – explicitly not a WYSIWYG renderer (the issue’s own non-goal).

quill/ui/main_frame_print.py (PrintMixin, extracted from main_frame.py along with the pre-existing page_setup/print_document to stay within GATE-11): _compute_print_preview uses a throwaway wx.PrinterDC for realistic font-metric-based pagination without starting an actual print job – the same DC type the real job prints through, so Print Studio’s page count matches what actually prints. The inner wx.Printout in _build_text_printout now paginates for real inside OnPreparePrinting (where wx attaches a live DC) and maps a requested page-set through an index indirection: wx is told there are len(selected_pages) “pages,” and each OnPrintPage(n) resolves n back to the real document page it represents. This is the standard technique for custom page ordering in wx’s printing API, which has no native odd/even/reverse concept of its own.

File > Print Studio… shows the preview and options, then hands off to the identical wx.Printer flow File > Print already uses – Print Studio is a step in front of the existing dialog, not a replacement for it. Header/footer authoring stays explicitly out of scope; that is #892.

Keyboard-first Header/Footer Builder (#892, shipped 0.9.0 Beta 2)

No header/footer authoring existed at all beyond wx.PageSetupDialogData margins (which has no header/footer concept of its own). Per the issue’s own framing, this is deliberately named presets over a small, fixed token set – not a blank canvas, and not a general macro/field-code system.

quill/core/header_footer.py (pure): HeaderFooterSpec holds six zones (header/footer × left/center/right), an optional different-first-page set of six more, a page-number style (arabic/roman), and a start page number. Each zone is a template string using {title}/{filename}/ {date}/{page} tokens or literal text, rendered by render_zone. Four named presets cover the issue’s own examples directly: “Title left, page number right,” “Filename and date,” “Roman numerals for front matter,” and “Blank.”

quill/core/header_footer_store.py persists a spec as document metadata – keyed by the document’s normalized path, the same DocumentMemory.key_for shape core/bookmarks.py already uses – so it is part of the document’s identity and survives save/reload; an unsaved document is simply never persisted.

quill/ui/header_footer_dialog.py is the keyboard-first builder: a preset picker fills the six main zones, each independently editable from there; a “different first page” checkbox enables its own six fields; page-number style and start-number controls sit below. File > Header and Footer… (quill/ui/main_frame_print.py, extending #891’s PrintMixin) opens it for the current document.

Both File > Print and File > Print Studio… now draw the saved header/footer on every printed page – the displayed page number accounts for start_page_number and whichever page-set Print Studio’s odd/even/ reverse/skip-first-page filtering selected, and a different first page applies correctly to the document’s actual first page, not the first page of a filtered print run.

Deliberately out of scope for this pass, per the issue’s own build order: DOCX/RTF native header/footer XML export. The issue’s own text says to confirm that round-trip before committing further, once real usage exists to validate against – this ships the authoring + print-drawn half first.

Platform-aware keymap profiles and macOS Preferences placement (shipped 0.9.0 Beta 2)

The built-in keymap profiles now defer to the platform-aware defaults for quit, back/forward navigation, and document switching rather than forcing Windows-style overrides into the shipped profile JSON. This keeps macOS users on the correct Cmd-based shortcuts while leaving Windows and other platforms unchanged. The Preferences command also uses the stock macOS app-menu id so it appears in the standard Quill app-menu location on macOS. The same pass also routes macOS file-open, folder-reveal, installer-launch, and voice-preview playback through native macOS launch behavior instead of Windows-only os.startfile assumptions.

PDF/Office text extraction unbundled to an on-demand download (#909 refinement, shipped 0.9.0 Beta 2)

#909’s original bug: markitdown/pdfplumber/pypdf (the free-first Tier-1 Office+PDF text extractor, quill/io/docconvert.py + quill/io/pdf.py) lived nowhere the shipping build actually installed — not a base dependency, not in the extra a clean install pulled — so a fresh install had no PDF/Office text extractor at all. The fix that shipped first made them a base runtime dependency (present on every install, whether or not that install ever opens a PDF). This refines that to the more honest fix: the three packages move to a named pyproject extra (pdf-ocr) and become a one-click download via Help > Download Optional Components > “PDF and Office text extraction” (~30 MB) — matching how every other optional QUILL component (Kokoro, whisper.cpp, Pandoc, the braille pack, mpv) already works, and keeping the minimal-install footprint small for installs that never touch a PDF or Office document.

quill/core/pdf_ocr_install.py is the on-demand installer, modeled directly on speech/engine_install.py’s MP3-support pack: wheel-only pip install --target <app data>/engine-packs/pdf-ocr, Safe Mode gated, activated on sys.path at startup (activate_pdf_ocr_pack, called from __main__.py alongside the speech-engine and AI-SDK pack activations). No import-safety changes were needed anywhere in quill/ — every existing call site (quill/io/pdf.py, markitdown_bridge.py, structured.py, pages.py, docconvert.py, action_builder_dialog.py) already imports these three packages lazily inside a try/except, so “these packages might not be installed” was already a handled case; only the four stale “pip install …” remedy messages needed updating to point at Download Optional Components instead.

tests/unit/test_packaging_dependencies.py’s #909 guard now asserts the opposite of its original claim (not a base dependency) plus two new invariants: the packages are named in exactly one place (the pdf-ocr extra), and the installer’s own pinned requirements are kept in sync with that extra — evolving the regression coverage rather than deleting it outright when the fix’s shape changed.

Self-voice fallback is logged, not announced (shipped 0.8.1 Beta 1)

QUILL’s SAPI 5 self-voice (sapi5.py/prism_bridge) is a fallback used only when no screen reader is present; announcements otherwise route to the reader via Prism / accessible_output2. A SAPI init failure was being spoken on startup with an alarming, mis-worded prompt even while a reader was handling speech. _check_tts_fallback_on_startup now always records a quiet diagnostic note and only speaks — with the correct Tools → Retry TTS Engine path — when _screen_reader_handling_speech() is false (no SR backend and none detected), i.e. when the user genuinely has no voice. Separately, comtypes’ generated-wrapper cache is redirected to a writable per-user dir so SAPI initialises under a read-only install, and screen-reader detection enumerates processes through the Windows Toolhelp API (ctypes) rather than spawning tasklist, so it never creates a console window a screen reader or braille display would announce.

Strengthened in 0.9.0 Beta 3 (#966, Narrator double-speech): the announce() SAPI branch previously let force_speech bypass the live-reader suppression. That branch is only reachable when no speech bridge could be acquired (Prism/accessible_output2 drive JAWS/NVDA before this point), so a detected-but-unbridged reader there is Narrator — and the bypass made QUILL’s SAPI voice talk over One Core. The contract is now unconditional: a running screen reader always suppresses the self-voice, force_speech included. force_speech retains its purpose on the bridged paths (interrupting the reader’s current utterance) and for users with no reader running.

Narrator as an announcement target (same release): two additions make Narrator first-class rather than merely not-talked-over:

System-wide Clipboard Collector (shipped 0.9.0 Beta 3, #964)

The EDS-11 Clipboard Collector (Power Tools) originally captured only copies made inside QUILL (an EVT_TEXT_COPY bind on the editor). Per the EdSharp parity request, it is now system-wide: while toggled on, a 750 ms timer polls the OS clipboard change counter (GetClipboardSequenceNumber on Windows, NSPasteboard.changeCount on macOS — one cheap call, no clipboard open unless it changed) and appends each distinct new clipboard text to the open document, autosaving as before. The in-app bind stays for instant response; a last-collected guard deduplicates the two paths and QUILL’s own writes. See _clipboard_change_counter / _start_collector_watch in quill/ui/main_frame_power_tools.py.


Part Two: Competitive Study, Ulysses

Why Ulysses is the right mirror for QUILL

Ulysses is a mature, respected, markup-first writing app for Mac, iPad, and iPhone, in active development since 2003 and an Apple Design Award winner. Like QUILL, it bets on markup rather than visual rich editing, keeps the writer’s hands on the keyboard, and produces clean text as output. That shared philosophy makes it the most instructive comparison: where Ulysses is strong, it validates QUILL’s direction; where QUILL is strong, it shows QUILL’s distinct advantage.

The comparison below reflects Ulysses as described on its official site and feature pages.

Side-by-side at a glance

Dimension Ulysses QUILL
Core philosophy Markup-first, distraction-free, keyboard-driven Markup-first, screen-reader-first, keyboard-driven
Platforms Mac, iPad, iPhone (Apple only) Windows first, with a macOS path
Accessibility stance General Apple platform accessibility Accessibility is the product thesis: NVDA, JAWS, Narrator parity
Library and organization Unified library, groups, filters, keywords Tabs, profiles, bookmarks, headings outline, quick navigation
Writing goals Deadlines, daily goals, character and word targets Word count and statistics
Proofreading Built-in grammar and style check, 20-plus languages Spellcheck, with AI assistance as explicit opt-in
Export and publishing PDF, Word, ebook, plus WordPress, Ghost, Medium, Micro.blog Pandoc-backed multi-format export and conversion
Sync Seamless first-party cloud sync across Apple devices Local-first storage, privacy-first
Privacy posture Cloud library by design No silent network calls, explicit consent per action
AI Limited Opt-in assistant, branchable sessions, local-first options

What Ulysses does that QUILL can learn from

These are the ideas worth bringing into QUILL, each reframed through QUILL’s accessibility-first, privacy-first, plain-text-first lens so it arrives as a QUILL feature rather than a transplant.

Writing goals you can hear

Ulysses lets a writer set a deadline and a daily or per-document target and watch progress fill toward it. QUILL has word count and statistics but not goals as a first-class, motivating object. The magical QUILL version is a spoken, glanceable goal: set a target by voice or command, and QUILL announces progress on demand and at milestones (“halfway to your 800 words”, “goal met”) through the existing announcement grammar, with an optional status-bar field. This turns a visual progress bar into something a blind writer experiences exactly as richly as a sighted one.

Keywords as a navigation and filtering layer

Ulysses attaches keywords to sheets and filters the library by them. QUILL has headings, bookmarks, and profiles, but not lightweight, user-defined tags that cut across documents. A QUILL keyword system, stored in schema-validated JSON and fully keyboard and screen-reader navigable, would let a writer mark passages or files (“research”, “todo”, “chapter-3”) and then jump or filter by tag through the quick navigation surface. The magic is making tags audible and command-driven rather than a mouse-driven sidebar.

A unified, navigable library

Ulysses keeps every text in one searchable library that syncs everywhere. QUILL is file-and-tab oriented. Without abandoning local-first files, QUILL could offer an optional library view: a single accessible list or tree across a writer’s project folders, with type-ahead, keywords, and goals visible, built from the same stock-control, first-letter-navigable patterns QUILL already favors. It is the Edge-style list pattern QUILL just adopted for Preferences, applied to a writer’s whole corpus.

Built-in style and grammar assistance, on QUILL’s terms

Ulysses ships an integrated proofreader across many languages. QUILL has spellcheck and an opt-in AI assistant. The recommendation is not to bolt on a cloud grammar service; it is to make style and grammar suggestions a local-first, consent-gated capability that announces each suggestion and never sends text anywhere without explicit per-action consent, consistent with QUILL’s egress rules. Ulysses proves writers want this; QUILL can offer it without surrendering privacy.

Frictionless, beautiful export with live preview

Ulysses turns text into polished PDF, Word, and ebooks with on-the-fly style switching and live preview. QUILL already has strong Pandoc-backed export. The borrowable idea is the experience: a single command that previews the chosen output format and lets the writer switch styles before exporting, with the preview itself accessible. QUILL’s advantage is that its preview can be screen-reader legible, which a purely visual preview is not.

Distraction-free focus as an announced mode

Ulysses is built around a calm, distraction-free interface. QUILL can offer a focus mode that does more than hide chrome: it can announce entry and exit, mute non-essential notifications, and optionally narrow read-aloud to the current paragraph, making focus a multi-sensory state rather than a visual one.

What QUILL should deliberately not copy

Where QUILL already beats Ulysses

It is worth naming QUILL’s lead so the roadmap protects it.

In rough order of value-to-effort for QUILL’s audience:

  1. Spoken writing goals, building on the existing metrics and announcement surfaces. High value, modest effort, distinctly QUILL.
  2. Keyword tags with audible navigation and filtering, reusing the quick navigation and schema-validated JSON storage patterns.
  3. An optional accessible library view across project folders, reusing the first-letter-navigable list pattern.
  4. Consent-gated, local-first style and grammar suggestions that announce each finding.
  5. An accessible export preview with on-the-fly style switching.
  6. An announced, multi-sensory focus mode.

Each of these takes a proven Ulysses idea and re-expresses it as something a blind or low-vision writer experiences fully, which is exactly the territory where QUILL should aim to be not just competitive but singular.


Part Three: Compose Mode, a Workshop for Assembling Documents

The idea in one sentence

Add an optional Compose mode where a document is built from a list of parts (call them segments, QUILL’s answer to Ulysses sheets), each part holding a chunk of writing or a pulled-in source, where the writer reorders parts, promotes and demotes their heading levels, and merges them into one finished document, while a live HTML preview shows the assembled whole in real time, and the parts can be written in Markdown, RTF, or HTML.

This is the writing-as-architecture idea that David Hewson praised in Ulysses (“shuffle around the many different parts and scenes”), rebuilt so a screen-reader user can architect a document by ear with exactly the same fluency as by eye.

What a writer actually does in Compose mode

Picture a long piece: a report, a thesis chapter, a book section. Instead of one unbroken file, the writer sees an accessible list of parts:

Each part is a real, editable unit. The writer can:

The structure tree: the document as a navigable outline

A flat list of parts is good; a tree is magical, and it is the natural fit for QUILL because the codebase already proves the pattern. QUILL ships an accessible tree-navigator (_NavigatorNode and _show_tree_navigator in quill/ui/main_frame.py) used today for the heading outline, EPUB chapters, and the misspelling list, and a tree-based structure editor (the YAML editor) that already performs add-child, add-sibling, rename, and delete on a wx.TreeCtrl with a live preview beside it. Compose mode should reuse this exact, battle-tested pattern rather than invent a new surface.

Represented as a tree, the part list becomes the document’s true shape:

A wx.TreeCtrl is a first-class accessible control (it sits in the dialog contract’s preferred-focus list), and on Windows, macOS, and Linux it gives blind writers what sighted writers get from an indented outline: nesting they can feel.

The rich context menu described next hangs directly off this tree, and the live HTML preview sits beside it exactly as the YAML editor places a preview beside its structure tree, so moving a node and hearing the reflowed result is one gesture.

The live HTML preview, made accessible

A real-time HTML preview is the visual half of the magic. The accessible half is making that preview meaningful without sight.

Why this is delightful, not just powerful

The right-click that builds a document: a rich, spoken context menu

Reordering and re-leveling should not require remembering commands. The fastest, most direct way to architect a document is to act on the part you are standing on, and the context menu is where that lives. In Compose mode, invoking the context menu on a part (by right-click, by the keyboard context-menu key, or by QUILL’s own context command) opens a structure menu that is the command center for the whole assembly. Every item is keyboard reachable, every item announces its outcome, and destructive items confirm.

The menu is organized so the most common architectural moves are at the top:

Menu item What it does What QUILL announces
Move Up / Move Down Swaps this part with its neighbor “Moved Method up to position 2 of 6.”
Move to Top / Move to Bottom Jumps this part to the start or end “Moved Conclusion to the end, position 6 of 6.”
Move to Position… Accessible entry to type or pick an exact slot “Moved Background to position 3 of 6.”
Promote (heading level up) Raises this part’s heading one level, outdenting it “Background is now heading level 1, a top-level section.”
Demote (heading level down) Lowers this part’s heading one level, nesting it “Background is now heading level 3, nested under Method.”
Make Top-Level Section Sets this part to heading level 1 in one step “Introduction is now a top-level section.”
Group Under Previous Nests this part (and re-levels it) beneath the part above “Grouped Findings under Method.”
Promote With Children / Demote With Children Re-levels this part and everything nested under it together “Promoted Method and its 3 sub-parts.”
Change Format… Switches this part between Markdown, RTF, and HTML “Changed Background to HTML.”
Convert Link to Embedded Copy Freezes a linked source so it stops tracking the original “Background is now an embedded copy.”
Refresh Linked Source Re-pulls a linked source’s latest content “Refreshed Background from research-notes.md.”
Split Part Here / Merge With Previous Breaks one part into two at the caret, or fuses two “Split into Method and Method, part 2.”
Duplicate Part Copies the part as a new sibling “Duplicated Findings.”
Rename Part Accessible text entry for the part’s outline label “Renamed to Literature Review.”
Add Keyword to Part… Tags this part (see Part Four) “Added keyword research to this part.”
Preview This Part Jumps the live HTML preview to this part “Previewing Findings.”
Flatten From Here… Merges this part and those after it into a document spoken summary of the flattened result
Remove Part Deletes the part (with confirm) “Removed Background. 5 parts remain.”

What makes this context menu magical rather than ordinary

Beyond the single part: acting on a selection

The magic compounds when the writer selects several parts in the list. The same context menu then operates on the whole set, spoken as a set: “Move 3 parts up,” “Demote 3 parts,” “Group 3 parts under Introduction,” “Add keyword research to 3 parts.” A scattered draft becomes a structured manuscript in a handful of audible, reversible gestures, all from the one context menu every editor estate already makes feel familiar.

This context menu must, like all QUILL dialogs and menus, be registered and classified under the DLG-3 dialog and menu estate, with no menu items mutated while the menu is open (deferring label and enable updates until close), per the repository’s dialog and menu lessons.

How Compose mode fits QUILL’s architecture

This respects the same boundaries as the rest of this document.

Honest constraints


Part Four: Accessible Keywords for Screen-Reader Users

Keywords (tags) recur through this document as a borrowed Ulysses strength. They are only worth building if they are fully usable by ear, so they deserve their own design. The goal: tagging and tag-navigation that a screen-reader user performs as fluently as a sighted user clicks a colored label.

What a keyword is in QUILL

A keyword is a short, user-defined label (“research”, “todo”, “chapter-3”, “verify”) attached to a whole document or to a specific part or passage. Keywords are stored in schema-validated JSON alongside other per-document state, never embedded invisibly in the prose, so they never pollute the text a screen reader reads.

Assigning keywords by ear

Hearing which keywords are present

The hardest part of tags for a screen-reader user is usually discovery: sighted users see colored chips at a glance. QUILL replaces the glance with sound and structure.

This is where keywords become a navigation superpower rather than mere labels.

Why this is the accessible version Ulysses never built

Ulysses keywords are fundamentally visual: chips you scan and click. QUILL’s keywords are spoken objects you can add, hear, filter, and jump through without ever seeing them. Every keyword action has an audible outcome, every keyword location is announced rather than implied, and discovery is a command away instead of a visual scan. That is the through-line of this entire document applied once more: take a feature the industry built for the eye and make QUILL the place where it finally speaks.


Part Five: Embedded Grammar Checking, and How It Understands Language

Grammar checking is the most technically ambitious idea in this document, and the honest framing matters: spelling asks “is this token a word?” while grammar asks “is this sentence well-formed, and why?” The second question requires the software to understand the structure of language, not just a wordlist. This part explains how QUILL would gain that understanding, how it would surface findings through the same accessible machinery QUILL already uses for spelling, and where the real complexity and the honest limits lie.

Spelling is the proven template to build on

QUILL already has the right shape. quill/core/spellcheck.py is a pure, wx-free pipeline: list_misspellings returns typed Misspelling records with start and end offsets, next_misspelling and previous_misspelling walk them efficiently from the caret, misspelling_at_position identifies the issue under the cursor, and suggest_words offers fixes. The UI layer turns those records into navigation, a context menu, and the misspelling tree navigator. Grammar checking should mirror this contract exactly: a pure core engine that returns typed issue records with offsets, a category, an explanation, and suggested rewrites, and a UI that reuses QUILL’s existing navigation, dialog, and announcement surfaces. If grammar issues look like richer misspellings to the rest of the app, the integration cost collapses.

How a computer understands grammar and parts of speech

This is the core of your question. There is no single trick; there is a pipeline, and QUILL can choose how far down it to go based on cost and accuracy. Each stage turns raw text into more structure than the last.

  1. Tokenization and sentence segmentation. Split text into sentences and words, handling abbreviations, decimals, and punctuation. QUILL already has word and line tokenizing patterns to build on.
  2. Part-of-speech tagging. Label each token with its grammatical category: noun, verb, adjective, determiner, preposition, and finer tags (singular noun, past tense verb, comparative adjective). This is how the software knows that in “the dog runs,” “dog” is a singular noun and “runs” is a singular present-tense verb, which is what lets it judge subject-verb agreement. POS tagging is a solved, well-understood task: a tagger is trained on large hand-labeled corpora and learns the probability of each tag given the word and its neighbors.
  3. Morphological analysis. Determine number (singular or plural), tense, and person from word forms, so the checker can compare “dog/dogs” or “run/runs/ran.”
  4. Shallow or full parsing. Group tagged words into phrases (noun phrase, verb phrase) and, in fuller form, build a dependency tree linking each word to the word it modifies or agrees with. The dependency link between subject and verb is precisely what an agreement rule inspects.
  5. Rule and model checking. With tags and structure in hand, the checker applies rules (“a singular subject takes a singular verb”; “this preposition does not fit this verb”) and statistical signals (this n-gram is improbable) to flag likely errors and propose fixes.

The crucial idea: parts of speech are the bridge. Once every word carries an accurate grammatical tag and the words are linked into phrases, most common grammar checks become tractable rules over that tagged structure rather than guesses over raw characters.

Don’t build the engine, adopt one: the open-source options

The first instinct, a hand-written ruleset, is a trap. Real grammar checking is years of linguistic work, and several mature open-source engines already exist. The honest job is to pick the one whose language understanding, license, runtime, and privacy posture fit QUILL, then wrap it. The survey below reflects each project as of mid-2026.

Engine Language stack License Offline and private Coverage Honest fit for QUILL
Harper (Automattic) Rust core, callable from Python via a PyO3 binding Apache-2.0 Yes, fully local by design English only today; real POS tagging (its harper-brill tagger) and many rules Strongest fit. No Java, no VM, milliseconds per lint, roughly one-fiftieth of LanguageTool’s memory. Cost: a compiled native dependency to bundle and sign per platform, and English-only for now.
LanguageTool Java engine, driven from Python by language_tool_python Engine LGPL 2.1+; Python wrapper GPL-3.0 Yes, but only via a bundled local Java server; the public-API mode sends text off device and must never be used in QUILL 25-plus languages, thousands of rules, optional large n-gram data Most complete and multilingual, but requires bundling a Java 17+ runtime and managing a local server subprocess, a heavy footprint for a Python and wxPython app. The GPL-3.0 wrapper and remote-API default are both cautions.
spaCy Pure Python and Cython MIT Yes, fully local Excellent POS tagging and dependency parsing, many languages Not a grammar checker; it is the structural toolkit you would build rules on top of. Useful if QUILL ever wants its own multilingual structural layer, but it is a build-it-yourself path, not a finished checker.
proselint Pure Python BSD Yes, fully local Style and usage advice, not true grammar Lightweight and trivially embeddable, but it lints style, not subject-verb agreement; a complement, not the engine.
Consent-gated AI QUILL’s existing AI path n/a No, sends text off device Broadest coverage and fluent rewrites Best raw coverage and style help, but it must be strictly opt-in per QUILL’s no-silent-network rule; latency, cost, and non-determinism apply.

A note on what each actually understands. Harper, LanguageTool, and spaCy all do genuine part-of-speech tagging and some structural analysis, which is what lets them catch agreement and tense errors rather than just typos. proselint does not; it pattern-matches style. A hand-rolled QUILL ruleset would sit below all of them and is not worth building when Harper exists.

For QUILL specifically, the leading candidate is Harper. It is open source (Apache-2.0), offline and privacy-first by design, fast and light enough to run live in an editor, and it does real POS tagging, which is exactly the parts-of-speech understanding the checker needs. Crucially for QUILL, it needs no Java and no virtual machine.

The caveats must be stated plainly, because they drive the engineering:

The staged recommendation, then, is engine-led rather than rule-led: wrap Harper as the default local, private grammar engine behind a feature flag; offer LanguageTool as an opt-in heavyweight for writers who need its breadth of languages and rules, bundling its Java server only when enabled; and expose deep, fluent AI grammar only through QUILL’s existing consent gate. spaCy stays in reserve as the toolkit if QUILL ever wants to grow its own multilingual structural checks.

Why this is genuinely complex, stated plainly

You called grammar complex, and that is correct. The honest difficulties:

Surfacing grammar through QUILL’s existing accessible machinery

Here the earlier request for “keyboard commands, a dialog, and more to help with all of the document interaction” is answered directly. Grammar reuses every spelling surface, so a screen-reader user already knows how to drive it.

Architecture and where it lives

Recommendation

Do not hand-roll a grammar checker; adopt a real engine and treat grammar as “spelling, with structure.” The leading choice for QUILL is Harper: open source (Apache-2.0), offline, privacy-first, fast enough to run live, real POS tagging, and no Java. Accept its honest costs (a Rust native binding to package per platform, English-only for now, and a Python distribution to verify) and wrap it behind a pure quill/core adapter that emits the same typed records as spellcheck. Offer LanguageTool as an opt-in multilingual heavyweight (bundled local Java server only, never its remote API), keep spaCy in reserve as a structural toolkit, and expose fluent AI grammar only through QUILL’s existing consent gate. Whichever engine answers, make the finding speak its reasoning, parts of speech included, so QUILL becomes not just a grammar checker but the rare one a blind writer can fully hear, interrogate, and learn from.


Closing note

Part One argues QUILL can host a rich surface without betraying its plain-text, screen-reader-first heart, provided it refactors to an editor-surface protocol first and keeps the rich control an honest, opt-in power mode. Part Two argues the best ideas in the category’s most admired markup app, Ulysses, can each be brought into QUILL in a form that is more accessible than the original. Part Three proposes Compose mode, a workshop that assembles a document from reorderable, re-levelable parts across Markdown, RTF, and HTML with a live, screen-reader-legible HTML preview and a rich spoken context menu for architecting structure by ear. Part Four designs keywords as spoken objects a writer can add, hear, filter, and jump through without ever seeing a chip. Part Five shows how grammar checking can understand parts of speech and sentence structure, in tiers from a private local ruleset to opt-in AI, and surface every finding, reasoning included, through the same accessible commands, dialog, and tree QUILL already uses for spelling.

The throughline of all five is one ambition: take features the rest of the industry built for the eye, and make QUILL the place where they finally speak.