Skip to content

feat: pull the wall down to lay it again, and clean up the feeds it offers - #75

Merged
antstanley merged 4 commits into
mainfrom
polish
Jul 28, 2026
Merged

feat: pull the wall down to lay it again, and clean up the feeds it offers#75
antstanley merged 4 commits into
mainfrom
polish

Conversation

@antstanley

Copy link
Copy Markdown
Owner

Two commits on one polish branch: the pull gesture, and an AGENTS.md that describes the repo as it is.

Pull to lay again

The refresh control shipped as a button on a bar that never wraps at 375px, and the gesture a reader actually makes at the top of a wall is to drag it. This is that gesture, wired to the same feed.refresh(): one trigger more, not a second kind of refresh, with the same one-shot flag and the same rate limit behind it.

  • The wall itself moves, because the gesture says the reader has hold of the wall rather than of an indicator over it. main#wall is what translates and not the layout wrapper, which on a phone holds the fixed control bar.
  • The band. 1:1 to a 72px threshold, which is where letting go lays the wall again, then it stiffens toward 120px without reaching it. 72 rather than the ~110 a browser uses, because this gesture only starts at the top of a wall, where a downward drag is not a scroll.
  • A gesture is read once, in its first 8px: downward and more down than across is a pull, anything else is handed back to the browser and stays handed back. A pull that eases back up unwinds to zero and costs nothing.
  • overscroll-behavior-y: contain turns off the browser's own version, and that is half the reason to build this at all. Chrome on Android answers the same gesture with a full page reload, which drops the laid wall, its scroll, the seed behind its arrangement and anything playing, then warms a new wall from a cold service worker. That is the restart the refresh control exists to avoid, offered by the platform on the exact drag this claims.
  • Reduced motion: the pull is direct manipulation and is not gated; the glide home is motion-safe: and so a reader who asked for less motion gets the wall back at once.
  • No new live region. The indicator is aria-hidden: the wall's one polite region already says "laying bricks" while warming, and the button remains the named, platform-disabled way to ask.

Every decision lives in state/pull.svelte.ts, which names no DOM global and is pinned by 16 vitest cases in node. The component keeps the listeners, the indicator, and the one question the module cannot answer (at the top, not busy, nothing open over it, one finger). tests/pull-refresh.test.ts is the other half, on a touch device against the real build, because no other lane in this repo renders a component.

Two things that cost time and are named in the code:

  1. A touch listener on window is passive by default, so preventDefault needs addEventListener rather than a svelte event attribute, and it is called only on a claimed move that is still cancelable (iOS stops cancelling once a scroll is under way).
  2. An effect that reads the state it writes makes svelte abort the flush, which leaves the DOM holding the last thing written to it. The symptom was the wall staying pulled down after the finger had gone, with the wall's own state already back at zero.

Specs: a Pulling section in 08-wall-and-bricks.md, a pull.svelte.ts row and a second trigger in 07-web-client.md, and the change spec at .specs/changes/merged/2026-07-28-pull_the_wall_down.md. This closes the Pull to refresh open question 08 has carried since the refresh landed, and leaves two smaller ones (a trackpad pull, and pulling up at the end of a wall).

AGENTS.md

