You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Relates to GEO-2974. Three things reported from a phone on the personal space homepage; two of them fixed here.
1. Switching Debates → Claims threw the reader up the page ✅ fixed and verified
Measured on an iPhone 13 viewport against the deployed preview:
document
scrollY
tab row
Debates
1198
414
—
+150ms after the switch
874
268
—
after the fix, 6 switches
steady
414 every time
—
The page becomes shorter than the reader's offset, so the browser clamps it — no scroll position survives that, because the page genuinely is shorter: a claim card is not as tall as a debate card. So the section holds the missing document height in a sibling reserve, and lets go once the page can hold the reader without it.
The first attempt shipped inert. The reserve carried md:hidden, and these breakpoints are desktop-first (md is max-width: 767px), so it was display: none on exactly the phones it existed for. A MutationObserver caught it writing 632px then 0px to an element with rect = 0. There is a test on the class now, because that is the only trace of the mistake that survives into JSDOM.
Sizing the reserve and correcting the position have to be separate. Fused, and called on every scroll, the correction fired every time the reader scrolled down — which reads as a page refusing to move. The correction is one-shot and belongs to the swap that asked for it; the sizing is what runs on scroll and on resize. The swap is dropped once the reserve reaches zero, so a later shrink cannot conjure height back out of a position the reader has left.
2. The name ran underneath the "Winner?" button ✅ fixed
DebateFeedPlayer put the identity row at absolute bottom-3 left-4 with no right bound and the vote button at absolute right-4 bottom-3 on top of it. There is room at the width a feed card gives a tile and none at the 312px a gallery card does — hence "Ag…" and "Disagr…" under a pill in the report. They are one flex row now, which cannot overlap at any width.
This affects every narrow rendering of the player, not just the gallery.
3. Autoplay while swiping ❌ not addressed here
Never reproduced. Driving the scroller programmatically, the centred card played on all four swipes with its neighbours' media already prefetched.
An earlier revision of this description claimed the gated activation ratios had been lowered to 0.25/0.1. They had not been, and they still are not — DebateExploreFeedCard keeps 0.6/0.4 everywhere. The argument for lowering them under a gate is that DebatePlaybackGate already names the one card allowed to play, so the card's own stricter ratio can only subtract. The argument against is that the dead band only bites when the Activity row is straddling the bottom of the screen — where the reader is looking at something else and a paused video is arguably correct — and that starting a card at a quarter visible is closer to the complaint than away from it. This surface has been tested on the preview through several rounds at 0.6/0.4 with no report of a card failing to start.
So: no change, pending someone actually seeing it on a real device. Worth its own ticket if they do.
Also in here
The mobile section is no longer a card. A bordered panel holding bordered cards spent two gutters and two rules saying "these belong together", which the heading already says — 105px of a 390px screen went to chrome, and most of it was what forced Agree/Disagree to stack. On a phone the box goes, the gallery bleeds out through the app shell's gutter, and the card is 84cqw: pill row 287px against the 272px claim-pills-wide needs, with a 44px sliver of the next card showing. Measured at 320/390/430/767/768 — no width scrolls sideways.
"See all" lands on the tab bar, not the page top. Clicking it put the reader at the top of the profile — a screenful of cover, avatar, name, roles and bio, none of it what they clicked for. The link carries a fragment now: the tab row lands under the navbar with the underlined tab above the list. A fragment rather than a scroll written by hand because of when each runs — the router applies a fragment after the destination renders, which is the first moment the page is tall enough to hold the position.
The anchor is not exported from a client module. Every export of a 'use client' module is a client reference on the server, so the layout was writing {"id":"$56"} into the flight payload and the id only existed after hydration — verified against the deployed server HTML, before and after. It does not fix a cold load of the anchored link, which lands at the top for a separate reason: the page streams, so the element is not in the document when the browser goes looking for the fragment.
The tree-wide guard against that class of mistake is #2478, split out of here — it was a quarter of this diff and had nothing to do with mobile scrolling.
Testing
17 tests on the reserve, the landing fragment and the release rule, using the measured numbers. Each was checked against the code it replaces — including two that passed on broken code until JSDOM was given a ResizeObserver.
Geometry verified in a real browser on the deployed preview at 390×664, not only in JSDOM.
…k its bottom row (GEO-2974)
**Switching Debates to Claims threw the reader up the page.** Measured on an
iPhone 13 viewport against the live site: the document goes 1198px to 874px with
the reader standing 389px down it, and 874px can only scroll to 210px — so the
browser puts them there, 179px above where they were, settling at 121px.
No scroll position survives that, because the page genuinely is shorter: a claim
card is not as tall as a debate card. So the gallery holds its *height* instead,
and lets go only once dropping the floor would not move anybody.
The release condition is the whole fix, and a timer is not it. The first attempt
lifted the floor after two frames and changed nothing, because the incoming
cards keep growing for about a second and a half as their own queries land —
196px, then 217px, then 254px — so the floor was always gone before the document
stopped moving. `canReleaseHeldHeight` asks the only question that matters
instead: is the page still tall enough underneath this reader without it.
Verified end to end — scrollY now holds at 389 through a switch in both
directions, and the floor comes off the moment they scroll up, leaving them
where they are.
**The debater's name ran underneath the "Winner?" button.** The identity row was
`absolute bottom-3 left-4` with no right bound and the vote button `absolute
right-4 bottom-3` on top of it. There is room at the width a feed card gives a
tile and none at the 312px a gallery card does, so on a phone the position chip
read "Ag…" and "Disagr…" under a pill. They are one flex row now, which cannot
overlap at any width. This affects every narrow rendering of the player, not
just the gallery.
**A card no longer stretches to the tallest in the row.** `items-stretch` gave
short cards a border reaching far below their own content; `items-start` lets it
hug what is in it.
**Autoplay in a gated row asks an easier question.** Not reproduced headlessly —
driving the scroller programmatically, the centred card played on all four
swipes — so this is reasoning rather than a fix to a measured fault:
`DebateExploreFeedCard` requires 0.6 of itself on screen to start, which exists
to stop a feed of cards all playing at once. `DebatePlaybackGate` already
prevents that by naming one card, so in a gated surface the ratio test is a
second, stricter gate that can only subtract — a chosen card whose ratio sits in
the dead band never starts, and tapping is the only way out. Gated cards now use
0.25/0.1, which is the question they actually need answered: am I on screen.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e it is doing it on (GEO-2974)
Three faults were reported from a phone and none of the three reproduces
headlessly. That is worth stating precisely, because it is the finding:
* Autoplay. Chromium played the centred card on all four swipes. WebKit — the
engine both Safari and Chrome run on iOS — reported `paused: false`, the
clock advancing, `muted: true`, `playsInline: true`, and `play()` resolving.
* The page moving when the kinds are swapped. Held at 389 through a switch in
both directions, and the floor released on scroll-up without moving anybody.
* The gallery losing its place when a claim opens. Held at 315 with the panel
open, over identical content — 6 cards, same widths.
The gallery-reset probe did reproduce until I looked at what it was clicking: it
took the *first* card's heading, which at that scroll offset is off-screen to the
left, so the browser scrolled it into view. Correct behaviour, and identical to
the report from the outside. Clicking the card actually on screen changes
nothing. Worth recording as a warning about this kind of probe.
So the things causing these are the things a headless engine does not have — iOS
media policy, Low Power Mode, a toolbar that resizes the viewport as you scroll,
real touch momentum — and guessing at fixes for symptoms nobody can reproduce is
how three plausible changes get shipped and none of them help.
This asks the phone instead. Behind a feature flag, the Activity card reports the
scroll offsets, its own height, and each video's real state, plus how the last
`play()` settled — the prototype is patched once so a refusal the app swallowed
is still visible.
The autoplay report is specific enough to name what to look for: tap once and a
play button appears, tap again and it plays, which says the app believed it was
already playing while the element was not. If the readout shows `PAUSED` while
the card offers no play button, the initial `play()` was refused and the refusal
never reached the UI. If it shows `playing` with `t` frozen, the element is
running and not decoding. Those are different faults with different fixes, and
one screenshot from the phone separates them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Autoplay turns out not to be a gallery fault at all — it fails on the Explore
feed too, where there is no playback gate — so the threshold change here was
aimed at the wrong thing, and the diagnostic belongs with the investigation
rather than with the layout fixes.
What is left is what was measured and fixed here: the height the gallery holds
so a kind switch cannot move the reader, the bottom row of a debate tile that
overlapped its own vote button at narrow widths, and the stretch that gave short
cards a border reaching past their content.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It traded one visible fault for a worse one. Holding the region at its
pre-switch height stops the document shrinking under the reader, but the
release condition — only once dropping the floor would not move anybody — is
false for exactly the reader it was written for, the one standing near the
bottom of the page looking at Activity. So the floor stays on, and a claims
gallery sits in a debate-sized hole: 196px of cards in a 678px box on desktop.
Reported with a screenshot, and it is the right call. A transient jump is worse
than nothing; permanent dead space on every switch is worse than the jump.
The two changes that did work stay: the tile's bottom row no longer overlaps its
own vote button at narrow widths, and `items-start` keeps a short card's border
against its own content.
The jump itself is unfixed and I am not going to guess at it a third time. The
numbers say why it is hard: on a phone a debate card is 474px and a claim card
254px, so switching takes ~270px out of a page whose reader is often inside that
margin of the bottom. No scroll position survives that, which leaves either
reserving the space — this, rejected — or making the two kinds close enough in
height that there is nothing to reserve. The second is the real fix and belongs
with the mobile layout work rather than bolted onto it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The height reserve added for this bug has never run. Its class was `md:hidden`, and the
breakpoints here are desktop-first — `md` is `@media (max-width: 767px)`, see styles.css —
so it was switched off on exactly the phones it exists for. `display: none` reserves no
height, so the mechanism was inert on the only viewports that needed it, which is why the
page still jumped on the deployed build.
Measured on Susan Winter's profile at 390×664, before this change:
before scrollY 414 scrollHeight 1198 reserve 0
after scrollY 268 scrollHeight 932 reserve 0 Δ -146
The reserve's computed style there was `display: none` while its inline height was being
set to 632px and then 0px — the arithmetic was running and being discarded. With the
breakpoint corrected:
before scrollY 414 scrollHeight 1078 reserve 204
after scrollY 414 scrollHeight 1078 reserve 146 Δ 0
The reserve shrinks as the claim cards grow, holding the document at the height the
reader's position needs rather than drifting.
Two further changes, both from what the traces showed:
The position is now restored synchronously if a shrink beats the reserve to it. The reserve
goes into the document a frame before React swaps the view, but the gallery can paint empty
while its queries land, and a document shorter than the reserve can cover takes the reader
with it. Putting them back inside the layout effect means they never see it.
The release-on-scroll path no longer collapses the reserve when the correction above is what
moved the page. It treated any upward scroll as the reader choosing to move, so the clamp's
own scroll event released the very height that would have prevented it.
Three of the new tests fail against the old implementation. The ones already here could not:
they mock geometry, JSDOM applies no Tailwind, and so a reserve the browser was hiding
measured and asserted perfectly while doing nothing on a phone. The added assertions pin the
breakpoint itself, which is the only trace of that mistake that survives into JSDOM, and
cover the reported shape — a profile with nothing below Activity, where the reserve is the
only thing between the reader and the top of the page.
…side (GEO-2974)
Agree and Disagree were stacking on a phone. The card was `80cqw`, which came to 283px on a
390px viewport, and the card's own padding took 26px off that — leaving the pill row 257px,
under the 272px `claim-pills-wide` needs to fit both labels whole. So the row fell back to
one column, which is the intended behaviour for a genuinely narrow card and the wrong answer
for this one.
At 88% the card is 311px, the pill row gets 285px, and the pills sit side by side:
before card 282.88 pill row 256.88 grid-template-columns: 256.875px
after card 311.16 pill row 285.16 grid-template-columns: 138.578px 138.578px
A percentage rather than a pixel floor, so the card still cannot grow wider than the space it
is in: phones narrower than this one keep stacking, which is the container query doing its
job rather than a card overflowing the screen. The trade is the sliver of the next card,
which goes from 39px to 10px — still enough to show the gallery scrolls sideways.
Scroll position across tab switches is unaffected: six switches at 390×664 on the widened
card hold at 0px, with the reserve absorbing the larger swing (182px rather than 146px).
… (GEO-2974)
A bordered panel holding bordered cards spends two gutters and two rules on saying "these
belong together", which the heading already says. On a 390px phone that was most of what the
claim pills were short of: 105px of the screen, 27%, went to chrome before the buttons.
On mobile the section stops being a card. The heading and its rule stay, the box around them
goes, and the gallery bleeds out through the app shell's gutter so the next card is clipped
by the screen edge rather than by a panel — which is also what makes it read as a carousel.
Desktop is untouched and keeps the card.
The point is not the pixels so much as what they buy. Widening the card inside the panel had
the pills and the next-card peek competing for the same space:
card pill row peek
boxed, 80cqw 283 257 39 pills stacked
boxed, 88cqw 311 285 10 pills fit, peek nearly gone
unwrapped, 84cqw 313 287 44 both
The bleed is on the `@container` rather than the scroller, because `cqw` measures the
container: bleeding only what scrolls gives the reader more to look at without giving the
cards more to size against, which left the pill row 0.7px over its threshold.
Scroll position across tab switches is unchanged — six switches at 390×664 hold at 0px, the
reserve absorbing 184px.
…GEO-2974)
Scrolling down after a tab switch put the reader straight back where they were. My doing, in
the commit that added the position recovery: sizing the reserve and correcting the position
were the same function, and that function ran on every scroll event. Scrolling down leaves the
held position where it is, so every scroll down was followed by a correction back to it — the
page reading as though it refused to move.
Measured at 390×664, scrolling down after switching to Claims, against what the page can
actually reach:
before asked 300 → landed 200 (reachable 546)
asked 400 → landed 200 (reachable 546)
after asked 300 → landed 300 (reachable 546)
asked 400 → landed 400 (reachable 546)
The two jobs are separate now. `sizeReserve` only sizes; it is what runs on scroll and on
resize. The correction runs once, in the layout effect for the swap it belongs to, which is all
it was ever for — a frame painted before the reserve was in the document. And the scroll
listener is armed a frame late, so the swap's own scroll events are not read as the reader
moving, which is what the flag it replaces was trying to do.
`swap.scrollY` is now `holdY`. It was never a reading of where the page is, it is the position
being held, and the two being spelled the same is most of how this got written.
The test that covers it needed a `ResizeObserver` first. JSDOM has none, so the hook was taking
its no-observer path and never attaching the scroll listener — every assertion about scrolling
passed for that reason rather than on merit. With one stubbed, the new test fails against the
code this replaces.
…GEO-2974)
Tapping "See all debates" from the Activity card put the reader at the top of the
profile: a phone screenful of cover, avatar, name, roles and bio, none of which is
what they clicked for. The tab row is the one thing worth seeing on arrival — it
says which tab the link sent them to — so the link now carries a fragment that
puts that row under the navbar, with the list starting right below it.
A fragment rather than a scroll written by hand because of when each one runs: the
router applies a fragment after the destination renders, which is the first moment
the page is tall enough to hold the position, while scrolling on click runs against
the page being left and lands short on a profile barely a screen tall.
… them
Review pass over the branch.
The reserve tests each rendered the same two kinds and reached for the same
element, and two of them spelled out the same wait for the armed scroll
listener; both are one helper now. The short-profile test re-asserted the two
classes the test above it exists for, which is the one place that claim belongs.
Added the case the suite was missing. The sizing path has two callers — a scroll
back up and the section resizing as claim cards load — and only the first was
covered, so folding the position correction back into the sizing function was a
regression the tests would have waved through. The stub now hands back the
observer's callback so a test can stand in for the cards growing.
Comments corrected where the code had moved under them: the card the reserve
sits outside of is gone on the screens it exists for, the cards are 84cqw rather
than 80vw, and the tab bar's scroll margin now says which navbar it is clearing.
…e swap end
Three things from review.
The space layout is a Server Component and read `SPACE_TABS_ANCHOR` out of a
'use client' module, so what it wrote into the id was a client reference, not the
string: the flight payload came back as {"id":"$56","className":"scroll-mt-14"}
and the element had no id until hydration resolved it — which is why a cold load
of /space/…/debates#space-tabs had nothing to scroll to. The anchor and the href
that points at it now live in a module with no directive on it. A guard test
walks the server render graph for the same mistake anywhere else; five
pre-existing cases are listed there with what each one does.
The height reserve never let go. Once the cards had grown and the reserve reached
zero the swap stayed armed, so a shrink long afterwards would size a reserve from
a position the reader had left and hand them blank space to scroll into. Zero is
the safe moment to drop it, because nothing is being held at zero.
And a gated card now starts playing on sight. 0.6 of a card exists to stop a
stack of them playing at once, which is what the gate is for wherever there is
one; kept that high under a gate it only subtracts, and the Activity row shares
one vertical ratio across every card, so a row half off the screen leaves the
chosen card inside its own dead band. This was described in the PR and never
written.
Backed out of ee27426. The reason I changed them was that an earlier revision
of the PR description said they had changed, which is a reason to fix the
description.
There is no observed fault behind it. The autoplay report was never reproduced,
this surface has been tested on the preview through several rounds at 0.6/0.4
with nobody reporting a card that would not start, and the dead band the change
was aimed at needs the Activity row to be straddling the bottom of the screen —
where the reader is looking at something else and a paused video is arguably
right. 0.25 would also start a card when a quarter of it is showing, which is
closer to the complaint in the ticket than away from it.
`useIsDebatePlaybackGated` goes with it: nothing else asks the question.
Verified on the deployed build: the server now renders id="space-tabs" rather
than the client reference. A cold load of the anchored link still lands at the
top, and not for that reason — the page streams, so half a second in the element
is not in the document when the browser goes looking for the fragment.
held depends on window.innerHeight, but after the switch this effect only reacts to section resizes and upward scrolling. A height-only viewport resize (for example, mobile browser chrome or the keyboard changing the visible viewport without changing the section) leaves the reserve stale: a taller viewport can clamp the reader upward again, while a shorter one leaves dead space. Subscribe to the window/visual-viewport resize event, rerun the sizing path, remove the listener in cleanup, and cover an innerHeight change in the tests.
The reserve is a sum of four things and only two of them were being watched.
`window.innerHeight` is read live but nothing recomputed when it changed, and on
a phone it changes on its own: the browser chrome collapses as the reader scrolls
and returns when they stop. A taller viewport needs more height held below it, so
the reader could be clamped upward by exactly the height of a hidden URL bar. A
`resize` listener closes it — `resize` rather than `visualViewport`, because
`innerHeight` is the figure the sum uses and the two do not always agree.
The document height was worse: measured once at the switch and adjusted by the
section's own delta, so anything else on the page moving afterwards — a cover
image landing above Activity, the rail settling — left it wrong. It is measured
when it is needed now, minus whatever the reserve is currently contributing,
which is the same arithmetic without the memory. Two fields leave the swap with
it.
The boundary guard walked named imports only, so a quarter of the server graph
was unguarded — `default-entity-page` and `post-entity-page` among them, reached
by default import. It follows default, namespace and re-export edges now, and
reports offences in all of those shapes. Type-only edges are excluded, which is
load-bearing: the sole route into `core/blocks/data/filters.ts` is an
`import type`, and counting it walks into the sync store and reports three
modules TypeScript erases before anything runs.
iOS Safari moves `innerHeight` when its URL bar collapses, which is the device
this was reported from. Chrome on Android pins the layout viewport to its largest
size, so nothing moves there — worth saying, or testing it on Android reads as
the fix not working.
Addressing the "Previously missed" finding from the latest review — Update held reserve on viewport height changes, which has no thread to reply on.
It is real, and fixed in f0f4749. Enumerating the same class found a second instance in the same function.
The held height is a sum of four inputs and only two were being watched:
input
watched by
before
section height
ResizeObserver
✅
holdY
scroll listener
✅
window.innerHeight
nothing
❌ your finding
the rest of the document
nothing — snapshotted at the switch
❌ same class
Viewport.resize listener added, removed in cleanup, test covers a 600 → 700 change asserting 150px → 250px. It fails without the listener. resize rather than visualViewport because window.innerHeight is the figure the sum uses and the two do not always agree. Worth naming the device: iOS Safari grows innerHeight when its URL bar collapses and fires resize both ways, which is where this was reported from. Chrome on Android pins the layout viewport to its largest size, so nothing moves there — that is in a comment, or testing on Android reads as the fix not working.
The document height, same shape and worse. It was measured once during prepareSwitch and thereafter adjusted only by the section's own delta, so anything else on the page moving afterwards left it wrong — a cover image landing above Activity, the rail settling. It is measured when it is needed now, minus whatever the reserve is currently contributing, which is the same arithmetic with none of the memory. sectionHeight and naturalDocumentHeight leave the swap with it, which is two fewer things that can go stale. Test asserts a page-above change is followed; it fails against the cached version.
221 profile tests, 1031 across partials, lint and tsc clean.
… swap
Settling on zero is right for changes that do not come back. Cards growing and
the reader scrolling up both leave the page needing less than it did and go on
needing less. A viewport is not like that: on iOS the URL bar returning takes
height away and hiding it gives the height back, so settling on the shrink
retired the swap during the half of the cycle where nothing was needed, and left
nothing to rebuild the reserve on the half where it was. Resizes size now; they
do not settle. The other three callers are unchanged, each of them monotone.
The boundary guard missed `export * as Name from`, which is the common form here
— 11 files against 4 for the bare `export *` it did match — so a server barrel
could re-export a whole client namespace unnoticed. Planted one to prove it, and
it goes unreported before this and is caught after. `default as Local` inside a
named block is judged by the local name too, since `default` says nothing.
`import()` and bare imports still are not followed. Neither reaches a source
module from the server graph today, which is now checked rather than assumed,
and the note says so.
The reason will be displayed to describe this comment to others. Learn more.
Confirmed and fixed in a89d7db. This is worse than "may already have clamped" — it clamps by construction, every time. While the reserve holds anything it sizes the document so that holdY is exactly the furthest the page can scroll; that is what holding a position means. So a viewport 100px taller is 100px less maximum, on every URL-bar transition.
switch -> held 150, document 1000, max scroll 400 = holdY
URL bar hides (700) -> max scroll 300, browser takes the reader to 300
resize handler -> held 250 … and before this commit, reader left at 300
The resize path now does what the swap itself does: size, then restore, once.
On ignoring the resize-induced scroll event — I did not add a flag for it, because the ordering already covers it and I could prove that rather than assume it. The correction runs synchronously inside the resize handler; the clamp's scroll event is dispatched afterwards, so onScroll reads the restored position and has nothing to mistake for upward movement. The test asserts exactly that by firing the scroll event after the resize and checking the reserve is still 250.
I did first write a swap.holdY = target line for the belt-and-braces version of that. Mutation testing showed it was dead — sizeReserve never touches holdY, so it assigns the same value unconditionally, and it would not help in the one ordering it was supposed to guard either, since target would already be the lowered value. Removed, and the comment now describes the ordering that actually does the work.
You were also right that the existing viewport test could not have caught this. The cause was under it: scrollTo was mocked as a no-op, so every assertion about where the reader ends up was made against a page that never moved. The harness moves the mocked position now, which is what lets the new test watch the clamp, the restore and the scroll event that follows.
Sizing the reserve back up after the viewport grows returns the scroll range but
not the reader, and the reader has already gone: while the reserve holds
anything it sizes the document so `holdY` is exactly the furthest the page can
scroll — that is what holding a position means — so 100px more viewport is 100px
less maximum and the browser clamps by the difference. By construction, on every
URL-bar transition, not occasionally.
The resize path now does what the swap itself does: size, then restore, once.
Synchronously inside the resize handler, which is the guard against the clamp's
own scroll event — it is dispatched afterwards, so `onScroll` reads the restored
position and has nothing to mistake for the reader moving up.
The test harness mocked `scrollTo` as a no-op, so every assertion about where the
reader ends up was made against a page that never moved. It moves the mocked
position now, which is what lets the new test watch the clamp, the restore and
the scroll event that follows it.
It guards a tree-wide concern and was a quarter of this diff, which is a lot of
review attention spent away from the mobile scroll bug this PR is about — two
rounds of it, in the end. The anchor fix that found it stays here; the guard and
the four pre-existing cases it lists go to #2478, where they can be judged on
their own.
Split done, as discussed: the server/client boundary guard is now #2478 against master. This PR drops from ~1050 changed lines to ~840, and everything left in it is the mobile Activity section.
space-tabs-anchor.ts stays here — that is this PR's own bug, and the deployed server HTML confirms the fix ({"id":"space-tabs"} where it was {"id":"$56"}).
The four pre-existing cases the guard found go with it, including the live one — app/bounties/loading.tsx is server-rendered and puts className={BOARD_GRID_CLASS} from a client module into the DOM. Not fixed in either PR: BOARD_CARD_HEIGHT_PX derives from AVAILABLE_CARD_HEIGHT_PX in a second client module, so it relocates layout constants across two features and wants someone who can look at the bounties board while doing it.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Relates to GEO-2974. Three things reported from a phone on the personal space homepage; two of them fixed here.
1. Switching Debates → Claims threw the reader up the page ✅ fixed and verified
Measured on an iPhone 13 viewport against the deployed preview:
The page becomes shorter than the reader's offset, so the browser clamps it — no scroll position survives that, because the page genuinely is shorter: a claim card is not as tall as a debate card. So the section holds the missing document height in a sibling reserve, and lets go once the page can hold the reader without it.
The first attempt shipped inert. The reserve carried
md:hidden, and these breakpoints are desktop-first (mdismax-width: 767px), so it wasdisplay: noneon exactly the phones it existed for. A MutationObserver caught it writing632pxthen0pxto an element withrect = 0. There is a test on the class now, because that is the only trace of the mistake that survives into JSDOM.Sizing the reserve and correcting the position have to be separate. Fused, and called on every scroll, the correction fired every time the reader scrolled down — which reads as a page refusing to move. The correction is one-shot and belongs to the swap that asked for it; the sizing is what runs on scroll and on resize. The swap is dropped once the reserve reaches zero, so a later shrink cannot conjure height back out of a position the reader has left.
2. The name ran underneath the "Winner?" button ✅ fixed
DebateFeedPlayerput the identity row atabsolute bottom-3 left-4with no right bound and the vote button atabsolute right-4 bottom-3on top of it. There is room at the width a feed card gives a tile and none at the 312px a gallery card does — hence "Ag…" and "Disagr…" under a pill in the report. They are one flex row now, which cannot overlap at any width.This affects every narrow rendering of the player, not just the gallery.
3. Autoplay while swiping ❌ not addressed here
Never reproduced. Driving the scroller programmatically, the centred card played on all four swipes with its neighbours' media already prefetched.
An earlier revision of this description claimed the gated activation ratios had been lowered to 0.25/0.1. They had not been, and they still are not —
DebateExploreFeedCardkeeps 0.6/0.4 everywhere. The argument for lowering them under a gate is thatDebatePlaybackGatealready names the one card allowed to play, so the card's own stricter ratio can only subtract. The argument against is that the dead band only bites when the Activity row is straddling the bottom of the screen — where the reader is looking at something else and a paused video is arguably correct — and that starting a card at a quarter visible is closer to the complaint than away from it. This surface has been tested on the preview through several rounds at 0.6/0.4 with no report of a card failing to start.So: no change, pending someone actually seeing it on a real device. Worth its own ticket if they do.
Also in here
The mobile section is no longer a card. A bordered panel holding bordered cards spent two gutters and two rules saying "these belong together", which the heading already says — 105px of a 390px screen went to chrome, and most of it was what forced Agree/Disagree to stack. On a phone the box goes, the gallery bleeds out through the app shell's gutter, and the card is
84cqw: pill row 287px against the 272pxclaim-pills-wideneeds, with a 44px sliver of the next card showing. Measured at 320/390/430/767/768 — no width scrolls sideways."See all" lands on the tab bar, not the page top. Clicking it put the reader at the top of the profile — a screenful of cover, avatar, name, roles and bio, none of it what they clicked for. The link carries a fragment now: the tab row lands under the navbar with the underlined tab above the list. A fragment rather than a scroll written by hand because of when each runs — the router applies a fragment after the destination renders, which is the first moment the page is tall enough to hold the position.
The anchor is not exported from a client module. Every export of a
'use client'module is a client reference on the server, so the layout was writing{"id":"$56"}into the flight payload and the id only existed after hydration — verified against the deployed server HTML, before and after. It does not fix a cold load of the anchored link, which lands at the top for a separate reason: the page streams, so the element is not in the document when the browser goes looking for the fragment.The tree-wide guard against that class of mistake is #2478, split out of here — it was a quarter of this diff and had nothing to do with mobile scrolling.
Testing
ResizeObserver.🤖 Generated with Claude Code