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.1c-ES The error-specificity programme (phase 1 shipped 1.0.0)

A sighted user opens the failing file or dialog and sees the problem; a blind user cannot, so every user-visible error must carry the whole diagnosis and the next step. The programme rule: a new user-visible error names the specific condition (“The [APIKey] section is missing”, not “could not read the file”) and, where a next action exists, says it. Three mechanisms shipped in phase 1:

5.1c-SR Screen-reader death watchdog (shipped 1.0.0)

Detection was a one-shot startup probe; a reader dying mid-session was invisible to QUILL. core/sr_watchdog.py is a wx-free two-strikes state machine (30 s cadence, MISSES_BEFORE_DEATH = 2 so a routine JAWS/NVDA restart never fires it; arms only after a reader has been seen, so self-voice/sighted sessions never trigger; probe errors count as misses but never raise). ui/main_frame_sr_watchdog.py applies the collapse rule — flush first, then act: on confirmed death every open tab is snapshotted through the existing autosave machinery (active editor synced first; one failing tab never stops the rest), then the event is announced through the engine’s fallback chain and recorded in Notifications with severity warning. Recovery is announced too. The watchdog never closes QUILL. Generalised rule, recorded as product law: any code path that closes or degrades the app without the user asking persists work first.

5.1c-HD Hardening conventions (shipped 1.0.0)

Four shared foundations from the hardening pass, each wx-free, strict-typed, and unit-tested; new code should reach for these instead of hand-rolling:

5.1c-AU Audio identity (shipped 1.0.0)

The competitive assessment’s audio lesson, adopted whole (item 8): sounds get an identity, not just a presence, and everything rides the existing QSP pack system so packs stay swappable. Forty-eight new events, all synthesized in scripts/gen_ink_sounds.py (deterministic, regenerable) and mapped in the bundled Ink pack:

5.1c-FA Field-by-field AI apply (shipped 1.0.0)

The field counterpart of the word-level change review, taken from the competitive assessment’s strongest generic pattern (finding 5e.38): structured LLM output is applied to user data one reviewable item at a time, never as a bulk overwrite. core/ai/field_apply.py holds the wx-free model — FieldSuggestion, ApplySession (pending/accepted/skipped, accepted_values(), wrap-around next_pending), and guess_target_field (normalized exact > prefix > substring scoring; a guess only preselects, the reviewer confirms). ui/field_apply_dialog.py presents each suggestion with the field, its current value, and the proposal (Accept / Accept and Next / Skip / Copy Value / Apply Accepted), warns with a No-defaulted confirmation before replacing a non-empty field, and never writes — the caller applies accepted() as one undo step. First consumer: Suggest Document Metadata (ai.suggest_metadata, palette command) — core/ai/metadata_suggest.py asks the configured provider for title/summary/tags/category with defensive JSON parsing (fence stripping, outermost-brace fallback, its own QUILL-AI-METADATA-FAILED coded error), and the mixin applies accepted fields into the document’s front matter via the story front-matter codec, preserving list shape for tags and refusing to apply over a document that changed mid-request.

5.1c-ES2 Error specificity, phase 2 (shipped 1.0.0)

Phase 1’s mechanisms spread to the remaining high-traffic surfaces: all 29 user-visible error sites in the speech/component download flows, the three Quillin failure surfaces, and the remote-transport (SSH/FTP/S3/WebDAV) open/save errors now route through user_facing_message; the USER_HINTS catalogue grew eight entries (Whisper download 404/network/checksum, speech engine and ffmpeg installs, microphone capture, unsupported save format, AI metadata); a menu item disabled by a remote safety lock now carries the reason in its help string (the spoken counterpart of the palette’s reason suffix); and the review rule is codified in CONTRIBUTING.md. AI engine honesty joined the programme: make_default_backend records last_backend_note whenever resolution falls back past a provider the user explicitly configured, Ask Quill announces it at open, and the connectivity-fallback hint now computes real availability on both sides (a stored cloud key counts as cloud-available), so a failed on-device model offers the configured cloud provider — always as an offer, never a switch, with the privacy consequence stated.

5.1c-WR Word-level AI change review (AI-7, upgraded 1.0.0)

Every AI edit that touches the document routes through the accessible “Review AI Changes” dialog (core/ai/diff_review.py + DiffReviewDialog): an ordered checklist of hunks the user can accept or reject independently, applied as one text replacement (one undo step). Shipped 1.0.0, the model gained a second-stage word diff with sentence context, because line hunks are too coarse for prose — when the AI changes one word in a 90-character line, a line diff makes the user hear the whole line twice and spot the difference by ear.

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.

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, then the python-docx render (quill/io/docx_text.py: headings, lists, tables), with the raw word/document.xml walk as the 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; re-registered 1.0.0 (#1317). A single unnamed, one-shot jump point, distinct from named bookmarks: Set Temporary Bookmark (navigate.set_temp_bookmark, Ctrl+J) sets it at the cursor with no dialog; Go to Temporary Bookmark (navigate.go_to_temp_bookmark, Ctrl+Shift+J) jumps to it with no picker. Both live in the Navigate > Bookmarks submenu (immediately after List Bookmarks) and the command palette. Session-only by design (never written to DocumentMemory) — it is disposable scratch state, not a durable anchor; it stores a raw caret position and does not re-anchor to surrounding text the way persistent bookmarks do. Implemented as MainFrame.set_temp_bookmark/go_to_temp_bookmark, aliased per-tab exactly like the named-bookmark dict. The handlers shipped in 0.9.0 Beta 3 but lost their registration when their historical chords were reassigned — Ctrl+Shift+K to Unquote Lines (edit.unquote_lines, #608) and Alt+Shift+K to Keep Unique Lines (power.keep_unique_lines, §4.22) — leaving the feature unreachable from the UI; 1.0.0 restores the command ids, keymap entries, menu items, palette labels, and F1 topics on the previously-unbound Ctrl+J / Ctrl+Shift+J.

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:

The five line-manipulation commands above ship unbound in the default keymap — every VS Code-style chord this section once claimed for them (Ctrl+Shift+D, Ctrl+Shift+K, Ctrl+L, Ctrl+J) is now spoken for by a different command (format.describe_formatting, edit.unquote_lines, per-pack, and navigate.set_temp_bookmark respectively). They are reachable from the Format menu and the command palette, carry ready-made chords in the Notepad++ and VS Code keyboard packs (quill/core/keymap_packs.py), and can be bound to anything in Preferences > Keyboard Shortcuts.

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).

Family-wide non-goals, recorded so they are not relitigated without new information. Telemetry of any kind – no Sentry, no PostHog, no counters. A crash-report bundle is a local file the user reads and chooses to send, which is a different thing and stays. System-wide clipboard anything – QUILL keeps the clips it was given and helps assemble them; it does not watch the system clipboard and keeps no clipboard history, because a writing tool that silently records everything you copy is not one people should have to trust. Competitor data files – study a competitor’s ideas, never their data; interop is via open standards only (OPML, M3U, EPUB, VTT), never by reading another product’s private store.

