# Pywebview Desktop Apps

> Use for Python desktop GUI apps shipped as a single .exe.

- Skill: `wcpaka-lgtm/pywebview-desktop-apps` (Agent Skill, multi-file: 4 files)
- Install (CLI): `npx skillmds@latest add wcpaka-lgtm/pywebview-desktop-apps`
- Raw SKILL.md: https://api.skillmd.com/api/skills/wcpaka-lgtm/pywebview-desktop-apps/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: wcpaka-lgtm (https://skillmd.com/u/wcpaka-lgtm)
- Updated: 2026-09-21
- Page: https://skillmd.com/skills/wcpaka-lgtm/pywebview-desktop-apps

---


# pywebview Desktop Apps

Build native-feeling desktop apps with a Python backend and a web-technology frontend, packaged as a single executable. Stack: **pywebview** (native window + JS↔Python bridge) + domain libs (ebooklib/pandas/pillow/…) + **PyInstaller** (single-file packaging).

## When to use

Trigger phrases:
- "데스크톱 앱 만들어줘", "exe 파일로", "세련된 앱", "GUI 프로그램"
- "make me a desktop app / a little tool with a UI / package this as an exe"
- User wants a polished standalone utility (reader, viewer, editor, dashboard, converter)

NOT for:
- One-off HTML mockups → `claude-design`, `sketch`, `popular-web-designs`
- Browser games → `browser-game-development`
- Cross-platform with native widgets (Qt-style) → recommend PySide6 instead

## Architecture

```
┌─────────────────────────────────────────┐
│  Native window (pywebview / WebView2)   │
│  ┌───────────────────────────────────┐  │
│  │  HTML/CSS/JS UI (single string    │  │
│  │  or bundled file)                 │  │
│  │                                   │  │
│  │  window.pywebview.api.method()    │──┼──► Python Api class
│  └───────────────────────────────────┘  │     (your domain logic)
└─────────────────────────────────────────┘
```

**Why pywebview over Electron**: ~30MB exe vs ~150MB, uses the OS's webview (WebView2 on Windows, WKWebView on macOS, WebKitGTK on Linux), pure Python backend, no Node toolchain.

**Why over Tkinter/Qt**: modern CSS styling, flexbox/grid layouts, animations, the entire web design ecosystem.

## Workflow

### 1. Scaffold the app (single file is fine to start)

```python
import webview, json

class Api:
    def do_thing(self, arg):
        # Python domain logic — return JSON strings to JS
        return json.dumps({"result": ...}, ensure_ascii=False)

HTML_UI = r"""<!DOCTYPE html>
<html>...your UI...
<script>
async function callPython() {
    const result = await window.pywebview.api.do_thing("hello");
    const data = JSON.parse(result);
}
</script>
</html>"""

if __name__ == '__main__':
    api = Api()
    window = webview.create_window(
        'My App', html=HTML_UI, js_api=api,
        width=1100, height=750, min_size=(800, 600),
        background_color='#1a1a2e', text_select=True,
    )
    webview.start(debug=False)  # debug=True opens DevTools
```

See `templates/app_skeleton.py` for a fuller starter (file dialog, theming, settings persistence).

### 2. Install deps

```bash
pip install pywebview pyinstaller
pip install <domain libs>          # ebooklib, pandas, pillow, etc.
pip install <transitive deps>      # beautifulsoup4, lxml — see pitfall #1
```

### 3. Smoke-test BEFORE packaging

Run `python app.py` in a background terminal and verify the window renders:

```
terminal(background=true): cd ~/myapp && python app.py
computer_use(action='capture', app='python', mode='vision')  # confirm UI rendered
process(action='kill')
```

This catches missing modules and broken JS↔Python calls before you waste a 60-second PyInstaller build.

### 4. Package with PyInstaller

```bash
pyinstaller --noconfirm --onefile --windowed \
    --name "MyApp" \
    --hidden-import=<lib1> --hidden-import=<lib2> \
    --collect-all <lib_with_data_files> \
    app.py
```

Flag cheat sheet:
- `--windowed` — no console window (REQUIRED for GUI apps; omit for debugging to see tracebacks)
- `--onefile` — single exe (slower startup, ~5s extract; use `--onedir` if startup time matters)
- `--collect-all <pkg>` — bundle a package's data files + submodules (needed for libs like `ebooklib`, `babel`, anything with templates/locales)
- `--hidden-import=<mod>` — force-include modules PyInstaller's static analysis misses (libs that import dynamically)
- `--icon=app.ico` — custom icon (Windows wants .ico, not .png)

