Skip to content

feat(app): size the receptions card to what it holds, in steps - #570

Open
khagele wants to merge 3 commits into
efiten:masterfrom
khagele:feat/560-ticker-sizes
Open

feat(app): size the receptions card to what it holds, in steps#570
khagele wants to merge 3 commits into
efiten:masterfrom
khagele:feat/560-ticker-sizes

Conversation

@khagele

@khagele khagele commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Closes #560

What was happening

The receptions card was ten lanes or nothing: 298px, a third of a 915px phone, whether it held
one reception or two hundred.

A full card did not even show ten. The playhead sat six lanes down with three lanes of padding
under it, and rxFade reached zero exactly on the card's own top lane. Ten receptions rendered
as six readable rows, one active row, and three blank lanes.

The curve

The height follows the content now, in the steps @khagele specified:

receptions lanes shows
0 header only nothing
1, 2 1 the newest
3, 4, 5 3 all of them
6 to 9 5 the newest five
10 and up 10 the newest ten

Below six receptions the card shows everything it has; from six it caps, so the oldest roll off
the top rather than the card taking the screen.

Newest at the bottom, and the playhead where it was

The blank lanes were never the playhead's fault. They came from the padding under the last row,
so that is what goes. With no padding there the browser clamps the follow-scroll three lanes
short of the playhead, and that clamp parks the newest reception on the bottom lane while the
playhead stays six lanes down with three receptions rolling through below it. Both things at
once; an earlier commit on this branch moved the playhead to the bottom instead and lost the
roll-through, which is corrected here.

rxPlayhead holds the 6-of-9 proportion at every size, so a five-lane card puts it on lane 3
and a three-lane card on lane 1, never above the middle. rxBelow is the count under it.

Two consequences worth naming:

  • maxScroll now reads the browser's own maximum rather than (rows - 1) * lineH. With no
    padding under the last row the list cannot scroll far enough to put that row on the playhead,
    so comparing against the JS lane count made atBottom() permanently false, which latches
    follow off and stops the card following live traffic.
  • The fade had to follow. Its fixed divisors reached zero on the outermost lane of each
    side, which was harmless while those lanes were blank padding and is not now that they hold
    receptions: the newest row on a full card sits exactly three below the playhead. Each side now
    fades across the lanes there actually are on it, down to a floor. Measured on an
    eleven-reception card: ten rows visible, none below 0.22.

The chevron

Three stops rather than two: full, three lanes, one lane, then back to full. One control that
swaps state, pointing down while there is further to collapse and up on the last stop.

The cycle is computed per reception count, and that is not a detail. A stop at or above the
card's natural height changes nothing on screen and reads as a dead press. @khagele hit exactly
that by using it: at three receptions the card is three lanes anyway, so the three-lane stop
swallowed a tap and full was indistinguishable from it. collapseLevels(count) now offers only
the stops that make the card smaller, so at three receptions the cycle is full to one lane and
back. Pinned by a test asserting no two stops share a height, for every count from 0 to 60.

Geometry as one source

The numbers have to agree, so they are derived from one another rather than kept in step by
hand. rxLanes, rxPlayhead, rxBelow and rxPadBottom are pure and unit-tested, and the
stylesheet multiplies the lane counts they publish by --ch-rx-line-h.

playhead + below is always lanes - 1, so the two halves always account for the card. Where
the newest reception lands is asserted rather than trusted, worked out the way the browser does
it: content is padTop + rows, the box is lanes, scrollTop clamps to the difference, and
the newest row comes out on lanes - 1 for every size and every row count. That is the whole
reason rxPadBottom is zero.

The curve is pinned as the table above rather than as the thresholds the code uses, so a wrong
implementation disagrees with it instead of restating it.

State

Every on-screen state is one stored value under core-hunter-ticker: open, collapsed,
minimal, closed. A boolean plus a size would let a reload land on "closed and expanded",
which is not a state, and the round trip is asserted for every stop the chevron can reach.
open and closed are what pre-#560 builds wrote, so existing installs keep working and
anything unrecognised reads as a first visit.

Closing travels towards #ticker-btn. The card used to vanish on the spot, leaving nothing
connecting it to the control that now held it.

The parity guard

