Choosing mobile layout targets from production logs instead of device guesses
A device list looks precise until browser user agents start saying only Android 10; K or plain iPhone. For layout work, exact model names were the wrong target. Aspect ratio was the useful signal. Here is how we turned one day of production logs into a mobile layout matrix, checked it against public 2026 references, and decided what range the app must support.
The problem with designing for named phones
It is tempting to say a mobile app should be tested on a list of popular devices: a Samsung A-series, a Moto G, a Redmi, a recent iPhone, maybe an old iPhone SE. That list sounds concrete. It is also fragile.
The device name is not always available where layout problems show up. Browser user agents are increasingly reduced. A large slice of Android web traffic reports something like Android 10; K, which tells you almost nothing about the physical phone. iPhone browser traffic often says only iPhone, without a model. If you force those rows into exact devices, the report becomes cleaner and less true.
The layout question we actually needed to answer was simpler: what screen shapes must Daily Sudoku handle without cutting controls, hiding the board, or trapping the player behind an inaccessible button?
That is an aspect-ratio question. In our notes we called it factor: max(width, height) / min(width, height), rounded to two decimals.
The uncomfortable part is that this is not as satisfying as a device list. You do not get to say, “we support the iPhone 15 Pro Max” and move on. You have to admit when a row is unknown, infer only where the data supports it, and compare your local traffic against an external baseline.
That ended up being the right tradeoff.
The data source
We started with Loki logs for a production backend endpoint. The query targeted create-bet requests and filtered request logs with user-agent data. We sampled July 15, 2026 in five-minute windows every half hour across the day, then reduced every log line to two fields: timestamp and user_agent.
The raw file stayed as JSON lines. The analysis report grouped each row by:
- device name
- operating system name
- operating system version
- screen resolution when known or safely inferred
- factor
- occurrence count
Desktop and notebook traffic was removed from the layout report. That excluded rows explicitly classified as desktop, Windows, macOS, or Linux. Tablets stayed because they are still mobile layout surfaces for this app.
After that filter, the mobile report had 54,742 records and 1,559 grouped rows. Of those, 36,929 records had a known or inferred screen. 17,813 remained unknown.
That unknown number matters. It is not a cleanup failure; it is a guardrail. If a browser hides the model, the report should show that uncertainty instead of pretending to know the screen.
Why factor beat screen resolution
Physical screen resolution is useful when the model is known. It is less useful as the primary design target.
For example, all of these have the same factor after rounding:
720x16001080x24001220x27121440x3200
They differ in pixel density, GPU, RAM, and performance. But for layout shape, they are all around 2.22. A Sudoku screen cares a lot about that shape: the board, number pad, controls, banner space, modals, and victory state all compete vertically.
The same happened with iPhones. Recent iPhone models cluster around 2.16 and 2.17. Exact model inference from a generic iPhone user agent would be dishonest, but factor inference for modern iOS versions can still be useful. We marked those rows as inferred-ios-modern, assigned factor 2.17, and kept the screen label explicit so nobody mistakes it for a real physical resolution.
That distinction is the key. We did not need to know whether a generic iOS 26 browser was an iPhone 16 Pro or an iPhone 17 Pro Max. We needed to know whether it belonged in the modern tall-phone bucket.
What the local traffic showed
Once desktop was removed and modern generic iPhones were inferred by factor, the top groups looked like this:
| Factor | Occurrences | Share |
|---|---|---|
2.22 |
18,000 |
32.88% |
2.17 |
9,823 |
17.94% |
2.16 |
2,487 |
4.54% |
2.23 |
2,394 |
4.37% |
2.24 |
1,183 |
2.16% |
unknown |
17,813 |
32.54% |
The largest known bucket was 2.22. That lines up with common Android shapes: 720x1600, 1080x2400, 1220x2712, and similar tall screens. The iPhone-heavy buckets were lower but still meaningful: 2.16 and 2.17 together covered a large part of the known iOS surface.
The unknown bucket was still too large to ignore. The important observation was not “we solved every row.” It was that the known rows and external references pointed to the same corridor: roughly 2.16 to 2.24 for modern phones, with a smaller legacy tail around 1.78.
That is enough to guide layout work.
The external sanity check
Production logs can be biased. This endpoint has its own audience. It may overrepresent Android, a specific region, or a specific browser path. So we checked two external references.
First, StatCounter’s mobile screen-resolution stats for June 2026. StatCounter reports browser viewport or CSS resolutions, not physical device pixels, so it is not a one-to-one match with our physical-resolution table. But factor still compares well because the shape is preserved.
The StatCounter top factors were close to ours:
| Factor | StatCounter mobile share |
|---|---|
2.22 |
21.61% |
2.17 |
19.37% |
2.16 |
18.45% |
2.23 |
2.89% |
2.24 |
2.11% |
Second, TelemetryDeck’s iPhone model survey. Its CSV showed the top ten iPhone models for the latest available week, updated July 3, 2026. Mapping those models to physical screen resolutions produced only two factors in the top ten:
| Factor | Top-10 iPhone share |
|---|---|
2.17 |
78.92% |
2.16 |
21.08% |
That supported the iPhone inference. Recent iPhones are not spread across a wide range of shapes. For layout purposes, generic modern iPhone traffic can be treated as a 2.16-2.17 risk, with 2.17 as the conservative bucket for the current top models.
Turning analysis into layout targets
The report was not meant to become a trivia table. It needed to change how we test screens.
For Daily Sudoku, the product decision became: screens must remain functional across the complete 1.70 to 2.24 factor range.
Functional means:
- critical controls are reachable
- the board is not clipped in a way that breaks play
- modals can be dismissed or completed
- scroll is available when content does not fit
- banners and safe areas do not cover primary actions
This does not mean every factor receives equal visual polish. The design baseline should still favor the high-volume corridor, especially 2.22, 2.17, and 2.16. But 1.70-2.24 is the minimum operating range. Outside it, the app may work, but it is not the current validation contract.
This matters most for emotional screens. The victory screen is a peak moment. It has confetti, score, rewards, CTA buttons, and sometimes monetization or banner pressure. A layout that looks good only on a tall simulator but hides “New Game” on a short phone is not done.
What changed in the workflow
Before this analysis, viewport choices were too easy to argue about. One person would mention a device they owned. Another would use a simulator preset. A bug report would bring a third viewport into the room. None of those were wrong, but they were not weighted.
Now the sequence is clearer:
- Start with the user’s reported viewport if the bug has one.
- Validate against the required factor range.
- Prioritize polish around the observed high-volume factors.
- Keep unknown traffic visible instead of deleting it.
- Compare local traffic with an external reference before making a permanent rule.
The workflow also changed how we read user agents. Exact model names are useful when available, especially in app webviews that append structured metadata like device_name, os_name, and os_version. Browser user agents deserve more skepticism. Android 10; K is not a model. Plain iPhone is not a model. They are still traffic, but they should be represented as uncertainty or safe factor inference, not fake precision.
This is the same discipline we used in our heuristic UX critique of the React Native game: turn subjective discomfort into a ranked, testable surface. Here the surface was not heuristics. It was screen shape.
What changed once we implemented the layout
The first implementation proved that factor is a validation target, not a layout unit.
At one point we had four tab screens that all worked, but each one had its own small set of coefficients: shortSide * 0.045 for card padding here, shortSide * 0.065 for radius there, a slightly different min and max for icons somewhere else. That was better than fixed pixels in each JSX tree, but it was still not a durable system. The numbers had moved; they had not become meaningful.
The fix was to keep the feature layouts private, but move the fluid scale for tab screens into one place. The app now has a tab-level scale with named roles: space, radius, text, icon, and touch. The individual Home, Favorites, Shop, and Profile layouts still map those shared metrics into local names, because a daily challenge card and a profile streak card are not the same component. But the raw ratios and clamp limits live in one file.
That split mattered. We did not promote the scale to a global UI package. It has been proven for Sudoku tab screens, not for the game board, settings modals, victory flow, or another app. The boring boundary kept the change useful.
The second lesson came from Settings. A grid of theme buttons looked fine in a browser, then broke visually on an Android emulator because the cards were too narrow for the English copy. Switching the whole grid to one column fixed the wrapping, but it made the screen feel heavy. The better fix was smaller: keep two columns, cap the description at two lines, give each card a stable minimum height, and center the contents vertically. The layout stayed dense without leaving a dead blank area inside each button.
That is the kind of issue a factor table will not catch by itself. The table tells you which shapes deserve validation. The UI still has to be inspected as real content, with real strings.
Safe area is not just padding
The Android emulator also exposed a safe-area trap. The header looked too far from the top, as if someone had added a random spacer. The actual cause was the display cutout. The emulator reported a top cutout larger than the normal status bar, and our shell initially treated the whole cutout as visual padding.
On a real Redmi Note 7, the same code looked reasonable because the reported top area was smaller. On the emulator, it created a visible blank band. Removing the padding entirely fixed the band but pushed the header too close to the top. The final fix was to make the tab shell responsible for the safe area and keep the tab contents from adding their own SafeAreaScreen. For Android, the header padding respects the real status bar height instead of blindly using the full display cutout.
The practical rule is simple: one layer owns the top inset. If the shell handles it, child screens should not add another safe area on top. When both layers try to be safe, the UI gets lower and lower until it looks broken.
We validated that with UIAutomator bounds, not screenshots alone. On web, the first content block for Home, Favorites, Shop, and Profile aligned at the same vertical position. On Android, the header started where the reported cutout ended. That gave us a measurable answer instead of another round of visual guessing.
Web production is not native production
The same session caught a different class of problem: the Store provider. Production config correctly used RevenueCat for Android and iOS, but the local web production build tried to initialize the native RevenueCat SDK and sent an error to Sentry: Invalid API key. Use your Web Billing API key.
The YAML was not wrong. The platform gate was missing. Android production should use RevenueCat. Web production should not initialize the native billing SDK with an Android or iOS key. The Store config now switches revenuecat to a mock provider on web, while keeping RevenueCat on native platforms.
That also helped separate a real pricing issue from a mock artifact. The mock store had previously returned a fake priceLabel of Test. That is useful for proving a branch exists, but it is a poor user-facing value. The mock now returns null for price labels and lets the app show its localized “price unavailable” fallback. On the device build, logcat confirmed the native runtime was using RevenueCat and receiving real Brazilian prices.
This is still layout work in a broader sense. A price chip that says Test breaks trust just as much as a clipped button. Responsive UI is not only about boxes fitting the screen; it is also about the runtime choosing the right provider for the surface it is running on.
The useful answer
The useful answer was not “support these ten phones.”
It was this:
Design and test the app around the phone aspect-ratio corridor that appears in real traffic and public references. For this project, that corridor is dominated by 2.16-2.24, with 2.22 as the strongest Android bucket and 2.17 as the strongest modern iPhone bucket. Keep a shorter legacy case around 1.78, and do not hide the unknowns.
That answer is less glamorous than a device wall. It is also easier to maintain. New phones can arrive, user agents can get more private, and the exact top model can change. The layout contract survives because it is based on the shape the app has to handle, not the name printed on a spec sheet.