Output: `dist/MyApp.exe` (or `dist/MyApp.app` on macOS).

### 5. Verify the exe

```
terminal(background=true): ./dist/MyApp.exe
computer_use(action='capture', app='MyApp', mode='vision')
```

Confirm the window title, UI rendered, no crash dialog. Then `process(action='kill')`.

## Pitfalls

1. **Transitive deps not auto-detected.** A lib like `ebooklib` imports `bs4` lazily — `pip install ebooklib` does NOT install bs4, and the app crashes with `ModuleNotFoundError` on first use. Fix: run the app once, read the traceback, `pip install` the missing module, repeat. Common offenders: `beautifulsoup4`, `lxml`, `html5lib`, `Pillow` (for image libs).

2. **PyInstaller misses dynamic imports.** If a module is imported via `importlib` or string-based lookup, PyInstaller won't see it. Symptom: exe runs but feature X silently fails. Fix: `--hidden-import=<module>`. For whole packages with data files, `--collect-all <pkg>` is the bigger hammer.

3. **`--windowed` hides tracebacks.** If the exe crashes on startup you see nothing. Debug build: drop `--windowed` temporarily, run the exe from a terminal, read the console. Or `webview.start(debug=True)` for in-app DevTools.

4. **File paths break under `--onefile`.** PyInstaller extracts to a temp dir at runtime; `__file__`-relative paths point into the bundle. Use this helper for any bundled asset:
   ```python
   def resource_path(rel):
       base = getattr(sys, '_MEIPASS', os.path.abspath('.'))
       return os.path.join(base, rel)
   ```
   User-facing paths (open/save dialogs, config files) should use `Path.home()` or app-data dirs, NOT the bundle dir.

5. **`create_file_dialog` API drift.** Older pywebview: `window.create_file_dialog(webview.OPEN_DIALOG, ...)`. Newer (6.x): deprecation warning, prefers `FileDialog.OPEN`. Both still work as of 6.2; if you see the deprecation warning, the old form is fine for now but plan to migrate.

6. **JS↔Python returns strings only.** `js_api` methods must return JSON-serializable values; complex objects need `json.dumps(..., ensure_ascii=False)` (Korean/CJK will mojibake without `ensure_ascii=False`). On the JS side, `JSON.parse(await window.pywebview.api.method())`.

7. **Don't use shell `&` backgrounding in `terminal()`.** Hermes rejects `cmd &`. Use `terminal(background=true)` and `process(action='poll'/'kill')`.

8. **Content links navigate the webview → black screen (CRITICAL for content viewers).** If your app renders *external* HTML (EPUB chapters, markdown, saved pages) into the webview, any `<a href>` the user clicks makes the webview try to **navigate** to a URL that doesn't exist → blank/black screen, app appears frozen and untouchable. This was a real user-reported bug. Fix: after injecting content, attach a click handler to every `<a>` that calls `e.preventDefault(); e.stopPropagation()`, then resolve the href yourself (exact match → normalized relative path → basename fallback) and route to the right content via your Python API. Same-chapter `#anchors` should smooth-scroll; `http(s)://` links should open in the real browser (`webbrowser.open` via an API method). **Never let the webview itself navigate.**

9. **Tap-zone UIs must not fire on scroll-drags.** For e-reader-style zones (left 30% = prev / center = menu toggle / right 30% = next), use `pointerdown` + `pointerup` and treat it as a click ONLY if: pointer moved < ~10px, elapsed < ~600ms, `window.getSelection().isCollapsed` (no text selection), and the target isn't `a/button/input/select`. Also ignore clicks within ~20px of the right edge (scrollbar). Otherwise a normal scroll gesture turns a page. Show a subtle `‹`/`›` hover indicator so zones are discoverable.

10. **pywebview has NO native file-drop event.** To support drag-and-drop file opening, use JS `dragenter/dragover/dragleave/drop` listeners on `document`, read the dropped file via `file.arrayBuffer()`, base64-encode in 8KB chunks (`String.fromCharCode.apply(null, subarray)`), send to a Python API method that writes to a `tempfile.NamedTemporaryFile(delete=False)`, loads it, then `os.unlink`s the temp. Show a full-screen overlay (`position:fixed; inset:0; z-index:9999; pointer-events:none`) with a dashed border box during drag. Use a `dragCounter` (increment on dragenter, decrement on dragleave) to handle nested element enter/leave correctly. Validate extension before processing.

