---
name: tactility-app-development
description: Use when building, compiling, deploying, or debugging Tactility OS external ELF apps (ESP32-S3/P4). Covers SDK env, missing-symbol loader errors, asset packaging, and immersive LVGL patterns.
version: 1.0.0
author: Hermes Agent
license: MIT
metadata:
  hermes:
    tags: [tactility, esp32, elf-loader, lvgl, esp-idf, embedded]
    related_skills: [systematic-debugging]
---

# Tactility OS App Development

## Overview
Tactility loads third-party apps as dynamic ELF binaries at runtime. The host firmware exports a symbol table (`g_esp_espidf_elfsyms` / `g_customer_elfsyms` via `ESP_ELFSYM_EXPORT` / `DEFINE_MODULE_SYMBOL`). If your ELF references anything not exported, it fails at load time with `E ELF: Can't find symbol X`. The build system also requires ESP-IDF environment variables and a specific Python interpreter to avoid venv hijacking.

This skill consolidates the build-env fix, symbol hygiene, and asset/offline-data patterns discovered across sessions.

## When to Use
- Building `Apps/MyApp` via `tactility.py ... build esp32s3 --local-sdk`
- Fixing `ELF: Can't find symbol` on device serial
- Packaging large offline assets (bibles, fonts, images) for constrained RAM
- Implementing immersive single-screen LVGL UI with tap-to-toggle chrome
- Handling settings/progress persistence on SD card

Don't use for:
- Simulator (`FirmwareSim`) builds (statically linked C++ `tt::app::App` subclass, different workflow)
- Board support (devicetree, display drivers) — see `tactility-board-support` project skill

## Build Workflow (Local SDK)

Always source IDF first, export SDK path, and use IDF's Python binary directly:

```bash
. /Users/adolforeyna/esp/esp-idf/export.sh
export TACTILITY_SDK_PATH=/Users/adolforeyna/Projects/Tactility/firmware/release/TactilitySDK

# Use IDF python to avoid Hermes/host venv pollution (urllib3 | type error on 3.9):
/Users/adolforeyna/.espressif/python_env/idf5.3_py3.9_env/bin/python tactility.py Apps/MyApp clean
/Users/adolforeyna/.espressif/python_env/idf5.3_py3.9_env/bin/python tactility.py Apps/MyApp build esp32s3 --local-sdk --verbose
```

Manifest requirements (`Apps/MyApp/manifest.properties`):
```properties
[manifest]
version=0.1
[target]
sdk=0.8.0-dev
platforms=esp32s3,esp32p4
[app]
id=one.tactility.myapp
versionName=1.0.0
versionCode=1
name=My App
```

CMake component (`main/CMakeLists.txt`):
```cmake
file(GLOB_RECURSE SOURCE_FILES Source/*.c)
idf_component_register(SRCS ${SOURCE_FILES} REQUIRES TactilitySDK)
# Relax noisy werrors that other apps also ignore
target_compile_options(${COMPONENT_LIB} PRIVATE -Wno-format-truncation -Wno-unused-but-set-variable)
```

Verification: `build/*.app` (tar) contains `assets/` + ELF; `build/cmake-build-esp32s3/*.app.elf` should exist.

## Resolving Missing-Symbol Loader Errors

Tactility's ELF loader resolves via firmware symbol table. Pre-flight check before flashing:

```python
# After IDF export.sh, run:
# xtensa-esp32s3-elf-nm -D Apps/MyApp/build/cmake-build-esp32s3/MyApp.app.elf | grep " U "
# Compare against firmware export macros ESP_ELFSYM_EXPORT / DEFINE_MODULE_SYMBOL via grep -R in firmware/
```

### Known Missing Symbols & Workarounds

- `lv_font_montserrat_14`, `lv_font_montserrat_18` → **Not exported**. Use exported abstraction:
  ```c
  #include "tactility/lvgl_fonts.h"
  lv_obj_set_style_text_font(label, lvgl_get_text_font(FONT_SIZE_SMALL), 0);
  // FONT_SIZE_SMALL/DEFAULT/LARGE mapped to board-specific sizes
  ```
