Mobile: the top tiles overflowed on /income, hugged text on /sites, and repeated on invoices

Three separate layout faults in the header rows, all reported from a phone.

/income scrolled sideways by 158px. Three causes stacked, none of them responsive:
the wrapper was p-8 (32px each side at 375px), the header row was
`flex items-end justify-between` with no breakpoint, and the KPI row was
`flex items-center gap-3` with no wrap, so the two tiles physically could not
stack. Measured: main sat at x=36 w=339 at 375px, and the YTD Profit tile began
at x=355 in a 375px viewport — about 20px of its 178px was on screen.

- Wrapper is p-4 sm:p-8, and the header row stacks until lg, not sm. This one
  matters: at 768px a side-by-side row leaves the tile grid only 317px of the
  704px available (the title block takes 371px), so two columns of 152px are too
  narrow for text-2xl money and the figures spilled out over their own tiles.
  Note the DOM check said pageHScroll:false at 768 — the page did not report
  overflow even though the text was visibly broken, and only the screenshot
  caught it. The tiles may not go 2-up until the title is above them.
- The KPI row is grid grid-cols-1 sm:grid-cols-2, so the two tiles fill the row
  instead of being sized by their content.

The machine card's P/L pill hung 32px outside its card (pill ended at x=374, the
card at x=342), jammed against the viewport edge. IncomeView's mobile row 2 is
sparkline + YTD + pill, and both ends are shrink-0 with nothing allowed to give:
Sparkline's root is a fixed 120px box and the pill is shrink-0. /sites already
passed width={72} for exactly this reason (see the note in Sparkline.tsx);
/income never did. It does now, with min-w-0 on the YTD block. The pill now sits
16px inside the card.

/sites was not broken — it never overflowed — it just looked wrong. `flex
flex-wrap items-stretch` wraps, so nothing broke, but flex-wrap sizes tiles by
CONTENT: "Revenue (ex VAT)" measured 197px inside a 343px row and the second
tile wrapped to a line narrower still, both left-aligned, leaving a ragged edge
and dead space. That is the same anti-pattern AGENTS.md already records as fixed
on /income/invoices, never carried over here. It is a grid now.

/income/invoices showed "Booked to income" and "Billed (incl VAT)" as two tiles
with the same figure, which read as a duplicate. Not a bug: one sums booked
lines, the other sums every invoice header, and all 10 invoices are currently
fully booked (0 unbooked, problem inbox empty), so they are legitimately equal.
They separate the moment a line is left unbooked — which is the whole point of
the screen. Merged into one tile that says what the pair actually means,
"R 780 003,61 of R 780 003,61 billed", with the VAT breakdown kept underneath
because AGENTS.md requires VAT to stay visible. The gradient moved onto the
merged tile so the money figure keeps its emphasis.

Verified in the browser at 375, 768 and 1280: no page overflow, no element past
the viewport edge outside the table's own scroller, nothing clipped inside the
merged tile, and both desktop layouts unchanged. Typecheck and build green.
This commit is contained in:
David 2026-10-03 16:45:23 +02:00
parent aa1e75e399
commit 299c20ae57
4 changed files with 52 additions and 20 deletions

View file

