Skip to content

A phone travels between stops by swiping #987

Description

@ExtraToast

Epic: a phone travels between stops by swiping

Problem Statement

On a phone, the only way to move between board years or esports seasons is to hit a node on
the strip.

The strip is a horizontal scroller on a narrow screen — it shows roughly four of its stops
and scrolls rather than shrinking, so a band keeps the width its labels need. That means
moving to a stop off the window is a two-part job: scroll the strip sideways to find the
node, then hit it. The nodes are small, they sit above and below the line alternately, and
the whole strip is around 104px tall on a screen where a thumb covers more than that.

Meanwhile the page below the strip already knows how to travel. Changing stop sends the
band the width of the window in the direction the reader is going, and it does it well. The
association's history is a line, the page moves along that line, and the one gesture every
phone user would reach for to move along a line does nothing at all.

The result is that a visitor on a phone reads the board or the season they landed on and
rarely sees another, because getting to another is fiddly enough not to bother.

Solution

On a touch screen the band itself is draggable. A finger dragged sideways carries the page
with it, with the neighbouring stop's contents already there beside it, and on release the
page either completes the journey or springs back where it came from.

Dragging towards a stop that does not exist — back from the oldest board, on from the
newest season — 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 follows: the line scrolls smoothly to centre the stop that has arrived, so
the band and the line read as one movement rather than two.

Hitting a node on the strip keeps working exactly as it does now. The gesture is a second
way to do a thing that already had one, not a replacement.

User Stories

  1. As a visitor on a phone reading a board's page, I want to drag sideways to reach the
    board before or after it, so that I can walk the association's history with my thumb.
  2. As a visitor on a phone reading the esports index, I want the same gesture to move
    between seasons, so that the two pages behave the same way.
  3. As a visitor on a phone reading one game's page, I want the same gesture there too, so
    that a strip that looks identical on three pages behaves identically on all three.
  4. As a visitor on a phone, I want the page to move under my finger as I drag, so that I can
    see how far I have to go before the gesture will be taken.
  5. As a visitor on a phone, I want to see the neighbouring stop's real contents as I drag it
    in, so that the gesture reveals a page rather than an empty box.
  6. As a visitor on a phone, I want a quick flick to be enough, so that I am not made to haul
    the page half way across the screen every time.
  7. As a visitor on a phone, I want a slow deliberate drag past a reasonable distance to be
    taken too, so that the gesture works whether I flick or push.
  8. As a visitor on a phone, I want a short hesitant drag to spring back, so that brushing the
    screen does not throw me onto another board.
  9. As a visitor on a phone, I want the page to spring back if I change my mind and return my
    finger, so that a started gesture is not a committed one.
  10. As a visitor on a phone at the oldest board, I want dragging backwards to resist and
    spring home, so that I learn the line has ended rather than wondering if I missed.
  11. As a visitor on a phone at the newest season, I want the same at the other end, so that
    both ends behave alike.
  12. As a visitor on a phone, I want the strip above to scroll to the stop I arrived at, so
    that the line always shows me where I am.
  13. As a visitor on a phone, I want the strip to scroll smoothly alongside the band rather
    than snapping ahead of it, so that arriving somewhere is one movement.
  14. As a visitor on a phone, I want to keep scrolling the page up and down with my finger, so
    that a sideways gesture has not cost me the ordinary one.
  15. As a visitor on a phone, I want a drag that starts on a board member's slice not to also
    open that slice, so that travelling and reading are different gestures.
  16. As a visitor on a phone, I want no slice to open or close while the page is travelling, so
    that one movement is happening at a time.
  17. As a visitor on a phone, I want to keep hitting nodes on the strip, so that the gesture
    has added a way rather than replaced one.
  18. As a visitor on a phone who swipes twice quickly, I want to end up at the stop I asked for
    last, so that a gesture abandoned mid-flight cannot land on top of a newer one.
  19. As a visitor on a phone on a slow connection, I want the page to hold rather than jump if
    the next stop's contents have not arrived when my finger lifts, so that nothing snaps.
  20. As a visitor on a phone, I want the url to name the stop I arrived at, so that I can share
    or reload what I am looking at, exactly as hitting a node already gives me.
  21. As a visitor on a phone, I want the back button to take me to the stop I came from, so that
    a swipe is a navigation like any other.
  22. As a visitor on a phone who has asked for reduced motion, I want the page still to follow
    my finger, so that I have not lost a whole way of navigating.
  23. As a visitor who has asked for reduced motion, I want nothing to ease across the screen on
    its own once I let go, so that the movement I did not initiate is the one that is shortened.
  24. As a visitor on a desktop, I want nothing to change, so that hovering a slice, hitting a
    node and using the strip's pan arrows all still work.
  25. As a visitor on a desktop with a narrow window, I want my mouse not to drag the band, so
    that dragging across a row of slices does not open every one it crosses.
  26. As a visitor using a keyboard, I want the strip's nodes to remain the way I change stop, so
    that a gesture I cannot make has not become the only route.
  27. As a visitor using a screen reader on a phone, I want only the stop I am on to be announced,
    so that the neighbour rendered beside it for the gesture's sake is not read out as well.
  28. As a visitor using a screen reader, I want the neighbour panel out of the tab order, so that
    tabbing does not land me in a page I am not looking at.
  29. As a visitor on a phone reading one game's page, I want a season the game was not fielded in
    still to be reachable by swiping, so that the gesture offers what the strip offers.
  30. As a visitor on a phone who swipes on the esports index, I want the game I was reading to
    stay open across the change, so that the gesture costs me my place no more than a tap does.
  31. As a board member who may edit, I want a drag not to trip the pencil on a slice, so that
    travelling never opens an editor.
  32. As a developer, I want the gesture in the shared band swipe rather than in three pages, so
    that there is one implementation and one behaviour.
  33. As a developer, I want the arithmetic that decides whether a drag commits to be a plain
    module I can test without a browser, so that thresholds are proven by assertion rather than
    by synthesizing touch velocity.
  34. As a developer, I want the existing pass — the one a tap on the strip plays — to keep working
    unchanged, so that a new gesture cannot regress the behaviour three pages already rely on.