- `lv_obj_set_style_bg_grad_color/dir/stop` → **Not exported** on 0.8.0-dev (caused `Application failed to start: missing symbol` blue OK dialog). Use solid `0x0E0E14` instead.
- `LV_OPA_95`, `LV_OPA_5`, etc. → **Not defined**. Only 0/10/20/30/40/50/60/70/80/90/100/COVER/TRANSP exist in vendored LVGL 8.x. Use `COVER` or `90`.
- `lv_arc_create`, `lv_arc_set_range/value/bg_angles`, `lv_arc_get_value` → **Not exported on 0.7.0-dev**, added in PR #496 `dff93cb6 Add lv_arc.h symbols` after 0.7 release. Device modal `Error / Application failed to start: missing symbol` with heap still healthy (~31KB free) indicates loader failure, not OOM. Fix: arc-free horizontal slide using `lv_slider_create` + swipe via `lv_indev_get_point` / `lv_indev_active` (exported since 0.6). See `references/book-browser.md`. `lv_roller` also not exported on 0.7/0.8.
- `lv_anim_init`, `lv_anim_set_var/values/duration/exec_cb/path_cb/start`, `lv_anim_path_ease_out/linear` → **Exported since 0.6** (lines 435-446). Safe for 0.7/0.8 fleet. Use for verse fade 220/300ms editorial transition. Verified no leak, heap stable 54KB on .129.
- `lv_binfont_create/destroy` → **Exported on 0.8.0-dev** (`lv_binfont_create`) but VFS path handling fragile — `tt_app_get_assets_path` returns void, check `buf[0]!=0`. Prefer **embedded C LVGL fonts** (v5 July 12): `lv_font_conv --format lvgl --lv-include lvgl.h --bpp 4 -r 32-126 -r 0x2018-0x201D` → `Source/fonts/*.c` ~40-85KB each, `LV_FONT_DECLARE`, `(lv_font_t*)&georgia_italic_26`, lives in PSRAM via `CONFIG_ELF_LOADER_LOAD_PSRAM=y`. Heap stable 47539 free. See `references/pretty-font.md`. Legacy binfont `.fnt` also bundled in `assets/fonts/` 52KB but not used by default.
- `LV_SYMBOL_STAR` → Symbols may be absent depending on LVGL symbol set. Use `LV_SYMBOL_OK`, `LV_SYMBOL_DUMMY`, or plain UTF-8.
- `lv_obj_set_style_max_width` → **Not exported on 0.7.0-dev and 0.8.0-dev** (July 16 2026 — `check_symbols.py` found 1 missing, `E ELF: Can't find symbol`). Triggers `Application failed to start: missing symbol` blue OK dialog (`/api/screenshot` returns PNG 320x240). Fix: use `LV_PCT(92)` / `LV_PCT(80)` fixed percents for side gutters, no max_width. Verified `xtensa-esp32s3-elf-nm -D` 89 undef → 0 missing after removal.
- `lv_obj_set_scrollbar_mode` → Version-dependent, not reliably exported on 0.7/0.8. Avoid — use default scroll (add `LV_OBJ_FLAG_SCROLLABLE`) and let LVGL handle it. On QVGA intro, make scrollArea scrollable and use flex column with `LV_FLEX_FLOW_COLUMN` + `pad_row 8`.
- `xTaskGetCurrentTaskHandle` → Not exported. Replace wait loops with `tt_lvgl_unlock(); vTaskDelay(10); tt_lvgl_lock(portMAX_DELAY);`
- `lv_image_set_rotation` → Use `lv_obj_set_style_transform_rotation(obj, 1800, 0)` (value 1800 = 180°)
- `cJSON`, `esp_websocket_client`, `lwip_htons` macros → Vendor sources directly or use raw `lwip_socket`/`lwip_connect` etc., which *are* exported.
- C++ lambdas in `.c` files → `auto make_btn = [](...){}` fails compile. Keep app source as pure C (no `auto`, no lambdas) or rename to `.cpp` with `extern \"C\"` shims.
- **How to bring new widgets (arc, roller, chart, etc)** → Edit `firmware/Modules/lvgl-module/source/symbols.c`, add `DEFINE_MODULE_SYMBOL(lv_arc_create)`, etc., rebuild device firmware via `python device.py es3c28p && idf.py build flash -p /dev/cu.usbmodem101`, then rebuild app SDK. Device .129 on 0.8.0-dev already has arc, but .112 0.7.0-dev does not. See session July 12 teaching how to expose more LV elements.

