Skip to content

Feat/submit stepper - #32

Merged
yjaphzs merged 5 commits into
mainfrom
feat/submit-stepper
Sep 21, 2026
Merged

yjaphzs merged 5 commits into
mainfrom
feat/submit-stepper

Conversation

@yjaphzs

@yjaphzs yjaphzs commented Sep 21, 2026

Copy link
Copy Markdown
Owner

What and why

Verification

  • npm run typecheck
  • npm run lint
  • npm test
  • npx expo-doctor
  • npm run test:rules — required if security rules changed
  • Ran on a device/emulator — required for UI, map or native changes

Native impact

  • This changes app.json, a config plugin, or a native dependency
    (needs npx expo prebuild --clean and a fresh dev build)

yjaphzs and others added 5 commits September 21, 2026 07:57
Groundwork for the submit stepper, separated so the feature diff is
readable. No behaviour change.

react-native-gesture-handler was the one rendering-layer package without a
wrapper and without a firewall entry — installed, mounted in AppProviders,
and importable from any screen with a clean lint. That was an oversight
rather than a decision. It now has both, added together, because a wrapper
without the rule is only a suggestion. Verified in both directions: a
features/ file importing it fails lint, and app-providers.tsx still imports
GestureHandlerRootView directly, which it must, since components/ is exempt
and the root view has to sit outside HeroUINativeProvider.

The wrapper's docblock carries the two things a caller gets wrong alone: a
horizontal pan inside a ScrollView steals the scroll without activeOffsetX,
and a gesture callback is a worklet, so touching React state needs runOnJS.

motion.tsx gains runOnJS and interpolate — the first to commit a swipe from
the UI thread, the second for the slide. Its docblock said "Two rules" and
listed two; AGENTS.md §3 has always had three. The third, honour
useReducedMotion, is now written where someone reaching for the wrapper will
read it, along with the closes-over-a-prop-produces-a-step trap.

view.tsx exposes accessibilityValue, which no wrapper did and a progressbar
role is meaningless without.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Seven controls on one page means a first-time contributor meets all of them
at once, and the one that matters most — the pin — is the one they scroll
past. Now: Where, Photo, Finding it, Details, grouped by the question being
answered rather than by control type.

⚠️ Steps are in-screen state and every one stays MOUNTED. That is forced, not
chosen: (app)/_layout.tsx and pin-draft-store.ts both record that /submit
cannot be unmounted while the full-screen placer is open, because picked
photos are local file URIs. A step-per-route stepper would drop every photo
the moment someone adjusts the pin. The track translates; nothing unmounts.

Step 1 is location, and it reuses PinField unchanged — a static mini-map
preview that opens the full-screen placer, which is what it already was.

use-form-steps.ts holds the only new logic, and one rule is the point of it:
the indicator can never be a skip button. Forward is refused while a step is
unfinished; backward is always free. Each step yields a `missing` string, so
FormMessage's `incomplete` slot keeps working and the button never looks dead
without saying why.

The indicator is assembled from three things already here rather than
invented: geometry from password-strength.tsx, accessibility from SlotPips
(one accessible container carrying "Step 2 of 4, Photo", never four dots each
announcing themselves), motion from floating-tab-bar.tsx including its
first-placement-jumps guard. HeroUI ships no Progress and no Stepper, and its
Tabs announces as a tablist and springs at stiffness 1200 against the house
220 — it would visibly not match the tab bar below it.

Swipe uses activeOffsetX([-12, 12]) so a vertical drag still reaches the
ScrollView, rubber-bands when the way is blocked, and commits through runOnJS.
Reduce Motion sets positions directly instead of animating.

⚠️ Next stays in the scroll flow rather than a fixed footer. There is no
KeyboardAvoidingView in this codebase and no keyboard-controller package;
avoidsKeyboard is one iOS ScrollView prop that pads scroll CONTENT, so a
pinned footer would sit under the keyboard exactly when step 3's text fields
are open.

The last step gains a recap, each row jumping back to the step that set it —
a stepper otherwise hides its own answers, and the only way to check is to
walk backwards and lose your place. It shows what is there and never a blank
row, for the reason score-bar.tsx gives about empty scales.

The quota line and the guest JoinBenefits block move to the last step, where
they are relevant. /edit-restroom is untouched and stays one page.

First screen test for /submit — there has never been one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
connection-store kept a single global fromCache flag that both campus
listeners wrote to, so whichever reported last won. restrooms reporting a
live snapshot set online, buildings reporting a cached one set offline, and
with nothing further to deliver nothing ever set it back — an online phone
stuck showing "You're offline" with its writes blocked, because
useWriteBlock reads this. Shipped in v1.2.0.

The comment that used to sit there claimed taking whichever spoke last was
"self-healing rather than a race". It was exactly a race.

Each listener now reports under its own name and the store is online if ANY
source is server-backed, going offline only once every one of them is
answering from cache.

⚠️ Worth being straight about how this was found: I went looking for it
because the emulator showed an offline banner, and it turned out NOT to be
the cause — logging both listeners showed they were both reporting cached,
because the app could not reach the Firestore emulator at all and was
serving last session's disk cache. The banner was telling the truth. The
race is real and worth closing on its own merits, but it is not what I saw.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The track was as tall as its tallest child, so step 1 — a heading and one
button — inherited the height of step 4's chips and summary and sat in most
of a screen of nothing, pushing Next far below the content.

Only the active step renders now, sliding in from the side it came from.

⚠️ That is safe, and the reason is worth writing down because the opposite
looks true: every value lives in useRestroomForm, held by the screen, and
PhotoPicker has no state of its own — so unmounting a step cannot drop a
picked photo. The docblocks claiming otherwise were mine and overstated it.
What genuinely must not unmount is /submit itself, which is why steps are
screen state rather than routes.

Verified on the emulator through all four steps: indicator, per-step gating,
the placer round trip, the photo picker, and the recap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two things were stale, one of them since before this branch.

The numbered walkthrough described five items in one long scroll. It is now
four steps — Where · Photo · Finding it · Details — with swiping and a recap
on the last one, so the README says that.

⚠️ And the claim "only the pin and a landmark are required" was in TWO
places and had been false since photos became required. I broke it and did
not fix it at the time.

The screenshot beside the caption still shows the old single-page form.
Reshooting it is outstanding — the caption was reworded to describe what is
required rather than what the form looks like, so it is not a lie in the
meantime.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@yjaphzs
yjaphzs merged commit bc32da4 into main Sep 21, 2026
1 check passed
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