Sound Enhancements: two engines, one of them upstream’s own (quill/core/optilab.py, quill/core/optilab_adapter.py, quill/native/optilab/). The three broadcast-polish modes are a faithful adaptation of OptiLab Core by Lanes Audio / dgl1984 (https://github.com/dgl1984/optilab, Apache-2.0 with the Commons Clause v1.0), rebuilt as ffmpeg filter chains. That adaptation carries one documented limitation: upstream eases its lift and withdraws bass assistance while final limiting runs heavy, and an ffmpeg graph is feed-forward – no stage can observe how hard a later one is working – so the feedback loop cannot be expressed there at all.

Upstream’s engine is therefore also vendored unmodified at v1.4.0 under quill/native/optilab/upstream/ (with its LICENSE and NOTICE) and linked into an adapter QUILL owns (quill/core/audio/exact_optilab.py runs it).

One setting, three states, named for the surface and not for the engine: off (the built-in chain everywhere, the default), when saving (radio recordings and audio conversion), or when saving and while listening. A control named after an engine would imply it applies wherever that engine’s name appears; naming the surface removes the whole class of confusion, and the third option states its own cost inside the option (rule A-9: no control may silently apply to only part of what it names).

Saved files are the recommended state and cost nothing. Recordings and conversion already shell out to ffmpeg offline. A recording is post-processed after it finishes and after any dropped-and-resumed parts are joined, into a temp file that replaces the original only on success – a fault in the engine must never be able to cost somebody the show they were recording – and a raw capture is never post-processed at all, because re-encoding it is exactly what raw capture exists to avoid.

Live playback through the engine is possible, opt-in, and normatively not the default. The reason is structural, not a missing feature: the engine is a separate process (upstream’s API is explicitly not a stable C ABI), and nothing on QUILL’s live path ever holds a PCM sample – mpv applies enhancement natively from a filter string, which is why every enhancement previews instantly with no gap and why one graph serves live radio, podcasts and recordings. There is no filter string that expresses another product’s DSP, and mpv does not host CLAP. So live means relaying: decode, pipe through the engine, re-encode, play a loopback URL (EnhanceRelay, on both engines, with mpv’s native graph cleared so nothing is processed twice). The costs are a slower start, an MP3 re-encode generation, more CPU, and a reconnect on every settings change, because the engine is prepared with a mode and a sample rate at start-up and cannot be re-parameterised mid-stream. Rejected alternatives, recorded so they are not re-proposed: a custom libavfilter wrapping optilab-core, an in-process Python extension over an ABI upstream does not offer, and an mpv CLAP host.

Never both engines on the same audio. Whenever the real engine will run, the OptiLab filters leave the ffmpeg graph; everything else (EQ, compressor, channel mode, night mode) still applies.

State the differences; do not imply them. The chain contains none of upstream’s gated AGC, six-band density processing, adaptive bass and top control, stereo processing or hybrid final stage; its Podcast and Limiter ceilings are -1.5 and -2.0 dBFS where upstream delivers to -0.1; its Input default is 0 dB where upstream’s inputDriveDb is 3.5; and the limiter feedback loop cannot be expressed in a feed-forward graph at all, which is the one difference that can be claimed with confidence. On gentle material the two may be indistinguishable, and no QUILL text may suggest otherwise.

An adapter executable rather than a Python extension, because upstream’s native/API.md states its C++ API is “not a stable C ABI” and asks consumers to “wrap this C++ class in a small adapter owned by your project”. QUILL’s adapter contains no DSP. Entirely optional: absent adapter -> available() is False and every caller uses the chain. Upstream’s NOTICE grants royalty-free commercial use of OptiLab Core as a tool for producing, processing, broadcasting or streaming audio; the Commons Clause withholds the right to sell the Software itself, which QUILL does not do.

**Crash fingerprinting (quill/core/crash_fingerprint.py, feedback-hub

=1.1.0).** A repeat of a crash somebody already reported comments on that issue instead of filing a new one. QUILL files crash reports from two places holding different things – the excepthook has a live exception, the crash-recovery dialog has saved text from a session that already ended – and they must produce the same fingerprint for the same crash, or one crash reported live and later from its file lands on two issues and the deduplication has bought nothing. Both entry points therefore live in one module, and a test raises a real exception and compares the two paths. Two invariants: an empty fingerprint means “do not deduplicate”, never a real value (a hash of nothing would collapse every unparseable report onto one issue), and deduplication can never lose a report – every failure path falls through to creating a new issue. The id ignores line numbers (they shift every release, exactly when duplicates arrive fastest), the exception message, and absolute paths (which carry usernames and frozen-build layout).

Transient-failure retry, as one policy (quill/core/net_retry.py). A feed that answers 503 because its host is briefly overloaded, a connection dropped mid-read, and a request that timed out all usually succeed on the second or third try seconds later; a 404 and an unresolvable hostname never do, and retrying them only makes the failure take longer to report. The module draws that line – not “did it fail” but “is failing again the likely outcome” – and it is deliberately a short allowlist rather than “anything that is not a 404”: an unrecognized failure is treated as permanent, so a new failure mode surfaces at once instead of being silently retried three times. Two retries, a second then two seconds apart. Adopted by podcast feed refresh, the podcast directory search, and the OPML reachability sweep; the sweep uses a shorter schedule, because it runs across thousands of feeds and its verdict is what the import report offers to prune out of a listener’s subscription list – a false “dead feed” from one flaky moment is the most expensive wrong answer in the podcast stack. Downloads deliberately do not use it: they already have a resumable reconnect with a Range header, which is the better answer for a partially transferred file. sleep is injected, and resolved at call time rather than captured as a default argument, so a test that thinks it has disabled the waiting actually has.

Revealing a file in the file manager (quill/core/file_manager.py). Four surfaces had grown their own copy of this, and two were wrong the same way: Windows Explorer parses /select, and the path as a single argument, and split across two it drops the switch and opens Documents instead. The feature appeared to work – a window opened – while doing something else entirely, which for a screen-reader user comes with no cue at all that the wrong folder just appeared. One pure, tested argv builder now serves the editor’s Reveal in Explorer, the app shell’s post-update Open folder, Audio Studio, the radio Recordings list, and QUILL Cast’s Show in File Explorer. The path is treated as text rather than re-parsed through Path, because Path applies the running machine’s flavour and would mangle a POSIX path on Windows.

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.

Dictation reliability pass (2026-08-17). Five changes landed together, studied against the Handy project’s production dictation experience (an MIT-licensed offline speech app whose design notes and failure catalogue proved directly transferable; local clone at D:\code\handy). The through-line is reliability a listener can hear, not accuracy percentages:

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’s audiobook and batch-speech wizard (Tools → Speech → Audiobook & Batch Speech…; the menu label was “Audio Studio…” until 1.0.0, renamed to remove the clash with the standalone app’s name). 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 wizard (Tools → Speech → Audiobook & Batch Speech…) — 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.59b A file that is not there is a sentence, not a crash (shipped 2026-08-19; #1423)

Opening a document must never reach the crash handler because the file moved. Reported against a OneDrive path – a placeholder whose contents were never downloaded – and equally reachable from a renamed Recent entry or a disconnected drive.

5.59c A dialog’s required arguments cannot drift from its caller (shipped 2026-08-19; #1422/#1417/#1395)

SpeechSetupDialog gained two required keyword-only parameters and MainFrame was updated to supply them in the same change – but nothing checked the two agreed, so the window between them shipped and three people hit it while installing a Kokoro voice. A missing keyword-only argument is a TypeError at construction: the Speech Hub does not open at all, so there is no dictation, no voices, and a crash report instead.

The rule is that a constructor contract crossing a module boundary is checked by a test, not by review: tests/unit/ui/test_speech_setup_kwargs_contract.py reads the dialog’s real signature (inspect.signature, required keyword-only parameters) against the dict literals MainFrame builds (parsed with ast, since building them for real needs a live frame and a machine probe), and fails when a required parameter is added without being supplied.

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

Tools → Git and GitHub → GitHub → 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.

Voice reply delivery (core/ai/voice_reply.py, ui/voice_reply_dialog.py, ui/assistant_reply_delivery.py). Ask Quill has always supported a spoken question (Ctrl+F9 → offline transcription → send), but the answer had exactly one delivery path: _announce_incoming, a whitespace-collapsed 140-character summary handed to the screen reader. Two full speech stacks already existed and were wired only to document read-aloud — QUILL’s offline voices, and the cloud AI voices in core/ai/cloud_tts.py (OpenAI 11 voices, Gemini 30, ElevenLabs export-only). The join was missing.

ai_voice_reply_mode selects one of four deliveries — announce (default, unchanged behaviour), text, local_tts, ai_voice — and ai_voice_reply_announce_limit (0–2000, 0 = no cap) governs announcements only. Truncation is deliberately not applied to the spoken modes: a character cap is the right shape for a summary and the wrong shape for speech, where stopping mid-sentence is worse than not speaking.

truncate_announcement() is the single rule every announcement in the chat shares — replies, errors, and edit proposals alike. It replaces a literal 140 that lived inside the panel’s announcement helper, which meant the configured length governed replies while errors and proposals silently kept the old number. Errors and proposals remain announcements whatever the reply mode (a 4,000-character error read out in full helps nobody); they simply respect the same length. The limit is read once per chat session rather than per utterance, since it sits on the path of every announcement and reading settings is file I/O.

Policy lives in plan_voice_reply(), which is wx-free, synchronous, and returns a VoiceReplyPlan the UI performs; the mechanics (threads, wx, TTS calls) stay in the UI mixin. Two invariants hold in the policy layer: a plan never both speaks and announces (that would talk over itself), and every degradation moves towards the offline default, never away from it. An ai_voice mode with no API key, or with an export-only provider (ElevenLabs), falls back to local_tts and then to announce, carrying a fallback_reason the UI states once — silently doing something other than what a setting says is how trust in the setting is lost.

The dialog is the single place the whole chain is configured, and it closes a real defect: ai_tts_voice was surfaced in the generic settings list as one flat catalog of every provider’s voices, so a Gemini voice could be selected while ai_tts_provider was OpenAI, failing only at synthesis. The model and voice lists are rebuilt from the selected provider, making the invalid pairing unrepresentable. Cost and egress are stated in the dialog body (not a footnote) with a live estimate from estimate_cost_usd, because ai_voice is the only mode that bills money and transmits the reply text off the machine. It is opt-in and never selected on the user’s behalf.

Voice conversation mode (Alt+Shift+Q) predates the setting and has always spoken its answers; while the setting is at its default it continues to, so entering voice mode does not silently become text-only. An explicit choice always wins.

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
Quick Insert… unbound by default (format.quick_insert)
New Abbreviation from Clipboard… unbound by default

Per-abbreviation settings (schema v2). Every entry decides its own behaviour rather than inheriting one global policy. abbreviations.json is stamped version 2; each new field has a default, so a v1 library loads unchanged and a v2 library opened by an older build simply ignores the extra keys and still expands.

Field Values Default Effect
category free text "" (Uncategorised) Groups the manager list and Quick Insert.
triggers both, space, punctuation, manual both Which trigger characters fire this entry. manual never auto-expands and is reachable only from Quick Insert – the safe home for long or destructive expansions.
speak_mode silent, name, expansion silent What is announced on expansion.
sound inherit, on, off inherit Per-entry override of the global expansion sound.
trailing_space bool false Adds one space after the trigger punctuation (“Ltd., “ not “Ltd ,”). Never applies to a space trigger, which already leaves one.
usage_count, last_used int, ISO timestamp 0, "" Order Quick Insert; written in batches of ten, never per expansion, so no disk write lands in the middle of typing.

Unknown values in a hand-edited file degrade to the safe default rather than failing the load. Abbreviation.accepts_trigger(char) is the single place the trigger rule is decided, used by both the editor matcher and the system-wide one.

Fill-in fields. ${field:Label} and ${field:Label=default} make an expansion ask before it finishes; a label used twice is asked once and filled everywhere (matched case- and whitespace-insensitively). The rules are one wx-free module (core/expansion/fields.py) and the form one shared dialog (ui/fill_in_dialog.py, entry point prompt_for_fields), so a template behaves identically in the editor, in Quick Insert, and in Quill Inkwell. The expansion is re-resolved from the filled template rather than patched afterwards, so ${cursor} still lands correctly once the answers have changed the text’s length; a cancelled form leaves the typed text untouched.

Quick Insert. quill/ui/quick_insert_dialog.py – a type-to-filter picker over every enabled abbreviation, ordered most-used first, with a live preview of the full expansion. Inserts at the caret, resolving variables and honouring ${cursor}. Shared verbatim with Quill Inkwell (§5.86), which is the point: one library, one picker, two hosts.

System-wide expansion. Abbreviations also expand outside QUILL, through Quill Inkwell – see §5.89f. Both read and write the same abbreviations.json; neither imports from the other.

Only one expander per window. QUILL’s editor expansion always wins inside QUILL: it edits the document directly, so it is instant, keeps the editor’s own undo, fires abbreviation.expanded for Quillins, can run handler-based contributed abbreviations, and works in Safe Mode where no keyboard hook exists. AbbreviationsMixin._claim_own_expansion marks the frame with the QuillHandlesOwnExpansion window property at startup and a running Inkwell refuses that window outright. Without the marker a single keystroke would fire both paths and mangle the text.

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:

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)

**Release scope (1.0.0).** The **editor-embedded** Internet Radio is
**developer-build-only** in the public 1.0.0 build (gated with `QUILL_DEV_BUILD`,
exactly like Podcasts): no `Tools > Media` radio items, palette `radio.*`
commands, status-bar mini-player, or tray radio controls. Internet radio ships
to users as the standalone **Quill Radio** app (§5.89e), one keystroke from QUILL
via the QuillVille switcher — which is unchanged. The rest of this section
describes the feature as it stands in a developer build and in that standalone
app.

**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.

**Browse is a contract, not a window (Quill Radio 3.0; `core/radio/browse_nodes.py`,
`core/radio/browse_sources.py`, `core/radio/browse_visibility.py`,
`core/radio/directory_cache.py`, `core/radio/browse_helpers.py`).** The Browse
Stations dialog used to know the shape of every source in it: adding iHeart cost
three internal node-kind strings and edits in six places, and *Find in this
folder* carried a **second** copy of that knowledge, hand-synchronised — so a
source added to the display and forgotten there was silently unsearchable, with
no error to notice. `BrowseNode` is now the whole contract (folder / leaf /
action, plus a note and an optional child count), `ROOT_SOURCES` plus one handler
per source is the registry, and every source answers exactly one question: *what
is inside this folder*. The dialog knows only that a row is something to open or
something to play. Adding a source is a registry entry and a function, both
testable with no `wx` at all; the dialog ended **199 lines smaller while the tree
grew from thirteen root branches to twenty-eight**, and its GATE-11 budget was
ratcheted down to hold that, so re-introducing a source-specific branch inside it
fails the build. `browse_tree_helpers.py` moved out of `quill/ui/radio/` to
`core/radio/browse_helpers.py`, because the core registry needs it and core may
not import from the UI layer. Three behaviours ride the same refactor: an empty
branch distinguishes *nothing here* from *could not be reached* (indistinguishable
before, which is how somebody concludes a working source is broken); a folder
announces its size with its name wherever the source can say cheaply; and
`directory_cache` adds the fourth cache tier — fresh → live → stale — so a level
survives the session, a failed refresh keeps what was there rather than blanking a
working branch, and a cached answer reports its own age instead of implying it is
current. `LOCAL_SOURCES` is what Safe Mode reads, **per branch**, rather than one
app-wide switch.

**The keyless-source rule, and the fifteen branches it produced (3.0).** The
organising constraint is stricter than "free": **no API key, no account, no
developer registration, and no business relationship** for anything a listener
needs. Four of the new branches are axes over data QUILL was already downloading
and spending on two dropdowns — By Country → state/region, By Language, Trending
Now (`topclick`, genuinely distinct from the `topvote` behind Popular Stations),
and Recently Added or Changed. The rest are libraries: **Apple Podcasts**
(`core/podcasts/apple_podcasts.py`: storefront → genre tree → charts → lazy
`lookup` → the publisher's own `feedUrl`; genre tree cached a week, charts six
hours, feed URLs a month, one chart request serving every genre in a storefront);
the **Internet Archive** (`core/radio/internet_archive.py`, the collection tree
walked to any depth because every item declares its parents, `Retry-After`
honoured per the Archive's automated-access rules, and a **More...** node that
states how many rows it is hiding); **LibriVox** (`core/media/librivox.py`);
**Project Gutenberg** (`core/radio/gutendex.py`); **Audius, Mixcloud and
ccMixter** (`core/radio/free_music.py`); and **Wikidata**
(`core/radio/wikidata.py`: By City / Format via P131 / P415, plus
FM dial bands, joined conservatively against Radio Browser, which still supplies
every stream — hence the "from Wikidata" label on every row, because the join is
ours; a place or a format is asked of Radio Browser *directly* and topped up
with Wikidata's call signs, because the reverse order returns an arbitrary
capped slice and Arizona opened to nothing while forty-eight stations played).
**By Network was removed** (2026-08-16) and **By Format re-pointed off P2360**:
P449 is recorded for two US radio stations and P2360 for none, so both folders
were structurally incapable of listing anything — the same non-goal reasoning as
RadioDNS below, found by opening every axis in one pass rather than by report.
**By Owner (P127) was removed** (2026-08-17), which fixes the rule: *an axis
stays only if Radio Browser can answer it directly.* Ownership is not a field
Radio Browser carries, so that folder had no lead query and depended on
call-sign matching alone — it counted correctly and opened to nothing, or to a
fraction of the named company, roughly three times in four. Three standing decisions: **Podcast Index is out**
(2026-08-13, reversing the earlier plan — it needs a free key, and a key is
something to configure, support and explain at the worst possible moment, while
transcripts never came from a directory in the first place); **Mixcloud is Mode A
only**, metadata browse with activation opening the show in the listener's own
browser and the row saying so *before* activation, per its widget terms; and
**LibriVox has no By Title**, because the catalogue supports no title filter in
any form and an axis that quietly finds nothing is worse than one not offered.

**Where you stopped (3.0; `core/radio/resume.py`, `ui/radio/resume_playback.py`).**
Deliberately separate from `core/media/positions.py`, which Cast and the Media
Player use: that store keys on a *file* by name and size, and nothing Radio plays
here is a file. These key on the normalised stream URL, so a session token or a
changed scheme still finds the place. A position under `MIN_RESUME_MS` is not a
position and saving one *clears* the entry; within `END_MARGIN_MS` of the end
counts as finished and clears it too; and every failure degrades to "no saved
position" rather than reaching the player. Which rows have a timeline at all is
`RadioStation.is_recording`, declared by the source that produced the row — a
live station has no position worth remembering, because tuning in *is* the
position.

**Federated search over the libraries (Quill Radio 3.0;
`core/radio/federated_search.py`, `ui/radio/library_search.py`).** LibriVox, the
Internet Archive, Project Gutenberg and Apple Podcasts are queried alongside the
station directories, into the **existing** results list — each row carries its
own `RadioStation.source`, which the Source column renders and the Source filter
narrows, and the cross-directory merge de-duplicates them unchanged. No new
egress hosts. Four invariants: no cross-provider ranking (each source's order
preserved within its group); one task per source, appended as they land with the
reader's cursor preserved; one announcement when the last source reports; and a
source that cannot answer free text declares `search=None` with a reason and is
named, because "found nothing" and "cannot be asked" are different facts.

**Video (Quill Radio 3.0; `core/radio/video_formats.py`,
`core/radio/caption_style.py`, `ui/radio/video_window.py`,
`ui/radio/video_commands.py`, `ui/radio/video_output.py`).** **Video is a view
onto playback, never a mode of playback** — mpv's `wid` is set to the panel's
handle and `vid` flipped `no`↔`auto` at runtime, so showing and hiding the
picture never restarts the stream and never costs the position; never opening it
leaves prior behaviour unchanged. Adaptive video and audio are two URLs, so a
**video-only** format (≤1080p) is loaded and the audio handed to `audio-files`,
avoiding a download-and-merge that would not be streaming at all. The surface
carries an accessible name and description, sits in the tab order once with no
trap, and never self-focuses; a top-level frame keeps the main window's tested
tab order intact; there are no on-screen buttons and the status line is not a
live region; the close handler opens no modal. Section 508 503.4 places Captions
and Audio-and-Described-Audio beside Volume. Captions default to an **opaque**
background (contrast over arbitrary video is otherwise unguaranteeable) and scale
to 300%; automatic tracks are announced as such. Photosensitivity is answered
with control rather than a claim: video never auto-starts, the picture dims, and
hiding it is one keystroke from anywhere. No web player, no downloading, never
the default; `YOUTUBE_CONSENT` rewritten, consent flag not reset.

**Described audio (Quill Radio 3.0; `core/radio/audio_tracks.py`,
`ui/radio/audio_track_dialog.py`, `ui/radio/track_selection.py`).** The product's
reason for existing, stated as a requirement rather than a feature: a described
audio track narrates what a sighted viewer can see, and the desktop state of the
art is an audio-track menu reading "Track 1 / Track 2 / Track 3" — which for a
blind listener is a puzzle, not a control. `is_described` is a **pure,
unit-tested** predicate, generous about form (label, language tag, BCP 47
`x-description` subtags) and strict about meaning, so the heuristic improves
without touching UI; `describe_track` **names** each rendition and drops yt-dlp's
quality notes. **Ctrl+Shift+A** lists them with the described track first and its
availability announced above the list; **Ctrl+Alt+D** switches straight to it.
Invariants: ordered never filtered; **position carried across the switch** (a
rendition is a separate URL, so it is a reload, and losing an hour of a film to
enable description would defeat the feature); absence reported with what the
video *does* have, never a disabled command; and the selection cleared on every
new play.

**The shared transcript reader (`quill/ui/transcript_reader.py`).** The window
the timed cue model existed for, opened by Quill Cast (**Read Transcript...**)
and Quill Radio (**Transcript...**, Ctrl+Shift+T) alike — one window, because two
would drift. A read-only `wx.TextCtrl` rather than a custom list, deliberately:
arrow keys, word and line movement, selection, the screen reader's own review
cursor and Find are all free and identical to everywhere else, and a list would
have removed them and returned nothing. `line_starts` / `cue_index_for_offset`
map character offsets to cues and back, so Enter seeks correctly however the
caret arrived. Invariants: Follow Playback is **off by default** and silent while
on; positions are spoken as words via `spoken_duration`; jump and follow are
offered only where a seekable player exists and say why when they are not; Save
As keeps the timings (`cues_to_vtt`, `cues_to_srt`, both round-trip-tested); an
automatic caption track is labelled automatic. The player is injected as two
callables, so the reader knows nothing about either app's controller.

**A dropped live stream reconnects, in two layers (Quill Radio 3.0;
`ui/radio/mpv_radio_engine.py`, `ui/radio/live_reconnect.py`,
`core/radio/iheart.py`).** Reported fault: some stations play for about twenty
seconds and stop, and one replayed its last five seconds. iHeart's
`secure_hls_stream` answers 302 to a *per-listener* host carrying `rj-ttl=5`
plus a session cookie, and the media playlist behind it holds three segments at
`EXT-X-TARGETDURATION:10` — a thirty-second window refilled every ten seconds —
so a single failed refresh drains the buffer and ends the stream twenty to
thirty seconds later. The gap was total: no ffmpeg reconnect options were set at
all, so one transient read error was terminal, and `_on_finished` read EOF on a
live station as the end of the stream (`_attempt_engine_fallback`, the only
retry that existed, is gated on `CONNECTING`). Three parts, and each answers a
different failure: `stream-lavf-o` reconnect options heal the read below the
player, where most of these belong and nothing needs announcing;
`live_reconnect.py` retries a dropped **live** station three times at 2 s / 5 s /
15 s with every attempt and outcome spoken; and `_STREAM_KEYS` prefers the
progressive `secure_shoutcast_stream`, which has no segment window, no token and
no per-listener session to lose. The invariants: a **bounded** source is never
reconnected (a recording reaching its end has ended, and reconnecting replays
it), a retry whose `_play_token` has moved on is dropped so Stop always wins, and
"Reconnected" is spoken only from `_on_loaded` so no success is claimed that was
not observed. Both `live_reconnect.py` and `engine_selection.py` are extractions
from `player_controller.py` under GATE-11 — extract, never rebaseline.

**One playback state, and the two words it was missing (`ui/radio/playback_state.py`,
`ui/radio/playback_status.py`, `ui/radio/stream_stall.py`).** `RadioPlayerState`
carried five members, and two facts about a stream had nowhere to live. A stall
-- mpv reporting `paused-for-cache` when a live stream runs out of audio -- was
*announced* ("Buffering...") while the state stayed PLAYING, so the focusable
status bar's Now Playing cell and the tray tooltip both claimed playback through
dead air. A reconnect re-entered CONNECTING, which is true of the wire and wrong
for the listener: "connecting" is what a station somebody just chose does, and a
listener who pressed nothing is owed a different word. Both are states now
(**BUFFERING**, **RECONNECTING**), the engine reports **both edges** of a stall
rather than only its start, and the wording lives in one pure module the status
bar, the tray tooltip and the mini-player all read.

The requirement that makes the split safe is the one that is easy to miss: **a
state model may not have private copies of its own membership rule.** "Is a
stream on the air?" was spelled `state in (PLAYING, CONNECTING)` in fifteen
places, so adding two members would have silently broken every one of them --
Stop turning back into Play mid-stall, the sleep inhibitor releasing during a
reconnect, a favorite losing its now-playing badge every time its stream
hiccuped. Three named sets replace all fifteen: `ACTIVE_STATES` (on the air or
on the way), `RESTARTABLE_STATES` (plus PAUSED -- there is a station here to
load again), and `RUNNING_STATES` (PLAYING or BUFFERING -- what every site that
compared against PLAYING alone actually meant).

**A reconnect attempt is spoken (`ui/radio/playback_status.py::transition_announcement`).**
The paragraph above promised that every reconnect attempt is announced with its
number. It was not: `live_reconnect` composed the sentence into
`RadioPlaybackState.message`, which nothing spoke and which the status text
ignored for CONNECTING. What a listener actually got was one earcon and up to
twenty-two seconds indistinguishable from a hung player. The sentence is now
both rendered into the status line and spoken once per attempt, deduplicated on
the message rather than the state -- three attempts are three different
sentences and all three are news, while a re-entry into the same attempt is not.
Ordinary playback transitions stay cued rather than spoken, unchanged.

**Absent is not the same as announced (`core/radio/media_health.py`,
`ui/radio/media_preflight.py`).** `playback_engine` defaults to `"auto"`, and on
"auto" a missing `libmpv-2.dll` fell through to Windows Media in silence: the
one spoken line about it was reachable only by somebody who had opened
Preferences and named mpv explicitly. Everyone else got a radio that had quietly
lost live pause and rewind, output-device choice, Volume Boost, native Sound
Enhancements, stream track titles, stall detection, and every Ogg Vorbis, Opus
and HLS station -- with no sentence anywhere saying so. This is the other half
of the 2026-08-17 runtime lesson: *"is it present?" is not "does it work?"*, and
**absent is not the same as announced.**

The rules: a degraded installation says **one** plain sentence at launch naming
what is missing, what it cost and what to do -- spoken and deferred, never a
modal that fights a screen reader for focus a launching app has not settled yet
(cf. #259). A **healthy** installation says nothing at all, because a launch
that reports "all is well" every time is a launch nobody can listen past. The
mark is a **signature** of which tools are missing rather than a "seen" flag, so
a machine that later loses a second tool is told again and one told once is not
told forever. The probe asks the *same* predicate the engine selection asks
(`mpv_output_device_available`), because a health report that probed differently
from the code it describes would eventually describe a machine nobody has. And a
station whose container needs libmpv gets the real reason rather than the
generic "that stream could not be opened" -- conservatively, so an ordinary MP3
station that was merely off the air is never blamed on a missing component.

**First run (`core/radio/onboarding.py`, `ui/radio/first_run_dialog.py`).** A new
listener opened Quill Radio and got an empty favorites tree -- an accurate
picture of having no favorites, and an answer to none of the questions somebody
arrives with. Three screens, not seven, the same shape QUILL Cast uses: welcome,
find something to listen to, keep the ones you like. It does **not** run for
somebody who already has favorites, however they got there -- an imported list, a
restored backup, an upgrade -- because explaining how to find a first station to
somebody with forty is a way of saying nobody checked. Skip is a first-class
button and skipping counts as done. Every keystroke a screen teaches is resolved
through the live keymap, so a rebound key is the key taught; a command with no
binding names its menu item rather than rendering empty braces. The words live in
the wx-free core module, so they are testable and cannot drift between a label
and an announcement. **The flow is wired to a caller** -- Cast's equivalent dialog
has existed for months with none, which is indistinguishable from having no
first-run flow at all.

**Action rows, and the two branches you add to yourself (Quill Radio 3.0;
`ui/radio/browse_actions.py`, `core/radio/my_servers.py`,
`core/radio/youtube_channels.py`).** `BrowseNode.is_action` existed from the
first day of the contract and `browse_tree_dialog` never handled it, so "Add a
Server..." and "Add a Channel..." were rows that did nothing on Enter — the exact
failure `bounded_playback_ui`'s house rule exists to prevent, and the only thing
standing between those two branches and being usable. Dispatch is a registry in
`browse_actions`, one entry per action, so the window still learns nothing
source-specific. The shared rules: the address is **probed before it is stored**
and one that answers with nothing is refused rather than kept as a dead row; the
probe runs on `QuillTaskManager`, never the UI thread; and Safe Mode refuses out
loud, per action, before anything is asked. `my_servers` carries the app's one
deliberate plain-`http` exception — most small Icecast boxes are http on a high
port, refusing them would refuse the audience the branch exists for, the address
is one the listener typed, only a GET is sent, and no credential is attached.

**TuneIn stream choice prefers progressive over HLS, host-scoped (Quill Radio
3.0; `core/radio/tunein.py`).** The same failure mode as `iheart._STREAM_KEYS`,
through a different directory. `best_stream` demotes an HLS manifest **only when
a progressive URL on the same host exists** — because for the station that
prompted it the two URLs are on different CDNs and the second one's query string
names a different station id and a music genre where the station is sports, so a
blind preference could play a different broadcaster entirely. Where the hosts
differ, the reconnect layer covers the dropout instead. `_is_hls` matches the
path only, since TuneIn URLs carry heavy tracking query strings.

**A web address is a scan, not a query — in every search box (#1491;
`core/radio/page_url.py`, `core/radio/federated_browse.py`).** Find Stations
already folded in the website scanner: an entry that parsed as an address was
scanned for streams rather than searched for by name. The browse tree's **Search
All Sources** and **Find in this folder** did not, and those are the surfaces a
listener reaches first. Search All Sources handed `oj991.com` to sixteen
directories as a station *name*; Radio Browser matched the token "com" and the
answer was thirty-three rows of `Cruisin92.com` and `STAR1079.com` with the
station in none of them. Find in this folder answered nothing at all, and could
not have done otherwise — an address is never a row label, so scoping one to a
folder cannot match.

The cause was placement, not logic. `looks_like_url` lived inside
`ui/radio/station_browser_dialog.py`, a UI module the browse tree cannot import,
so the knowledge existed and was unreachable from the two places that needed it.
It moves with `normalize_page_url` into a wx-free `core/radio/page_url.py`, and
`federated_browse.search_everything` asks the question **first**: an address
routes to a `WEBSITE` pseudo-source that scans the page and answers with
ordinary playable leaves — the same node ids, the same context menu, the same
Add to Favorites as a browsed row, because it *is* one — and the directories are
not asked at all. A candidate with no anchor text takes the page title, since a
row labelled by a bare mount URL cannot be picked out of a list by ear. Safe
Mode and an unreachable site report through `failed` exactly as any other source
does. Only the **unscoped** search does this: "Search for a Podcast..." passes
its own targets and means podcasts whatever was typed. `browse_find.on_find`
routes an address to the same place from wherever the cursor is standing,
mirroring what it already did for the Search All Sources row — read a clear
instruction rather than refuse it.

The gap was wider than one station. No directory carries everything: WWOJ
("OJ 99.1", Avon Park FL) is absent from TuneIn — verified against
`api.tunein.com/profiles`, the service tunein.com's own site uses, as well as
the OPML directory QUILL queries — and absent from Radio Browser, so no spelling
of its name could ever have found it, while its own home page gives up the
stream on the first fetch. For a whole class of small and independent stations
the web address *is* the only route, and it was reachable from one of three
places a listener can type. No new egress: the scan is `link_finder`'s existing
reviewed fetch of the one page the listener typed.

**A player page is a page, not a stream (#1491; `core/radio/securenet.py`,
`core/radio/link_finder.py`).** SecureNet Systems' Cirrus player — used by
hundreds of US broadcasters — is served from hosts named
`streamdb9web.securenetsystems.net` and `radio.securenetsystems.net`, and the
`/stream` inside that *hostname* satisfied `_STREAM_PATH_HINTS`, which matches
against the whole URL. So a station homepage's "Listen Live" link was offered as
a playable candidate; it answers HTML, which is precisely what an endless
"Connecting." looks like. Worse, having produced a candidate the scan then
skipped the follow-the-link step, which only ran when nothing at all had been
found — so the real mount was never reached. `securenet.is_player_page_url`
decides by **host** rather than by enumerating player paths (`/v5/`,
`/cirruscontent/`, `/cirrusencore/` — the platform keeps adding them), and never
catches the `ice<N>.` mount hosts that genuinely do play.
`link_finder._is_player_page_url` unions that with the existing iHeart/TuneIn
portal test; such links go to a `portal_urls` bucket followed
**unconditionally**, and player pages are stripped from the final candidate list
however they arrived — anchor, inline script, or a followed page. A page removed
from the results can add no noise to a list it is not in, so there is no reason
to make it wait for the list to be empty; that gate had been quietly costing the
iHeart/TuneIn follow too, where one stream-shaped podcast link on a homepage was
enough to suppress the only link that reached the station.

**A capture that recorded nothing is a failure, not a recording (Quill Radio
3.0; `core/radio/recording_outcome.py`).** ffmpeg writes the output container the
instant it opens the file, so file existence was never evidence that audio
arrived — and a zero-byte job was reported through `on_state_changed`, which the
frame announces as "Recording saved". The verdict now lives in one module:
`captured_nothing` (missing, or under an 8 KiB floor — generous on purpose,
because deleting wanted audio is far worse than keeping a very short file),
`empty_capture_reason` (ffmpeg's own last stderr lines, most recent match first,
falling back to an honest admission rather than a guess), `discard_empty_capture`,
and the fatal/recovery classifiers that answer the same question.
`RadioRecorder.on_capture_failed(station, reason)` is separate from
`on_state_changed` because the two report opposite facts, and it is spoken on the
error cue. A listener-stopped capture is exempt.

**Song Details (Quill Radio 3.0; `ui/radio/song_facts.py`).** MusicBrainz
release, year and length for a song in the history — opt-in (a button; a per-row
lookup would spend a listener's connection on curiosity they did not express), on
the task manager, self-rate-limited to one request per second, degrading to
"nothing more is known" rather than to an HTTP message.

**RadioDNS is a non-goal (2026-08-14).** The module was deleted rather than left
as dead code: it resolves a broadcaster's service document from *broadcast*
parameters (frequency, PI code, ECC), and QUILL has no source of PI codes, so
wiring it would have meant a form asking for a value nobody has. `dnspython` was
dropped with it.

**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.

**SecureNet Systems "Cirrus" players — the shape-heuristic blind spot
(`core/radio/securenet.py`).** SecureNet hosts the player used by a large
number of US broadcasters, at `…securenetsystems.net/v5/<CALLSIGN>`. Unlike
Triton, its real stream *is* a literal string in the page HTML — and the scan
still threw it away. The mount is a bare Icecast path
(`https://ice66.securenetsystems.net/ROM`): no file extension, no
`/stream`-style path hint, nothing that distinguishes it by *shape* from an
ordinary link, so the generic filters that keep a page's help articles out of
the results discarded the one URL that actually played. This was a real,
reported gap (Radio Once More and Radio Once More 2, 2026-08-07): the first
station offered two useless candidates and the second offered none at all.

Recognising the platform is what makes the URL trustworthy, since its shape
never can be. When the page is a Cirrus player — by host, or by carrying an
`ice<N>.securenetsystems.net` mount when a broadcaster embeds the player on
their own domain — QUILL reads the mount straight out of the HTML and offers
it as the *first* candidate, ahead of the merely stream-shaped links from the
same page. Per-visit `playSessionID` query strings are stripped so a saved
favourite does not pin one visit's tracking id forever, and the shared
`/media` interstitial mount is skipped. The ice-server number is not derivable
from the callsign (ROM is on `ice66`, WARL on `ice25`), so it is read rather
than computed; a player page carrying no mount reports nothing instead of
guessing a URL that will not play. This resolver needs **no** network call of
its own — it only parses the page the scan already fetched — so it adds no
egress entry and works in Safe Mode.

Self-healing recovery inherits it, with one extra rule. `recovery.py` only
auto-applies an *unambiguous* result, which previously meant a resolved
StreamTheWorld mount or a single candidate overall — and a SecureNet player
page yields neither: alongside the true mount it carries ordinary links that
survive the scan, so a station saved from one read as "3 possible streams" and
recovery declined to act. `_is_resolved_player_mount` now counts a lone
`ice<N>` mount as resolved on the same footing as StreamTheWorld, so such a
station repairs itself on the first failed play. The match is on the `ice<N>`
host specifically, **not** the domain: the player front-ends (`radio.`,
`streamdb<N>web.`) share `securenetsystems.net`, and a player page links to
itself, so a domain match would promote the page's own links right back into
the "confident" set and defeat the check.

**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.

**YouTube stations (`core/radio/youtube.py`, #1268).** A listener asked for
YouTube -- including YouTube Live -- to be playable and recordable "just like
any other radio stream." It is, with three constraints shaping the design.

*A YouTube link is a web page.* `is_youtube_url` recognizes watch links,
`youtu.be` shorteners, `/live/`, `/shorts/`, `/embed/`, YouTube Music, and a
channel's live page (`/@handle/live`, `/channel/<id>/live`);
`canonical_youtube_url` collapses a video to
`https://www.youtube.com/watch?v=<id>`, dropping playlist, timestamp, and
tracking parameters so a favorite stores a durable link. `resolve_youtube_stream`
turns that page into a playable audio URL through yt-dlp
(`extract_info(download=False)`, best audio-only format, falling back to a
combined HLS manifest so live broadcasts still play).

*The playable URL expires.* YouTube signs its media URLs and they die within
hours, so the resolved URL is **never persisted**: the favorite holds the page
link and the stream is re-resolved on every play (`RadioPlayerController`) and
at the moment of capture (`recording.py::_resolve_capture_url`). That is what
lets a recording scheduled days in advance still work -- resolving at schedule
time would capture nothing.

*Resolving is a network round trip.* It cannot run on the UI thread; a frozen
window is unusable with a screen reader. `ui/radio/youtube_playback.py` runs the
resolve on a worker thread, announces CONNECTING immediately, and applies the
result via `wx.CallAfter`. Every play and stop bumps a token, so a resolve that
lands after the listener stopped or switched stations is discarded rather than
hijacking playback. The station keeps its page URL in `state.station` (favorites,
the now-playing line, and the recorder all need the durable one); the short-lived
URL lives only in `_playback_url_override`, which the one cross-engine rescue
reuses instead of resolving twice.

*Consent and posture.* yt-dlp is **never bundled** -- it reaches arbitrary media
hosts and updates constantly -- so it installs on demand, exactly as the
converter's URL import does (§4.6). The one-time consent + rights notice
(`ui/radio/youtube_ui.py`, persisted as `RadioHistory.youtube_consented`) is
taken when a YouTube link is **added**, not when it plays: a scheduled recording
can fire while nobody is at the computer, and that must never be the first time
QUILL reaches YouTube. Refused in Safe Mode at every layer (core resolve, host
resolver, consent prompt). The single egress site is recorded in the
network-egress audit. A private, removed, region-blocked, or not-yet-live video
produces a speakable reason rather than a silent failure or an HTML page handed
to the audio engine.

**What's Playing: copy and review (`ui/radio/now_playing_commands.py`, #1134,
fixed #1282).** Two commands go beyond the spoken announcement: copy the track
to the clipboard, and open a read-only, character-reviewable window with a Copy
button. A listener reported both doing nothing while a station played, yet
speaking correctly when stopped. Three faults, all fixed together: (1) a
*missing* cached title was treated as "nothing is playing" -- the normal state
for the first seconds of a station, for a stream whose ICY tap is refused, and
for every station when track announcements are off; (2) the fallback was the
asynchronous fetch-and-speak command, so the palette closed with nothing on
screen, and its `on_failure` swallowed errors, leaving silence; (3)
`AppShellFrame._copy_to_clipboard` returned `None` while
`MainFrame._copy_to_clipboard` returns `bool`, and the shared mixin branches on
that value, so in the standalone apps a successful copy announced failure. The
contract now: with a station on, both commands resolve the title first
(`_radio_fetch_track_title(on_resolved=...)`, which fires on the UI thread for
success *and* failure) and then complete; a stream with no titles says so and
the review window still opens naming the station; the copy confirmation names
what it copied. Both hosts' clipboard helpers return `bool`.

**The announcement service (`core/announce/`, #1290-#1297).** Nothing owned
announcements. Speech lived in two of three shells, braille lived nowhere,
earcons lived only in QUILL, and the verbosity system (Quiet Mode, Meeting Mode,
profile suppression, repetition collapse, the announcement budget) was wired
into `MainFrame` alone -- so the companion apps could mute,
brailleize or record nothing, and severity was a single `force_speech` boolean
that made an error indistinguishable from a routine confirmation.

`quill/core/announce` is the wx-free, strict-typed owner. An `Announcement` is a
value (text, braille text, severity, channels, sound event, region, dedupe key);
the `AnnouncementService` fans it out; a `Sink` per channel does the delivering.
Sinks are **injected callables**, which is what keeps the package free of wx and
ctypes while still driving real hardware in the shell, and lets every channel be
tested with a two-line fake.

Two service guarantees make it safe to depend on: **a failing sink is isolated**
(an unplugged display must not cost the user their speech), and **the first
failure per channel is recorded rather than repeated**, so a storm through a
broken channel leaves one honest diagnostic instead of ten thousand log lines.

The policy resolves settings + modes + severity into a per-channel decision.
ROUTINE is polite and throttled; INFO is polite; WARNING interrupts and is
recorded; ERROR interrupts, sticks on the display, and **is never suppressed by
any mode, setting or throttle** -- a user must not be able to configure away the
message that says something broke. The headline behaviour, impossible before:
**Quiet and Meeting Mode drop speech only and keep braille**, which is exactly
what a braille user in a meeting wants. Per-channel dedupe windows (braille
shortest, because a flash physically replaces what the reader is touching) run
off an injectable clock, and the #425 compact braille style (`p7/87 l14 c3 mod`)
is available opt-in, falling back to the spoken text when there is no position
to render.

The sinks are adapters, not rewrites: `SpeechSink` wraps `AnnouncementEngine`
unchanged (preserving the #966 no-double-talk rule and the #700 Prism
context-lifetime rule), `BrailleSink` promotes the #1289 dispatch to a channel,
`SoundSink` leads the message with its cue, `VisualSink` owns a message slot
standing status cannot clobber, `NotificationSink` records only problems,
and `EchoSink`/`HistorySink`/`TranscriptSink` feed review, the redacting
history, and the headless capture the UIA suite asserts against. The channel
probe reports availability, backend and reason for every channel -- including
"no sink installed" -- serialisable straight into the support bundle.

**Shell adoption of the announcement service (#1298-#1307).** Phase A built
the channels; Phase B moved every shell onto them. `ui/announce_wiring.py` is
the one factory: it reads the accessibility settings block into policy modes and
installs whichever sinks the host can actually serve, duck-typed and optional, so
a companion app without a notification store simply lacks that channel and the
probe says so rather than pretending.

`MainFrame._announce` keeps its verbosity gate -- suppression, repetition
collapse and the budget still decide -- and hands the result to the service;
**delivery moved, policy did not**, which is what makes the swap low-risk.
`AppShellFrame._announce` does the same for every companion app at once. (The
same pass also gave the out-of-repo family member its first screen-reader speech;
that half is recorded in `docs/apps/beacon/README.md`.)

One hand-off deserves recording: the #1289 braille dispatch lives inside
`AnnouncementEngine.announce`, so the factory disables it and installs a
`BrailleSink` over the engine's new public `braille()`. Without that, every
message would reach the display twice. `ui/announce_shim.py` translates the
legacy `force` boolean into a severity (WARNING, not the never-suppressible
ERROR), so hundreds of `_announce` call sites stay untouched.

A sink can also answer `can_deliver()` -- "no display connected" is *skipped*,
not *failed*, and the self-test report distinguishes them, because a channel
that reports success while writing to nothing is the exact dishonesty the probe
exists to prevent.

Two commands ship in every shell: **Repeat Last Announcement** (#1304), reading
the echo history or the visual message slot, and the **Announcement Self-Test**
(#1305), which announces on every channel and renders the probe as prose --
delivered, failed with a reason, or unavailable with a reason. Both ship unbound
by default: the QUILL-key namespace has no free letter left, and taking one from
an existing command would cost a binding people already use.

**Burst coalescing on the braille channel (shipped 1.0.0).** The dedupe layers
only suppress an *identical* repeat inside the window; a burst of *different*
messages still flashed across the display one on top of the next. The braille
sink's writer is now wrapped in `CoalescingBrailleWriter`
(`ui/announce_wiring.py`): the first message of a quiet period writes through
immediately (zero added latency), which opens a 150 ms conflation window;
messages landing inside the window replace each other and only the newest is
written when it closes, re-arming while the burst continues. Sticky ERROR
messages take the sink's `hold` path with the raw writer and are never
conflated. Where no UI-thread timer can exist (worker thread, headless test),
every message writes through -- the pre-coalescing behaviour.

**Report Editor Surface (shipped 1.0.0, `app.report_editor_surface`).** The
spoken sibling of Copy Diagnostic Summary, in MainFrame: one keystroke speaks
the editor surface kind, native window class, live SES_EMULATESYSEDIT state,
the two braille editor settings, and the braille bridge state
(active / disabled in settings / no display bridge) with the active
announcement backend. Content-free by construction. Exists because a braille
bug report from someone who cannot screenshot is otherwise unactionable;
palette-only and unbound by default, like the self-test.

**Braille announcement routing (`platform/windows/braille_output.py`, #1283).**
A braille user reported that spoken status messages never reach the display. The
cause was categorical rather than incidental: **no QUILL announcement path had
ever called a braille API.** Both bridges already supported it -- Prism's
`Backend.braille()` behind a `supports_braille` capability bit, and
accessible_output2's per-reader implementations (JAWS `BrailleString`, NVDA
`nvdaController_brailleMessage`) -- and QUILL called only `speak()` on each.

Braille routes at the same choke point as speech
(`AnnouncementEngine.announce`), so QUILL and every companion app are fixed
by one change with no per-call-site edits; the standalone apps construct the same
engine through `AppShellFrame`. The dispatch lives in its own module with three
invariants: (1) **braille never costs speech** -- every call is wrapped, a
failure is recorded into `last_error` and never propagated, because a display
unplugged mid-session must not silence the app; (2) **no truncation** -- both
readers let the user pan a flash message, and clipping to display width would
drop the tail of a track title, the exact information the command was asked for;
(3) **consecutive-duplicate suppression (2s)** -- a flash replaces what the
reader is touching, and the radio now-playing poller can repeat a title.

`backend.braille()` deliberately, not `backend.output()`: `output()` couples
speech and braille into one call, so one failure would kill both. On the
accessible_output2 path the *concrete* output is used, never the `Auto` wrapper
-- the shipped `Auto.output()` speaks twice and never brailles, so routing
through it would double-speak and still leave the display blank; the live-reader
probe therefore returns the concrete output alongside the speaker.

`Settings.announcement_braille` (default **on** -- a bug fix, not an opt-in)
exposes it in Preferences > Accessibility, and `diagnostics_environment()`
reports whether braille was enabled and supported so a support bundle can say.
The change also connects the verbosity system's `Channel.BRAILLE`, rendered by
the engine and discarded at `AnnouncementOutcome` since it was built: the outcome
now carries `braille`, and a suppressed announcement (Quiet/Meeting/throttled)
brailles nothing, matching speech. Unit tests cannot prove a physical display lit
up, so a manual pass with JAWS and NVDA on real hardware remains the acceptance
step.

**Insert Equation (`ui/main_frame_equations.py`, #1197).** Maths authoring is
text-first by design: the author types LaTeX or pastes MathML into the shared
accessible web form and QUILL supplies the delimiters (`$...$` inline, `$$` on
their own lines for a block). MathML is inserted verbatim -- it is a complete
element and wrapping it would break it. A selected equation round-trips for
editing: `split_existing_equation` strips `$`/`$$` and preselects the mode it
found, so the shortcut doubles as "edit this equation" without retyping. The
rationale is accessibility, not preference: a symbol palette is a mouse
affordance, while text is keyboard-only, reviewable character by character, and
already spoken by screen readers with maths support. Rendering needs nothing
new -- `core/browser_preview.py` already loads MathJax for a document that
contains maths, so preview and HTML export show the equation while the source
stays plain text. The command lives on its own mixin (`EquationsMixin`) rather
than in `main_frame.py`, and is registered with `Ctrl+Shift+E` under the
`core.format` feature. Contributed by @salorajan; split out of PR #1197, whose
other changes (environment-variable API keys, an ungated MathJax CDN include, a
top-level installer script, a parallel manual) were reviewed separately and not
taken.

**Live365 link normalization (`core/radio/live365.py`).** The link a listener
has for a Live365 station is almost never its stream: it is the station page
(`live365.com/station/<slug>-a25891`) or the web player
(`player.live365.com/a25891`), both HTML. Pasted into Add Custom Station those
saved a station that could never play. `normalize_live365` rewrites any
Live365 station/player link — or a bare `a#####` id — to the canonical
`https://streaming.live365.com/<id>` stream, and the dialog reports that it
did. It is deliberately a **pure string transform**: the station id is already
present in every such link, so there is no network call, no scraping, and no
use of Live365's auth-gated directory API — nothing to gate in Safe Mode and no
new egress site. A bare station *slug* with no id would require that API and is
therefore left untouched, as is every non-Live365 URL.

**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.

**Winamp classic-skin playback keys in the Recordings player (#1344,
shipped).** The Recordings Manager plays through the same controller as live
radio but bound only Ctrl+Up/Ctrl+Down; there was no play, pause, stop, seek, or
way to move between recordings. A user with a Winamp background asked for the
classic-skin main-window keys, which is muscle memory a great many blind
listeners still carry.

- **The map is data, and it is wx-free** (`quill/ui/radio/winamp_keys.py`):
  `resolve_winamp_action(key, ctrl=, shift=, alt=)` takes a normalized token
  (`"X"`, `"LEFT"`, …) and returns an action name, so the meaning of every key is
  unit-testable without a display and the standalone Media Player
  (`main_frame_media_player.py`) and Cast can adopt exactly these keys rather
  than growing a second, subtly different set. `normalize_key_code` is the only
  wx-aware helper; `parse_time_to_ms` handles the Jump To Time entry (`90`,
  `1:30`, `1:02:03`).
- **Bound:** `X` play, `C` pause/unpause, `V` stop, `Shift+V` stop (Winamp's
  fade; the engine has none, so it stops cleanly rather than pretending),
  `B`/`Z` next/previous finished recording (selects the row and plays it),
  `Left`/`Right` ∓5 s, `Shift+Left`/`Shift+Right` ∓30 s, `T` elapsed/remaining,
  `J` jump-to-file (name substring), `Ctrl+J` jump-to-time, `L` open,
  `Ctrl+Up`/`Ctrl+Down` volume.
- **Two documented divergences.** `Ctrl+T` stays **What's Playing** — Quill
  Radio's meaning is the more valuable one and is documented in the 2.2 notes —
  so the elapsed/remaining toggle moves to plain `T`. And `Up`/`Down` stay list
  navigation, which is what Winamp itself does in the Playlist Editor; the
  Recordings list *is* a playlist editor by that analogy, so this is fidelity,
  not compromise.
- **Constraints.** Every action announces through `self._announce` (which reaches
  braille as of #1283) — a transport key whose result cannot be heard is not a
  working transport key. A letter is never swallowed while a text entry has focus
  (the trap #1263 hit). Seeking requires `controller.is_seekable()`; a live
  stream says why it cannot move rather than doing nothing. The letter keys are
  governed by `RadioHistory.winamp_playback_keys` (default **on** — every key
  they claim was otherwise unused in this dialog) with a Preferences checkbox;
  Ctrl+arrow volume is outside the opt-out, since it predates #1344 and cannot
  collide with typing.
- **Deliberately not bound:** shuffle (`R`), repeat (`S`), and stop-after-current
  (`Ctrl+V`). All three describe a play queue the recordings list does not have;
  binding them to something that only looked like it worked would be worse than
  leaving them out. They are follow-up work, gated on giving the list a queue.
- `RadioPlayerController.position_ms()` was added as the public form of what the
  transport surfaces were reaching into `self._engine` for.

**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 a recorded stream chains directly into the
Listening Companion (§5.84b) for transcribe-and-summarize, which no standalone
radio app offers.

---

### 5.84g Podcasts — relocated (not part of public 1.0)

**Status for 1.0.0: gated.** The editor-embedded Podcasts feature
(`core.podcasts`) is dev-build-only for QUILL 1.0.0, and the standalone Quill
Cast app that shares its code is gated behind `RELEASED_APPS`
(`quill/core/app_launcher.py`). Neither is reachable in a public build.

The full requirements — data model, discovery and subscription, OPML, downloads,
retention, playback, chapters, sorting, show notes, settings, the Phase 4-11
surfaces (pinned views, Inbox, Play Queue, playlists, filters and Search
Everywhere, transcripts, episode notes, local podcasts, the ACB Media directory,
per-podcast Sound Enhancements and skip settings, the per-podcast **Episode
Filters** with their eight scopes, and the **per-podcast settings** — the
four-level shared-default/folder/podcast chain, the described-and-searchable
settings catalogue, per-podcast row speech, and the twenty-five settings the
proposal named) and the non-goals — were moved
verbatim to **`docs/apps/cast/README.md`**, Part 2. Nothing was deleted, and this
section number is kept so that every existing `§5.84g` cross-reference in this
PRD and in the code comments still resolves.

Radio (§5.84f) and the shared Sleep Timer (§5.84h) remain public and are
unaffected.


---

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

**Goal.** One sleep timer that covers every media feature 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. In
the public 1.0.0 build both editor-embedded players — Internet Radio (§5.84f)
and Podcasts — are held back to developer builds (`QUILL_DEV_BUILD`); internet
radio ships to users as the standalone Quill Radio app (§5.89e). The Podcasts
half of the contract is recorded in `docs/apps/cast/README.md`.

**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." Media players are
independent of each other (nothing stops one when another starts), so every
controller that is active at the moment the countdown lands is faded and
stopped.

**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 (developer builds only as of 1.0.0)

**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.

**Public visibility (2026-08-06).** The redeem surface (the Help-menu item and
the `help.redeem_unlock_code` palette command) is gated behind
`is_dev_build()` and hidden from public builds of every app; it was also
removed from the user guide and release notes. Redeemed codes are still
honored by the feature layer in all builds — only the way to enter one is
tester-only.

**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 app — Quill Radio

> The gated companion apps that this section once described alongside Quill
> Radio are documented outside the public PRD for 1.0.0: **QUILL Cast** in
> `docs/apps/cast/README.md` and the standalone **Audio Studio** in
> `docs/audio-studio/README.md`.

**Goal.** Radio is useful without the editor: someone who wants to listen to
internet radio should not have to load all of QUILL to do it. Quill Radio is a
small standalone executable — its own window, its own menu bar, its own system
tray icon — that reuses QUILL's feature code *unchanged* rather than forking it.

**Architecture (`quill/ui/app_shell.py` + `quill/apps/`).** The feature
mixin `MainFrame` already uses (`RadioMixin`) only ever
touches a small, fixed host protocol on its 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` or the
  shared dialogs lands in the standalone app automatically — same modules, same
  imports.
- **One data store.** The app loads the same `core.settings`/`core.keymap`
  and reads/writes the same favorites, history, recordings, and schedules
  under `app_data_dir()` — what you favorite in Quill Radio is a favorite 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 surface.**

- **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.
**Open in Quill.** The app carries 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 app skips the tray there instead of misrepresenting it. `QUILL_SAFE_MODE`
is honored on launch.

**Installer integration.** The Windows installer creates a Start Menu entry
for Quill Radio alongside QUILL's own, launching it via the bundled Python
runtime (`-m quill.apps.radio`), with both launcher variants (bundled runtime
and exe) covered. The desktop icon for the companion is 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.** The app opens on a real, tabbable main
surface — never a bare frame: a live now-playing line, the favorite-stations
list focused on launch with Enter to act, and its core action buttons, every
control named via `dialog_contract.set_accessible_name`.

**Every app declares what it needs (`standalone/runtime/app-profiles.json`,
`tests/unit/structure/test_app_profiles.py`, `scripts/runtime_layer_report.py`).**
The shared QuillVille Runtime is the union of every app's Python dependencies,
and that union is dominated by the editor: measured on the 2026-08-19 build,
**113.1 MB of the 284.1 MB Python payload -- 39.8% -- is reachable by exactly
one app of ten** (the documents stack at 85.0 MB and the spell checker at
28.1 MB). Nine apps install all of it. `REQUIRED_COMPONENTS` in each app module
already declared external tools; nothing declared Python layers, so nothing
could notice that Quill Weather ships a PDF renderer, Windows OCR and a spell
checker to read out a forecast.

The requirement is the declaration, not the split: **every shipped entry point
has a profile row, and the two halves of a declaration may not drift.** A test
checks that each row names a module and a standalone directory that exist, that
each row's components match that module's own `REQUIRED_COMPONENTS` exactly --
the control for the risk the idea carries, since a declaration that can be wrong
is worse than none, and Audio Studio shipped for months declaring ffmpeg while
its build staged libmpv too -- that no package belongs to two layers, and that
the editor is still the only app requiring either heavy layer. A second app
legitimately needing one is the moment to re-price the split, not to discover it
afterwards.

`runtime_layer_report.py` measures a built runtime against the declaration and
prints core, per-layer and **unattributed** totals. It never fails a build:
gating the split while 100.9 MB is unattributed would gate a guess. Scope,
measurements and the open decisions are in
`docs/design/2026-08-20-documents-layer-scope.md`.

The declaration surfaced three findings on its first day, recorded rather than
fixed because adding a component to an app changes what its installer must
stage: **Quill Converter** calls `find_ffmpeg()` and offers exactly `["wav"]`
without it -- an audio converter that can only write WAV -- while declaring no
components and staging none; **Quill Media Player** announces "Audio effects
need the libmpv engine" and declares nothing; **Quill Beacon** documents an
opt-in libmpv backend and declares nothing, degrading rather than breaking.

**Non-goals (v1).** No single-instance enforcement. The phased plan for the
rest of the family, including the apps gated out of 1.0.0, lives in
`docs/planning/apps.md`.

---

### 5.89f Standalone companion app -- Quill Inkwell (system-wide abbreviation expansion)

**Goal.** The abbreviations QUILL expands in its own editor should expand
everywhere else too. Quill Inkwell is a small, free, tray-resident app that does
exactly that, reusing QUILL's code rather than forking it.

**The one-library rule.** Inkwell and QUILL read and write the *same*
`abbreviations.json` in the same data directory. There is no import, no export,
and no synchronisation, because there is nothing to synchronise. Inkwell re-reads
the file when its timestamp changes, so an abbreviation added in QUILL is usable
system-wide within moments and vice versa. An installed Inkwell therefore keeps
its data in `%APPDATA%\Quill` deliberately; only a portable build (a `data`
folder beside the executable) moves the library onto the stick.

**Architecture.** Engine in `quill/core/expansion/` (wx-free, strict-typed):
`ring_buffer.py` (the bounded memory of recent keys), `matcher.py` (the same
library, matched against that buffer), `targets.py` (where expansion must never
fire), `settings.py` (the app's own preferences). Platform in
`quill/platform/windows/`: `expansion_hook.py` (a `WH_KEYBOARD_LL` hook on its
own thread with its own message pump), `text_injector.py` (SendInput, plus a
clipboard-paste fallback that always restores), `foreground.py`,
`inkwell_startup.py`. App shell in `quill/apps/inkwell.py` on `AppShellFrame`,
with the expansion service extracted to `quill/ui/inkwell_expansion.py`. The
manager and Quick Insert dialogs are shared with QUILL unchanged.

**Threading.** The hook procedure decodes one key, appends it to the buffer, and
queues any match -- nothing more. Windows silently removes a low-level hook whose
procedure is slow (`LowLevelHooksTimeout`), which would end expansion for the
session with no error anywhere, so the injection happens on a worker thread and
every UI touch goes through `wx.CallAfter`.

**Ignoring its own output.** Synthesised keystrokes return through the same
hook, so every key Inkwell sends carries a signature in `dwExtraInfo` and those
are skipped. Deliberately *not* implemented as "ignore all injected keys":
dictation software and on-screen keyboards inject too, and their users must get
expansion like everyone else.

**Keyboard layouts.** Characters are decoded with `ToUnicodeEx` against the
foreground window's own layout, with shift and caps read from live key state. A
dead key yields nothing and is replayed into the layout, so composing an accent
still works. AltGr counts as typing, not as a command: Windows reports it as
Ctrl+Alt, so the naive rule discards real characters on every layout that uses
it.

**Reaching every editable surface.** Three rules, all biased towards expanding
rather than refusing (the one rule that fails closed is the credential
deny-list): the focused element must accept text -- decided once when a match
fires, never per keystroke, from a real caret, a known text window class, or UI
Automation, with "no opinion" meaning yes; a window belonging to an elevated
process is detected and reported once, because a normal-privilege process never
sees its keys; and delivery resolves per application, so the clipboard-paste
route can be enabled for one stubborn program without every other program having
its clipboard borrowed.

**Undo, on-demand expansion, and survival.** Backspace immediately after an
expansion restores the abbreviation (expiring after a few seconds, any other
keystroke, or a change of window). A system-wide chord expands the word before
the cursor without a trigger character, which is how a `manual` entry is reached
and how mid-word expansion works. A watchdog re-installs the hook every few
minutes, because Windows silently removes a hook that exceeds
`LowLevelHooksTimeout` and offers no way to ask whether one is still live.

**Security and privacy contract** (enforced in code, covered by tests):

1. The keystroke buffer holds at most 64 characters, in memory only.
2. Nothing typed is written to disk, logged, or transmitted; there is no network
   call anywhere in the expansion path.
3. The buffer is cleared after every expansion, on Escape, on any navigation or
   editing key, on any Ctrl or Alt combination, on a change of foreground
   window, and on pause.
4. No decision is made from the *content* of typed characters. The suppression
   rule reads the foreground window's process, class, and title only.
5. Password managers, the Windows credential and lock surfaces, and the UAC
   prompt are permanently excluded; users may add their own applications.
6. `QUILL_SAFE_MODE=1` prevents the hook from being installed at all.
7. The clipboard is read only to resolve `${clipboard}`, and the paste fallback
   always restores the previous contents. Inkwell keeps no clipboard history.

**Distribution.** Windows installer and portable zip from
`standalone/inkwell/scripts/build_release.ps1`, sharing the QuillVille Runtime;
updates through the shared release repository. MIT licensed, free permanently.
Product docs live with the product in `standalone/inkwell/docs/`.

### 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.90.12 On-device output-quality shaping and long-job foundations

Five modules land in the 1.0.0 cycle to raise the quality of the free, on-device AI path and to lay groundwork for long-running AI jobs. **Two are active and user-observable; three are tested core modules that are not yet wired to any UI surface** — the distinction is load-bearing and must be preserved in user-facing docs.

**Active (live in `Assistant.transform`).**

- **#1319 — output-quality shaping for small local models.** `quill/core/ai/quality_filter.py` is a pure module: `negative_examples_block()` returns wrong-then-corrected worked-example pairs to prepend to a prompt (on small models, examples outperform instructions); `find_quality_issues(text)` is a deterministic post-filter that detects hedging (`HEDGING_PHRASES`), editorializing (`JUDGMENT_WORDS`), and filler openers (`FILLER_OPENERS`); `retry_instruction(issues)` names the offending phrases for a single corrective retry. Wiring lives in `Assistant._respond_quality(prompt, *, shape)`: shaping runs only when `shape` is true **and** the backend reports `is_local`. `shape` is true only for the generative operations `_GENERATIVE_OPS = {rewrite, summarize, continue, shorten}`; the faithful transforms `fix_grammar`, `structure` (OCR reflow), and `reading_order` are deliberately excluded so a banned phrase that is genuinely in the source is never rewritten. On a hit, exactly one retry is issued; a blank retry falls back to the original. Cloud backends receive the plain response byte-for-byte.
- **#1320 — two-pass observe-then-rewrite for local Summarize.** `quill/core/ai/two_pass.py` provides `observe_prompt(text, kind)` (pass 1: extract plain, non-interpretive observations from the source) and `rewrite_prompt(observations, *, word_budget, output)` (pass 2: write to at most `word_budget` words using only the observations, source unseen); `DEFAULT_WORD_BUDGET = 150`. `Assistant._summarize` invokes the two-pass path via `_use_two_pass_summarize()`, which is gated on `two_pass_summarize` (default `True`) **and** backend `is_local`. Pass 1 is unwrapped (faithful extraction); pass 2 is wrapped so the user's instructions/style still shape the final summary, and its output still passes through the #1319 shaping. Cloud backends and the disabled case fall back to the single-pass summary, which also gets #1319 shaping. Note: the two-pass length control is the word budget, not the one-fifth-length target of the `summarize` custom-instruction default (that default still applies to the single-pass cloud path).

**Foundations (core shipped and unit-tested; UI/surface adoption pending — not yet reachable by any user action).**

- **#1321 — focus-free progress + ETA.** `quill/stability/progress.py` is a pure, timestamp-injected state machine for long jobs (bulk transcription, agent runs, bulk OCR). It exposes progress through two non-focus-stealing channels the UI is expected to drive: window-title text (`ProgressReporter.title_text`, e.g. "Transcribing 42 of 247 - QUILL") and a status line the UI registers as a UIA live region (`ProgressReporter.status_text`). The ETA is computed from the **second** completed unit to avoid warm-up skew (model load, cache miss); a run projected past `_HOURS_WARNING_SECONDS` (3600 s) emits an up-front heads-up. **No caller drives it yet** — dictation/transcription and agent surfaces are the intended adopters.
- **#1322 — auto-loaded sidecar context.** `quill/core/ai/sidecar_context.py` resolves a `<document>.quill-context.md` sidecar (`DOCUMENT_CONTEXT_SUFFIX`, cap `_MAX_CONTEXT_CHARS = 8000`) carrying reference *facts* (character names, terminology, project background) — the facts companion to the existing `.quill-instructions.md` writing-*rules* sidecar. When resolved it is intended to be injected as reference context and announced (never applied silently). The assistant seam exists (`Assistant.set_context_preamble`), but the document open/edit surfaces do **not** call the resolver yet, so creating a sidecar has no observable effect today.
- **#1323 — resumable + rebuildable batch records.** `quill/core/ai/resume_record.py` provides crash-safe records for expensive batch AI runs, persisted via `core.storage.write_json_atomic` (atomic replace: a kill leaves either the old record or the fully-written new one). A `signature` fingerprints run parameters (model, options) and drops stale results rather than blending configurations. `run_resumable(...)` skips already-recorded units (resume) and computes only the remainder; `rebuild(...)` returns stored results with zero `compute` calls (regenerate outputs without re-querying a model). `compute` is injected, so the module touches no real model or network. **Batch-transcription adoption is pending** — no shipped surface constructs these records yet.

### 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`, the optional `pdf-ocr` extra — not bundled; see "Reader chain and the document-text contract") | 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.

**Math in imported e-books (EPUB), shipped (#1355).** EPUB text extraction
(`quill/core/epub.py`) detects `<math>` MathML blocks, LaTeX-classed
`<span>`/`<div>` elements, and inline/block LaTeX delimiters (`$…$`, `$$…$$`,
`\(…\)`, `\[…\]`) in each chapter and converts them to the same spoken reading
the editor uses — `math/speech.speak()` via MathCAT, falling back to the
`navigator` template renderer, and to the raw formula if neither can parse it —
wrapping the result inline as `[Math Equation: …]` so an equation reads as a
sentence in place rather than being dropped or read out as tag soup. Extraction
never raises: a malformed equation degrades to stripped text, and a chapter with
no math is byte-for-byte unchanged.

**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.

---

### 5.95 NLS BARD catalogue search (Book Library source, shipped)

**Release scope (1.0.0).** The Book Library (`core.library`) is
**developer-build-only** in the public 1.0.0 build, held back by the same
`QUILL_DEV_BUILD` switch as Podcasts and Internet Radio, and is **moving to the
forthcoming QUILL Social app** where the authenticated online libraries on the
roadmap belong. A public build surfaces no `Tools > Media > Book Library` item
and no `library.*` palette commands; `QUILL_DEV_BUILD=1` restores them. This
section (and the BARD source below) describes the feature as it stands in a
developer build.

The Book Library gains **NLS BARD** as a search source alongside Project Gutenberg, Google Books, and the OPDS catalogues. BARD is the collection of the National Library Service for the Blind and Print Disabled (Library of Congress), and NLS publishes a free, no-key **public catalogue API** over its metadata.

- **Scope: catalogue search only.** QUILL queries BARD's public metadata search and shows matching titles (title, authors, subjects) as ordinary Library results. It does **not** download or store BARD files — borrowing a title requires an eligible BARD patron account and is done on the BARD website. Each BARD result therefore carries a `site_url` (the title's stable `hdl.loc.gov` Library of Congress page), and the Library dialog offers an **Open in BARD** action that opens it in the browser. In-app borrowing of BARD titles is planned for a future release.
- **No credentials, no key.** The search endpoint is unauthenticated; QUILL sends only the search terms. No BARD account, token, or secret is involved.
- **Implementation.** `quill/core/library/bard.py` (`parse_search`, `search`) mirrors the other single-source providers; because BARD's search is an HTTP `POST` with a JSON body, `quill/core/library/http.py` gained `fetch_json_post` (same HTTPS-only, Safe-Mode, timeout and size-cap guarantees as `fetch_bytes`). `providers.py` registers the `"bard"` source; `quill/ui/library_dialog.py` adds the source and the accessible **Open in BARD** action. The new egress site is reviewed in `network_egress_audit.py` (GATE-9). Disabled in Safe Mode. Unit-tested in `tests/unit/core/library/test_library_catalog.py` and `tests/unit/ui/test_library_dialog.py`.

---

### 5.95a The Accessible Libraries Hub (Book Library, shipped)

**Release scope** is 5.95's: developer-build-only, behind `QUILL_DEV_BUILD`.

The Book Library stopped being a list of catalogues and became a hub over them.
Three things changed, and each is here because of how the window *sounds* rather
than how it looks.

**One row per book, not one per library.** *Middlemarch* found in Project
Gutenberg, Standard Ebooks, LibriVox and Open Library used to be four
near-identical rows differing only in a source name near the end -- which under a
screen reader means hearing the same title four times to learn one fact. Results
are now `quill/core/library/works.py` `Work` records: every edition found, on one
line, naming every library it came from.

Grouping is on **normalised title and first author**, deliberately not on ISBN: a
public-domain text and a volunteer recording of it share no identifier at all,
and those are exactly the two rows worth joining. Titles lose subtitles
(*Middlemarch: A Study of Provincial Life* is *Middlemarch*), authors reduce to a
surname (*George Eliot*, *Eliot, George* and *Eliot, George, 1819-1880* are one
person), and the **plainest title wins** as the row's own name. Nothing is
hidden: an **Edition** chooser lists every library the work was found in, because
"which edition" is a real question -- a professionally proofread Standard Ebooks
EPUB is materially better with a screen reader than a raw scan, which is why it
is marked *professionally proofread and formatted* and is the edition Enter acts
on.

**Every row says what can be done with it** -- the four-category rule, now real
in code (`quill/core/library/availability.py`): *open now*, *catalog record*,
*account required*, *partner integration required*. The category is computed from
what the record **carries**, not from which provider returned it, because Open
Library holds both public-domain texts QUILL can fetch and records it can only
point at, and a rule keyed on the source name would get one of those wrong every
time. Download and Download & Open are **disabled** on a row QUILL cannot open,
which agrees with what the row already said; pressing them anyway explains rather
than failing. Category 4 has no source today and exists so that adding a closed
collection later cannot quietly present it as something it is not.

**Read or listen is a property of the book, not a separate search.**
`quill/core/library/audio_sources.py` maps a LibriVox recording to an ordinary
`Book` carrying one `audio` format, which is all the grouping needs to put the
recording and the text on one row that says *read or listen*. One result per
recording rather than one per chapter: a LibriVox book is dozens of section
files, and playing the whole thing in order is Quill Radio's job. This is the
join nothing in QUILL previously made.

**Open Library** (`quill/core/library/openlibrary.py`) is the new bibliographic
source: it knows a book exists when none of the free libraries hold it. It asks
for only the fields used rather than the whole record, and it is careful about
one thing above all -- a **borrowable** scan is a catalog record, never a
download. Reproducing controlled digital lending inside QUILL would be a worse
version of Open Library's own site with more ways to fail, and offering a
borrowable scan as a free full text would be QUILL claiming a right nobody
granted. Only `public_scan_b` / `ebook_access: public` records carry a text.

**Catalogs you add yourself** (`quill/core/library/catalogs.py`, `Catalogs...` in
the Library window) is the payoff for building the ebook half on OPDS rather than
one adapter per provider: a catalogue is a name and a URL, so a personal Calibre
library, a school repository or a community collection becomes searchable with no
QUILL release. Plain HTTP is **accepted and labelled** -- a library on a home
network usually is not encrypted, and refusing those outright would rule out the
case the feature mostly exists for, while a quiet downgrade would be worse than
either. Removing a built-in **switches it off and says so**, because the
built-ins are code and deleting one would only bring it back next launch. Stored
atomically; classified `content` in the persistence audit.

**The filter is local.** *Everything found / only what QUILL can open now / only
books to read / only recordings to listen to* narrows what has already been
fetched, so it costs nothing rather than a second wait, and a filter that hides
everything says so rather than presenting an empty list -- an empty list and a
failed search sound identical and are not the same thing. The status line counts
by what can be done ("2 books found, 1 you can open here, 1 you can listen to")
rather than totalling rows, because "40 results" of which two are openable is a
worse answer than the truth.

**Egress:** every call still funnels through `quill/core/library/http.py`, the
library layer's single reviewed site, so Open Library and LibriVox added no new
outbound site (GATE-9). Refused in Safe Mode like every other library fetch, and
a slow catalogue returns nothing rather than failing the whole search.

**Not built, deliberately:** in-app borrowing of anything, and any provider
needing an organizational agreement.

---

### 5.84j Carrying your place between machines (QuillSync, wired)

**Everything underneath this existed and nothing connected it.** The QuillSync
engine (`core/sync/`) commits, pushes and pulls encrypted records;
`core/media/positions.PositionStore` already satisfied its `RecordStore`
protocol and `merge_positions` its `MergeFn`. What did not exist was anything
that *moved a record*. `core/sync/places.py` is that adapter.

**Two stores, one vault, one folder.** The media store keys on a file's
contents; Quill Radio's `ResumeStore` keys on a normalised stream identity, and
no single key can mean both. They therefore stay two stores and sync as two
entity types over two commit logs, sharing one recovery phrase and one remote
folder in separate subdirectories. `ResumeStore` gained the `RecordStore`
contract and a `merge_resume_points` `MergeFn` to make that possible. Merging
them into one identity space would mean inventing a key that is neither, and
forcing every future provider into it.

**Bring your own remote.** The transport is a folder
(`FolderTransport`) -- in practice one inside whatever the listener already
syncs: OneDrive, Dropbox, iCloud Drive, a network share, a stick carried between
two machines. QUILL runs no server and holds nobody's listening history, and the
contents are encrypted before they reach the folder, so its provider learns
nothing but sizes and timings.

**The key management story, which the plan had left open.** "Type this base64"
was never an answer, and a self-chosen password for a key protecting a listening
history is usually four characters and a birthday. So: a **recovery phrase** --
eight words from a fixed 256-word list (`core/sync/recovery_phrase.py`), 64 bits
in front of scrypt. The list is chosen for *listening*: nothing that sounds like
anything else, short and common words only, and fixed forever, because the list
*is* the format and editing it would invalidate every phrase already written
down. Typing it back is forgiving about case, spacing and punctuation; a wrong
word is **named** rather than the phrase merely refused, because with eight words
read off paper "one of these is wrong" means checking all eight again; and
*Read My Phrase Aloud* says it numbered, one word at a time, which is what
somebody writing it down needs.

**The salt travels, and so does a check value** (`core/sync/vault_file.py`). The
second machine has only the phrase, so `quillsync/vault.json` in the remote
folder carries the scrypt parameters, the salt, and one fixed known string
encrypted with the derived key. That file gives nothing away -- a salt is not a
secret -- and the check is what turns a mistyped phrase from *a decryption
failure part-way through a pull* into *"that recovery phrase does not match this
folder; nothing was changed"*. A vault is **never overwritten**: doing so would
orphan every commit already there, unreadable forever, while the machine that
did it appeared to sync correctly to an empty remote.

**Local-first, always.** Sync is additive, nothing is called on a playback path,
and the failures are sentences: a folder that has gone missing says so rather
than reporting "everything was already up to date", which would be the most
misleading thing this feature could possibly say. Syncing twice in a row
manufactures no history -- a fingerprint of the store decides whether there is
anything to commit -- so a machine left running does not grow a commit log made
entirely of duplicates on somebody's cloud folder.

The recovery phrase is stored in the platform credential store like every other
secret; `core/sync/places_config.py` records only *that* one was saved, plus the
folder, a device label, and whether to sync when playback stops (off by default:
a write to somebody's cloud folder is a real cost and should be asked for).
`ui/sync_places_dialog.py` is the setup window, built around the one question
that makes or breaks the feature -- **is this the first machine or the second?**
-- which it answers itself by looking at the folder rather than asking somebody
who would have to already understand the thing they are setting up.

---

### 5.84k Listening Places -- the same folder, in a format other apps can read

QuillSync (5.84j) carries a listening position between two copies of QUILL,
encrypted, over a folder the listener already syncs. That is right, it works,
and it will only ever work QUILL-to-QUILL. Somebody who listens on a phone with
a podcast app somebody else wrote is not helped by it at all.

**Listening Places** (`core/sync/listening_places.py`, format id
`listening-places/1`) is the interchange half: a small, published, plain-JSON
format any app can read and write, in the same chosen folder, alongside the
encrypted vault and independent of it. The specification and its conformance
fixtures live at `docs/engineering/listening-places-spec.md` and
`tests/unit/core/sync/fixtures/`, so a change that breaks the other
implementation fails a test rather than a user.

**It is a separate switch and needs no recovery phrase.** Gating an interchange
format behind the encrypted half would mean a feature nobody can set up, which
syncs nothing. `PlacesConfig` therefore carries `encrypted` and `interchange`
independently, plus `include_labels` -- three switches because they carry three
different exposures, and collapsing them would misdescribe what the folder
learns.

Four properties are requirements rather than implementation choices, each ruling
out one failure:

- **One writer per file.** Every device writes exactly one file in `devices/`
  and reads every other. A cloud drive's answer to two devices editing one file
  is a conflicted copy, which is the worst outcome available here; if no two
  devices write the same file, it cannot happen -- and three devices work for
  free.
- **Last write wins, never furthest position.** Jumping back twenty minutes on
  purpose and then opening the episode elsewhere must not be undone.
- **Reads happen at launch and on an explicit Sync Now, and nowhere else.** A
  position arriving mid-session has no acceptable behaviour: moving the playhead
  under somebody is unacceptable, and worse without a visual cue that it
  happened. At launch nothing is playing.
- **Identity is derived, never a path.** An episode is `sha256(guid)[0:16]` --
  the GUID alone, because two apps disagree about a feed's URL far more often
  than about GUIDs. A local file reuses `media_identity()`: size plus a digest
  of the two ends, which is the one key that agrees across Windows and iOS for
  the same file in the same folder. The hash is not for secrecy; it is so a
  plain file in a shared folder does not list what somebody listens to.

**What Cast owed itself regardless.** A podcast episode's `position_ms` lived
inside the monolithic library file with no timestamp, so there was nothing to
merge on. `PodcastEpisode.position_updated_at` closes that, and **every** site
that moves a position now routes through `core/podcasts/position_sync` -- one
site that forgot the stamp is a device whose place silently stops travelling.

**Not built, deliberately:** subscriptions in the shared file. A subscription
record cannot be hashed the way a position can -- the whole point of it is a URL
the receiving app must be able to fetch -- so it carries a different exposure and
belongs behind its own switch. Also not built: any server, any account, and any
third-party cloud SDK. The app talks to a folder; the provider's own client does
the syncing, which is somebody else's already-solved problem.

### 5.84i Continue Listening, across every provider

**The problem was that four memories never met.** QUILL remembered where you
were in a podcast episode (`episode.position_ms`), in a streamed recording
(`core/radio/resume.py`), and in a local file (`core/media/positions.py`). Each
worked. None of them could answer the question people actually have, which is
*what was I in the middle of?*

`core/media/continue_listening.py` gathers them into one list, newest first,
**with the provider named on every row** -- because "The Moonstone, chapter 4"
means something quite different depending on whether Enter starts a podcast, a
stream, or a file, and a list that hides which is one you have to try things in
to understand.

Three decisions:

- **Each source is asked separately and may fail alone.** A podcast library that
  will not load must not cost you the LibriVox chapter you were halfway through.
- **Only things that can actually be resumed.** Radio's store keys on a
  normalised stream identity that deliberately cannot be turned back into an
  address, so it gained `label` and `url` fields and the listing skips any entry
  saved before they existed. An offer that cannot be honoured is worse than an
  absence.
- **Newest first, and that is the only order.** Not "most nearly finished", not
  grouped by provider: the question is chronological and so is the answer.

`ui/continue_listening_dialog.py` shows it; `ui/continue_listening_command.py`
discovers the sources from the host's own attributes, so Quill Cast contributes
podcasts, Quill Radio contributes recordings, and QUILL contributes both with no
per-app copy of the list. **Resume is disabled** on a row the running app cannot
play -- Cast has no radio player and Radio has no podcast library -- rather than
silently declining. **Forget** is a first-class button: the reason people avoid
a resume list is that it fills with things they abandoned on purpose, and the
place is dropped while the episode stays unplayed, because "I am not going back
to this" and "I finished it" are different statements.

---

### 5.97a Quill Inkwell: choices, application scope, and Quillin reach

Three capabilities, each removing a reason people switch an expander off.

**Choice fields.** `${choice:Label|one|two|three}` in an expansion offers its
options instead of asking for typing (`core/expansion/fields.py`,
`ui/fill_in_dialog.py`). Picking is one arrow key; typing *"Second reminder"*
exactly right is a spelling test nobody asked to sit. The first option is the
default, an answer that is not one of the options falls back to it (a choice
whose result can be anything is not a choice), and the form asks in the order the
template reads -- being asked for the closing before the greeting is
disorienting when the form is heard rather than seen.

**Per-application abbreviations.** `Abbreviation.apps` scopes an entry to named
executables; empty means everywhere, which is every entry that exists today.
Only Quill Inkwell honours it -- inside QUILL's editor there is one application
and scoping to it could only ever switch an entry off. A signature belongs in a
mail client and a code snippet in an editor, and an expander that fires both
everywhere is one people turn off. A scoped entry does **not** fire when the
foreground application cannot be identified: the safe direction for "I do not
know where this would land" is not to type into it. Names are normalised on
read, so a hand-edited `Outlook.exe` and `outlook` are one application rather
than a comparison repeated on every keystroke.

**Quillin abbreviations, system-wide.** A Quillin's contributed abbreviations
used to stop at the edge of the editor, which is the wrong boundary.
`core/expansion/contributed.py` discovers the same manifests and merges them for
matching. The user's own entry always wins on a collision -- anything else means
an installed extension silently changing what a key sequence does -- contributed
entries are never persisted, so uninstalling a Quillin takes its abbreviations
with it, and the merge is cached and rebuilt on Quillin reload rather than per
keystroke.

Inkwell's own preferences also adopted the **versioned persistence contract**: a
`schema_version` stamp, every field defaulted individually on read, and a file
written by a newer build is read but never written back over -- which is how a
preference silently disappears when two machines run different builds.

---

### 5.98 Structure the reader cannot see: heading and table cues (shipped)

**The problem is in the control, not in the reader.** No Windows edit control
exposes a paragraph style to the accessibility tree. `RICHEDIT50W` will tell JAWS
or NVDA the font name, the point size and the weight; there is no property that
says "this paragraph is a heading", and `wx.TextCtrl` has less. Word announces
"heading level 2" because Word ships a UIA provider of its own with
`StyleId_Heading2` behind it. Hosting a stock control buys that control's
provider and no seam to extend it.

So a heading in QUILL was, to a listener, an ordinary line -- and had been since
the first release. Every route was affected equally: rich mode's point-size
ladder, Markdown hashes, HTML `<h2>`, and a real `Heading 2` style read out of a
Word document by `io/docx_reader.py`. The information survives perfectly at the
file layer in both directions and dies at the control boundary.

**QUILL says it itself.** `core/structure_announce.py` holds the rules and the
latch; `ui/main_frame_structure.py` and `apps/lite_window_headings.py` are the
thin wx wiring. Arriving at a heading announces **"Heading 2, Installing"** --
the level first, then the heading's own words, as one sentence QUILL owns.

The restraint is the design, and each rule earns its place:

- **The level first, and the text with it.** Ordering, not decoration. A cue
  *queued behind* the reader is at the reader's mercy: on a large caret jump --
  `Ctrl+Home`, a search hit, a bookmark -- NVDA and JAWS cancel what is pending
  and start again on the new line, so a "Heading 1" waiting its turn is never
  heard at all. That was reported exactly that way: arrowing onto a heading
  announced it, `Ctrl+Home` onto the same heading did not, and the announcer had
  the right answer in both cases. Said first, carrying the line's words, the cue
  survives every kind of move -- and interrupting is safe *because* the text the
  reader was about to speak is inside the sentence replacing it. It is also the
  order a browser gives you. `heading_announce_position` = `after` restores the
  level-alone-behind-the-reader ordering for anyone who prefers it; the
  announcer's `replaces_reader` flag is what tells the caller which of the two
  to do.
- **Once, on arrival.** A latch keyed on the paragraph makes movement *inside* a
  heading silent. You are told again when you leave and return.
- **An edit is not an arrival.** The paragraph key is a line index, so deleting a
  line above a heading moves it without the caret going anywhere. A move that
  follows a change in the text re-latches silently, or typing would be
  interrupted with "Heading 2".
- **Interrupting only when the phrase carries the words.** A bare "Heading 2"
  and every list cue queue behind the reader (`force=False`; QuillLite's
  `_announce(..., interrupt=False)`), because cutting across the reader would
  cost the listener the very text they moved to hear -- the same ordering
  discipline as speak-then-spell. The composed "Heading 2, Installing"
  interrupts, because it *is* that text.
- **The first position is never announced.** A document that opens on its own
  title would otherwise greet every user with "Heading 1".
- **Commands do not echo.** `format_heading` and `_adjust_heading_level` already
  announce, then `sync_structure_announcer()` moves the latch without speaking.
- **It can be switched off where you stand.** `view.toggle_heading_announcements`
  (**Ctrl+Alt+F3**, a View check item, the `announce_headings` setting) silences
  both cues and restores them, announcing the consequence rather than the state.
  This is the one place the "a verbosity setting is not the fix" rule in GATE-13
  is deliberately not applied, and the reason is that the answer genuinely
  differs by *document* rather than by person: writing a report, the level is
  the point; reading a file as text, it is a sentence between you and the line.
  A key you press in place is not the setting the rule warns about -- it is the
  opposite of asking somebody to go and configure how much the app says. The
  latch keeps being fed while the cue is off, so switching back on inside a
  heading does not stay silent until you leave and return.
- **A `#` is only a heading where `#` means heading.** QUILL was never exposed
  here because `_effective_markup_kind()` already answers "plain" for a `.py` or
  a `.conf`. QuillLite had no such notion and briefly read every shell comment as
  a Heading 1; `quill.core.lite.filetypes.has_markdown_headings` now scopes it to
  `.md` / `.markdown` / `.mdx` / `.txt` and untitled buffers. The asymmetry is
  deliberate: a false positive is heard on every line, a false negative costs one
  silent key.

**Form controls, inserted whole** (`core/html_forms.py`). Insert HTML Tag
offered elements one at a time, which is the easy half of the job. A dropdown a
screen reader can use is four things — a `<label>`, a `for` matching the field's
`id`, the field, its `<option>`s — and three of the four are invisible: a
missing `for` renders identically to a present one, and the page looks finished
until somebody tabs into an unlabelled edit box.

Twenty controls therefore arrive complete, with the wiring generated together so
it cannot drift: `for`/`id` from one value, a `name` that submits, options inside
a select, a legend inside a fieldset, one shared `name` across a radio group, an
empty first option so a dropdown cannot submit an answer nobody gave, and
`aria-describedby` joining a required field to its hint and its error alongside
`aria-invalid` and a live `role="alert"`. **The generated id is checked against
the document first** — two fields sharing one id is the commonest way a form
that was accessible when written stops being so when copied, and its only
symptom is that the second label focuses the first field. A selection becomes
the label and the id is derived from it.

The radio group is the case that justifies doing it this way at all: without a
shared `name` the buttons are not a group and every one can be on at once;
without a `fieldset` and `legend` they are a group with no name, so a reader
announces three options and never the question they answer. Four elements and
two attributes, with no visible evidence of any of it.

Both pickers are also **complete** now — 111 HTML elements and 22 Markdown
constructions — on the principle that a *searchable* list has no cost to being
so. The forty-six elements that shipped before left out `<dl>`, `<dt>` and
`<dd>`, which QUILL announces as the caret moves through them; `<figure>` and
`<figcaption>`; `<caption>`, `<thead>` and `<tbody>`; `<abbr>`; and `<br>` and
`<hr>`, which `VOID_HTML_TAGS` already handled and no row could reach. Ranking
is by **what a thing does**: "glossary" finds `<dl>`, "subtitles" finds
`<track>`, "acronym" finds `<abbr>`.

**Lists.** The third cue, and the one a screen reader gives you everywhere
else. In a browser an `<ul>` reaches the accessibility tree as a list with a
count, and NVDA says "list with 5 items" going in, "level 2" a rung down and
"out of list" coming out. In an editor the list is characters rather than a
widget, so the reader has nothing to go on, and a listener writing a nested
outline hears "dash space item" over and over with only the number of spaces,
counted by ear, to separate one level from the next.

`core/list_structure.py` does the reading and the same announcer decides what is
worth saying. Three kinds, because all three carry different meaning and a
reader names all three: **bullet** (`-`/`*`/`+`, `<ul>`), **numbered**
(`1.`/`1)`, `<ol>`) and **definition** (`Term` / `: definition`, `<dl>`). Four
events and nothing else -- in, out, a rung, and the term/definition alternation
inside a `<dl>`, which is the one distinction inside a list that changes what
the text *means*.

Two counting rules, both chosen because the alternative is a lie told
confidently. **Items are counted at the caret's own level within its own
parent**, so a three-item list whose second item has four sub-items is "3 items"
at level one and "4 items" at level two, never "7", and two sub-lists under two
different bullets are two lists. **A fenced code block is not a list**, and
neither is a `---` rule.

The HTML side is a scanner rather than a parser, on purpose: an editor buffer is
usually a fragment, often mid-edit and rarely well-formed, and an `html.parser`
that raised or resynchronised on an unclosed tag would take the cue out at
exactly the moment somebody is halfway through typing one. Comments, `<script>`
and `<style>` are blanked to same-length runs so every offset still means what it
meant.

The switch is `view.toggle_list_announcements` (**Ctrl+Alt+F5**, a View check
item, the `announce_lists` setting), deliberately **separate** from the heading
one: the two answer different questions and people want different answers --
reorganising an outline the list level is the work, and proof-reading the same
file it is a phrase between you and every item. `Ctrl+Alt+F4` was the obvious
neighbour and was passed over: a finger that misses the Control key on a chord
pressed this often finds `Alt+F4`, and what that costs is the document. Off is
expressed by building the point with `list_markup=None`, which skips the scan as
well as the sentence and makes switching back on announce the list you are
already in on the very next keypress.

**Tables.** The same hook replaced `_maybe_announce_table_transition`, which was
untested and said only "Entering table". Entering now gives the shape --
**"Table, 4 rows, 3 columns"** -- and leaving still says "Out of table"; cell
navigation (5.x, `core/table_nav.py`) is unchanged. This half is **QUILL only**:
QuillLite passes `include_tables=False`, because accessible table navigation is a
full-QUILL feature and announcing the edge of a grid the small editor cannot then
navigate advertises something that is not there. That does not offend the
QuillLite rule, which forbids Lite being *ahead* of QUILL, never the reverse.

**Two defects surfaced on the way and were fixed with it.**

*Heading navigation refused in plain text.* QuillLite's Next Heading, Previous
Heading and headings list answered "Headings are only available in rich text" in
documents whose Markdown headings **Alt+Shift+Right would happily re-level**.
Only navigation pretended the document had no shape. They now read
`core/markdown_sections.parse_heading_blocks`, so a `#` inside a fenced code
block is correctly not a heading -- which a regex would have got wrong.

*A heading saved to RTF was not a heading.* `io/rtf.py` emitted `\b` and an
outline level with no style reference and no point size. Word showed "Normal" in
its style box and an empty navigation pane; worse, reopened in QUILL's rich mode
every heading came back as a **Heading 4 whatever level it went out as**, because
the ladder recognises a heading by its size and the file carried none. The writer
now emits a `\stylesheet` declaring Word's built-in `heading 1`-`heading 6`
(`io/rtf_styles.py`) and stamps each heading with `\sN` plus the editor's own
point size. That is why the ladder moved to `core/heading_ladder.py`: `quill/io`
may not import `quill/ui`, and a second copy of those numbers is a silent
round-trip bug.

**Prior art.** Leasey 11.5 solves the same problem the same way and with no UIA
anywhere -- `accessible_output2` plus its own `heading_positions`,
`imported_word_tables` and `announce_word_table_cell` models, backed by a
JAWS-only script layer QUILL deliberately does not have. Confirmation that
self-narration is the answer available to any hosted control, not a workaround.

---

## 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-key VK_OEM_3 (0xC0) via GetRawKeyCode()
  3. Physical scan code 0x29 (key below Esc / above Tab on PC keyboards) via bits 16–23 of GetRawKeyFlags()

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.14a Every menu item advertises a keyboard route — the Alt path (1.0.0, 2026-09-10)

The rule and why it could not be met. CLAUDE.md’s house rule is that every enabled menu item shows a keyboard route in its label, and no two items in one menu bar claim the same key. Quill Radio meets it with 115 items and a global chord each. QUILL’s menu bar has 757 items, and 560 of them advertised no route at all. That is not a rule somebody forgot to apply; it is a rule that cannot be met that way at this size. There are not 560 chords to give out: the one-level QUILL-key namespace is exhausted (keymap.py says so in its own comment) and the plain-chord space went years ago.

What every item already had. The Alt path: Alt+F opens File, I opens Import, W picks Word Document. It is as global as any chord, it needs no binding, it consumes no chord space, and it scales to any depth and any number of items. The only thing missing was somebody saying it out loud in the label. quill/ui/menu_routes.py computes each item’s path from the live menu bar and writes it into the label, so it cannot drift and every menu item added after this lands is covered on the day it lands.

Advertised in parentheses, not after a tab. Word Document... (Alt+F, I, W). wx parses post-tab text as a native accelerator (wxGetAccelFromString) and Alt+F, I, W is not one; tab form would log “Unrecognized accel key” per item at every build and leave the label advertising an accelerator that does not exist. This is the same call §8.14 already recorded for QUILL-key chords, which is why 44 items were already complying in that form while the gate — which looked only for a tab — scored them as gaps.

Two preconditions, both fixed by the same pass.

Mnemonics have to resolve, or the path goes nowhere. There were 119 collisions across 30 popups — the File menu alone had three items on N, three on R and three on S. Windows answers a duplicated mnemonic by cycling focus rather than pressing, so one of each pair silently cannot be reached and nothing announces the loss. GATE-14’s access-key checker never saw any of them: it scopes a wx.Dialog/wx.Frame subclass as one window, and this menu bar is built across a dozen mixin methods, so every collision fell between the scopes.

166 items had no mnemonic at all — the fonts, the point sizes, the colours, the 32 braille translation languages, the AI agents. Rows of data rather than commands, which is why nobody wrote a mnemonic for them, and a row you cannot type a letter at is a row you have to arrow to.

How seats are decided, in this order, so a reviewer can check the pass rather than trust it:

  1. An item holding a real accelerator keeps its mnemonic. A command important enough for a global chord is important enough to keep its letter. This is what makes the pass safe to run unattended: Save keeps S from Snapshots, Print keeps P, Bold keeps B, Select All keeps A.
  2. Submenus keep theirs next, because a parent’s letter is the first step of the path for its whole subtree — one unseated parent costs a subtree, not a row.
  3. Earlier-in-menu wins among the rest.
  4. Whoever is left is seated by a maximum matching (Kuhn’s), not first-fit: greedy stranded nine File-menu items including the whole Open from Remote subtree, because it took a letter that was some later item’s only one.

A letter the title does not contain is appended in the Windows/CJK convention — Print Studio... (&Z) — and folds into the path rather than standing beside it: Print Studio... (Alt+F, &Z), one parenthetical and not two. Where a popup genuinely runs out, the loser gets no mnemonic rather than a duplicate, which is GATE-14’s rule: a duplicate advertises a key that may not work, while silence is merely silent and Tab still arrives.

Where it runs. Once at the end of _build_menu_bar, after _refresh_contextual_menu_items, _apply_ai_menu_enabled and _apply_feature_lock_menu_state — the pass skips disabled items, so running it before those would route rows about to be dimmed and miss rows about to be enabled. Menus rebuilt outside that build (Open Recent, the contextual rows) re-apply it through MainFrame._reapply_menu_routes; the pass is idempotent by construction, so a call site never has to reason about which subtree its change could have reached. Open Recent was the live proof of why this is needed: its rows are file paths built outside the bar build, and they were the only rows in QUILL with no keyboard route at all.

The Format menu’s overflow, and why it needed no product decision. A mnemonic can only be a letter or a digit, so 36 is the hard ceiling, and the Format menu had 40 direct items. The two groups over the line were already duplicated by a submenu sitting beside them: Grow Font and Shrink Font next to a Font Size submenu, and the three keyed line spacings (Ctrl+1, Ctrl+5, Ctrl+2) directly above a Line Spacing submenu offering the same three values without the keys. The keyed commands are the better half of each pair, so they moved into those submenus — keeping their accelerators and gaining the Alt path they could not have at the top level — and the unkeyed line-spacing preset rows are gone rather than repeated underneath, because a listener arrowing that submenu should not meet “Single Spacing” and then “Single”. Thirty-five direct items, and nothing was demoted to make room.

Measured on the real menu bar, 2026-09-10:

before after
mnemonic collisions 119 0
items with no mnemonic 166 0
enabled items with no route 539 0

The pass is idempotent (a second run changes nothing), and wx can still bind every real accelerator afterwards — no new Unrecognized accel key noise.

Gated. tests/unit/ui/test_quill_menu_accelerators.py now runs five assertions: every enabled item advertises a route (a parenthesised path counts, which is what had been mis-scoring the 44 QUILL-key items as gaps), every advertised accelerator is one wx can bind, no two items claim one key, no popup claims one Alt letter twice (the hole GATE-14’s per-class scoping leaves), and every item has a mnemonic at all. tests/unit/ui/test_menu_routes.py tests the module’s own rules — the seating order, the maximum matching, the appended-letter fallback, the fold, and idempotence.

8.14b Two kinds of feedback, and the user picks (1.0.0, 2026-09-10)

The problem. A copy, a paste, an undo and a started selection change the document and change nothing a screen reader announces: focus does not move, no control gains a name, no selection changes. To a listener they are indistinguishable from a key that did not work. QUILL’s answer has been an earcon, and it is a good one – instant, non-interrupting, and free to hear a hundred times an hour. It is not everyone’s answer: a tone must be learned first, and somebody who has learned the pack finds “Copied” on every Ctrl+C intolerable within a minute. Neither position is wrong, so it becomes a setting rather than a decision.

settings.action_feedbacksound (default), speech, both, silent. settings.find_not_found_feedback – the same four, same default, asked separately because F3 is pressed in runs and somebody may want every success spoken and every miss kept to a tone. Both live on QUILL’s Settings and, with the same names and defaults, on QuillLite’s (quill/core/lite/settings.py).

One rule, one module: quill/core/action_feedback.py. resolve(mode, *, has_sound) -> (play, speak), and both editors call it rather than each implementing the enum. Three properties it guarantees, each of which is a bug somebody would otherwise have shipped:

  1. sound on a moment with no clip in the pack speaks instead. Choosing the mode QUILL has always been in must never make the app quieter than the day before. sound_manager.has_sound_for() answers the question, and it deliberately ignores the global mute and sound_events_disabled – those are the user asking for quiet, and answering a request for quiet with a voice would be wrong. A pack with no clip is an absence; a silenced event is a decision.
  2. silent stays silent, including through that fall-through.
  3. Failures and counts are outside the setting entirely. “Pasted 1,234 characters” and “Selected 412 characters, 68 words” are always spoken – no tone carries a number, and the number is usually the point of asking. A command that could not do what was asked always says so in words. The status bar is written in every mode, because it is the record and not the feedback; QUILL’s _set_status speaks what it is given, so the quiet modes route through _set_status_quiet.

Call sites. QUILL: CueMixin.action() / action_channels() (main_frame_cues.py) over the clipboard cues and undo/redo, SelectionSpanMixin.start_selection, and SearchCommandsMixin._report_search_missed. QuillLite: DocumentFrame._action() / _has_sound_for(), and _report_not_found. action_channels is public because a caller that owns its own status line has to make the “show it without saying it” call itself.

Two misses, two sentences. Wrapping on gives Not found: <needle> – change the pattern. Wrapping off gives No more matches. Reached the end of the document, and wrapping is off. – go to the other end and press again. The fixes differ, so the sentences must. QuillLite now honours wrap_find in both directions, which it never had a way to express.

Surfaced, or it is not a setting. Two SettingSpec entries in the Accessibility group (QUILL) and two wx.Choice rows plus a wrap_find checkbox in Preferences (QuillLite), both built from ACTION_FEEDBACK_LABELS rather than retyped, so the two panes cannot describe the same four modes under different names – which reads as two settings and sends somebody looking for the other.

8.14c GATE-LITE-COVER: existence is not behaviour (1.0.0, 2026-09-10)

cmd_start_extend_selection shipped with a green build: the command table had gates for its key, its mnemonic and its label, and a test asserted the handler existed and could be called. Extend mode had never once worked from the keyboard. F8’s accelerator set the anchor, F8’s own key-up then reached the control, and the rule in extend_selection_after_move was “anything that is not a navigation key ends the mode” – which F8 is not. The mode ended on the keystroke that started it, every time. No table gate can see that: it is an interaction between a command and an event hook.

quill/tools/lite_command_coverage.py classifies every handler in quill.core.lite.commands.COMMANDS as covered (some test in tests/unit/apps actually calls it) or shape_only, against a committed snapshot. Detection is an AST walk for <something>.cmd_x(...), not a grep: a test that lists handler names in a table – the shape of test that let F8 through – must not read as coverage.

The ratchet runs in both directions. A new command with no entry fails. A handler that lost its test fails, because a deleted test is exactly as invisible as one never written. A handler that gained one fails too, asking for a regenerate, because a snapshot left behind is a ratchet that has quietly stopped ratcheting. At introduction: 93 covered, 68 shape-only, the remainder being commands that open real wx dialogs.

Closed to zero on 2026-09-11: 161 of 161, and the gate now asserts an empty list rather than a shrinking one. The two say different things to whoever reads a failure – a ceiling says “you went over budget”, zero says a command shipped without a test, which is the sentence that gets the test written.

Fourteen of the sixty-eight turned out to be already tested and invisible. They were driven as getattr(win, name)() over a parametrized string, which the AST scan correctly refuses to count – it cannot see a name that is only a string. Those were rewritten to parametrize over lambdas ((lambda w: w.cmd_italic(), ...)) rather than weakening the detection, because the weaker rule would have re-admitted exactly the inventory-shaped test the gate exists to distrust.

The remaining fifty-four were reached by giving the harness a seam for modal dialogs: DialogRecorder replaces every dialog entry point with a stub that records how it was called and answers cancel unless a test says otherwise. Cancel-by-default is the substantive choice – it is the branch people forget to write, and a command that saves after Escape or reports success after a cancelled Save As is the class of bug this group hides. Two details are load-bearing and are documented in the recorder: a dialog imported at module scope has to be patched in the module that imported it (lite_window_clipboard holds its own binding of choose_from_rows, and patching only lite_dialogs left the tray opening a real wx.Dialog mid-test), and fake_wx_dialog covers the wx-owned classes (FileDialog, FontDialog, PageSetupDialog, Printer) that a command constructs itself.

Three stand-in bugs surfaced on the way, each of which had made a command untestable rather than merely untested: FakeEditor.next_heading took forward= and returned -1 where the real one takes reverse= and returns None or a pair, so _navigate_heading unpacked an int; the window stub carried line_ending where DocumentFrame and cmd_file_format read newline; and it had no _find_dialog, so the “raise the window already open” branch of Ctrl+F could not run.

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)
    • Controlled-vocabulary linter over *.po translation sources.
    • Plain-language linter (Flesch ≥ 60) over UI strings.
    • Keymap audit (no editor-focus single printable letters; all commands have ids).
    • Theme contrast checker.
    • Tab-order snapshot diff (changes require an explicit reviewer approval).
    • Command registry coverage (every command has a name, description, default keybinding or explicit unbound, and a help topic).
  2. Headless UI layer (every PR, runs on a Windows runner)
    • pytest + pywinauto boots the app and walks every dialog, modal, and palette mode.
    • Inspect.exe parity: a custom UIA dumper records the tree for each window; the dump is diffed against a golden snapshot.
    • Accessibility Insights for Windows CLI is run; build fails on any error.
    • DPI smoke tests at 100/150/200%.
  3. SR-in-the-loop layer (every release branch and nightly)
    • A custom SAPI 5 speech driver records every utterance into a transcript file.
    • NVDA is launched with the recording driver via NVDA’s command-line; Quill is driven through a scripted scenario set (open, find, F3 chain, palette, outline jump, spell check fix, keymap rebind, restore backup, AI confirm-and-cancel).
    • The recorded transcript is diffed against a golden transcript per scenario per SR. The harness is in v1.1 of the project plan but the scenario scripts and golden transcripts are produced manually for v1.0.
    • JAWS scenarios run separately when a JAWS-enabled runner is available; Narrator runs via UIA notifications.

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.14a Background callbacks and dialog liveness (#1067, #1353)

Any callback a worker thread posts back with wx.CallAfter must check that its window still exists before touching a widget. A dialog closed while its work is in flight otherwise raises RuntimeError: wrapped C/C++ object of type StaticText has been deleted — the shape of both #1067 and #1353.

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).

§25.2 Menu Structure

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 > Git and GitHub > 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:

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 > Git and GitHub > GitHub command handlers (§25.13)

§25.11 Implementation Status

SHIPPED (2026-06-12). All five implementation phases complete:


§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 > Git and GitHub > 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 > Git and GitHub > 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 > Git and GitHub > Local Git and the Command Palette; freely assignable in Preferences > Keyboard Shortcuts.

§25.14a Git Worktrees (1.0.0)

Why worktrees are an accessibility feature, not a power-user feature. Checking out a branch in place rewrites every file in the working folder while keeping every path and every filename identical. A sighted user sees the editor’s contents change and infers what happened; a screen-reader user receives no cue at all — the paragraph under the review cursor silently becomes a paragraph from another branch, in a document that still claims to be the document they opened. That is the same class of uncued context switch QUILL rejects everywhere else (dialog entry/exit cues, region announcements, “Entered … dialog”). A worktree removes the ambiguity structurally: each branch has its own folder, so the open document never mutates, and changing context becomes open a different file — a user-initiated, announced action — rather than this file is now a different file. Worktrees therefore complete the Local Git story (§25.14) rather than extending it sideways.

Model. A repository has exactly one main worktree (the repository folder itself) and zero or more linked worktrees. Each worktree holds at most one branch, and a branch may be checked out in at most one worktree — git’s own constraint, surfaced as a QUILL refusal rather than a git error. A worktree may additionally be bare (no working copy), detached (on a commit, not a branch), locked (exempt from prune/remove, with an optional reason), or prunable (its folder is gone from disk). WorktreeEntry.describe() renders exactly this state as one spoken sentence, which is what the list dialog uses as its row text; four narrow columns would be four arrow-key journeys per row.

Commands (Tools > Git and GitHub > Local Git):

Engine operations (quill/core/git_worktree.py, wx-free, injected Runner exactly as §25.14): list_worktrees (parses git worktree list --porcelain: blank-line-separated records, keys worktree/HEAD/branch/ bare/detached/locked/prunable), add_worktree (both add <path> <branch> and add -b <new> <path> [<from-ref>]), remove_worktree (with an explicit force the user must opt into), prune_worktrees (prune --verbose, reporting which records were removed), move_worktree, lock_worktree (optional --reason) and unlock_worktree.

Error contract. Every operation returns a WorktreeResult carrying ok, a finished user-facing message, the parsed data, and detail (git’s own output, kept for logs and support triage and never announced). Raw stderr is never surfaced to the user. Refusals are pre-flighted where a better sentence is possible — a target path that is non-empty, is a file, or lies inside the repository; a branch already checked out in another worktree (QUILL names the folder that holds it) — and git’s own refusals are translated where they are recognisable (“already checked out”, “a branch named X already exists”, “invalid reference”, “contains modified or untracked files”, “is a main working tree”, “is locked”). The single exception class, WorktreeError (QUILL-GIT-WORKTREE-NO-GIT), covers only “git did not run at all” and carries its what-to-do sentence in USER_HINTS per GATE-EC.

Destructive-action contract. Removing a worktree deletes a folder, so it always confirms with wx.NO_DEFAULT (the dialog-button-contract gate enforces this). When git refuses because the worktree holds uncommitted work, QUILL surfaces that refusal and asks a second, separate question before retrying with --force; it never adds --force on the user’s behalf. The main worktree can be neither removed nor locked from the dialog.

Open in QUILL. QUILL’s file surface opens documents, not folders, so “open this worktree” resolves the current document’s path relative to its own worktree root and opens the counterpart inside the chosen worktree. When no counterpart exists (a file new on this branch, or nothing open), QUILL says so and offers a file picker already rooted at the worktree folder rather than a dead end.

Path input. The folder field routes through quill.core.path_input.clean_typed_path, so quoted Explorer paths, %VAR% paths, file:// URLs, ~, smart quotes and non-breaking spaces all work.

Testing. tests/unit/core/test_git_worktree.py drives everything through a fake runner — porcelain parsing (multiple worktrees, detached HEAD, bare, locked, prunable), the exact argv of every command, and every refusal path — with no real git and no repository on disk; the engine’s behaviour against real git is already covered by §25.14’s suite. tests/unit/ui/test_main_frame_worktrees.py covers the mixin’s gating, sequencing, and the two-confirmation remove.

Implementation files:

File Purpose
quill/core/git_worktree.py The wx-free worktree engine: porcelain parsing, add/remove/prune/move/lock/unlock, refusal sentences
quill/ui/git_worktree_dialogs.py WorktreesDialog (the spoken list) and NewWorktreeDialog (the create form)
quill/ui/main_frame_worktrees.py WorktreesMixin — the two command handlers, confirmations, Open in QUILL, and command-palette registration

Feature flag. None, matching §25.14; gated only by Safe Mode and by git being available.

Keybindings. None by default, for §25.14’s reason; both commands are in the menu and the Command Palette and are freely assignable.

§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 > Git and GitHub > 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 > Git and GitHub > 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 > Git and GitHub > 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 > Git and GitHub > 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 the punctuation and whitespace of prose – forty named characters (2026-09-13; it was a bare code-point prompt before that), plus a code-point row for anything outside the list. Emoji need a different workflow again: thousands of characters where you might not know the name either, so browsing and searching by description is the whole feature. 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:

Public in QUILL 1.0.0:

Gated out of the public 1.0.0 build (RELEASED_APPS in quill/core/app_launcher.py admits only quill, radio, and weather; a developer build with QUILL_DEV_BUILD=1 sees the rest). Each app’s own requirements, surfaces, and remaining work live in its per-app doc bundle rather than in this PRD:

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 already follows this shape; the gated apps adopt it as they converge.

§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 two family apps 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 (a location-beacon engine, a future audiobook 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 (QUILL 1.0.0 and Quill Radio 2.1.1 can both 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. Packaging toolkit — one parameterized spec/installer/build script consumed by every app in the family, retiring the per-app forks; PyInstaller onedir for the light apps, embeddable-CPython for apps with on-demand engine installs.
  4. Per-app convergence — the reverse-vendor, first-release, and fold-onto-the-shared-shell steps for the apps gated out of QUILL 1.0.0. Each app’s remaining steps are recorded in its own doc bundle (docs/apps/cast/README.md, docs/apps/converter/README.md, docs/apps/beacon/README.md, docs/audio-studio/README.md) rather than in this PRD, and an app rejoins RELEASED_APPS only when its steps are done.

§35.6 One update path and one feedback path

Every app resolves updates from its own GitHub Releases (the companion apps 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.


§35.7 Visual identity: one design system, one face per app

Every app in the family carries a distinct application icon drawn from a single shared system. This is a product requirement, not a styling preference, because the icon is the app’s identity everywhere a listener navigates by it: the taskbar, Alt+Tab, the Start menu, the desktop, the installer’s Setup.exe, the Add/Remove Programs entry, and – for the tray-resident apps – the notification area, where several of these products spend their entire runtime.

The defect this requirement exists to prevent has already happened. As of 2026-08-13, four apps shipped a byte-identical .ico (Inkwell, Audio Studio and Weather all carried Quill Radio’s broadcast wave, same SHA-256), and two more shipped installers with no icon at all and so wore PyInstaller’s generic default. Nobody chose either outcome; both followed from scaffolding each new app from the last one, which is exactly the sharing model §35.2 prescribes. Build-time sharing is right for code and wrong for identity, and the family needs an explicit rule at that seam.

The system. One tile shape (rounded square, corner radius 22% of the edge, full bleed) so the apps read as siblings. One accent, the family amber established by Radio and Cast. One bold white glyph of two or three shapes – detail that cannot survive 16x16 is detail that only exists in a screenshot.

Two separators, both required.

  1. Distinct silhouette. The test is squinting: if two icons blur to the same shape, colour is doing all the work.
  2. Distinct hue and lightness. Not hue alone. A set separated only by hue is a set some colour-blind users cannot tell apart, and colour is the first thing to go at small sizes and for low-vision users.

The 16x16 rule. Every glyph is judged at tray size, not at 256px. Three of the eight were re-cut for failing it: Radio’s three thin arcs merged into a single smear (now two, thicker, further apart), Studio’s five waveform bars merged into a solid slab (now three, with real gaps), and Inkwell’s chosen glyph beat two alternatives that failed – expanding text lines blurred to a grey block, and a bare fountain nib read as a flame.

The register. scripts/build_app_icons.py holds the design system and every glyph, with each glyph’s intent recorded beside it so the next person changing one knows what it was trying to say. --check fails when a committed .ico has drifted from source; --preview writes 256px and magnified-16px PNGs for eyeballing before shipping. tests/unit/scripts/test_app_icons.py enforces the requirements above as assertions – no two apps may render the same face, no two may share a background colour, the tiles must differ in lightness and not only in hue, every .ico must carry all seven sizes Windows asks for (16/24/32/48/64/128/256), and any app that gains a Windows installer without an icon entry fails by name. That last one is what closes the scaffolding seam: a new app cannot inherit somebody else’s face by accident, because it cannot ship without declaring one.

Icons are generated, never hand-edited. An .ico edited by hand would be silently reverted by the next person who runs the script, and a generator nobody trusts is worse than no generator. Quill Converter’s private one-app generator was retired into the family one for the same reason: two generators are two design systems.

App Glyph Tile
Quill Radio waves leaving a source indigo
QUILL Cast a microphone capsule under waves teal
Quill Converter two arrows passing in opposite directions violet
Quill Inkwell a nib dipped into an inkwell terracotta
QUILL Audio Studio a three-bar waveform slate
Quill Weather sun behind cloud sky
QuillBeacon a place-marker pin crimson
QUILL Social two overlapping speech bubbles plum

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:
    • ruff check .
    • pytest -q
  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).

    • Acceptance: a unit test that the poster issues a POST with the correct application/x-www-form-urlencoded body and parses a JSON reply; TLS verification is on (no ssl._create_unverified_context).
  2. Consent / progress dialog DeviceLoginDialog in quill/ui/.

    • Shows: the device code, the verification URL, and the expiry; an “Open in browser” button; an “I’ve authorized — continue” button; a Cancel path. Must follow the A11Y-4 dialog contract (outer sizer, default button, Destroy on close, focus return to editor).
    • Speaks announce_device_code(grant) on open and describe_login_result(...) on completion.
    • Acceptance: a source-contract test (the cloud-safe bar) asserting the dialog uses show_modal_dialog, shows code/URL/expiry, and wires the three buttons; add a row to dialogs.md with the menu path that opens it.
  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.

    • Acceptance: round-trip test through the credential manager (save → load → delete) under a target_name namespaced per provider.
  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.

    • Acceptance: a unit test that, given a stored device-login token and an empty pasted key, the auth header carries the token; given both, the device-login token wins (or whatever precedence you choose — document it).
  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:

    • Start the flow → QUILL shows the code + URL → you authorize in the browser → QUILL polls and retrieves the token → the token is used for subsequent AI requests → no pasted key required.
    • Confirm the whole flow is keyboard- and screen-reader-accessible at every step (NVDA/JAWS/Narrator parity).
    • Reality check: confirm your target provider actually exposes a public device-authorization endpoint for API tokens. Several major API providers do not (they issue API keys from a dashboard instead). If yours doesn’t, AI-19 cannot be honestly closed against that provider — pick one that does (or keep the engine ready and mark AI-19 Done only once one real provider validates it).

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).

    • Submenu shape: a top “Send to Quill” flyout enumerating Open / OCR / OCR structured Markdown / Read aloud, filtered by file extension via verbs_for_extension.
  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: dist\windows\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" `
  dist\windows\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).
    • On Win11 you may need to arrow to “Show more options” first.
  3. Confirm these items are present and screen-reader friendly:
    • OCR with Quill
    • OCR with Quill (structured Markdown) — only meaningful when AI is on
    • Read aloud in Quill (For a .txt/.md/.html file you should instead see Open in Quill and Read aloud in Quill.)
  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 dist/windows

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: dist/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:
    • runs tests (with --ignore for the two known-hanging tests)
    • builds the installer and portable ZIP
    • uploads artifacts
    • creates a GitHub release draft
  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:

    • Republish as a higher version number (e.g., 0.5.1-hotfix) containing the older code, or
    • Ask users to reinstall manually.

    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:

What it enables:

Developer notes:


2. smart_list (Virtual Lists)

Status: Optional (integration in progress)
Files:

Developer notes:


3.5. html_to_text (HTML Paste Cleaning)

Status: Fully integrated (production-ready)
Files:

What it enables:

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:


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:

Developer notes:


4. app_elements (Common Dialogs)

Status: Candidate for selective adoption
Files: None yet — audit and wrap per-component

What it enables:

Developer notes:


5. platform_utils (Clipboard, Paths, Stdout)

Status: Candidate for later
Files: None yet

What it enables:

Developer notes:


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:


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:


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):

The system edit fix no longer costs the blank line (2026-09-09). Emulating a classic edit control made RICHEDIT50W misreport the caret’s line at the very end of a document that ends in a paragraph mark, and that is the question a screen reader asks: type a line, press Enter, and JAWS read the line above instead of saying “blank” — on every empty final line, which in an editor is most documents. It was recorded as a trade (braille or blank lines, pick one) until the control was measured. Under the flag it stays right about everything it knows — the line count, the index of an explicit line, EM_GETLINE for the empty line, every interior empty line — and gets only the character-to-line mapping past the final line’s start wrong, contradicting its own answers: line 1 begins at character 16, and character 16 is reported as being on line 0.

Because the right answer follows from facts the control itself supplies, QUILL supplies it. quill/ui/richedit_line_fix.py installs a comctl32 window subclass that answers EM_EXLINEFROMCHAR, EM_LINEFROMCHAR, EM_LINEINDEX(-1) and EM_LINELENGTH for that position — a subclass because that is where a screen reader’s cross-process SendMessage is dispatched, verified by asking from a second process. The rule it applies (every index at or after the last line’s start is on the last line) holds for any document, so it never disagrees with a control that is answering correctly, and it is installed by set_emulate_system_edit itself so that setting the flag can never again mean taking the bug. Two findings from the same measurement rule out the simpler fixes: SES_EMULATESYSEDIT is set-onceEM_SETEDITSTYLE is ignored once the control holds text, in both directions, which is why the preference genuinely needs the editor rebuilt — and no caret position reports correctly, so nothing is fixable by moving the caret. Guarded by tests/unit/ui/test_richedit_final_line_fix.py; the measurement and the remaining braille A/B are in scripts/jaws_blank_line_repro.md. QuillLite had the same symptom from a different cause (EM_SETTEXTMODE / TM_PLAINTEXT), fixed separately in quill/ui/richedit_editing.py.

Braille A/B: System Edit Fix… (app.braille_ab, command palette). Whether SES_EMULATESYSEDIT is needed at all is still open – the fix is that flag plus the hidden border, and only the border was ever proved load-bearing – and it is a question only a person with a braille display can close. It stayed open because it also required a restart per reading: the flag is set-once, so toggling the preference cannot change a live editor. This window builds both editors at once instead (quill/ui/braille_ab_window.py): editor A is exactly what QUILL ships, editor B differs by that one flag and nothing else, and the comparison is a Tab apart. Select the Sample Word puts the same selection in both so the two readings are of the same thing (#813); Report Caret Line speaks what each control says its caret’s line is, for the blank-line half. Modeless, palette-only and keyless – the same reach app.report_editor_surface has, because a diagnostic earns no menu row and no chord. If A and B read alike, the flag and richedit_line_fix.py are both deleted.

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.

Paragraph formatting completed (2026-09-08, via QuillLite). The surface has always been able to justify a paragraph, set a list type and set a line-spacing rule; nothing in QUILL was bound to any of it, so the small editor-only sibling QuillLite (standalone/quilllite/) could do things the editor could not. That is backwards, and it is now closed. quill/ui/main_frame_rich_paragraph.py adds Justify (Ctrl+Alt+J), single / one-and-a-half / double line spacing (Ctrl+1 / Ctrl+5 / Ctrl+2), Grow Font and Shrink Font (Ctrl+Shift+> and Ctrl+Shift+<, stepping a ladder of real point sizes that includes the heading sizes, so growing a heading stays on the ladder rather than falling off it into a paragraph that merely looks large), rich-mode bullets (Ctrl+Alt+B now drives ITextPara.ListType in a Rich Text document instead of inserting a Markdown dash into one), and Paste Text Only (Ctrl+Alt+V), which neither product had. create_richedit_rtf builds RichEditDocument (quill/ui/richedit_editing.py) — a QuillRichEdit plus the paragraph and view capabilities — so every tab gains them by construction rather than through a second factory.

QUILL takes Ctrl+Alt+J and Ctrl+Alt+V where QuillLite uses WordPad’s Ctrl+J and Ctrl+Shift+V, because Ctrl+J has been Set Temporary Bookmark and Ctrl+Shift+V has been Preview here for far longer: an existing binding somebody’s hands already know outranks a new command’s convention. The divergence is a comment in keymap.py, not folklore.

Two TOM bugs fixed in the same change (both isolated by Steven Scott in PR #1490, with a standalone reproduction that imports neither QUILL nor QuillLite — standalone/quilllite/tests/repro_tom_true.py):

Both are pinned by regression tests in tests/unit/ui/test_richedit_rtf_surface.py, against a fake TOM that models the real control’s behaviour: assigning tomUndefined is accepted and changes nothing, which is exactly why the bug was silent for as long as it was.

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.

Reader chain and the document-text contract (#1279 / #1278, shipped 1.0.0)

Two user reports on the same surface — “when I load a Word document it says # DOCX Extract at the top” (#1279) and a JSONDecodeError: Unexpected UTF-8 BOM crash on opening a .json (#1278) — exposed three separate defects and one bad packaging decision.

No synthetic text in an opened document. An extract’s text is the document’s own text. The fallback Word, ODT, PowerPoint, and spreadsheet extracts no longer prepend a # <FORMAT> Extract banner, and format_pdf_document no longer prepends # PDF Extract plus engine, quality score, and a low-confidence hint. All of that provenance already lives in source_metadata and is reported by build_intake_summary (the spoken/status line on open) and build_intake_report (Document Intake Report), which gained the low-confidence/OCR advice the PDF header used to carry. This extends EDS-21’s earlier removal of the # RTF Extract placeholder to every remaining reader. # Notebook and # SQLite Database: <name> stay: those texts are wholly synthesized reports of a non-text source, not a document’s own words. The AI document-QA path reads format_pdf_document, so it also stops feeding the banner to the model.

BOM-aware structured reads. read_structured_document’s text branch now reads through quill.io.text.read_text_document instead of path.read_text(encoding=...), so .json/.xml/.toml/.yaml/.ipynb inherit the contract plain text has had since #648/#649: a UTF-8 BOM is stripped from the editable text and remembered as utf-8-sig (a save re-adds it byte-for-byte), the original line ending survives, and the #867 cp1252/latin-1 fallback applies. Before this, a BOM crashed the open (0.9.0) and, after #1228’s guard, silently degraded the file to unformatted raw text.

Word reads through python-docx, not raw XML. The root cause of #1279 was that a packaged install never had MarkItDown (an optional download), so every Word open fell to _format_docx’s hand-rolled word/document.xml walk — one flat line per paragraph, headings/lists/tables discarded. python-docx is a base dependency, so the fallback now renders through it (quill/io/docx_text.py::read_docx_text: heading levels, list styles and direct w:numPr numbering, tables as GFM) with the XML walk kept as the last resort (read_docx_xml_text). The read chain is therefore: MarkItDown when present → python-docx → raw XML.

Packaging: the lean half ships, the heavy half stays optional. Bundling the whole pdf-ocr extra was measured, not assumed: MarkItDown hard-requires magika, which hard-requires onnxruntime and numpy, and its xlsx/xls converters require pandas — about 150 MB installed, not the “~30 MB” the component dialog claimed. A new lean extra, office-text (pdfplumber, pypdf, openpyxl, ~16 MB), is bundled instead — in DEFAULT_BUNDLED_DEPENDENCY_GROUPS for Windows and in setup_macos.py’s includes plus macos-release.yml‘s install specs for the .app, where the readers’ function-local imports also defeated py2app’s tracer (the macOS bundle had no docx, no PDF reader, and no openpyxl at all). Without pdfplumber/pypdf there is no PDF text reader; without openpyxl an .xlsx cannot open. MarkItDown remains a one-click download, relabeled with its true size and described as the enhancement it is (“most useful for legacy .doc/.ppt/.xls files and awkward layouts”), and a test asserts it is not in the bundled groups so a future re-add is deliberate. A read that lands on a reduced reader stamps extraction_pack_missing in source_metadata, which the intake summary and report turn into a one-line pointer at the download — suppressed when python-docx already read the file well, so it never nags about 150 MB for a document that came through fine.

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.