See `references/symbols.md` for full list.

## Asset & Offline Data Pattern

Large data (e.g., 4.8MB Zefania XML bible) must be split to keep RAM tiny. Pattern proven for BibleVerse:

- Monolith → per-book split: `assets/bible/<bnum>.bin` = concatenated NUL-terminated UTF-8 verses, `<bnum>.idx` = header `BIBK` + version u16 + count u32 + entries `{offset u32, cnum u16, vnum u16}`, `books.json` catalog.
- Biggest chunk Psalms ~215KB vs 4.1MB monolith → load only one book at a time via `tt_app_get_assets_path`.
- Hybrid fallback: try `A:` assets path, else scan `/sdcard/bibles/WEB/` same binary format for user translations.
- Always `tar tvf build/*.app | wc -l` to verify 60+ bins packaged.

See `references/offline-data.md` for converter recipe and `templates/convert_bible.py`.

## Persistence (Settings-like File)

Follow BookPlayer/EpubReader pattern:

```c
char ud_path[320]; // from tt_app_get_user_data_path(app, ud_path, sizeof)
char prog_path[320];
snprintf(prog_path, sizeof prog_path, "%s/progress.txt", ud_path); // via tt_app_get_user_data_child_path(app, "progress.txt", ...)
```

Store `bnum cnum vnum globalIdx`; write atomically on verse change. Same for `favorites.txt`.

## Immersive UI Pattern

- Single verse fullscreen centered, container `LV_OBJ_FLAG_SCROLLABLE` removed to avoid drag recalc.
- Header + bottom toolbar hidden by default (`LV_OBJ_FLAG_HIDDEN`), tap toggles.
- **Adaptive scaling**: `apply_verse_scaling()` measures `strlen(verse)` → selects `FONT_SIZE_LARGE/DEFAULT/SMALL` + line_space 4-14 + pad tightened v4 2-10 + letter_spacing. Ultra-short (<40c) gets letter_spacing 1 and breathing room. Very long (350c+ Esther 8:9) gets compact small.
- **Editorial v4/v5 (July 12)**: Warmer verse `#F2F0E8` flat no bubble, ref uppercase `EPHESIANS 1:4` tracking 2 `#9A9AA8`, gap 10 (was 14) for closeness like PSALMS 23:1 reference image, fade transition 220ms verse / 300ms ref via `lv_anim_init/set_var/values/duration/exec_cb/path_ease_out/start` (exported since 0.6, stable heap 54KB). **v5 pretty font**: Georgia Italic embedded C 26/22/20/18/16pt + regular 24 via `lv_font_conv --format lvgl --lv-include lvgl.h` → `LV_FONT_DECLARE` + resolve helpers `resolve_verse_font/book_font` fallback to `lvgl_get_text_font()`, lives in PSRAM 315KB `.rodata`, heap 47539 stable, vision confirms "serif italic editorial like PSALMS 23:1" (Habakkuk 1:17). See `references/pretty-font.md`.
- Center column `90%x80%` centered (not 88x84) to avoid bottom ref crop on 240px screens.
- Auto-advance: `lv_timer_create(timer_cb, 60000, ctx)` + `lv_timer_reset` on manual nav.
- Rate-limit label updates (store last values) to avoid audio stutter if audio plays.
- Reference: `references/immersive-ui.md`
  ```c
  static void apply_verse_scaling(AppCtx* ctx, const char* verse) {
      size_t len = strlen(verse);
      enum LvglFontSize f; int line_sp, pad_v, pad_h, letter_sp=0;
      if (len < 40)        { f=FONT_SIZE_LARGE;   line_sp=14; pad_v=32; pad_h=36; letter_sp=1; } // ultra-short
      else if (len < 100)  { f=FONT_SIZE_LARGE;   line_sp=11; pad_v=24; pad_h=28; }               // short
      else if (len < 200)  { f=FONT_SIZE_DEFAULT; line_sp=9;  pad_v=18; pad_h=22; }               // medium
      else if (len < 350)  { f=FONT_SIZE_SMALL;   line_sp=6;  pad_v=12; pad_h=18; }               // long
      else                 { f=FONT_SIZE_SMALL;   line_sp=4;  pad_v=8;  pad_h=14; }               // epic 350+
      lv_obj_set_style_text_font(ctx->lbl_verse, lvgl_get_text_font(f), 0);
      lv_obj_set_style_text_line_space(ctx->lbl_verse, line_sp, 0);
      lv_obj_set_style_text_letter_space(ctx->lbl_verse, letter_sp, 0);
      lv_obj_set_style_pad_top(ctx->lbl_verse, pad_v, 0);
      lv_obj_set_style_pad_bottom(ctx->lbl_verse, pad_v, 0);
      lv_obj_set_style_pad_left(ctx->lbl_verse, pad_h, 0);
      lv_obj_set_style_pad_right(ctx->lbl_verse, pad_h, 0);
  }
  // Call after lv_label_set_text(verse)
  ```
  Tiers tuned from real device screenshots: short verses get giant breathing room, long verses shrink + tighten.