Implementation Decisions

The interaction model

A live drag, not a fling. The band follows the finger, with the neighbouring stop's
contents rendered beside it, and on release either completes the pass or springs back.

This is a deliberate departure from the rule the shared band swipe currently states — that
the band moves only once the arrived stop's contents are in hand, because "a band half way
across the screen while a request is still in flight is a worse answer than a band that has
not set off yet." The rule was written about a movement the visitor did not initiate. Under a
finger, a band half way across the screen is where the visitor put it. The comment must be
amended rather than quietly contradicted, and the reasoning recorded there.

Coarse pointers only. The gesture binds where the pointer is coarse. On desktop the band
is a horizontal row of slices that open under the pointer, so a mouse dragged across it would
open every slice it crossed while the band also translated. The shared band already branches
on pointer and width this way, so the instrument is established.

Commit rule

Release commits if either the finger is still moving faster than roughly 0.5px/ms, or
it has travelled past a quarter of the viewport's width. Otherwise it springs back. A quick
flick and a slow haul are both intentions; distance alone punishes the flick, which is how
most people actually swipe.

At the ends of the line the band follows at roughly a third of the finger's travel, capped at
about 2.5rem, and always springs home.

All of this arithmetic — direction, whether a release commits, how far the band leans, the
rubber-band ratio and its cap — goes in a plain module beside the band swipe, in the
island's established pattern: stripAxis holds the strip's geometry, boardAxis and
seasonAxis hold their domains' ordering, and this holds the gesture's. Named for the shape,
not the domain, per the island's naming rule.

Where the neighbour's contents come from

The two pages differ sharply and the epic must not paper over it:

  • The board page reads every board, with its members, in one call. Every neighbour is
    already in memory. The gesture needs no fetch at all here.
  • The esports pages read one season at a time and hold exactly one answer. The neighbour
    does not exist until asked for.

The finger going down is the signal. On the gesture's start, before it has moved far
enough to commit, both neighbours are requested. The travel of the gesture itself hides most
of a round trip, and a visitor who never swipes fetches nothing extra — which an idle prefetch
of every neighbour would not honour.

Answers that arrive are kept, keyed by stop, so a visitor going back and forth does not
re-request, and returning to a season already seen is free. This turns the esports page's
single held answer into a small cache. In-flight requests are deduplicated so a second gesture
does not double-ask.

Where an answer has not arrived by the time the finger lifts, the band holds rather than
jumping — see the handover below.