@ -25,8 +25,13 @@ export default async function IncomePage() {
const totalProfit = rows.reduce((s, m) => s + m.ytdProfit, 0);
return (
<div className="p-8 max-w-[1400px]">
<div className="flex items-end justify-between mb-6">
<div className="p-4 sm:p-8 max-w-[1400px]">
{/* Stacks until lg, not sm. As a side-by-side row the title block took 371px
of the 704px available at 768px, leaving the tile grid 317px — two 152px
columns, too narrow for text-2xl money, so the figures spilled over their
own tiles. At 375px the same row could not even fit, and the page scrolled
sideways by 158px. The tiles may only go 2-up once the title is above them. */}
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-4 mb-6">
<div>
<div className="flex items-center gap-3 mb-1">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[#00798C]/15 to-[#003D5B]/15 grid place-items-center text-[#00798C]">
@ -36,7 +41,11 @@ export default async function IncomePage() {
</div>
<p className="text-gray-500 dark:text-gray-400 text-sm">Project income per machine per month · 2026</p>
</div>
<div className="flex items-center gap-3">
{/* grid, not flex-wrap: a wrapping flex row sizes tiles by CONTENT, which
is the ragged-left-edge trap AGENTS.md records for /income/invoices.
Full-width stacked on mobile, two-up from sm — same shape the dashboard
cards already use there. */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="bg-gradient-to-br from-[#00798C] to-[#003D5B] rounded-2xl px-5 py-3 text-white shadow-lg shadow-teal-500/20">
<div className="text-xs text-white/80">YTD Income</div>
<div className="text-2xl font-semibold tabular-nums">{ZAR(totalIncome)}</div>

View file

@ -28,7 +28,10 @@ export default async function SitesPage() {
return (
<div className="p-4 sm:p-8 max-w-[1400px]">
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-6">
{/* Stacks until lg, not sm: at 768px a side-by-side row leaves the tile grid
only 317px (the title block takes 371px of 704px), so two columns of 152px
are too narrow for text-2xl money and the figures spill over their tiles. */}
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-4 mb-6">
<div>
<div className="flex items-center gap-3 mb-1">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[#EDAE49]/20 to-[#00798C]/15 grid place-items-center text-[#00798C]">
@ -41,7 +44,11 @@ export default async function SitesPage() {
{invoices.length === 1 ? "" : "s"} · {year}
</p>
</div>
<div className="flex flex-wrap items-stretch gap-3">
{/* grid, not `flex flex-wrap items-stretch`: flex-wrap sizes tiles by
CONTENT, so these two hugged their text — 197px inside a 343px row —
leaving a ragged left-aligned pair with dead space beside each. Grid
items stretch by default, which is what items-stretch was doing here. */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<Tile label="Revenue (ex VAT)" value={ZAR2(totalExcl)} sub={`+ ${ZAR2(totalVat)} VAT`} />
<div className="bg-gradient-to-br from-[#00798C] to-[#003D5B] rounded-2xl px-5 py-3 text-white shadow-lg shadow-teal-500/20">
<div className="text-xs text-white/80">Billed (incl VAT)</div>

View file

@ -183,8 +183,13 @@ function CardsView({
</div>
{/* Row 2 (mobile only): sparkline + YTD + P/L pill */}
<div className="flex sm:hidden items-center justify-between gap-3 pt-2 text-xs">
<Sparkline data={r.months} color={palette.income} />
<div className="text-right">
{/* 72/28, not the 120/36 default: Sparkline's root is a fixed-width
shrink-0 box, and the pill beside it is shrink-0 too, so with
nothing allowed to give the pill was pushed ~32px outside the
card and sat on the viewport edge. /sites already passes 72 here
for exactly this reason. */}
<Sparkline data={r.months} color={palette.income} width={72} height={28} />
<div className="text-right min-w-0">
<div className="text-xs text-gray-400 dark:text-text-soft uppercase tracking-wider">YTD income</div>
<div className="text-sm font-semibold tabular-nums text-[#003D5B] dark:text-white">{ZAR2(r.income)}</div>
</div>

View file

@ -65,27 +65,38 @@ export function InvoiceList({
return (
<>
{/* A grid, not flex-wrap. The middle tile is far wider than the other two
because of its ex-VAT/VAT sub-line, so flex sized them by CONTENT and pushed
the third one onto a second row the moment the viewport dipped under ~644px —
which is squarely tablet territory. Equal columns keep all three on one row
from 640px up, and below that they stack as one clean column. */}
<div className="mb-5 grid grid-cols-1 sm:grid-cols-3 gap-3 items-stretch">
{/* TWO tiles, not three, and a grid rather than flex-wrap. Both reasons are
the same lesson: "Booked" (summed over booked lines) and "Billed" (summed
over every invoice header) are the SAME figure whenever every line is
booked — which is the goal state — so two tiles showing one number read as
a duplicate rather than as a reconciliation. The reading that carries
information is "booked OF billed", so the single tile now says that, and
the two figures separate on their own the moment a line is left unbooked.
Equal columns, not content sizing: this tile is far wider than the invoice
count beside it because of the "of … billed" line and the VAT breakdown, and
flex would size by content and shove the second tile onto its own row well
above the 640px where a tablet has plenty of room. */}
<div className="mb-5 grid grid-cols-1 sm:grid-cols-2 gap-3 items-stretch">
<Stat label="Invoices" value={String(invoices.length)} tone="teal" />
{/* What actually got booked against the machines, per the shared policy. */}
<div className="min-w-0 px-4 sm:px-5 py-3 rounded-2xl bg-[#00798C]/10 border border-[#00798C]/15">
<div className="text-xs text-[#00798C]">{BOOKED_LONG_LABEL}</div>
<div className="text-lg sm:text-xl xl:text-2xl font-semibold tabular-nums text-[#003D5B] dark:text-white">
{/* What actually got booked against the machines, per the shared policy,
set against what the debtors have actually been invoiced. */}
<div className="min-w-0 bg-gradient-to-br from-[#00798C] to-[#003D5B] rounded-2xl px-4 sm:px-5 py-3 text-white shadow-lg shadow-teal-500/20">
<div className="text-xs text-white/80">{BOOKED_LONG_LABEL}</div>
<div className="text-lg sm:text-xl xl:text-2xl font-semibold tabular-nums">
{ZAR2(bookedAmount(bookedExVat, bookedVat))}
<span className="text-xs sm:text-sm font-normal text-white/75">
{" "}of {ZAR2(totals.incl)} billed
</span>
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 tabular-nums">
{/* VAT stays visible — the ex-VAT/incl-VAT split is the one figure a reader
cannot reconstruct from the headline above. */}
<div className="text-xs text-white/80 tabular-nums">
{BOOK_INCLUSIVE_VAT
? `${ZAR2(bookedExVat)} ex VAT + ${ZAR2(bookedVat)} VAT`
: `${ZAR2(bookedVat)} VAT not counted as income`}
</div>
</div>
{/* What the debtors are actually invoiced. */}
<Stat label="Billed (incl VAT)" value={ZAR2(totals.incl)} tone="brand" />
</div>
<div className="bg-white dark:bg-surface rounded-2xl border border-gray-100 dark:border-border shadow-sm overflow-hidden">