feat(island): a phone travels between stops by swiping - #996
Merged
Conversation
A panel is a stop's worth of contents, and nothing about a panel says it has to be the stop showing. The slot is handed the stop instead of leaving each page to close over the one it holds, so a second panel for a neighbour becomes possible to render at all. The season swipe passes it through and answers with a season, because which season a stop is is seasons' knowledge and the shared band carries no season vocabulary.
Every derivation the band draws becomes a function of the stop it was given: the eyebrow, the name, the cheer, the words, the photograph and its label, the member slices and a member's blurb. Nothing inside the slot reads the board the page holds any more. The slices are kept by board number rather than worked out per render, because the slice band watches the set it is given and reads a new array as a different board. Every board is already in memory with its members, so the map costs one pass over what was read.
The index and a game's page both read one season at a time and hold exactly one answer, so each gains one gate: the answer belongs to the season on show and to no other stop. Everything the band draws — the loading block, the empty answer, the slices, a roster, the links and the season's own name — goes through it rather than reaching for the held answer directly, and a stop nobody has asked about reads as still loading. That gate is where #990 will keep answers by season, at which point each of these becomes a lookup rather than a comparison.
Two plain modules, in the pattern the island's other arithmetic already uses. `heldAnswers` keeps an answer under the key it was asked for, does not ask twice about a key already in flight, and can be asked about a key before anybody has navigated to it. `asksInOrder` keeps the reasoning the two esports composables each carry a counter for: a slow answer for a season nobody is looking at any more may not land on top of a newer one. Nothing uses either yet.
The index and a game's page each held exactly one answer and each carried their own sequence counter for it, against different adapters and different shapes. Both now read through a holder of their own making — the index keyed by season, a game's page one holder per game keyed by season — so more than one season's answer exists at once and a season can be asked about before anybody is sent there, which is what a page dragged towards its neighbour needs. Nothing a visitor sees changes, with one exception that is a gain: returning to a season already read no longer re-asks the api about it. A write still re-asks, and drops everything held rather than that one season's answer, because a season taken away or a game corrected changes what the other seasons answer too.
A committed gesture asked the page for a stop and held the track on screen until it arrived. Every further grab was refused while it waited, so on a real connection a visitor who swiped again during a slow read got nothing at all: the second gesture was swallowed by the first rather than superseding it. Under mocks that answer instantly the state never lasts long enough to touch, which is why the test here slows one season's read down. A finger claiming the axis now spends the mark and puts the track home, and the read the page is part way through is left to the page's own sequence guard rather than a second mechanism being invented to call it back. The track is also re-established on every move, so a stop arriving under the finger that superseded it no longer leaves the band standing still.
… is asked for Both esports pages declined to read a season they had already *chosen*, and the season is chosen before the read rather than after it. So a season whose read failed could never be asked for again: the page answered instantly with the season it was still showing, the gesture was told the season was not coming, and every further attempt was refused the same way for the life of the page. What the two now decline is the season they have arrived on, which is the season re-asking about was always about — that is what reload is for. A game's page also holds nothing under a season the api answered about something else, so the retry reaches the api instead of being handed the same disappointment out of the holder.
Both esports route watchers ignored a season leaving the url, so popping back to an address with no season in it left the url and the band disagreeing: the address named nothing and the band went on showing the season the finger had reached. Newly reachable on this branch, because a swipe pushes a history entry where a hit on a node replaces one — the gesture is what made that entry exist. Read as the newest season, which is what both pages already open on, and the same reading the board page gives a url naming no board: it falls through to the board in office. That page is unaffected precisely because it reads its stop out of the route as a computed rather than loading on a watch, so there is nothing there to ignore.
…types The drag's arithmetic imported BandDirection from the band component while the band imported the arithmetic back, and the board axis reached into the component for it too. Type-only edges keep vue-tsc quiet, so nothing complained; the dependency was circular all the same, and the axes are the modules that answer the question rather than ask it. So it sits beside Stop in stripAxis, which is where the island's other shared types are, and seasonAxis stops re-declaring the same three words as SeasonDirection.
…e page holder's goes Both resets were documented "so a test or a page that writes a roster can ask again" and neither had a caller anywhere. One of them turned out to be wanted: the index's line-ups are held in a module that outlives a mount, so the page's own unit suite was reading the previous case's answers, and "every call named that season" was passing on a mount that made no call at all. It is now called beside forgetGames, which is there for the same reason, and the case says the api was asked as well as what it was asked. A game's page has no unit suite to need the other one, and a page that writes a roster reloads rather than forgetting, so that one is deleted rather than left claiming a caller.
The stop a gesture asked for, the arrival the strip is told about and the watch that spends the mark were copied word for word into all three island pages, and the two that fetch had their own copy of answering a gesture and naming a season they could not reach. That is one composable, and it belongs beside the band and the strip rather than in a domain, because nothing in it knows what a stop is: it is the same reasoning that puts useMotionAllowed and bandTravel there, and frontend ADR-001 keeps composables/ for what crosses domains. Each page now says only what is its own — the board page that a board asked for is a board arrived at, the esports pages how they push and what counts as arriving — and the refusal is reported only for the stop still being waited on, which is the page's half of a superseded gesture.
Each page kept a reactive mirror of the holder's map so a template could watch answers arrive, word for word the same in both files, and every write to the holder needed a write to the mirror beside it — a rule nothing enforced, and one a read the holder had thrown away could quietly break, which is exactly what happened to a season the api answered about some other season. Folded in, because Vue's reactivity is not a browser: the module is still proved without one. What the mirrors were really carrying was a second lifetime rather than a second map, and that is now said out loud — a key called out of date is read again while what is held stays drawable, so a correction does not blink the band, and a key dropped was never an answer about that key at all. The two pages ask, read and hand out one map each.
EitherSide left the board domain's public API as a bare domain-less word beside the precise SeasonsEitherSide it mirrors, so it is BoardsEitherSide. The answer holder was generic in its key and both callers hand it a season's id, so the key is a number and the answer stays the parameter — the half that actually differs between the two. And the sequence guard's BeginsARead named the outer half of a doubly-nested function while leaving the inner half, which is the one a read actually holds, unnamed: that is StillWanted. Its module block also stood immediately above a second one, which orphaned it.
… shortened The ticket asked for the strip's scroll to be clamped to the island's reduced ceiling and the code gates it on the remove-entirely knob instead, which reads like the policy was reached for and misapplied. It is defensible — scrollTo takes no duration, so there is nothing to clamp — but it has to say so where it happens rather than leave the next reader to work it out.
ExtraToast
marked this pull request as ready for review
September 3, 2026 16:34
Merged
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.
Delivers epic #987 — a phone travels between stops by swiping.
On a touch screen the band itself becomes draggable: a finger carried sideways brings the neighbouring board year or esports season with it, with that stop's real contents already rendered beside it, and on release the page either completes the journey or springs back. Dragging towards a stop that does not exist meets resistance and springs home, so the end of the line is something the gesture tells you rather than something it hides. The strip above scrolls smoothly to the stop that arrived, so the line and the band read as one movement.
Hitting a node on the strip keeps working exactly as it does today. The gesture is a second way to do a thing that already had one.
Tickets
What landed
#989 — the band swipe renders the stop it is handed. Its slot now takes the stop to draw, and all three island pages render the stop they are handed rather than the one they are holding — the prerequisite for a second panel existing at all. Verified as a pure no-op: 40 page/viewport/theme/role combinations screenshotted against the base, 39 byte-identical and the fortieth differing by 7 pixels of rasterisation noise out of 1.3M. One latent regression was caught and not shipped: a url can name a season the association never recorded, so the gate compares against the season on show rather than the one a read was made for.
#990 — one place holds a season's answer. The two esports composables each kept a single answer behind their own sequence counter, against different adapters. That splits into a holder keyed by stop, which shares a read between concurrent asks and can be asked for a season ahead of anybody navigating there — which is what a gesture needs when a finger goes down — and a separate sequence guard, kept separate because it governs one page's
loading, which a shared holder cannot see. A visitor returning to a season already read no longer re-requests it.#992 — a phone swipes between board years. The gesture, on the page where every neighbour is already in memory, so it is proven with no fetching in the picture. The arithmetic — direction, whether a release commits on signed pace or distance, the rubber-band lean and its cap — is a plain island module with unit tests, because a velocity asserted through a synthesized browser drag is a flake waiting to happen.
The part to review hardest: the handover
The existing cross-slide is untouched and still answers taps. The gesture adds a second, wholly separate mechanism whose only offset is in pixels and never reaches the cross-slide's variants. On release past the threshold the track eases to a full width, the page is asked only then, and when the arrived stop comes back the pass is suppressed for that one change — both panels render the same contents, so dropping the track is invisible.
That separation is the whole design, and its proof is that the five
esports-season-swipe.motion.spec.tstests pass unmodified. If they ever need editing, the two mechanisms have become entangled.BandSwipe's doc comment previously argued the band must never move before the contents have arrived. It now argues both halves: why a movement the visitor did not initiate waits, and why one under their finger does not.Verified
yarn lint,yarn typecheck,yarn vitest run(1086 tests) all exit 0. Board and esports specs across chromium, mobile-chrome and motion: 467 passed, exit 0, no flakes.Two things worth knowing
No e2e test in this repo has ever run with reduced motion set.
use.reducedMotiondoes not reach the page on Playwright 1.60 — open issue #852 — so every "deterministic" project runs with full motion. Pre-existing, not introduced here. The gesture's reduced-motion test therefore emulates the preference itself and cites #852: the band still follows the finger (deliberate — content under direct manipulation is not the unbidden movement the preference is about), while the commit, the spring-back and the strip's scroll clamp to ≤200ms against the unclamped 850ms.A page that never answers a committed gesture holds the track and refuses further grabs. The board page always answers. A comment on the
askedref says so, for #993's fetching pages to deal with.Closes #987
Closes #989
Closes #990
Closes #992
Diff breakdown —
█added░removed, scaled to the largest row.