The band swipe's surface changes

The shared band swipe has one unparameterised slot today: it renders the current stop's
contents, which each page binds to its own notion of "the one being shown". A live drag needs
the neighbour rendered beside it, so:

  • The slot becomes scoped, taking the stop to render. All three pages' templates become a
    function of a stop rather than of the current one. This is the epic's real cost and its main
    review risk.
  • The band swipe learns which stops lie either side of the current one. Which stop is a
    neighbour is knowledge about what the stops are, so the domain supplies it — exactly as the
    direction of travel is supplied today by the board and season axes rather than worked out by
    the island.
  • The band swipe asks the page to travel when a gesture commits. The page answers the way
    it answers a tap on a node: it changes the stop, sets the url, and the arrived stop comes
    back down. The gesture is an intent, not a mutation.

Two mechanisms, and the handover between them

The existing cross-slide stays exactly as it is and continues to answer taps on the strip. The
gesture adds a two-panel track, rendered and translated only for the duration of a drag.
The five existing motion specs must pass untouched; a tap on a node must remain byte-for-byte
the behaviour it is now.

The handover on commit is the delicate part, and it is resolved by letting the gesture finish
its own pass
:

  1. On release past the threshold, the track eases from wherever the finger left it to a full
    width, so the neighbour panel — already showing real contents — fills the window.
  2. Only then is the page asked to travel.
  3. When the arrived stop comes back down, the cross-slide is suppressed for that change: the
    movement has already happened, and the track's neighbour panel and the incoming child render
    the same contents, so dropping the track and resetting the offset is invisible.

No offset arithmetic is shared between the two mechanisms, which is the point. Threading a live
drag position into the cross-slide's variants is precisely the class of bug those variants were
made functions to avoid: the child that is leaving is not re-rendered, so it would read an offset
a pass old.

Where the new contents are slow, step 3 simply waits, and the track holds the neighbour on screen
until they land. Honest, and nothing snaps.

The direction of travel is still reported as it is today; that a change arrived from a gesture is
stated separately, for the strip's benefit below.

The strip follows

The strip already re-centres the selected node on any change except one it caused itself, but it
does so instantly. It learns how a change arrived — a hit on one of its own nodes, a gesture,
or somewhere the visitor cannot see (a shared link, the back button) — and scrolls smoothly for a
gesture, so the line travels alongside the band. Instant everywhere else, unchanged.

Smooth scrolling does not honour the reduced-motion preference by itself, so it is guarded by the
island's own motion policy.

Reduced motion

The band follows the finger as it always would. Content moving under direct manipulation is not
the unbidden large-area movement the preference is about, and removing it would leave a visitor
who asked for reduced motion with no gesture at all on the one device where the gesture matters
most.

What is clamped is everything the visitor did not do themselves: the commit's easing, the
spring-back, and the strip's smooth scroll, all held to the island's reduced ceiling. This follows
the policy's own principle — reduce rather than remove; movement that explains a change survives,
shortened.

Other behaviour the gesture must respect

  • Vertical scrolling stays native and only the horizontal axis is claimed, so the ordinary scroll
    and the scroll-driven opening of slices are unaffected.
  • A drag past the threshold must not also fire the click on whatever it started on — a slice, its
    pencil, a link. Suppressed at the band swipe's own root rather than by reaching into content it
    does not own.
  • While a gesture is in progress the band swipe already has a way to tell what it carries that a
    pass is on, and it is used: no slice opens or closes under a finger that is travelling.
  • The neighbour panel is out of the tab order and out of what is read, and carries no test names,
    by the same reasoning and the same mechanism the leaving child already uses.
  • A game's page must be able to swipe to a season the game was not fielded in, because its strip
    already offers one.
  • Which game is being read on the esports index survives a gesture, as it survives a tap.

Testing Decisions

What makes a good test here

Assert what the visitor gets, not how the gesture computes it. Two very different things are
being proven and they want different seams:

  • The decision — does this release commit? which way? how far does the band lean at the end
    of the line? — is arithmetic. It is exactly testable and miserable to assert through a browser,
    because synthesizing a touch with a controlled velocity is a flake waiting to happen.
  • The behaviour — a swipe changes the stop, a short one does not, the url follows, the strip
    centres — is what a visitor does, and belongs in e2e.

The seams