- Reference: `references/immersive-ui.md`

### QVGA intro layout: left info + right small icon rail (PocketDungeon v5-v7 July 16-17)

For 320x240 / 320x198 usable (after statusbar) intro screens cramp when vertically stacked (title+cards+wide text buttons = clipped GOAL text, `Loot gold, survive monsters` truncated). Pattern that fixes it:

- Structure: header (title+subtitle+best compact) + `mainRow` flex row (`ROW`, `flex_grow 1`, pad 4/8 column, bottom 8) = left `leftCol` flex_grow 1 scrollable column (pad_row 8, pad_bottom 6) + right `rightRail` fixed 44px narrow column (size CONTENT height, pad_row 10, no scroll).
- Left cards iteration (v6-v7): v6 had GOAL + HOW TO PLAY (2 cards, CONTROLS merged into HOW TO PLAY line `Arrows/WASD/Swipe/Quadrant`) + right icons 38/30. User then asked: keep only GOAL + CONTROLS on intro, teach symbols via actual playable tutorial levels instead of text explanation. So v7 final: `makeCard(leftCol, "GOAL", "Reach deepest floor. Collect gold, survive."), makeCard(leftCol, "CONTROLS", "Arrows / WASD / Swipe\nTap quadrant = move\nQ / ESC = exit")` = 2 cards, airy, no HOW TO PLAY. Each card `PCT 100%` width, `SIZE_CONTENT` height, pad 9, row 3, bg `COLOR_PANEL` radius 10, labels WRAP width 100%.
- Right icons: small icons on right as user requested — replace text `ENTER DUNGEON` button with icons: play 38px accent bg `COLOR_ACCENT` radius `s/3`, close/exit 30px muted bg `0x252836`, both `lv_btn_create` + centered label `LV_SYMBOL_PLAY` / `LV_SYMBOL_CLOSE`. No text hint below (was clipped). Saves vertical space, gives 40-50px more for cards.
- Verification via `/api/screenshot`: `curl -s http://<ip>/api/screenshot -o /tmp/intro.png` then vision check for clipping/padding. Screenshot shows 6.2-6.6KB PNG, header Best F3 G18, left 75% cards, right 25% icons. If bottom clipped, reduce cards to 2, shrink rail to 40px, icons 32/26.
- Symbol hygiene: no `lv_obj_set_style_max_width` (unexported, causes `missing symbol` blue OK dialog), no `lv_obj_set_scrollbar_mode` — use `PCT 92/80/70` for gutters and default scroll via `LV_OBJ_FLAG_SCROLLABLE`.

See `references/intro-fullscreen.md` v5-v7 and `references/sdk-mismatch-missing-symbol.md` for SDK 0.8.0-dev bump (board .112 OS 0.8.0-dev vs manifest 0.7.0-dev) and `lv_obj_set_style_max_width` removal (89 undef → 0 missing).

### Tutorial level pattern: 2-row teach-by-play (PocketDungeon v7 July 17)

User wanted: instead of text explanation in intro, have 3 intro playable levels — first presents player and stairs, next bat and gold, both with 2 rows regular cols, then go current first level. Implementation:

