The POTA self-spot feature shipped in early May. A week later I added Nearby Parks — instead of typing a park reference, the radio would query its local SQLite database, ask the GPS where you were, and offer the closest parks in a scrollable list. You press the Nearby Parks button, pick one, done.
It crashed every time.
What followed was eight separate bugs. The short version:
if (send) gate fixed it.lv_group_set_editing(keyboard_group, true) sends LV_KEY_UP/DOWN to the focused widget, which does nothing for lv_list_add_btn buttons. Removing that call fixed it.dialog_init() at construct time, so dialog.obj stayed NULL and widgets piled up on screen instead of replacing each other.prev_page state corruption bug where the spot dialog's button bar forgot what it was before the nearby dialog opened. Required a static app_prev_page to survive the round-trip.lv_obj_get_width() returns 0 before LVGL does its first layout pass. Fixed with hardcoded widths.main_screen.c that was stealing MFK focus back from the spot dialog after the nearby dialog closed.dialog_destruct() from inside its own construct_cb. Fixed with lv_async_call to defer cleanup until after the construct stack unwound.Eight bugs. All real, all fixed. Still crashing.
At that point I stopped looking for bug nine and read the files I had not actually read: the entire dialog_pota_spot.c, the entire dialog_pota_nearby.c, the LVGL lv_list source.
Three things became clear.
The spot dialog's main widget is already an lv_list. I had been thinking of it as a form — a park-reference field at the top, a list below it, a separate SPOT button as the confirmation step. Reading the actual source: there is no field. There is no confirmation step. Clicking a list row immediately spots that park. Selection is the action.
The two dialogs were nearly identical. Both used lv_list. Both ran in navigate mode. Both registered with keyboard_group. The only differences were the data source and some extra columns in the nearby version for distance and park name.
The nearby dialog had absorbed enormous scaffolding that existed solely to manage the handoff between the two dialogs — three lv_async_call callbacks, a pending_cancel flag, a static selected_park[] buffer, park_refs[][] static arrays so click callbacks could safely reference results, and the app_prev_page state in the parent dialog tracking what button page to restore. None of that code does useful work. It exists because two dialogs were trying to hand control to each other safely under LVGL's event-dispatch model, and that handoff is genuinely hard to get right.
The architecture was the bug. There was nothing the second dialog needed to do that the first dialog couldn't do directly by adding more items to its existing list.
LVGL also has a non-focusable list row primitive I hadn't known about: lv_list_add_text(). It creates a plain label, not a button. The encoder skips it automatically. I had been planning to write custom code to make a section divider widget. I did not need to.
Delete dialog_pota_nearby.c (261 lines). Delete the header. Delete app_prev_page, dialog_pota_spot_return(), in_nearby, pending_cancel, the async callbacks, the static buffers, the button that opened the second dialog, the dead ACTION_APP_POTA_NEARBY enum value. Remove the file from CMakeLists.
Then in dialog_pota_spot.c, one populate_list() function that builds two sections in the existing list:
static void populate_list(void) {
lv_obj_clean(list);
park_refs_n = 0;
lv_obj_t *first_btn = NULL;
int recent_n = pota_parks_count();
if (recent_n > 0) {
add_section_header(list, "── RECENT ──");
for (int i = 0; i < recent_n; i++) {
const char *park = pota_parks_get(i);
strncpy(park_refs[park_refs_n], park, POTA_DB_REF_LEN - 1);
lv_obj_t *btn = add_park_row(list, park, park_refs_n);
if (!first_btn) first_btn = btn;
park_refs_n++;
}
}
double lat, lon;
if (gps_get_fix(&lat, &lon) && pota_db_load() && pota_db_ready()) {
static pota_db_entry_t nearby[MAX_NEARBY];
int nearby_n = pota_db_nearest(lat, lon, nearby, MAX_NEARBY);
if (nearby_n > 0) {
add_section_header(list, "── NEARBY ──");
for (int i = 0; i < nearby_n; i++) {
strncpy(park_refs[park_refs_n], nearby[i].ref, POTA_DB_REF_LEN - 1);
char label[80];
snprintf(label, sizeof(label), "%-10s %4.1f km %s",
nearby[i].ref, nearby[i].dist_km, nearby[i].name);
lv_obj_t *btn = add_park_row(list, label, park_refs_n);
if (!first_btn) first_btn = btn;
park_refs_n++;
}
}
}
if (first_btn) lv_group_focus_obj(first_btn);
}
add_section_header() is one line: lv_list_add_text() plus styling. Section headers are labels, not buttons, not added to keyboard_group. The encoder skips them without any flag-clearing.
The result on screen:
┌──────────────────────────────────────────────────────────────────┐
│ MFK: scroll Press: spot │
│ ── RECENT ── │
│ US-0765 │
│ US-1234 │
│ ── NEARBY ── │
│ US-0765 0.3 km Tippecanoe River SP │
│ US-1234 4.1 km Kankakee River SP │
│ US-5678 9.8 km Indiana Dunes NL │
└──────────────────────────────────────────────────────────────────┘
[New Park] [Refresh Nearby] [Cancel]
One screen. Scroll past the divider and you're in nearby. No mode switch, no second dialog, no flash of the screen.
The numbers: +166 / −401 lines. Net −235. Two files deleted. Three async deferrals gone. Four static state variables gone.
Each of the eight fixes made the next bug harder to find. The app_prev_page static was added to fix the prev_page corruption — and it created surface area for the dialog_pota_spot_return() reconstruction logic, which made the next crash subtler because now three pieces of state had to agree about whose dialog was alive. Every fix added scaffolding. Every piece of scaffolding made the system more fragile. After three rounds you have a structure that exists to defend itself, not to do work.
The way out is not a ninth fix. It is deletion.
I think this is a specific trap in LLM-assisted coding because the loop reinforces it. Ask the model to fix a bug; it fixes the bug. New bug; ask again. Each fix is locally correct. The model is good at local fixes — it sees the immediate context, makes the immediate context right, and moves on. Stepping back and saying the thing I asked you to add shouldn't be here in the form I asked for requires a different mode. It requires reading code you were not going to read, holding the whole thing in mind, and being willing to throw away work.
There is a corollary about the wiki. Earlier in this project I had written a page cataloguing every selectable-list pattern in the X6100 codebase — lv_list, lv_table in two modes, lv_dropdown, textarea_window, all of them — with pros, cons, code samples, and a decision guide. I wrote it after the original nearby dialog shipped, as a reference for the next feature. When I came back to redesign this, that page was what let me see that an lv_table-based alternative would break because it requires edit-mode and the spot dialog runs in navigate-mode. Without it I would have built something wrong and found out later.
The wiki is not just documentation. It is a cache of past reasoning I can reload when I am back in the same domain weeks later. The cache is what lets the global view stay reachable.