Two, both established.

  1. A unit test on the gesture's arithmetic module. Prior art is directly on point: the island's
    strip geometry, the board axis and the season axis all have unit tests of exactly this shape —
    plain functions, no component, no browser. Cases: a fast short flick commits; a slow short drag
    does not; a slow long drag commits; direction is taken from the sign of the travel; at the end
    of the line the lean is reduced and capped; a release at the end never commits.

  2. The existing e2e suites, extended. The behaviour is observable under the suite's default
    reduced-motion setting, because the gesture still tracks the finger there — which is the reason
    that choice matters. A swipe on the board page changes the board and the url; a short drag
    changes neither; the strip centres the arrived stop; a drag that starts on a slice does not open
    it; two quick swipes land on the last one asked for.

For the choreography — the band actually tracking the finger, the handover not jumping, the
rubber-band at the ends — a new motion spec is added. The motion project is the only one that
sees animation and it runs a desktop device, so the spec overrides its own context with a phone's
viewport and touch. These are per-test context options, so no fifth Playwright project is added
and the existing desktop motion specs are untouched. The runner is already recorded as starved at
eight workers on four vCPUs; a fifth project is a cost this epic does not need to pay.

Prior art for that spec: the existing season swipe motion spec, which reads the transform off each
child of the band to prove both stops are on the page and which way each is going. The same
technique reads the track's offset mid-drag.

The regression that matters most

The five existing motion specs prove the pass a tap plays. They must pass unmodified. If any of
them needs changing, the two mechanisms have been entangled and the handover has gone wrong.

Verified by eye

Before each pull request: the dev compose stack brought up from this worktree, and a 390×844 Chrome
driven over the board page, the esports index and a game's page. Captured mid-drag, at the moment of
commit, springing back, and resisting at both ends of the line. Screenshots go in the pull request
body. The commit handover in particular cannot be proven by assertion — a jump of a few frames is
exactly what a green suite misses.

Out of Scope

  • Any change to the layout of a slice, on any screen. That is the first epic.
  • The desktop experience in any respect: hover, the strip's pan arrows, hitting a node, keyboard.
  • A trackpad's horizontal scroll, and any gesture for a fine pointer.
  • Vertical gestures of any kind.
  • Wrapping from one end of the line to the other.
  • Changing what the api answers, the shape of a board or season record, or the generated client.
  • Idle prefetching of neighbours for visitors who have not touched the screen.
  • Making the strip itself draggable — it is already a scroller and already scrolls.
  • Adding screenshot comparison to the suite.

Further Notes

This epic contradicts a stated design rule, on purpose. The shared band swipe's own documentation
argues that the band should never move before the contents have arrived. Under direct manipulation
that argument does not hold, and the fetch-on-touch-start plus the holding handover are what make it
not hold. The comment should be rewritten to say both things: why a tap still waits, and why a finger
does not. A reviewer who reads only the diff will otherwise reasonably think the rule was missed.

The scoped slot is the real cost. Turning three page templates from "render the current stop" into
"render the stop you are handed" is the largest and least glamorous part of the work, and it is where
a mistake will be subtle — a page that closes over its own current stop instead of using the one
passed will look right until a drag, and then render the same contents in both panels. Worth calling
out for review specifically.

The board page is the easy half and should be built first. Everything it needs is already in
memory, so it proves the gesture, the handover, the rubber-band and the strip's follow with no fetch
in the picture at all. The esports pages then add exactly one thing: where the neighbour comes from.

Sequencing against the other epic. Both branch off main and neither depends on the other. If they
are built in one sitting, the first should be seen on a real phone before the shared band swipe is
rewritten, because a change to the slice layout is a change to what the gesture drags.

One unknown worth measuring rather than guessing. Rendering a neighbour panel means rendering a
whole second band — on the board page, a board photo, a rule and six portraits. On a mid-range phone
that is real work to do at the moment a finger touches the screen, which is the worst possible moment.
If the first frame of a drag stutters, the answer is to mount the neighbour on touch-start and let it
paint during the dead zone before the threshold, not to abandon the live drag.

Slices

The board page's gesture landed with the epic's first pass. These are what remains.

Order: #993 carries the gesture to the esports pages and is independent. #1087 owns the open-on-arrival behaviour and holds its own slices, #1088 to #1092, which land under it.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestepicA parent issue holding a set of slices

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions