## The first run

Every walk and every deploy so far started from Kyle's own phone — signed
in, library folder set, a config full of his settings. What a phone that
has just installed the app actually has is different in three ways, and
only one of them is what you would guess.

**The library root is never missing.** `config.rs`'s `download_dir` has an
`#[cfg(target_os = "android")]` branch that falls back to
`app_data_dir()/Library` and returns before the desktop's `audio_dir()`
fallback is reached, so a fresh install has a real, writable, empty library
root at `/data/user/0/com.soulstream.app/files/Library` — invisible to
every other app on the phone, including the one that put the music there.
`pipeline::init` scans it at startup and `library_albums` honestly answers
`[]`. This is why "no folder yet" cannot be a null check anywhere: the test
is the PATH, `isPrivateFolder` in `setup.svelte.js`, which You.svelte had
first and now imports rather than keeping a second copy of.

**`setup_done` was already there and the phone never touched it.** It
round-trips through `get_settings`/`set_settings` like any other field and
`config::setup_done` unwraps a missing one to `false`, so **every phone in
existence reads as unstamped**, Kyle's included. Gating the tour on that
flag alone — which is what the desktop does — would have opened a setup
sheet on a phone already full of his own music, and then written the flag
into his config on dismissal.

**Nothing is signed in, and everything says so honestly and helpfully
nowhere.** `connectStored()` returns at once with no account saved, the
connection stays `offline`, and every stream and every Save ends as a red
toast with a sentence in it and no way out.

So: `src/mobile/setup.svelte.js` is the one store, and there are two
surfaces.

**The tour** is a half sheet (`kind === "setup"` in `HalfSheet.svelte`) —
one scroll, two cards, a foot. Where your music lives: the current folder,
a row that opens the storage plugin's folder picker, and the all-files
permission row beside it once a real folder has been picked. Soulseek: what
it unlocks, and one row that goes to the sign-in card on Community. **The
tour never handles a password** — there is no field in it; Kyle signs in
himself, on the card that already existed. Keeping the private folder and
staying signed out are both legitimate answers, so the sheet is answerable
by doing nothing, and the scrim and the back gesture close it like any
other half sheet.

**The boot gate is a three-way AND** — `!setup_done && !slsk.savedUser &&
!lib.albums.length` — and it is evaluated only after `loadLibrary()` and
`connectStored()` have both settled (`Promise.all` in `Mobile.svelte`'s
`onMount`). Read either of those early and they are `[]` and `""` on every
phone there is. Kyle's phone fails the second and third clause, which is
the only safe way to ship an auto-opening layer into a pipeline that
installs onto his device.

**The note** (`SetupNote.svelte`) is the fallback for the screens that are
empty because of the same two things, with the row that fixes each: Home's
empty state (whose copy still said music "lives in the app's own Library
folder for now", written before the picker landed in You), Library →
Albums, Library → **Artists** — which had no empty state at all, just a
`0 artists · 0 followed` line over nothing — and Search's empty page, where
"Sign in on Community to play what the library does not hold" was a flat
sentence and is now the row that does it. A row is drawn only for a thing
actually missing, and the component renders nothing when both are answered,
so a phone that is set up never sees it.

There are two doors onto the same folder — the tour's row and You's own
picker — and they tell each other: You re-reads when `setup.folder` moves,
and its picker calls `loadSetup()` on the way past, or the note on Home
would go on offering a folder that had already been picked on You. The
all-files row does the same trick You does with the grant: the system
screen takes the app away and gives it back, so `requestAccess` re-reads
the answer on the way back rather than leaving a row up that has been
answered.

**The preview door** is one row in You → This phone: "Set up this phone
again", `openSetup("preview")`. A preview reads the config and writes
nothing — `setup.writes`, the number of times the store has stamped
`setup_done`, stays where it found it. The phone walk drives exactly that
door on Kyle's phone and asserts the counter at zero on the way in and on
the way out; it types nothing, picks nothing and clears nothing.

The harness hosts the other device. `localStorage.mockFresh`, or `#fresh`
in the URL (which is what the walk uses — a reload cannot lose a fragment),
switches `src/lib/mockLibrary.js` to a fresh install: every library
command answers `[]`, `get_credentials` answers with no account,
`all_files_access` is not granted, and `mockSettings` is mutated in place
so the library root is the sandbox path and `setup_done` is false —
mutated rather than replaced so `set_settings` still works and picking a
folder in the walk genuinely moves the state the notes read. The
fresh-install block is the LAST thing in `MOCK_STEPS` because its reload
wipes every module's state, and it turns the flag off and reloads again at
the end so that anything appended after it is not silently running against
an empty device.

Two things a reload does NOT clear on its own, both handled in that block
because it is the first one on the mock leg. The webview's storage comes
back with the page, and the player writes the session out on `pagehide`,
which a reload fires — so removing the key up front puts it straight back;
the block adds a `pagehide` listener of its own, last and therefore run
last, and a phone with nothing in it comes up with no now-playing bar. And
the viewport emulation lives on the DevTools session rather than on the
page, so the re-attach after a reload drops it: the walk keeps what it set
and hands it back, or every still from there on would be the Chrome
window's own size at 1×.

Two commands the phone can reach had no mock at all until this round —
`player_control` and `open_external` — found by mapping every `api.js`
wrapper used under `src/mobile` against the mock (126 wrappers, 124
mocked). Unmocked they fall through to the real IPC, which on a `file://`
page rejects: a console warning and a toast, in whatever step happens to
touch them first. The transport now answers the way the real core does,
with an event; `resume` cannot restart the position ticks, so the state is
right and the position stands still.