web/parity.test.js's #322 guard pinned .rx-list's height to the literal 10-lane calc on both
surfaces. Its subject is the pitch, not the number, so it now asserts that neither surface bakes
a pixel value, then pins the map's literals and the app's two sources separately.

It also pins the divergence this creates rather than letting it drift unnoticed. Both surfaces
keep the playhead six lanes down; what differs is the padding under the last row, which the map
still reserves, so a ten-lane map card still ends in three blank lanes. #424 is where the map
follows.

Verified in the browser at 412x915

Driven with synthetic receptions against the table above: 0 gives the header only, 1 and 2 give
1 lane, 3 to 5 give 3, 6 and 9 give 5, 10 and 11 give 10, with zero pixels under the newest
reception at every step and the newest always the last visible row. The playhead measured at the
same time: lane 6 with three receptions below it on a full card, lane 3 of 5, lane 1 of 3, and
the faintest visible row on the floor rather than invisible. The chevron cycle measured per
count: 50 gives 10, 3, 1, 10; 6 gives 5, 3, 1, 5; 3 gives 3, 1, 3; 2 hides the control.

A first attempt animated the height. A running height transition fires scroll events, and
the scroll handler reads atBottom() against a box that is still moving, so follow latched
off and the card stopped following the newest line. The transition is gone.

Not verified here

The closing travel itself. This preview pane runs no CSS transitions at all, confirmed with a
control probe on a throwaway element whose opacity read 1 at 100ms of a 200ms transition, and
document.hidden is true. The end state is verified: the card hides, the button appears, the
state stores, and no class or custom property is left behind. The timeout that guarantees that
is what carried it here.

Tests

  • app: 935 passed, build clean
  • web: 509 passed
  • eslint: clean

The geometry assertions were mutation-checked, and the rewritten parity guard was checked by
baking a pixel height back into the app's rule.

Two things found while building, both out of scope

  • soundengine.test.js's "drifts them" case fails intermittently on master as well: 1 of 3
    full runs at 90c3375, same message. It asserts that a random drift produces a new pitch
    within four intervals, so CI can redden on it at random.
  • #no-capture-banner in #toast-stack (top 50, z-index 560) covers the ticker card's header
    (top 52, z-index 480) whenever it is up. Both rules predate this branch; the diff does not
    touch either.

🤖 Generated with Claude Code

khagele and others added 3 commits August 31, 2026 10:14
The card was ten lanes or nothing: 298px, a third of a 915px phone, whether
it held one reception or two hundred. A full card did not even show ten. The
playhead sat six lanes down with three lanes of padding under it, and the
fade reached zero on the card's own top lane, so ten receptions rendered as
six readable rows, one active row, and three blank lanes.

The height now follows the content, in the steps Kasper specified:

    0    -> header only      3..5 -> 3 lanes
    1..2 -> 1 lane           6..9 -> 5 lanes
                             10+  -> 10 lanes

Below six receptions the card shows everything it has; from six it caps, so
the oldest roll off the top rather than the card taking the screen.

Two things follow from "newest at the bottom, at every size":

- The playhead is the bottom lane now, not six lanes down, and the padding
  under it is zero. That is what makes ten lanes mean ten receptions.
- The fade had to follow. Its old divisor of 6 was the old geometry written
  twice, and it reached zero exactly on the top lane, so the height promised
  rows the opacity then hid. It now spans the lanes there actually are above
  the playhead and stops at a floor. Measured on an eleven-reception card:
  ten rows visible, opacity 0.22 at the top through to 1 on the newest.

The chevron beside the close button has three stops rather than two: full,
three lanes, one lane, then back to full. It points down while there is
further to collapse and up on the last stop, so it stays one control that
swaps state. It appears only once the card is taller than its smallest stop,
which is from three receptions; below that it would do nothing.

The geometry is four numbers that have to agree, so they are derived from one
another rather than kept in step by hand. rxLanes, rxPlayhead and rxPadBottom
are pure and unit-tested, and the stylesheet multiplies the lane counts they
publish by --ch-rx-line-h. playhead + padBottom is always lanes - 1, which is
exactly the condition under which the scroll code can still reach the newest
reception; that is asserted, not assumed. The curve is pinned as the table
above rather than as the thresholds the code uses, so a wrong implementation
disagrees with it instead of restating it.

