feat(web): bring the map's receptions ticker onto the app's card model - #571
Open
khagele wants to merge 8 commits into
Open
feat(web): bring the map's receptions ticker onto the app's card model#571khagele wants to merge 8 commits into
khagele wants to merge 8 commits into
Conversation
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>
The two tickers had made opposite decisions on almost every point. The app's sits on a plate, closes, remembers its size and follows its content; the map's lay straight on the tiles with two states, full or the header alone. F3 of the 26 August review was "the ticker has no backing plate, so it is unreadable over the map". It was agreed and then fixed only in the app. ## What the map gets - **A plate.** `--ch-surface-thin` plus a blur, the app's. `pointer-events` stays `none` on the box, so the plate paints and drags and wheels still reach Leaflet, which is the bargain efiten#287/efiten#322 set up. - **The height follows the content**, in efiten#560's steps: nothing but the header, one lane, three, five, ten. The geometry functions are mirrored from the app and parity.test.js now compares them by running both copies, over every count from 0 to 60, rather than by reading their constants. - **The newest reception on the bottom lane**, with the playhead still six lanes down and receptions rolling through below it. That came from dropping the padding under the last row, which is what reserved the blank lanes. - **Four stops on one control**, and the chevron says which way it goes. ## What it deliberately keeps Dragging, and its top-right anchor. Folding to the header stays the way the ticker is put away here, and is simply the last stop rather than a second control: the map has no top-bar button to travel to, and adding one now would be built twice, since efiten#561 is a design round over that whole bar. That is the only difference the parity guard still pins, and it pins the shared stops as equal. ## Three things measured rather than read - **The plate resolved to `rgba(0,0,0,0)`.** `--ch-surface-thin` was declared in the app's tokens by efiten#539 and nowhere on the map, so the card was a border and a blur with nothing behind them. The token is now declared for both themes with the app's values, and the guard pins that they match. - **A stop of zero lanes is falsy.** `cap ? Math.min(lanes, cap) : lanes` silently ignored the header-alone stop, so its click did nothing at all. It tests `=== undefined` now. Found by clicking through it; the unit tests did not cover a zero stop, and now do, mutation-checked. - **The stored level cannot be a bare number.** Links from before this carry 0 or 1 for expanded or folded, and folded meant the header alone, which is now the last of four. Writing the new level 1 as "1" would make an old link and a new one disagree, so the stops above full serialise as letters. Round trip asserted for every level, and `1` still reads as the header. The placement block runs before `createReceptionTicker` does, so the level restored from the URL could not reach the component from inside it; it is applied once the ticker exists. ## Tests The e2e walked one click to hidden. It now walks the cycle instead, asserting every click shrinks the card until the list is hidden and one more returns to full, without assuming how many stops there are: that depends on the traffic, since a stop that would not make the card smaller is skipped. Its fixture went from one reception to twelve, which is past the last step. - web: 517 unit, 199 e2e with --fail-on-flaky-tests - app: 935 unit, build clean - eslint: clean Verified in the browser at 1280x760 with fourteen receptions, dark and light: ten lanes, ten rows shown, zero pixels under the newest, faintest row on the floor, and the plate resolving in both themes. Closes efiten#424 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The map's ticker got efiten#560's behaviour but not its chrome: a header with no height and no rule under it, rows flush to the plate's edge, and a text glyph where the app draws a button. Nothing in the tests could see that, so it took someone looking at the two side by side. That is the pattern worth fixing, not the ticker. `docs/design-system.md` is the register: one system across the three surfaces, every control named as the standard SaaS pattern it is an instance of, and each deliberate difference written down as a rule about the SURFACE rather than an exception for one component. AGENTS.md §7 points at it. The surface rule this one produced: a floating panel over the map is draggable, closable and collapsible, and passes pointer events through to Leaflet. That now covers whatever is added to web/ next, not just the ticker. The map's header, list, row and column rules are the app's now. parity.test.js compares those six rules declaration by declaration and pins the fold button's box against the app's, so the chrome cannot drift again without a test saying so. Mutation-checked by putting the old header back. If a proposal cannot be named as a mainstream pattern, it is bespoke. That naming is in the register on purpose: it is the test that catches an invented control before it ships. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…g it back Putting the ticker away was the one thing still done differently on each surface: the app has a cross on the card and a button in the top bar, the map folded to its own header because it had no button to come back from. Kasper's call: give the map the same two, rather than keep the difference. So the map's bar carries a #ticker-btn beside the menu, in the same place as the app's, and the header carries the same cross beside the same chevron. The collapse stops are now the app's exactly, three lanes and one: away is the cross, not a further stop, and the zero-lane stop is gone. The stored field carries all four states, the way the app's one key does. A size plus a separate visible flag would let a link land on "away and expanded", which is not a state. Links from before this carry 0 or 1, and 1 was how the ticker was put away, so it reads as away rather than as a shrink stop; the states are written as letters so nothing new can be mistaken for it. A phone now starts at the smallest stop rather than away. The reason that default is per-surface is only that the card should not cover the map there, and a ticker nobody can see is a different thing from a one-line one. ## The register `docs/design-system.md` takes the three other decisions from the same round: - **The app uses sheets, the map uses floating panels**, as a named surface rule with the standard pattern behind each. What is inside them does not differ. - **44px is the rule except where the target lies over the map**, which turns the node markers from an exception into the carve-out every map product makes: the target swallows the pan gesture that starts on it. - **Closing works the same on both surfaces**, a cross plus a button in the bar. A panel that can be dismissed with no visible way back is not dismissible, it is lost. Notices and empty states, forms, and onboarding are named as the next sections rather than written from memory: each needs the code read first, and where the surfaces already disagree that is a finding, not something to smooth over. ## Tests The e2e now walks the shrink cycle without assuming its length, asserts it never reaches zero, and covers the cross and the bar button including across a reload. Two guards I had broken are repaired: the empty-state rule targets the list by id again, because parity.test.js's older matcher takes the first rule mentioning the class, and the stops guard asserts the two surfaces are equal rather than that the map has one more. - web: 518 unit, 200 e2e with --fail-on-flaky-tests - app: 935 unit, build clean - eslint: clean Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… menu The receptions button and the menu sit next to each other in both bars, and both drew three horizontal lines: one set staggered, one set equal. At 20px, side by side, that reads as two hamburgers. Kasper caught it on the map's bar, where efiten#424 had just put the two together; the app has had the same pair since efiten#539. It is a pulse now, which is what a live feed is drawn as. Changed on both surfaces in one commit, because it is one mistake made twice rather than a map problem. docs/design-system.md gains the rule, phrased as the check rather than the conclusion: an icon that reads fine on its own can still be wrong beside the one that follows it, so the thing to look at is the bar, not the icon. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…y div The load-time placement measured `#rx-log`, which is an empty `<div>` until `createReceptionTicker` writes the card into it: two pixels of border. Both answers that depend on the card's size were therefore wrong on load. - The room test. "Start small where the card would cover the map" was a width test, `max-width: 640px`. A phone held sideways is 844px wide, so it read as a desktop and opened at ten lanes: measured at 844x390, 298px of card over 309px of map, 110% of it, hanging past the bottom edge. It is now the space left under the bar, and the width test stays for the other half of the same question, since below 640px the card is full-bleed. - The clamp. A position remembered from a taller window stood, because the maximum was computed from those two pixels. Seen in the browser at 844x390 restoring y=386: four pixels of card on screen, 294 below the fold. The card at ten lanes is now computed from the geometry tokens instead of measured, which is what makes both decisions independent of when the markup lands. That needed the header's height as a token beside the row pitch, so `--ch-rx-head-h` is declared on both surfaces and pinned equal, the way `--ch-rx-line-h` and `--ch-surface-thin` already are. Verified in the browser at 844x390: was `rx=0,386,0` with a 298px card at 120% of the visible map; now `rx=152,94,b`, the smallest stop, fully on screen. web: 521 unit, e2e green with --fail-on-flaky-tests app: 935 unit, build clean Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
Closes #424
What was happening
The two tickers had made opposite decisions on nearly every point. The app's sits on a plate,
closes, remembers its size and follows its content; the map's lay straight on the tiles with two
states, full or the header alone.
F3 of the 26 August review was "the ticker has no backing plate, so it is unreadable over the
map". It was agreed, and then fixed only in the app.
rgba(0,0,0,0)--ch-surface-thin+ blurThe map gets the app's card
Same plate, same header, same rows, same geometry, same stops, same cross with a button in the
bar to bring it back.
web/parity.test.jscompares the geometry by running both modules overevery count from 0 to 60, and compares the
.rx-hd,.rx-list,.rx-ln,.rx-tm,.rx-rsand.rx-gtrules declaration by declaration.What stays different is the placement: the map's card is dragged and passes pointer events
through to Leaflet, the app's is fixed and catches its own. Both are written down as rules about
the surface in
docs/design-system.md, not as exceptions for this component.A design register, because behaviour parity was not enough
The first pass here brought the behaviour across and left the chrome behind: a header with no
height and no rule under it, rows flush to the plate's edge, a text glyph where the app draws a
button. Nothing in the test suite could see any of that, so it took someone putting the two
screens side by side.
docs/design-system.mdis the fix for the class, not the instance. One system across the threesurfaces, every control named as the standard SaaS pattern it is an instance of, and each
deliberate difference written down as a rule about the surface. AGENTS.md §7 points at it.
Rules it now carries that reach beyond this PR:
events through. That covers whatever is added to
web/next.If a proposal cannot be named as a mainstream pattern, it is bespoke. That naming is in the
register on purpose: it is the test that catches an invented control before it ships.
Four things measured or clicked rather than read
rgba(0,0,0,0).--ch-surface-thinwas declared in the app's tokensby app,web,landing: a design pass over the three surfaces, from a full review on 26 August #539 and nowhere on the map, so the card was a border and a blur with nothing behind them.
Declared for both themes now, and pinned equal.
cap ? Math.min(lanes, cap) : lanessilently ignored theheader-alone stop, so its click did nothing at all. It tests
=== undefinednow. That stop hassince gone, replaced by the cross, but the guard against the shape stayed.
how the ticker was put away, so it has to read as away rather than as a shrink stop. The states
are written as letters; the round trip is asserted for all four.
not reach the component from inside it. It is applied once the ticker is there.
Deliberate, and worth knowing
The map's bar gains a
#ticker-btn. #561 is a design round over that whole bar, so this is onecontrol that round now has to place. Taken knowingly: the alternative was keeping the one
remaining difference in how the ticker is put away.
Tests
The e2e walked one click to hidden. It now walks the shrink cycle without assuming its length,
because that depends on how much the ticker holds: a stop that would not make the card smaller
is skipped rather than swallowing a click. It also covers the cross and the bar button across a
reload. Its fixture went from one reception to twelve, which is past the last step.
--fail-on-flaky-testsVerified in the browser at 1280x760 with fourteen receptions, dark and light: ten lanes, ten rows
shown, nothing under the newest, faintest row on the floor, and the plate resolving in both
themes.
🤖 Generated with Claude Code