Skip to content

feat(island): a phone travels between stops by swiping - #996

Merged
ExtraToast merged 39 commits into
mainfrom
feat/swipe-between-stops
Sep 3, 2026
Merged

feat(island): a phone travels between stops by swiping#996
ExtraToast merged 39 commits into
mainfrom
feat/swipe-between-stops

Conversation

@ExtraToast

@ExtraToast ExtraToast commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

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.ts tests 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.reducedMotion does 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 asked ref 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.

frontend                                         +3804   -459   30
  production         █████████████████████░░░░░  +2111   -447   16
  unit tests         ████░                        +455     -2    6
  e2e tests          ████████████░               +1238    -10    8

──────────────────────────────────────────────────────────────────
production                                       +2111   -447
tests                                            +1693    -12  0.80 test lines per prod line
total (hand-written)                             +3804   -459  30 files

ExtraToast and others added 30 commits September 3, 2026 10:17
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
ExtraToast marked this pull request as ready for review September 3, 2026 16:34
@ExtraToast
ExtraToast merged commit 6b9574e into main Sep 3, 2026
25 checks passed
@github-actions github-actions Bot mentioned this pull request Sep 3, 2026
@ExtraToast
ExtraToast deleted the feat/swipe-between-stops branch September 4, 2026 08:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant