# expo has changed

read the exact versioned docs at <https://docs.expo.dev/versions/v56.0.0/> before writing any code

# ios only

this app does not support android or web. the `_layout.tsx` guard short-circuits to `UnsupportedPlatform` on non-ios. `app.json` restricts `platforms` to ios.

# place suggestion attribution

locationiq's free tier requires the exact string "Search by LocationIQ.com" as a **link** to <https://locationiq.com>, plus "© OpenStreetMap contributors" (osm/odbl), displayed wherever their data appears. use the `<Attribution />` wherever needed and do not remove it unless you know what you're doing.

# hermes lacks es2023 array methods

the app runs on hermes, which does not implement `toSorted` / `toReversed` / `toSpliced` / `with` — they throw `undefined is not a function` at runtime and tsc will not catch it (bun supports them, so tests pass). this applies to anything mobile imports, including `@crumbs/core`. use a non-mutating copy instead (e.g. `arr.filter(...).sort(...)` or `[...arr].sort(...)`).

# autosizing multiline TextInput needs both clears (rn 0.85)

a growing multiline `TextInput` (chat composer style) must be **controlled** — uncontrolled inputs never re-layout on fabric, so they don't grow. but a js-only clear (`setText('')`) never shrinks it back: fabric skips the native re-measure. clearing must go through both paths:

```ts
setText('')
inputRef.current?.clear() // native setTextAndSelection → re-measures
```

exception: calling `ref.clear()` synchronously inside an `onChange` handler is silently discarded — the command carries that event's stale `eventCount` and ios rejects it. defer it a frame (`requestAnimationFrame`) so only the js clear runs synchronously.

never put a static `maxHeight` on such an input with `scrollEnabled` left default — once it scrolls internally the height sticks. cap height the bluesky way: reanimated `maxHeight` + `scrollEnabled` via `animatedProps` that only engage near the keyboard. see `@/components/dm/dm-input-bar` (also handles the ios autocorrect-on-submit resurrect).

# imports use the `@/` alias

internal imports always use the `@/` alias, never relative (`./` / `../`) — even for same-directory siblings. e.g. `@/lib/rpc`, not `./rpc`. shared workspace code comes from `@crumbs/core/*` / `@crumbs/maps`.

# button over pressable

default to `<Button>` from `@/components/ui/button` for interactive elements (taps, controls, links-as-buttons, header actions). it gives consistent sizing, press state, focus, and a11y for free.

reserve bare `<Pressable>` from `react-native` for low-level primitives: modal/menu backdrops, custom inputs, video/image controls, list-row touch targets wrapped as `<Link asChild>` children, bespoke screen chrome.

# capitalize all ui copy

every user-facing string is sentence-case (first word capitalized, proper nouns capitalized). applies to button labels, link-as-button text, `TextInput` placeholders, headings, subtitles, body/helper text, error messages, empty-state copy, toasts.

examples: "Cancel", "Post", "Add to trail", "+ New", "Search trails", "e.g. Coffee", "Enter your email to continue", "Something went wrong".

never all-lowercase ("cancel", "enter your email to continue"). only exception is code-like content (urls, identifiers, debug strings not shown to users).

# e2e (maestro) selectors match whole a11y strings

`bun run e2e` drives the release build with maestro (`__e2e__/flows`). gotchas when writing/fixing flows:

- **maestro matches the entire accessibility string, not a substring.** `assertVisible: 'Syncing (1)'` matches a node whose whole text is "Syncing (1)"; `'@e2e-friend'` will NOT match a node labelled "Conversation with @e2e-friend". match the full label (or use an explicit `.*` regex).
- **touchable row primitives collapse their children into one node.** a `Link`/`Pressable` with an `accessibilityLabel` (our `ConversationRow`, `TrailPostRow`, `ActionableRow`) is a single a11y element — its visible inner text (peer handle, trail name, post body) is subsumed and NOT separately matchable. select the row by its label ("Conversation with @{handle}", "Post by @{handle}", "Trail invite: {trail} from @{inviter}"), not the content it wraps. a plain `View` with a label does NOT subsume, so a dm `MessageBubble` still exposes its text.
- an input with both an `accessibilityLabel` and a `placeholder` merges them into one name (the note field is "Note E.g. …"); with only a placeholder the name is just the placeholder (the dm composer is "Message"). match whatever the merge yields.
- the offline banner is a `FullWindowOverlay` (a full-screen container rn-screens adds to the ios window). it sets `unstable_accessibilityContainerViewIsModal={false}` so it no longer traps voiceover on the pill — the app beneath is navigable again. tradeoff: the pill then drops out of the a11y snapshot (not focusable, not maestro-matchable), so assert offline state via the inline `SyncBadge` ("Syncing…") or dm bubble ("Sending…"), NOT the banner. keep the prop.

the same subsumption is a screen-reader consideration: voiceover reads only a touchable row's label, so keep it meaningful — name the subject (e.g. the invited trail), since body text it merely wraps (a post's body) isn't announced.