Described a repo one feature behind: two walls rather than two wall sources, no reader, no feed picker, no pull, and a command list missing wasm and deploy. Also corrected the guards (whole-tree dash denylist, .play( outside VideoPlayer), the two tsc projects, and added two gotchas that cost real time: logic belongs in a .svelte.ts module, and an intentional wire change means regenerating the contract fixture before types.ts typechecks again.

Verification

just check green (159 cargo nextest, two tsc projects, 231 vitest, four guards, lint, fmt). just test-e2e green: 78 browser tests, four of them new.

🤖 Generated with Claude Code

AGENTS.md described a repo one feature behind: two walls rather than two wall
sources, no reader, no feed picker, no pull, and a command list missing wasm and
deploy. It also carried claims the tree had moved past: guard-dashes is a
whole-tree denylist now, guard-autoplay greps for .play( outside VideoPlayer,
and check:ci runs two tsc projects because svelte-kit sync excludes the service
worker from the app one.

Two gotchas added, both of which cost somebody an afternoon to learn: logic
belongs in a .svelte.ts module because nothing here typechecks or tests a
component body, and an intentional wire change means regenerating the contract
fixture before types.ts will typecheck again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

🧹 Preview wall removed.

antstanley and others added 2 commits July 28, 2026 09:25
The refresh control shipped as a button on a bar that never wraps at 375px, and
the gesture a reader actually makes at the top of a wall is to drag it. This is
that gesture, wired to the same feed.refresh(): one trigger more, not a second
kind of refresh, with the same one-shot flag and the same rate limit behind it.

The wall itself moves, because the gesture says the reader has hold of the wall
rather than of an indicator over it. main#wall is what translates and not the
layout wrapper, which on a phone holds the fixed control bar. It follows the
finger 1:1 to a 72px threshold, which is where letting go lays the wall again,
and past that the band stiffens toward 120px without reaching it. A gesture is
read once, in its first 8px: downward and more down than across is a pull,
anything else is handed back to the browser and stays handed back for the rest
of the drag. A pull that eases back up unwinds to zero and costs nothing.

A release settles the wall onto a shelf 54px open and holds it there until the
warm ends, so the indicator has a gap to sit in and the gap closing is what says
the refresh finished. That corrects the first cut, which sent the wall straight
home: a recording on a real iPhone showed the pill sitting on top of the first
card for the whole warm, three seconds and more on a live wall. The shelf is the
pill plus 8px of air above and below it, and the air is spent deliberately
rather than left over, because the first shelf cleared the bricks by two pixels,
which is a gap in the arithmetic and none on the screen.

overscroll-behavior-y: contain turns off the browser's own version, and that is
half the reason to build this at all. Chrome on Android answers the same gesture
with a full page reload, which drops the laid wall, its scroll, the seed behind
its arrangement and anything playing, then warms a new wall from a cold service
worker. That is the restart the refresh control exists to avoid, offered by the
platform on the exact drag this claims.

Every decision lives in state/pull.svelte.ts, which names no DOM global and is
pinned by 20 vitest cases in node, including the one number the wall and the
indicator both read so they cannot disagree about where the top of the wall is.
The component is left with the listeners, the indicator, and the one question
the module cannot answer: at the top, not busy, nothing open over it, one
finger. tests/pull-refresh.test.ts is the other half, on a touch device against
the real build, because no other lane in this repo renders a component.

Three things that cost time and are worth naming. A touch listener on window is
passive by default in every browser that matters, so preventDefault needs
addEventListener rather than a svelte event attribute, and it is called only on
a claimed move that is still cancelable, since iOS stops cancelling once a
scroll is under way. An effect that reads the state it writes makes svelte abort
the flush, which leaves the DOM holding the last thing written to it: the
symptom was the wall staying pulled down after the finger had gone, with the
wall's own state already back at zero. And the demo wall's refresh settles in
milliseconds, so the shelf is gone before the next animation frame there: the
spec reads it by draining microtasks and nothing else, which is the only
mechanism that can tell a shelf that never happened from one already let down.

A pull also has to start ON THE WALL, and that rule arrived late: a window
listener hears every touch on the page, so a drag on the open switcher panel
pulled the wall behind its own scrim and let go into a refresh. The blocked prop
cannot catch that one, because the switcher's openness is local component state
nothing outside the component can read; where the touch landed can. It is asked
of the element the touch STARTED on, since a gesture belongs to whatever it
began on and a finger that wanders off the wall mid-drag is still pulling it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The door to the feed picker is now the first control in the panel, its only
filled one, and where the panel puts focus. The recents sit directly under it,
because they answer the same question one tap sooner, and the handle box follows
below a rule with a quieter label and an outline submit.

Somebody opening this panel already has a wall and wants a different one, and
they have one follow graph and thousands of feeds. Focusing the handle field
also opened a keyboard on every phone that opened the panel, over the list they
were most likely reaching for.

The landing page keeps its weighting the other way round, and that asymmetry is
the point rather than an inconsistency: a reader who has just arrived has
nothing, and their own handle is the one thing they certainly have. There the
picker stays "or pick a feed to lay", under the box.

The panel now scrolls past 70vh rather than clipping. It grows UPWARD from a
fixed bar on a phone, so the door being first means the door is what a panel too
tall for its screen would lose; five recents still fit at 375px without it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…sumed

Every one of the top fifty popular feeds was asked for a logged-out getFeed.
Eleven do not clear the bar of more than five posts, and they fail two ways that
the one bar covers: most do not answer at all (502, or 401 for skyfeed's), and a
few answer 200 with almost nothing. Teams gives one post, spacecowboy17's For You
gives one, Trans+Queer Shitposters gives three, every time they are asked. A card
that opens onto three bricks reads as a broken feed whether the feed is gated or
merely quiet, and the reader cannot tell which either.

Two of the eleven were feeds the picker still listed: jcsalterego's Media, which
502s on every attempt three runs apart, and jaz.sh's Trans+Queer Shitposters.

The audit also re-tested the existing denylist, which is the half that keeps a
list like this from only ever growing, and it found the more interesting result:
mason was hiding two feeds that work. skygaze.io's For You answers 19 posts and
moonandbaboon's Only Posts answers 20, four times out of four, and both were
being hidden by name-only rules aimed at other publishers' copies. Worse, the
spaced "only posts" rule never matched skyfeed's "OnlyPosts" at all, so the one
copy that IS auth-gated was the one getting through.

So the list now splits on a rule it can defend: a name is denied outright only
when the name names the VIEWER (mutuals, mentions, popular with friends, quiet
posters), which cannot mean anything logged out whoever publishes it. Everything
else describes a KIND of feed a logged-out algorithm can produce, so it is pinned
to the publisher that was measured. Sixteen entries, twelve of them pinned.

web/scripts/audit-feeds.mjs is the tool, wired as pnpm feeds:audit. It pages the
popular list to fifty (one call answers about forty, so the cursor is what makes
"the top fifty" true), probes each feed twice because a 502 can be a bad minute,
and re-tests every denied name by searching for it. No lane runs it: it talks to
the live network, so it is a tool and not a test. The static list is what keeps
the picker at one request rather than fifty-one, and the cost is that the quiet
entries go stale in a way the gated ones do not, which the comment says out loud.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@antstanley antstanley changed the title feat: pull the wall down to lay it again, and refresh the cheat sheet feat: pull the wall down to lay it again, and clean up the feeds it offers Jul 28, 2026
@antstanley
antstanley merged commit baaedf7 into main Jul 28, 2026
5 checks passed
@antstanley
antstanley deleted the polish branch July 28, 2026 10:37
@github-actions github-actions Bot mentioned this pull request Jul 28, 2026
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.

1 participant