feat(mobile): 44px phone header, making the home button a true 44x44 target

The brand "C" got a 44px-wide hit box in the previous commit but was capped at
36px tall by the bar it sits in. The phone header is now 44px, so the one
control that gets you back to the home screen is square at the platform
minimum, and every other header control gains the same 8px.

Redefined as --header-height inside the phone media query rather than as a
literal, so the panels positioned off that token (file browser, project
insights, plan overlays) follow the bar instead of drifting 8px underneath it;
.app's top offset is derived from it for the same reason. The header also stops
top-aligning its children on phones: that read as centred in a 36px bar whose
contents were ~31px, and leaves a visible gap under everything at 44px.

Costs 8px of terminal height on a phone.

Verified on a real isolated instance at 390px: header 44px, button 44x44
spanning the bar, a touch tap at (4,41) - inside the new area, outside the old
one - reaches the home screen, tabs centred, and content still clears the fixed
header. Tablet (48px) and desktop are untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-08-09 18:34:35 +02:00
parent 4add38c4b1
commit 0aafabd28d
2 changed files with 47 additions and 24 deletions
+30 -20
View File
@@ -350,30 +350,34 @@ html.mobile-init .file-browser-panel {
Phone Breakpoint (<430px) Phone Breakpoint (<430px)
============================================================================ */ ============================================================================ */
@media (max-width: 430px) { @media (max-width: 430px) {
/* Phones get a 44px header, up from 36px. Every header control is a touch
target and 44px is the floor for one; the brand "C" that gets you home is
the one that matters most. Redefined as the TOKEN rather than a literal so
the panels positioned off `var(--header-height)` (file browser, insights,
plan overlays in styles.css) follow it instead of drifting 8px under the
header. Costs 8px of terminal height on a phone. */
:root {
--header-height: 44px;
}
/* Phone brand collapses to a single "C" home button: hide the wordmark, /* Phone brand collapses to a single "C" home button: hide the wordmark,
keep the tap target */ keep the tap target */
.header-brand { .header-brand {
padding-right: 0; padding-right: 0;
margin-right: 0.1rem; margin-right: 0.1rem;
border-right: none; border-right: none;
/* styles.css sizes this to the FULL header height, so inside the header's /* styles.css sizes this to the FULL header height, which is taller than the
0.15rem vertical padding it overflows and the last 2.4px are clipped header's padding box; centred by the header's `align-items: center` above,
(`overflow: hidden` on the phone header). Harmless while the brand was that overflow is symmetric and the button lands flush with both edges.
bare text; it would cut the bottom off the tap target's pressed state. Do not "fix" it with `height: 100%`: the header sets min/max-height and no
Cancelling the padding for this one item is what makes the button fill height, so the percentage has no definite containing block to resolve
the header edge to edge. `height: 100%` cannot do it: the header sets against and silently falls back to auto. */
min/max-height rather than height, so the percentage has no definite
containing block to resolve against and silently falls back to auto. */
margin-block: -0.15rem;
} }
/* The "C" was a 0.85rem inline span — about a 12x13px hit area, far under the /* The "C" was a 0.85rem inline span — about a 12x13px hit area, far under the
44px minimum, on the one control that gets you back to the home screen. 44px minimum, on the one control that gets you back to the home screen.
It is now a real 44px-wide button spanning the full header height, with the It is now a real 44x44 button: 44px wide, and the full height of the phone
glyph scaled to match. 44 is reachable on the horizontal axis only: the header, which is itself 44px for exactly this reason. The negative margin
phone header is pinned to 36px tall (min-height/max-height below) and
`overflow: hidden` clips anything taller, so a true 44x44 would mean growing
the header and taking that height off the terminal. The negative margin
spends the header's OWN left padding on the target instead of pushing the spends the header's OWN left padding on the target instead of pushing the
tab strip right. */ tab strip right. */
.header-brand .logo { .header-brand .logo {
@@ -437,8 +441,12 @@ html.mobile-init .file-browser-panel {
top: 0; top: 0;
left: 0; left: 0;
right: 0; right: 0;
min-height: 36px; min-height: var(--header-height);
max-height: 36px; max-height: var(--header-height);
/* styles.css top-aligns header children. That read as centred while the bar
was 36px and its contents ~31px; in a 44px bar it leaves a visible gap
under everything. */
align-items: center;
padding: 0.15rem 0.3rem; padding: 0.15rem 0.3rem;
padding-left: calc(0.3rem + var(--safe-area-left)); padding-left: calc(0.3rem + var(--safe-area-left));
padding-right: calc(0.3rem + var(--safe-area-right)); padding-right: calc(0.3rem + var(--safe-area-right));
@@ -453,8 +461,8 @@ html.mobile-init .file-browser-panel {
/* iOS safe area adjustment for fixed header - header extends into notch area */ /* iOS safe area adjustment for fixed header - header extends into notch area */
.ios-device .header { .ios-device .header {
padding-top: calc(0.15rem + var(--safe-area-top)); padding-top: calc(0.15rem + var(--safe-area-top));
min-height: calc(36px + var(--safe-area-top)); min-height: calc(var(--header-height) + var(--safe-area-top));
max-height: calc(36px + var(--safe-area-top)); max-height: calc(var(--header-height) + var(--safe-area-top));
} }
/* Push ALL content below fixed header (not just .main) so banners /* Push ALL content below fixed header (not just .main) so banners
@@ -464,11 +472,13 @@ html.mobile-init .file-browser-panel {
when keyboard is visible, and resetLayout() clears the inline when keyboard is visible, and resetLayout() clears the inline
style to re-expose this CSS value. */ style to re-expose this CSS value. */
.app { .app {
padding-top: 42px; /* Header height plus its 1px border and a little slack. Derived from the
token so the offset cannot fall out of step with the bar it clears. */
padding-top: calc(var(--header-height) + 6px);
} }
.ios-device .app { .ios-device .app {
padding-top: calc(42px + var(--safe-area-top)); padding-top: calc(var(--header-height) + 6px + var(--safe-area-top));
} }
.main { .main {
+17 -4
View File
@@ -163,15 +163,28 @@ describe('Phone home button tap target (static guard)', () => {
return decls; return decls;
} }
it('gives the brand button a 44px-wide hit area on phones', () => { it('gives the brand button a 44x44 hit area on phones', () => {
const decls = phoneLogoDecls(); const decls = phoneLogoDecls();
expect(decls.get('min-width'), 'the "C" home button needs an explicit 44px min-width on phones').toBe('44px'); expect(decls.get('min-width'), 'the "C" home button needs an explicit 44px min-width on phones').toBe('44px');
// A bare inline span ignores width entirely — the box only exists once it // A bare inline span ignores width entirely — the box only exists once it
// stops being inline. // stops being inline.
expect(decls.get('display')).toBe('inline-flex'); expect(decls.get('display')).toBe('inline-flex');
// Full header height on the short axis: the phone header is pinned to 36px // The other axis is the header's, so the two have to be read together: the
// (min/max-height) and clips overflow, so this is as tall as the target can // button is only 44 tall because the phone header is.
// get without growing the header and taking it off the terminal.
expect(decls.get('height')).toBe('var(--header-height)'); expect(decls.get('height')).toBe('var(--header-height)');
}); });
it('keeps the phone header at 44px, the height that makes that target square', () => {
// The bar was 36px. Shrinking it again silently takes 8px back off every
// header touch target, the home button included.
let phoneHeaderHeight: string | undefined;
postcss.parse(css).walkAtRules('media', (atRule) => {
if (!appliesToPhone(atRule.params)) return;
atRule.walkRules((rule) => {
if (rule.selector.trim() !== ':root') return;
rule.walkDecls('--header-height', (decl) => (phoneHeaderHeight = decl.value.trim()));
});
});
expect(phoneHeaderHeight, '--header-height must be redefined for phones in mobile.css').toBe('44px');
});
}); });