# Mobile

*5 August 2026*

## What is wrong

Every page of Convoro scrolls sideways on a phone. Not some pages — every one,
including the two a phone is most likely to land on first, `/login` and
`/register`.

An automated pass at 375×812 (an iPhone SE through 13 mini; the narrowest
screen still in common use) over fourteen URLs found the document
`scrollWidth` at **604px on a 375px viewport** on all fourteen. The cause is
the same element every time:

```
nav.header-nav [190 → 604]
```

The header navigation is a single flex row of links — menu, theme toggle,
Messages, notifications, Admin, username, Log out — with no arrangement for a
screen too narrow to hold them. It runs 229px past the right edge, drags the
document with it, and every other element on the page inherits the sideways
scroll. Content that was laid out correctly *looks* broken because the whole
page is offset.

So the site does not have fourteen mobile bugs. It has one, repeated fourteen
times, plus the consequences.

Behind it are two smaller problems that would still be there once the header is
fixed:

**Tap targets.** Every page has at least four controls under 32px in their
smallest dimension. The search button is 17px wide. Links inside topic rows and
leaderboard rows are 16–17px tall. Apple's guidance is 44pt, Android's 48dp,
WCAG 2.5.8 sets a floor of 24px. A 16px link fails all three, and on a real
phone it is the difference between tapping a topic and tapping the row behind
it.

**Seven breakpoints.** `560px`, `600px`, `640px`, `700px`, `768px`, `900px`,
and one `max-width: 640px` block that contradicts an earlier one. Each arrived
with whatever feature needed it. The result is a layout that reflows five
separate times between a phone and a laptop, and any two features can disagree
about whether 620px is "mobile".

## What this is not

This is not a rewrite of the stylesheet. 4,492 lines of CSS that renders
correctly on a desktop is not a problem to be solved by starting again, and a
rewrite would put every desktop page at risk to fix a phone. The header is
replaced; the breakpoints are consolidated; everything else is measured, and
touched only where the measurement says so.

## The header

Below the phone breakpoint the header keeps three things and hides the rest:

- the logo, which is also the way home
- search, because on a phone search is how people navigate
- notifications, because a badge nobody can see is not a notification

Everything else — the primary menu, Messages, Admin, the account, the theme
toggle, log out — moves behind a menu button into a panel that slides down from
the header.

Some of this is arrangement rather than judgement, but two parts are decisions:

**Notifications stay in the bar, out of the panel.** A count you have to open a
menu to discover does not do the one job it has. This is the only member
control that survives the collapse, and it survives because it is the only one
that communicates something while you are not looking at it.

**The panel opens without JavaScript.** The site's own script is then free to
fail, be blocked, or arrive late without the navigation going with it — which
matters more on a phone, on a slow connection, than anywhere else.

This was a `<details>` first, which is the better semantics and does not work.
A closed `<details>` sizes its contents to **zero** even when CSS forces those
contents to display — so on a wide screen, where the plan was to hide the
summary and let the panel lay out as an ordinary row, the nav became a 0px box
and its eight links spilled off the right of the screen. `display: contents` on
the details does not rescue it. The phone was right and the desktop was ruined,
which is a worse trade than the one this work set out to make.

What is there instead: a checkbox that is never seen, a `<label>` that is the
button, and the nav as their sibling. On a phone the checkbox's state shows and
hides the panel. On a wide screen the checkbox and label are hidden and the nav
is a plain row with nothing wrapping it, so nothing can size it to zero.

The checkbox is clipped to a pixel rather than `display: none`, which keeps it
in the tab order: a keyboard reaches it and Space works on it with no script at
all. That is the whole reason for choosing a checkbox over a button — a button
would need script to do anything. Script, when it arrives, gives the label
`role="button"` and `aria-expanded` so it is announced as a menu rather than as
a checkbox, and adds Escape, close-on-tap-away and close-on-follow. All
manners; none of it navigation.

The panel is not a hamburger icon alone. It is a button that says **Menu**,
because an unlabelled icon costs a moment of thought on every visit and saves
about forty pixels once.

## Breakpoints

Two, both named, both used everywhere:

| Name  | Width   | What changes |
|-------|---------|--------------|
| phone | ≤ 640px | one column; header collapses; controls reach full width |
| lap   | ≤ 900px | side columns fold under the main column |

Seven becomes two. Where an existing rule fires at 560, 600, 700 or 768 it moves
to whichever of these it was reaching for. CSS cannot put a custom property in a
media query, so the numbers are still written out — but there are now only two
numbers to write, and a comment at the top of the block saying which is which.

## Tap targets

44px is the floor for anything you are meant to press. Not 24px: WCAG's 24px is
the point below which a target is a failure, which is a different question from
the size a target should be.

Applied by giving controls a `min-height` rather than by padding each one, so a
control added next year inherits it. Where a link genuinely is a run of text
inside a sentence, it is exempt — the rule is for controls, and turning every
inline link into a 44px block would wreck the prose it sits in.

## The row is the target

A topic row currently has a 17px-tall title link inside a 70px-tall row. The
title is the target and the rest of the row is dead space, which on a phone is
the wrong way round: the row is what the thumb is aiming at.

The row's link is stretched to cover the row using a pseudo-element, so the
whole row is pressable while the markup stays a heading with a link in it —
which is what it is, and what a screen reader should hear.

## Verifying it, and keeping it verified

The audit that found this is not a one-off script. It becomes
`tests/mobile-audit.js`, run against a real Chrome, and it fails on:

- any document that scrolls sideways at 375px
- any element whose box crosses the right edge
- any control below the tap-target floor
- a Menu button that does not open the panel, or a panel that overflows once
  it is open — the state a screenshot of a closed menu cannot show
- **and the header at 1280px**, because the `<details>` version passed every
  phone check while the desktop header was collapsed to nothing. A mobile fix
  that breaks the desktop is not a fix, and nothing else in the repository
  would have noticed.

It measures the tap area rather than the element: a row title is a 27px link
stretched over a 70px row, and the row is what the thumb is aiming at. It also
exempts links that sit in a sentence — where "a sentence" means the parent has
words of its own that are not themselves controls. A row of links with nothing
between them is a toolbar, however small its text.

Every mobile bug in this note is invisible to the PHP test suite, which is why
there were fourteen of them. `TemplateSanityTest` exists for the same reason:
markup bugs that return HTTP 200 need a check that looks at the result, not at
the response code. This is that check for layout.

## What is deliberately not being done

**No separate mobile theme, no device detection.** One layout that responds. A
second set of templates is a second set of everything, including bugs, and the
device string has been unreliable for a decade.

**No bottom tab bar.** It is the right pattern for an app that lives on a phone
and the wrong one for a forum reached from a link, where it costs a permanent
strip of the screen to save one tap on a navigation people do rarely. Worth
revisiting if the reading is ever wrong.

**The admin area is not in scope here.** It is a desktop tool with tables that
genuinely need width, and squeezing it into 375px is its own piece of work with
its own answer. It gets a note of its own when it is done.

## Found on the way

Three things this work turned up that were not mobile bugs:

**`cache:clear` fatally errored every time.** `flush()` emptied the cache
directory and then called `mkdir` on it unguarded, so it always raised
"mkdir(): File exists", which the error handler turned into a fatal. It cleared
the cache and then died reporting failure — on the one command an operator
reaches for when something is wrong.

**"1 replies".** The topic row hardcoded the plural. `Str::plural` already
existed.

**The desktop lost its notification bell** partway through this change, when the
bell was moved out of the account group and into the bar with a
`display: none` above the phone breakpoint. Caught in a screenshot, not by a
check — which is the argument for the screenshot pass, again.