Every on-screen state is one stored value under core-hunter-ticker: open,
collapsed, minimal, closed. A boolean plus a size would let a reload land on
"closed and expanded", which is not a state, and the round trip is asserted
for every stop the chevron can reach. 'open' and 'closed' are what pre-efiten#560
builds wrote, so existing installs keep working and anything unrecognised
reads as a first visit.

Closing travels towards the top-bar button. The card used to vanish on the
spot, leaving nothing connecting it to the control that now held it.

web/parity.test.js's efiten#322 guard pinned .rx-list's height to the literal
10-lane calc on both surfaces. Its subject is the pitch, not the number, so
it now asserts that neither surface bakes a pixel value, then pins the map's
literals and the app's sources separately. It also pins the divergence this
creates: the map still puts the playhead six lanes down with three below,
the app puts the newest on the bottom lane. efiten#424 is where the map follows.

Measured in the browser at 412x915 against the specified table: 0 gives the
header only, 1 and 2 give 1 lane, 3 to 5 give 3, 6 and 9 give 5, 10 and 11
give 10, with zero pixels under the newest reception at every step and the
newest always the last visible row. The chevron cycle was measured too:
10 lanes, 3, 1, back to 10, storing open, collapsed, minimal, open.

A first attempt animated the height, which fires scroll events mid-flight;
the scroll handler reads atBottom() against a box that is still moving, so
`follow` latched off and the card stopped following the newest line. The
transition is gone.

Not verified here: the closing travel itself. This preview pane runs no CSS
transitions at all, confirmed with a control probe on a throwaway element
whose opacity read 1 at 100ms of a 200ms transition, and document.hidden is
true. The end state is verified: the card hides, the button appears, the
state stores, and no class or custom property is left behind.

Tests: app 928 passed + build clean, web 509 passed, eslint clean. The
geometry assertions were mutation-checked, and the rewritten parity guard was
checked by baking a pixel height back into the app's rule.

Note for a separate issue: soundengine.test.js's "drifts them" case fails
intermittently on upstream master too (1 of 3 full runs at 90c3375, same
message), so CI can redden on it at random. Untouched here.

Closes efiten#560

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…it instead

The first cut of efiten#560 moved the playhead to the bottom lane to satisfy
"newest at the bottom". That worked, and it threw away what efiten#130's playhead is
for: lines rolling through a fixed lane with newer receptions below it. Once
the playhead was the bottom lane there was no below, so scrubbing back showed
only older receptions and the newer ones fell off the edge.

The two were never in conflict. The blank lanes a full card ended in came from
the padding under the last row, not from where the playhead sat. With that
padding at zero the browser clamps the follow-scroll three lanes short of the
playhead, and that clamp parks the newest reception on the bottom lane while
the playhead stays six lanes down. Measured at 412x915: playhead on lane 6,
three receptions below it, newest on the bottom lane, zero pixels under it,
ten rows on a ten-lane card.

- rxPlayhead keeps the 6-of-9 proportion at every size, so a five-lane card
  puts it on lane 3 and a three-lane card on lane 1. Never above the middle,
  which is what keeps the older context the larger half.
- rxBelow is new: how many lanes sit under the playhead.
- rxPadBottom is zero and stays a function, because the reachability
  assertion is written against it.
- maxScroll now reads the browser's own maximum rather than
  (rows - 1) * lineH. With no padding under the last row the list cannot
  scroll far enough to put that row on the playhead, so comparing against the
  JS lane count made atBottom() permanently false, which latches `follow` off
  and stops the card following live traffic.
- rxFade takes both spans. Its fixed divisors reached zero on the outermost
  lane of each side, which was harmless while those lanes were blank padding
  and is not now that they hold receptions: the newest row on a full card sits
  exactly three below the playhead. Each side now fades to the floor instead.

The parity guard's divergence note was wrong on this point and is corrected:
both surfaces keep the playhead six lanes down, and what differs is the
padding under the last row, which the map still reserves.

Tests: app 935 passed + build clean, web 509 passed, eslint clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…k to

The list's padding-bottom fell back to three lanes, which is what the card
reserved before this branch. rxPadBottom returns zero at every size now, so a
card rendered before its first rebuild would show the blank lanes the change
is about, and then lose them a tick later.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

app: the receptions ticker is ten lines or nothing, and closing it gives no sign where it went

1 participant