11. **PyInstaller rebuild fails if the exe is still running.** Windows locks the file → `PermissionError: [WinError 5]` on `os.remove`. Fix: `taskkill //F //IM MyApp.exe` first (note: in MSYS/git-bash, use `//F` not `/F` to avoid path mangling), then rebuild. If taskkill doesn't release the lock immediately, `mv dist/MyApp.exe dist/MyApp_old.exe` works as a fallback since rename doesn't need the same lock as delete.

12. **ebooklib `Section` is a subclass of `Link`.** When flattening `book.toc`, check `isinstance(item, epub.Section)` BEFORE `isinstance(item, epub.Link)` in the elif chain — otherwise Sections are silently treated as Links and lose their heading semantics. Sections without an href should resolve to their first child chapter's index for a better UX (clicking a volume heading jumps to its first chapter).

13. **Python-side and JS-side DOM selectors MUST match exactly for index-based features.** When Python extracts elements (e.g. `BeautifulSoup.find_all(['p','h1',...])`) and JS tags/highlights the same elements (e.g. `querySelectorAll('p, h1, ...')`), the selector lists must be identical — same tags, same order, same filtering rules (e.g. `len >= 2`). If Python includes `div` but JS doesn't (or vice versa), highlight indices will be misaligned and the wrong paragraph gets highlighted. This is a silent bug — no error, just wrong UX. Test by counting elements on both sides for a known document.

14. **TTS: prefer paragraph-level over sentence-level.** Users find sentence-by-sentence playback choppy. Extract block elements (`p`, `h1`–`h6`, `li`, `blockquote`) as units and pass each whole paragraph to `synthesize()` — modern TTS engines (Supertonic-3) handle internal chunking with natural pauses. This also makes highlight tracking simpler (one highlight per paragraph, not per sentence).

15. **HTML defined AFTER the `<script>` that references it → silent total JS failure (CRITICAL).** Because a pywebview UI is one big HTML string, it's easy to append a new widget's markup *below* the `<script>` block. Any top-level `$('someBtn').addEventListener(...)` then runs while the element doesn't exist yet → `$('someBtn')` is `null` → `null.addEventListener` throws a `TypeError` → **the entire script halts at that line**, so every handler registered after it (including ones for elements that DO exist) never binds. Symptom: a feature "just doesn't work" with no error visible anywhere (especially under `--windowed`). This was the real cause of a user-reported "tts실행이 안돼". **Fix options:** (a) put ALL markup above the `<script>` that touches it; (b) wrap handler registration in `window.addEventListener('DOMContentLoaded', () => {...})`; (c) at minimum, null-guard: `const el = $('x'); if (el) el.addEventListener(...)`. When a whole feature goes dark with no traceback, suspect script/DOM ordering FIRST.

16. **`window` must be a MODULE-LEVEL global if background threads call `evaluate_js`.** The standard pywebview scaffold puts `window = webview.create_window(...)` inside `if __name__ == '__main__':` — that makes it a *local* variable of the main block. A background engine thread (TTS, inference) that does `window.evaluate_js(...)` at module scope then hits `NameError: name 'window' is not defined`, or references a stale/None global. Symptom: Python→JS callbacks silently never fire; the frontend never learns that work finished. **Fix:** declare `window = None` at module scope, then `global window; window = webview.create_window(...)` in main (or assign to the module global). Guard the callback: `if window is not None: window.evaluate_js(...)`. This pairs with pitfall #15 — when "the engine runs but the UI never updates," check both the JS handler binding AND that the Python callback can actually reach `window`.

17. **Patch tool converts `\r\n` escape sequences to literal newlines.** When inserting Python code that contains string literals like `'\r\n'` or `'\r'` via the `patch` tool, the tool's fuzzy matching interprets the backslash-r/backslash-n as actual carriage-return/line-feed characters, splitting one line into multiple broken lines and causing `SyntaxError: unterminated string literal`. **Workarounds:** (a) write the code using `chr(13)+chr(10)` instead of `'\r\n'` — functionally identical, no escape sequences to mangle; (b) if the damage is already done, fix via `terminal` with a Python script that reads the file's lines, deletes the broken fragments, and inserts the correct line; (c) use `write_file` for the whole file if the edit is large. Always run `python -c "import ast; ast.parse(...)"` after any patch that touches string literals with escape characters.

## Debugging a feature that "doesn't work" in a `--windowed` exe

A windowed exe has no console, so a JS TypeError or a swallowed Python exception is invisible. Use this sequence — it resolved a TTS failure in one pass:

1. **Test the risky backend standalone FIRST**, outside the app, with a `python -c` one-liner that exercises the exact call chain (import → model load → compute → output). If it prints success, the backend is innocent and the bug is in the JS↔Python wiring or the frontend. This isolates the half of the stack to investigate before you go instrumenting.
   ```bash
   python -c "from supertonic import TTS; t=TTS(model='supertonic-3'); import sounddevice as sd; w,d=t.synthesize('테스트',voice_style=t.get_voice_style('F1'),lang='ko'); sd.play(w.squeeze(),t.sample_rate); sd.wait(); print('OK')"
   ```
2. **Add file-based debug logging** (not prints — there's no console). A tiny append-to-file helper at module scope, called at every stage of the suspect path (API entry, thread start, model load, each unit, each `evaluate_js`), writes a timestamped trail you can read after the fact:
   ```python
   _LOG = os.path.join(os.environ.get('APPDATA','.'), 'MyApp', 'debug.log')
   def _log(msg):
       try:
           os.makedirs(os.path.dirname(_LOG), exist_ok=True)
           with open(_LOG, 'a', encoding='utf-8') as f:
               f.write(f"[{__import__('datetime').datetime.now():%H:%M:%S}] {msg}\n")
       except Exception: pass
   ```
   Log exception tracebacks too (`traceback.format_exc()`), not just `str(e)`.
3. **For pure-frontend suspicion, you don't even need the log** — check script/DOM ordering (pitfall #15) and whether any top-level `$(...).addEventListener` could hit a not-yet-rendered element. A single null there kills everything below it.

## Background threads + JS callbacks (async long-running work)

For any feature that runs longer than a UI frame — TTS synthesis, model inference, batch conversion, audio playback — use this pattern:

```python
import threading, json

class AsyncEngine:
    def __init__(self):
        self._stop = threading.Event()
        self._thread = None

    def start(self, ...):
        self.stop()
        self._stop.clear()
        self._thread = threading.Thread(target=self._run, daemon=True)
        self._thread.start()

    def stop(self):
        self._stop.set()
        if self._thread and self._thread.is_alive():
            self._thread.join(timeout=3)

    def _run(self):
        for i, unit in enumerate(work_units):
            if self._stop.is_set(): return
            result = heavy_compute(unit)       # synthesize / infer / convert
            self._notify(f'onProgress({i}, {len(work_units)})')
        self._notify(f'onDone()')

    def _notify(self, js_code):
        try:
            window.evaluate_js(js_code)      # push to frontend
        except Exception:
            pass
```

**Key rules:**
- `window.evaluate_js()` is thread-safe from background threads (pywebview marshals to the UI thread).
- Define the JS callback functions (`onProgress`, `onDone`, `onError`) as **global functions** in your HTML `<script>` so `evaluate_js` can call them by name.
- The JS side handles UI state (progress bar, auto-advance to next chapter/page) — Python only reports events.
- For auto-advance flows (e.g. TTS finishes chapter → JS navigates → JS calls `api().start_next(idx)`), let **JS own the navigation** and call back into Python to start the next unit. This avoids race conditions between Python pushing navigation and the webview's render cycle.
- Always check `self._stop.is_set()` between units AND after any blocking call (e.g. `sd.wait()` for audio).

**PyInstaller flags for ML/inference packages:**
```bash
--hidden-import=supertonic --hidden-import=sounddevice \
--hidden-import=soundfile --hidden-import=onnxruntime \
--collect-all supertonic
```
ONNX-based packages bundle model files + shared libs that static analysis misses. Expect exe size to grow 50-100MB. First run downloads the model to a cache dir (HuggingFace Hub); subsequent runs use the cache.

**PyInstaller flags for PDF support (pymupdf):**
```bash
--hidden-import=fitz --hidden-import=pymupdf --collect-all pymupdf
```

**Excluding unwanted heavy deps from the bundle:**
If your venv has large packages installed for experimentation (e.g. `torch`, `transformers`, `qwen-tts`) that the app does NOT use, PyInstaller will try to collect them → build times out or exe balloons to 2GB+. Add explicit excludes:
```bash
--exclude-module=torch --exclude-module=qwen_tts --exclude-module=transformers \
--exclude-module=accelerate --exclude-module=gradio --exclude-module=librosa \
--exclude-module=torchaudio --exclude-module=flash_attn
```
This keeps the build fast (~3min) and the exe at expected size (~120MB).

See `references/supertonic-tts-integration.md` for the full Supertonic-3 API: paragraph-level extraction, voice selection (F1~F5/M1~M5), volume gain, highlight+auto-scroll architecture, and the selector-matching requirement.

## Multi-format content loading (viewers/readers)

When the app must open more than one file type, use a **dispatcher + per-format loader** pattern:

```python
def load_file_auto(filepath):
    ext = os.path.splitext(filepath)[1].lower()
    loaders = {'.epub': load_epub, '.txt': load_txt, '.pdf': load_pdf,
               '.html': load_html_file, '.htm': load_html_file}
    loader = loaders.get(ext, load_txt)  # fallback: treat as text
    result = loader(filepath)
    # Auto-register in library on success
    try:
        data = json.loads(result)
        if not data.get('error'):
            _add_to_library(filepath, data.get('title',''), data.get('author',''))
    except Exception: pass
    return result
```

**Key patterns per format:**
- **TXT**: try encodings in order `['utf-8', 'utf-8-sig', 'euc-kr', 'cp949', 'latin-1']` (Korean files are often EUC-KR/CP949). **Do NOT split on `\n{3,}` or chunk at 5000 chars** — that produces dozens of tiny choppy "chapters" that feel fragmented. Instead:
  1. **Merge lines into paragraphs**: consecutive non-blank lines join into one `<p>` (space-joined); a blank line ends the paragraph. This gives natural prose flow instead of one-line-per-`<p>` choppiness.
  2. **Detect chapter headings** with a regex: `제\s*\d+\s*[장화절편권부과]`, `Chapter\s*\d+`, `PART\s*\d+`, `Prologue`, `Epilogue`, `서장`, `에필로그`, `프롤로그`, numbered items (`1. 제목`), Roman numerals, separator lines (`───`). Split chapters at headings.
  3. **Fallback chunking**: if no headings found, split at **15000 chars** (not 5000) to keep long stretches of prose together.
  4. Normalize line endings with `text.replace(chr(13)+chr(10), chr(10)).replace(chr(13), chr(10))` — see pitfall #17 for why NOT to write `'\r\n'` literally in patch-tool edits.
- **PDF**: `pymupdf` (`import fitz`) — one chapter per page via `page.get_text("text")`. Metadata from `doc.metadata`. PyInstaller: `--hidden-import=fitz --hidden-import=pymupdf --collect-all pymupdf`.
- **HTML**: BeautifulSoup parse, strip `<script>`/`<style>`, extract `<body>` or full soup. Single-chapter.
- **EPUB**: ebooklib spine-ordered extraction (see references/epub-reader-case-study.md).

All loaders return the same JSON shape: `{title, author, bookKey, totalChapters, toc, linkMap, textLengths, chapter}` — the frontend doesn't care about format.

**File dialog**: offer a combined filter first: `'모든 지원 파일 (*.epub;*.txt;*.pdf;*.html;*.htm)'` then per-format filters.

**Drag-and-drop**: validate extension against the supported list before processing (pitfall #10 pattern).

## Library / bookshelf persistence

Store a `library.json` in the app-data dir (`%APPDATA%/<AppName>/`):

```python
def _add_to_library(filepath, title='', author=''):
    lib_file = os.path.join(STATE_DIR, 'library.json')
    library = []
    try:
        with open(lib_file, 'r', encoding='utf-8') as f:
            library = json.load(f)
    except Exception: pass
    abspath = os.path.abspath(filepath)
    library = [b for b in library if b.get('path') != abspath]  # dedup
    library.insert(0, {  # most recent first
        'path': abspath, 'title': title, 'author': author,
        'format': os.path.splitext(filepath)[1].lstrip('.').upper(),
        'added': datetime.now().isoformat(),
    })
    with open(lib_file, 'w', encoding='utf-8') as f:
        json.dump(library, f, ensure_ascii=False, indent=2)
```

**Frontend**: card grid (`grid-template-columns: repeat(auto-fill, minmax(200px, 1fr))`), each card shows format badge + icon + title + author. Check `os.path.isfile()` server-side and flag missing files (dim the card, block open). Auto-register on every successful file open — user never manually "adds to library."

## Full-text search (across chapters)

Python-side search is simpler and more reliable than JS DOM search for multi-chapter content:

```python
def search_text(self, query):
    query_lower = query.lower()
    results = []
    for i, (title, html, name, tlen) in enumerate(current_chapters):
        soup = BeautifulSoup(html, 'html.parser')
        text = soup.get_text()
        for line in text.split('\n'):
            if query_lower in line.lower():
                results.append({"chapter": i, "title": title,
                                "snippet": line.strip()[:120]})
                break  # one hit per chapter is enough for navigation
    return json.dumps(results, ensure_ascii=False)
```

**Frontend**: fixed search bar (Ctrl+F to open), input + Enter triggers search, results render as clickable items that call `goToChapter(result.chapter)`. Escape closes.

## UI design tips for "세련된" (polished) look

The user asked for *stylish*, not just functional. Defaults that read as polished:
- **Dark theme first** (`#1a1a2e` / `#16213e` navy palette) with a violet accent (`#6c63ff`) — offer light + sepia as alternates via CSS variables and `data-theme` attribute
- **CSS variables for theming**: `--bg`, `--text`, `--accent`, `--border`, `--radius` — swap via `[data-theme="light"]` selector
- **System font stack**: `'Segoe UI', -apple-system, BlinkMacSystemFont, sans-serif` (no web font download needed)
- **Grid layout** for app shell: `grid-template-rows: auto 1fr auto` (header / content / footer)
- **Subtle motion**: 0.2-0.3s transitions on hover, `fadeIn` keyframe on content swap, `backdrop-filter: blur()` on header
- **Persist settings** in `localStorage` (theme, font size) — restores on next launch
- **Keyboard shortcuts**: arrows for nav, Ctrl+O for open — wire via `document.addEventListener('keydown')`

See `templates/app_skeleton.py` for a working example with all of the above.

## Verification

The deliverable is a **working exe**, not source code. Before reporting done:
1. exe exists at `dist/<Name>.exe` with reasonable size (>10MB for a pywebview app)
2. Launched it via `terminal(background=true)`
3. `computer_use` capture shows the actual window with your UI rendered (not a crash dialog, not a blank window)
4. Killed the test process

Report the absolute exe path so the user can find it. Offer a desktop shortcut as a follow-up.

**Desktop shortcut (Windows):**
```powershell
powershell.exe -NoProfile -Command "
$ws = New-Object -ComObject WScript.Shell
$desktop = [Environment]::GetFolderPath('Desktop')
$sc = $ws.CreateShortcut(\"$desktop\MyApp.lnk\")
$sc.TargetPath = 'C:\Users\<user>\myapp\dist\MyApp.exe'
$sc.WorkingDirectory = 'C:\Users\<user>\myapp\dist'
$sc.Description = 'MyApp'
$sc.Save()
"
```
Note: PowerShell `$` variables need `\$` escaping when called from git-bash/MSYS terminal.

**If `computer_use` fails with "session ... has ended; call start_session"** — the cua-driver session died. Do NOT retry the same capture in a loop. Fallback:
- Screenshot directly: `python -c "from PIL import ImageGrab; import time; time.sleep(1.5); ImageGrab.grab().save('shot.png')"` then `vision_analyze` the PNG.
- If another app (e.g. a fullscreen game) owns the screen, bring your window to front first via PowerShell `Add-Type` P/Invoke of `SetForegroundWindow`/`ShowWindow(…,9)` on the process whose `MainWindowTitle` matches your app name.
- If the screen is genuinely un-capturable, verify the **backend logic headlessly** instead: `import` the app module (guard window creation under `if __name__=='__main__'`), call its parse/resolve functions, and assert on the returned JSON. This proves the risky logic (link resolution, chapter extraction, image inlining) even without pixels. See `references/epub-reader-case-study.md` for the assertion script.

## Support files

- `templates/app_skeleton.py` — full starter: Api class, themed HTML/CSS/JS shell, file dialog, settings persistence, PyInstaller-friendly `resource_path`. Copy and modify.
- `references/epub-reader-case-study.md` — concrete worked example (Lumina Reader): ebooklib integration, image inlining via data URIs, spine-ordered chapter extraction, the exact PyInstaller invocation that worked.
- `references/supertonic-tts-integration.md` — Supertonic-3 local TTS: API reference, paragraph extraction, background-thread + evaluate_js callback architecture, auto-advance without duplicates, **prefetch pipeline (pre-synthesize next 4 paragraphs to kill inter-paragraph delay)**, **start-from-visible-paragraph (viewport detection)**, **0.5~2.0x speed control**, **TTS settings persistence (voice/speed/volume/autoVoice → state.json)**, **auto next-chapter with render polling**, **gender-based automatic voice switching (GenderDetector for Korean novels)**, TTS model selection guide (RTF benchmarks, GPU compatibility), PyInstaller flags for ONNX packages.