- `DungeonModel.h`: `TUTORIAL_ROWS=2`, `renderRows` field in `DungeonState`, `floor 0,1 = tutorial`, `displayFloor()` maps internal floor to user-visible (tutorial → 1), `isTutorial()`, `tutorialId()`, `setBests`, `reset(seed, tutorials=true)`, `generateTutorialFloor(tId)`, `clearAllTiles()`.
- `DungeonModel.cpp`: `reset` sets floor 0 if tutorials else 2 (first real = 2 internally → display 1), `clearAllTiles` border walls, `generateTutorialFloor` sets `renderRows=TUTORIAL_ROWS`, hides rows >=3 via walls, player {1,1}, Tut0: Stairs at {COLS-2,1}, Tut1: Treasure {3,1} + Stairs + Bat {5,1} hp1, no random walls, `moveMonsters` skipped when `isTutorial()`, best update only floor>=2.
- `DungeonRenderer.cpp`: respects `state.renderRows`, hides cells y>=renderRows via `HIDDEN` flag, re-centers board for tutorial `set_height grid.cellPx*TUTORIAL_ROWS align CENTER 0,-10`, status shows `TUTORIAL X/2 HP Gold`, message `TUT 1: Move to > @=you` / `TUT 2: Kill b, take $, use >` vs normal `Arrows/Swipe/Tap`.
- `PocketDungeon.cpp` intro final per user July 17: only GOAL + CONTROLS cards (2), symbols taught via play, not text.
- Verification: hermes-verify-tutorial*.py checks 2 cards, no HOW TO PLAY, tutorial gen Bat+Treasure+Stairs, renderRows, `TUTORIAL_ROWS`, isTutorial, build package ok, nm 0 missing.
- Result: flow Intro (Goal+Controls left, icons right) → Tut1 2×9 @→> → Tut2 2×9 @+b+$→> → Real game F1 infinite (display floor). Installed to 192.168.68.112, screenshots intro_tutorial.png 6.3K.

See `references/pocket-dungeon.md` tutorial update and `references/intro-fullscreen.md` v7.

## Deployment & Running

Old docs say `POST :6666/app/install`. Firmware 0.7.0-dev+ dashboard moved to port 80:

- `PUT /api/apps/install` multipart `file` field (not POST, not :6666)
- `POST /api/apps/run?id=<appId>`, `GET /api/apps`

`tactility.py` tool v3.5.1 still uses :6666 and needs Developer mode enabled on device. If port 6666 refuses, use dashboard API directly or enable dev server in Settings. Always pass `esp32s3` platform arg to `install` unless you built all platforms.

Full recipe + discovery in `references/deploy.md`.

## Common Pitfalls

1. **Hermes venv hijacks IDF Python — two flavors**
   - **Flavor A (tactility.py)**: `requests` import fails with `TypeError: unsupported operand type(s) for |: 'type' and 'type'` on Python 3.9 because `PYTHONPATH` includes Hermes venv's `urllib3` using Python 3.10+ union syntax.
   - **Flavor B (idf.py build/flash)**: `Cannot import module "pydantic_core._pydantic_core"` / `ModuleNotFoundError` — Hermes 3.11 venv's `pydantic_core/*.so` shadows IDF's 3.9/3.10 venv. Even `idf.py --version` fails.
   - **Fix both**: clear `PYTHONPATH=""` and explicitly set `IDF_PYTHON_ENV_PATH=/Users/adolforeyna/.espressif/python_env/idf5.3_py3.9_env` before sourcing export.sh. Also:
     ```bash
     unset PYTHONPATH; unset PYTHONHOME
     export IDF_PYTHON_ENV_PATH=/Users/adolforeyna/.espressif/python_env/idf5.3_py3.9_env
     . /Users/adolforeyna/esp/esp-idf/export.sh
     ```
   - Stale Python version: If project was configured with `idf5.3_py3.10_env` but you now use 3.9 env → `idf.py fullclean` required or you get "project was configured with .../idf5.3_py3.10_env/bin/python". Do fullclean then re-generate `python device.py <id>`.
   - Verification:
     ```bash
     $IDF_PYTHON_ENV_PATH/bin/python -c "import pydantic_core; print('ok')"
     env -u PYTHONPATH -u PYTHONHOME idf.py --version  # ESP-IDF v5.3.2-dirty
     ```
