Not Missing a Scroll Wire, Just Unscrollable: Overturning a 'Confirmed' Diagnosis with Three Lines of Math
An on-device acceptance test revealed a symptom, and Claude provided a diagnosis on the spot. Looking back, that diagnosis was beautifully structured: the symptom, the mechanism, and the fix aligned perfectly, and my notes marked it as “confirmed.” Later that same day, while Claude was writing the remediation plan and reviewing the files one by one, three lines of math overturned it—the defect described by the diagnosis was geometrically impossible, and the proposed fix addressed a non-existent problem.
How Acceptance Testing Works on This Project
The background is the same as other stories from this project: an Android TV app rewrite, migrating from the old version (Android 9, Java) to the new version (Android 16, Kotlin, Jetpack Compose). After completing each phase, I ran on-device acceptance tests on a TV box in the lab—I operated the remote control, Claude interpreted the logs, and I verified the acceptance criteria item by item. The phase this time was system settings.
The settings list UI is a shared component called SettingCardGrid. Under the hood, it uses a LazyVerticalGrid paired with rememberLazyGridState(), and the item holding focus (focusedIndex) is tracked all the way through. This background context becomes very important later.
The Symptom
The system settings list has seven items arranged in a single column with seven rows. My original report was this exact sentence:
After moving down, the options did not scroll up, so they went off-screen.
Pressing DOWN moves the focus down, but the screen does not scroll along with it—the bottom two items (CA Status, STB Data Reporting Settings) moved outside the visible area and never entered the screen from start to finish. In the actual video recording, only five rows were visible on the screen, and the counter displayed 01 / 07.
This directly blocked two acceptance test cases: the target options were off-screen, making it impossible to visually verify if the features worked correctly. I simply noted down “pending fix and retest” for the walkthrough and continued moving forward.
The On-the-Spot Diagnosis
Claude provided the root cause on the spot, writing the fix simultaneously. The diagnosis was that the list did not wire up “focus movement” to “scrolling,” missing this snippet:
LaunchedEffect(focusedIndex){ gridState.scrollToItem(focusedIndex) }
—meaning there was no “focus → bringIntoView” wiring. The fix was simply to add this snippet.
Looking back, the persuasiveness of this diagnosis came from its structure: the symptom was “not scrolling,” the mechanism was “not wiring scroll to focus,” and the fix was “wiring it up.” The three parts aligned far too smoothly, missing precisely the most fundamental question: did this wire even need to be connected manually?
Overturned in Three Steps
Later that same day (in the afternoon, resuming after hitting a quota limit), Claude began writing the remediation plan and reviewing the files one by one. The diagnosis was overturned right here.
Step One: Crashing into a contradiction with framework behavior. The settings card uses the TvFocusableItem wrapper, and underneath it is androidx.compose.foundation.focusable(interactionSource = …). And the foundation’s focusable has built-in bringIntoView—when a focusable item receives focus, the framework automatically brings it into view. The “missing wiring” diagnosis directly contradicted the framework behavior: if it were merely missing a handwritten wire, it would have scrolled automatically anyway.
Step Two: Finding the real location. The problem was at SettingCardGrid.kt:109:
Modifier.offset(x = 186.px, y = 282.px).width(1656.px)
This modifier pushes the grid 186px to the right and 282px down, setting the width to 1656px—yet it uniquely leaves the height unconstrained. The layout semantics of Modifier.offset dictate that it only translates the placement position after measurement is complete; it does not change the constraints received during measurement. Therefore, the maxHeight the grid received during the measurement phase was the full 1080px provided by the parent’s fillMaxSize()—it thought it was an entire screen tall.
Step Three: Math settles the case. Seven items, 132px per row, plus six 24px gaps:
7 × 132 + 6 × 24 = 1068px < 1080px
The content was shorter than the constraint. The grid determined that “the content fits perfectly,” setting the scrollable distance maxScrollOffset = 0—this grid was fundamentally unscrollable. It was not missing a scroll wire; there was simply nothing to scroll.
Continuing to calculate with this same set of numbers aligned every detail of the symptom perfectly: the grid was pushed down by 282px, leaving the true visible height at only 798px, exactly enough to fit five rows (282 + 5 × 156 = 1062)—matching the real-world recording of “five rows, 01 / 07” word for word.
Math accomplished two things simultaneously here: it falsified the diagnosis (the scrollable distance was zero; “missing wiring” was solving a non-existent problem), and it provided a positive explanation (why exactly five rows were visible). The originally “confirmed” diagnosis had never even answered “why five rows.”
The Original Fix Was Not Just Ineffective
Adding scrollToItem from the original fix would have been useless—the scrollable distance was already zero, so there was nothing to scroll.
Even worse, it would have layered on a new problem: an explicit scrollToItem and the framework’s built-in bringIntoView have different semantics—bringIntoView means “bring the item into view using the minimum distance,” while scrollToItem means “align this item to the top of the viewport.” Running both together would mean that with every directional key press, the screen would aggressively yank the focused item to the top, causing a jarring jitter. The remediation plan ultimately added “must not add scrollToItem” to the out-of-scope hard ban list—specifically to prevent the implementation side from writing code based on the speculation in the old notes.
A wrong diagnosis is never neutral: it not only blocks the correct answer but also spawns its own side effects along the way.
The Fix
Change the vertical displacement from offset to padding:
.offset(x = 186.px).padding(top = 282.px).width(1656.px)
The difference between padding and offset lies exactly at the point of failure: padding participates in measurement, shrinking the maxHeight the grid receives to 798px, while the visual position on screen remains unchanged. The 1068px content now exceeds 798px, meaning maxScrollOffset is no longer zero, and the grid becomes scrollable again—giving focusable’s built-in bringIntoView something to actually bring into view.
The implementation was handed to Gemini 3.1 Pro; the independent review PASSED, and the on-device retests all passed, including those two cases that were originally blocked off-screen.
Since the Math Worked, Calculate It Once More
There was also a side benefit this time. Since the math checked out, Claude applied the same calculation back to the lists on other layers: the first layer of settings had nine items, spanning two columns and five rows, totaling 906px—which also exceeded the 798px visible area. The ninth item, “Google Settings,” was actually barely visible the whole time: with a row height of 162px, only 54px was exposed. No one had ever reported this defect, but it was fixed and passed acceptance testing together with the others this time.
As for why earlier phases had not blown up: older lists mostly had five items or fewer, not exceeding the viewport. The defect had been there since the day the component was written, but the content length had simply never tripped over it.
Conclusion
The takeaway from this incident is this: a diagnosis must be testable by math. The sense of completeness when “symptom → mechanism → fix” perfectly align is a property of narrative, not a property of evidence—it tells you the story makes sense, but it does not tell you the mechanism actually exists.
Distilled into a habit, it is just one sentence: before writing a fix, calculate “is this defect geometrically possible?” 1068 < 1080; a single less-than sign was enough to pass a death sentence on a diagnosis marked “confirmed”—and calculating it required no tools, only the willingness to write the numbers down.