2. **Werror=format-truncation** — `snprintf` with 320B buffers flagged. Add `-Wno-format-truncation` via `target_compile_options` (safe, buffers intentionally oversized).
3. **Direct font symbols** — Any `&lv_font_montserrat_*` will compile but fail at runtime. Always use `lvgl_get_text_font()`.
4. **Asset size bloat** — Bundling 4MB monolithic bin triggers OOM at runtime. Split per-book and stream.
4b. **Book browser OOM — 66 buttons = 275B heap free** (July 11 BibleVerse on 192.168.68.112 0.7.0-dev). Building 66× `lv_button_create` + labels + nested flex rows = 132-264 LVGL objs → heap `total 310KB free 275B min_free 23 largest_block 23` → `failed to create temp directory` on install, screenshot 500. Fix: v4 clean horizontal slide ~10 objs (no top bar/Go/card, center LARGE 50% bigger directly on bg tappable, bottom row [X left][slider flex][39/66 right]), swipe via `LV_EVENT_PRESSED/PRESSING` tracking `book_drag_start_x` 28px threshold, plus `lv_slider` fast scrub. After fix heap `10943B free largest 7168` on .112, `54471→54355` stable on .129 0.8.0-dev (0.8 has more heap: 64KB pre). ELF itself lives in PSRAM (`CONFIG_ELF_LOADER_LOAD_PSRAM=y`, 5MB free), but LVGL objects are internal heap only — PSRAM doesn't help browser. Editorial v4 also adds fade transition `lv_anim_*` 220/300ms no leak.
5. **Touch input swallowed by full-screen containers — GameKit bubbling pitfall** (PocketDungeon July 16 v3) — Symptom: after adding `introContainer` + `gameContainer` full-screen covering `root`, `GameKit::attachInput(root, scene)` alone stopped working. Tap/swipe dead. Root cause: LVGL event bubbling — topmost `LV_OBJ_FLAG_CLICKABLE` container captures `CLICKED`/`GESTURE` before `root`. Also `rulesBox` inside introContainer swallowed taps needed for `pointToQuadrant`. Fix pattern `attachInputToCurrent()`:
   - Re-attach input to root + current active containers with `CLICKABLE|GESTURE_BUBBLE`
   - Make inner info boxes non-clickable/non-scrollable so taps bubble to parent
   - Start button calls `lv_event_stop_bubbling(e)` to prevent double trigger
   - Handle `Touch`/`Swipe` InputKind at game-over restart
   - After fix + rebuild `esp32s3`, touch quadrants + swipe + keys work on 192.168.68.112. See `references/intro-fullscreen.md` v3 section.
6. **Fullscreen toolbar-less apps trap user — no exit** (PocketDungeon July 16 v4) — Removing `tt_lvgl_toolbar_create_for_app` gains 40-50px but loses system back button → no way to exit. Fix: three exit paths:
   - (1) Hardware: `InputKind::Cancel/Menu` (Q/ESC → Cancel via `keyToInput` maps q/Q → Cancel, `LV_KEY_ESC` → Cancel) → `tt_app_stop()`, include via `TactilityCpp/App.h` (pulls `tt_app.h`), save bests + SFX Cancel
   - (2) X button top-right in game phase (`LV_SYMBOL_CLOSE` 36×28 bg #2A2D40 radius 6 → `onExitButtonClicked`)
   - (3) Long-press board `LV_EVENT_LONG_PRESSED` on `gameContainer` → pause overlay `AppPhase::Paused` with Resume/Exit Game (overlay bg #000 OPA_70, box 180px, Resume blue accent #365CF5, Exit grey #3A3A4A, hint Q/ESC = exit / Long press = pause); any input in Paused resumes. Intro also has Exit button. Header adds `pauseOverlay` + `Paused` + 3 static callbacks. Verification: hermes-verify-exit*.py checks `tt_app_stop`, `pauseOverlay`, `LONG_PRESSED`, `Cancel`, build ok.
7. **Black bar crop + Georgia sizing + 15px lift correction** (BibleVerse v6 July 12) — Verse cropped by header bar `size 100%x44 bg #15151F COVER + border 1 #232338` overlapping center col. User: "black bar on top cropping text" + "reduce text size for this font" + "main text can be moved a bit up 30px" then corrected "should have been 15px". Fix: header `36px` (was 44), `bg 0x0E0E14 TRANSP 0` (was 15151F COVER), border 0, pad ver 4, center `PCT(90)x82% align CENTER 0,8` initially → `-22` (30px up) → corrected to `-7` (15px up: base +8 -15). Ctrl bar also TRANSP, verse pad 4. Georgia Italic wider than Montserrat → downtier: `LARGE→22` (was 26), `DEFAULT→18` (was 20), `SMALL→16`, ultra `<40c` still 26 via `resolve_verse_font_ultra`. Pads tighter `6/4/3/2/1` (was 10/8/6/4/2). Vision: Hab 2:9 "No extra black bar overlapping, fully visible" + Zeph 1:3 "balanced now, optical center correct" PASS, heap 46215-47591 stable. Screenshot rate-limit 2s+ to avoid `ConnectionResetError 54` heap spike. Gotcha: `lv_timer_t` incomplete in SDK — `t->user_data` invalid, must use `lv_timer_get_user_data(t)` (exported). See `references/black-bar-crop.md` + `references/pretty-font.md`.
8. **Book browser animation + auto-show 10s** (BibleVerse v6 July 12) — User: "add animation to the bible book selection. Also let's start the app with the menu showing, but make the menu UI auto hide after 10s." Impl: `AppCtx { lv_timer_t* chrome_auto_hide_timer; bool ui_visible; }`, `chrome_auto_hide_cb(lv_timer_t* t){ AppCtx* ctx = lv_timer_get_user_data(t); if(book_browser_visible) return; set_chrome_visible(false); delete timer; }`, `schedule_chrome_auto_hide(ctx,10000)` creates timer, `onShowApp` `set_chrome_visible(true)` + schedule 10s, `toggle_ui` re-arms 10s when shown else deletes, `toggle_book_browser` deletes auto-hide timer, `onHideApp` cleanup. Book browser anim: `book_y_anim_cb(void* var,int32 v){ lv_obj_set_y((lv_obj_t*)var,v); }` + `book_fade_anim_cb` → `text_opa`, `set_book_browser_visible(true)` → `remove FLAG_HIDDEN`, `mid_wrap y 20→0 260ms ease_out` + name fade `OPA_20→COVER 220ms`, `book_dial_update_center` also fades name `60→255 180ms` on swipe/slider change. Verified .129: 8683 bytes at 0.8s after run menu visible → 6882 hidden at ~2s, auto-hide 10s configured. See `references/immersive-ui.md` + `references/book-browser.md`.
9. **Assuming `lv_font_montserrat_18` exists** — Not even compiled for all boards. Stick to SMALL/DEFAULT/LARGE.
10. **Build cache stale after main.c edit** — `tactility.py clean` before rebuild when CMake glob changes.
11. **Install without platform arg** — `tactility.py Apps/X install <ip>` tries all `manifest platforms`; if only esp32s3 ELF built you get `ELF file not built for esp32p4`. Pass `esp32s3` explicitly.
12. **6666 refused** — Dev server disabled. Use dashboard `PUT /api/apps/install` on port 80, or enable Developer mode on device Settings first.
13. **Wrong board flashed (color vs RLCD)** — Family has two boards: ES3C28P (2.8" color, `es3c28p`) and waveshare-esp32-s3-rlcd (4.2" mono). Symptoms: monochrome UI = flashed RLCD firmware onto color board. Always `grep CONFIG_TT_DEVICE_ID sdkconfig` and `ls /dev/cu.usbmodem*` before flash. Family color board is ES3C28P at `/dev/cu.usbmodem101`, 16MB flash@80MHz, `partitions-16mb-with-sd.csv`.
14. **Personal remote app not yet pulled locally** — User says pull it from repo (2026-07-16). PocketDungeon existed only on personal remote (git.reynafamily.com/adolforeyna/tactility_apps) commit d976595 feat: add Pocket Dungeon game prototype, not in local Apps/ after fetch of origin. Fix `cd apps && git fetch personal && git merge personal/main`.
15. **Board ending in 112 shorthand** — User shorthand ending on 112 means WiFi IP in local subnet. Discovery: 192.168.68.0/24 scan, 192.168.68.112 ping OK (ESP32-S3, osVersion 0.8.0-dev, MAC 14:c1:9f:d1:56:90), 192.168.68.111 also online = 2 boards active. No USB present is fine for network install via port 6666 or dashboard 80. Use arp + socket probe. Must pass esp32s3 platform arg else fails with ELF file not built for esp32.
16. **GameKit / SfxEngine shared libraries (2026-07-16)** — New pattern for game apps. `main/CMakeLists.txt` globs `../../Libraries/GameKit/Source/*.c*` + `SfxEngine` + INCLUDE_DIRS + REQUIRES `TactilitySDK esp_driver_i2s esp_driver_gpio`. GameKit provides Loop, Scene, Input (arrows/WASD+gestures+quadrant), Grid, Draw, Prefs. App entry still registerApp. 40KB .app tar contains elf/esp32s3.elf. Extended v2-v4 with AppPhase Intro/Playing/Paused, introContainer+gameContainer+pauseOverlay, `attachInputToCurrent()` bubbling fix, exit handling via Cancel/Menu + X + long-press. See `references/pocket-dungeon.md` + `references/intro-fullscreen.md` v3/v4.

## Verification Checklist

- [ ] `TACTILITY_SDK_PATH` set, IDF `export.sh` sourced, using IDF python binary for `tactility.py`
- [ ] `build/<App>.app` exists, `tar tvf` shows expected assets
- [ ] `xtensa-esp32s3-elf-nm -D *.app.elf` undefined checked; 0 missing vs firmware exports (0 undef total v4 editorial = healthy)
- [ ] No direct `lv_font_montserrat_*` or `LV_SYMBOL_STAR` refs in source (grep `lv_font_montserrat`)
- [ ] `main/CMakeLists.txt` has `-Wno-format-truncation` allowance
- [ ] Settings path via `tt_app_get_user_data_child_path`, tested load/save
- [ ] If immersive UI: tap toggle, timer 60s, hidden chrome by default verified on device/sim, fade 220/300ms no leak heap stable >10KB
- [ ] If book browser: ~10 objs, [X left][slider flex][N/66 right], no top bar/Go/card, LARGE name 50% bigger tappable, swipe 28px, heap >10KB (275B = OOM, 31KB+error dialog = missing symbol)

## References

- `references/build-env.md` — full env error transcript + fix
- `references/symbols.md` — exported vs missing symbol list, OOM vs missing-symbol diagnosis, heap sizes `275B vs 31KB vs 54KB stable`, anim/binfont exports
- `references/offline-data.md` — per-book binary format and converter usage
- `references/deploy.md` — dashboard vs dev-port install, PUT /api/apps/install, platform arg handling, discovery via browser tools, .112 0.7.0-dev vs .129 0.8.0-dev fleet
- `references/pocket-dungeon.md` — 2026-07-16 PocketDungeon GameKit+SfxEngine via GLOB_RECURSE, personal remote pull (git.reynafamily.com), board shorthand 112 discovery, PYTHONPATH fix, 40KB .app build + network install esp32s3
- `references/intro-fullscreen.md` — Intro state machine AppPhase + fullscreen no-toolbar, rules/controls screen, HideStatusBar flag not parsed for ELF apps
- `references/sdk-mismatch-missing-symbol.md` — SDK/OS version mismatch .112 0.8.0-dev vs 0.7 manifest + unexported `lv_obj_set_style_max_width` / `scrollbar_mode` causing `missing symbol` dialog, QVGA cramped fix, `/api/screenshot` remote QA
- `references/immersive-ui.md` — UI pattern v4 editorial: warm #F2F0E8, uppercase tracking 2 ref #9A9AA8 gap 10, fade transition anim, binfont future
- `references/book-browser.md` — book selector evolution: 66-button OOM → arc rotary (0.7 missing) → horizontal slide v3 → clean v4 no top/Go/card LARGE name + [X][slider][N/66], PSRAM vs heap
- `references/black-bar-crop.md` — v6 black bar 44px #15151F COVER overlapping verse crop fix, Georgia down-tier LARGE→22 DEFAULT→18 memo, screenshot rate-limit
- `references/audio-apps.md` — RLCD Mp3Player task stack/prio/YIELD + VoiceRecorder stereo→mono downmix + ES7210 vs ES8311 MCLK/DMA fixes (2026-07-12)
- `scripts/verify_symbols.py` — rerunnable undefined-symbol checker
