Files
SoulStream/src/mobile/Mobile.svelte
T
KeelrLeek 1f9b30c51f android: the page ends at the clock — the bars go, the hero keeps the top edge
The bar was anonymous until you scrolled, then faded in carrying the title the
page already had in 27px type two centimetres below it. Kyle's rule for what
replaces it: the page is cut off where it meets the Android status bar and
dissolves there, and — the part that decides the whole shape of this —
**backgrounds may pass under the clock, words may not**.

A mask on the scroller cannot tell a photograph from a sentence. It takes both.
So the artist hero is painted twice: the page's own copy inside the masked
scroller, cut away in that band with everything else, and a second copy behind
the scroller (heroart.svelte.js), unmasked, showing through exactly where the
mask took the first one away. Same image, same box, same focal point, same
scale, offset by the same scroll — one picture to the eye, and only the name
dissolves. The second copy carries `overflow: hidden` for the same reason .hero
does: without it the 1.04 scale renders 268px in a 258px box and five pixels of
photograph appear under the hero's foot, below the black the scrim fades to.

That also retires an attempt that did not survive contact. The mask first
stepped aside for the hero on a scroll-driven `--fade-on`, which meant
rebuilding a mask-image on the scroller every frame; measured at 60 Hz median
and p90 either way, but it is work for nothing, and the flip that replaced it
put the artist's name out of existence in one step. With the photograph
protected by being OUTSIDE the mask, neither is needed: the mask is a constant
and nothing recomputes it.

No page has a bar now. A tab page's ⋯ — which is the app's own menu, the queue
and the sleep timer and Settings — moved beside that page's own large title in
`.phead`; Search and You grew the row they did not have, Community kept the one
it had. A pushed page floats a back chevron and a ⋯ over the page instead, on
flat discs with no backdrop blur, because the bar's own note records what three
blurred bands did to a 60 Hz scroll on the Pixel 6a and these would have been a
fourth.

Three numbers now come from one place. `--chrome-d` and `--chrome-gap` are the
disc and its margin; `.screen.has-chrome` reserves exactly that much at the top
of a pushed page, and `--fade-top` reaches exactly the chrome's foot. They were
independent for one build and it showed immediately: the page began underneath
its own buttons, so "Transfers" sat behind the back chevron before you had
scrolled at all, and the fade stopped 17px short of the discs so they stood on
crisp text and a record's floating ⋯ landed on a track row's own ⋯.

The walk followed. Nineteen steps reached a page's overflow through
`.gbar .iconbtn`, which now matches nothing: the four on a tab page go to
`.phead .iconbtn.muted`, the rest to `.fchrome.more`, and the album's back
chevron to `.fchrome.back`. The `title` field read the bar's own text, which is
a worse source than it looks — it only existed when chrome happened to be
drawn. The screen carries its meta as data attributes now and the three probes
read those, spelled exactly as `.gb-t`'s textContent was (the title, then the
kicker when it differs) so every expectation written against it still holds.

Checked on the device rather than argued: no `.gbar` and no `.gb-t` anywhere,
`.phead .iconbtn.muted` present on all four tab pages and opening "SoulStream ·
Queue · Sleep timer · Settings", both `.fchrome` controls present on a record,
and the title probe answering "Random Access MemoriesDaft Punk".

One thing this leaves, and it is a real cost rather than an oversight: a tab
page's ⋯ scrolls away with its header. The bar used to carry it the whole way
down, so on Home the sleep timer now wants a scroll back to the top.
2026-09-14 15:30:55 -05:00

479 lines
26 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script>
/* SoulStream on a phone — the shell.
research/mobile-mockup's phone layout, wired to the real Rust core
over the same IPC the desktop uses: the library from library.db,
covers from the thumbnail cache, playback on the audio thread, the
lane from the app's own waveform decode. The page ground is the
artwork in play, blurred; the accent follows it, per screen (a record
page wears the record's colour). Five destinations; Home and Library
are real, Search and Community say what they will be. */
import { onMount, tick, untrack } from "svelte";
import * as api from "../lib/api.js";
import Icon from "../components/Icon.svelte";
import Lane from "./Lane.svelte";
import Cover from "./Cover.svelte";
import Sheet from "./Sheet.svelte";
import HalfSheet from "./HalfSheet.svelte";
import Viewer from "./Viewer.svelte";
import { heroArt } from "./heroart.svelte.js";
import SyncRoom from "./SyncRoom.svelte";
import Home from "./screens/Home.svelte";
import Search from "./screens/Search.svelte";
import Library from "./screens/Library.svelte";
import Album from "./screens/Album.svelte";
import Artist from "./screens/Artist.svelte";
import Discography from "./screens/Discography.svelte";
import CatalogAlbum from "./screens/CatalogAlbum.svelte";
import Playlist from "./screens/Playlist.svelte";
import Import from "./screens/Import.svelte";
import Browse from "./screens/Browse.svelte";
import Peer from "./screens/Peer.svelte";
import Community from "./screens/Community.svelte";
import Room from "./screens/Room.svelte";
import Thread from "./screens/Thread.svelte";
import You from "./screens/You.svelte";
import Transfers from "./screens/Transfers.svelte";
import Diagnostics from "./screens/Diagnostics.svelte";
import { transfers, bindTransfers, active as activeTransfers } from "./transfers.svelte.js";
import { xfernote, syncShade, bindXferNote } from "./xfernote.svelte.js";
import { home, bindHome, loadHome } from "./home.svelte.js";
import { lyr, loadLyrics } from "./lyrics.svelte.js";
import Stub from "./screens/Stub.svelte";
import { lib, loadLibrary, refreshRecents, albumById, albumsBy, watchLibrary } from "./lib.svelte.js";
import { art, artTally, wantArt, accentFor, applyAccent } from "./art.svelte.js";
import { player, hooks, bindEvents, restoreSession, next, prev, toggle, seekSecs, playQueueAt } from "./player.svelte.js";
import { nav, TABS, top, isRoot, bootNav, go, push, back, patch, openSheet, closeSheet, openHalf, closeHalf } from "./nav.svelte.js";
import { loadPrefs } from "./prefs.svelte.js";
import { setup, loadSetup, maybeOpenFirstRun, openSetup, finishSetup, setupState } from "./setup.svelte.js";
import { catalog, artistKey } from "./catalog.svelte.js";
import { bindMedia, syncMedia } from "./media.svelte.js";
import { slsk, bindSlsk, connectStored, refreshRooms, unreadTotal } from "./slsk.svelte.js";
import { net, bindNet } from "./net.svelte.js";
import { resolver, streamSong, streamCatalogAlbum } from "./resolve.svelte.js";
import { notice, clearNote } from "./notice.svelte.js";
import { say, signedOut } from "./trouble.js";
import * as pls from "./playlists.svelte.js";
import { importer, bindImporter, startFolder, startFile } from "./importer.svelte.js";
import { bindShare } from "./share.svelte.js";
import { downloadsAllowed, cellular } from "./network.svelte.js";
import { contacts, loadContacts, display } from "./contacts.svelte.js";
import * as sources from "../lib/sources.js";
import { boot, sealBoot } from "../lib/boot.js";
import { greeting, drag } from "./util.js";
let screenEl = $state(null);
let miniArt = $state(null);
let scrolled = $state(0);
const entry = $derived(top());
const root = $derived(isRoot());
/* what the glass bar says, and which artwork the page wears */
const meta = $derived.by(() => {
const e = entry;
switch (e.n) {
case "home": return { title: greeting(), kicker: "Home" };
case "search": return { title: "Search", kicker: "Search" };
case "library": return { title: "Your library", kicker: "Library" };
case "community": return { title: "Community", kicker: slsk.connection === "online" ? "online as " + slsk.username : "Soulseek" };
case "room": return { title: "#" + e.c.name, kicker: "Room", flush: true };
case "thread": return { title: display(e.c.username), kicker: "Chat", flush: true };
case "you": return { title: "You", kicker: "You" };
case "transfers": return { title: "Transfers", kicker: "Downloads" };
case "diag": return { title: "Diagnostics", kicker: "Dev build" };
case "import": return { title: "Import", kicker: importer.draft?.source_name || "Playlist" };
case "browse": return { title: e.c.username, kicker: "Their files" };
case "peer": return { title: e.c.username, kicker: "On Soulseek" };
case "album": {
const a = albumById(e.c.id);
return { title: a?.title ?? "", kicker: a?.artist ?? "", amb: a?.art_path ?? null, flush: true };
}
/* the whole discography wears the artist's own ground, but no
hero of its own: it is a list, and the bar is over it from the
first pixel rather than after 210 of them */
case "disco": {
const as = albumsBy(e.c.name);
const cat = catalog.artists[artistKey(e.c.name)]?.page;
const p = cat?.banner || cat?.artist?.picture
|| as.find((a) => a.artist_art_path)?.artist_art_path || as[0]?.art_path || null;
return { title: e.c.name, kicker: "Discography", amb: p };
}
case "artist": {
const as = albumsBy(e.c.name);
const cat = catalog.artists[artistKey(e.c.name)]?.page;
const p = cat?.banner || cat?.artist?.picture
|| as.find((a) => a.artist_art_path)?.artist_art_path || as[0]?.art_path || null;
return { title: e.c.name, kicker: "Artist", amb: p, hero: true };
}
case "catalogAlbum": {
const p = catalog.albums[e.c.id]?.page;
return { title: p?.album?.title ?? e.c.title ?? "", kicker: p?.album?.artist ?? e.c.artist ?? "", amb: p?.album?.cover ?? null, flush: true };
}
case "playlist": {
const p = lib.playlists.find((x) => x.id === e.c.id);
return { title: p?.name ?? "Playlist", kicker: p?.kind === "setlist" ? "Setlist" : "Playlist", flush: true };
}
default: return { title: e.n, kicker: "" };
}
});
const groundPath = $derived(meta.amb ?? player.album?.art_path ?? null);
/* A tab page has no bar at all: nothing fades in over what you are
reading. A pushed page has one because it carries the back chevron,
and it is static — there and titled from the first frame. */
const showBar = $derived(!root);
/* A hero page paints its picture a second time behind the scroller
(heroart.svelte.js): the mask cuts the page where it meets the clock,
and that copy fills the cut back in — so the photograph passes under
the status bar and the name does not. The mask itself never moves. */
const playing = $derived(player.status === "playing" || player.status === "buffering");
const played = $derived(player.duration ? Math.min(1, player.position / player.duration) : 0);
/* ── what the machine says ───────────────────────────────────────── */
/* A tab's own name: the section, and when the dot is up, what the
number counts. "You, 3 transfers" — not a stray 3 read out before
the word it belongs to. */
function tabCount(id) {
if (id === "you") return activeTransfers().length;
if (id === "community") return unreadTotal();
return 0;
}
function tabName(id, label) {
const n = tabCount(id);
if (!n) return label;
return label + ", " + n + (id === "you" ? " transfer" + (n === 1 ? "" : "s") : " unread");
}
/* The one live region. It is mounted for the life of the app on
purpose: a region that appears with its words already inside it is
announced reliably by neither TalkBack nor VoiceOver, so an
announcement here is a TEXT CHANGE in a node that was always there.
The desktop does the same (FollowToast's always-mounted .layer,
PreviewOverlay's .sr-announce). Never drawn — shell.css clips it to
a pixel. */
const announce = $derived([say(player.error || lib.error), notice.text, notice.action?.label].filter(Boolean).join(". "));
/* ── the ground: two layers, the incoming one fades up over the old ── */
let ambA = $state(null);
let ambB = $state(null);
let lit = $state("b");
let ambCur = null;
$effect(() => {
const p = groundPath;
const url = p ? art[p] : null;
if (p && url === undefined) { wantArt(p); return; }
if (!url || url === ambCur) return;
ambCur = url;
untrack(() => {
if (lit === "a") { ambB = url; requestAnimationFrame(() => { lit = "b"; }); }
else { ambA = url; requestAnimationFrame(() => { lit = "a"; }); }
});
});
/* ── the shade's download notice follows the transfer store ───────
An effect, not a hook off the download event: the effect tracks the
store itself, progress ticks and all, so a row written straight into
it — the walk, with nothing fetched — drives the notice exactly as a
real one does. It reads the store and never the notice's own record,
which is why that record is a plain object. */
$effect(() => { syncShade(); });
/* ── the first run stamps when it goes away, whatever closed it ────
The foot's button calls finishSetup itself; the scrim, the back
gesture and the Soulseek row (which navigates, and navigating clears
the half sheet) do not — and a tour that ran is a tour that ran.
finishSetup writes only in boot mode and only once, so all four
doors land in the same place, and a PREVIEW opened from You writes
nothing at all.
untrack is not optional here: finishSetup calls closeHalf, which
reads nav.half, which is exactly what this effect tracks. Read your
own write in a Svelte 5 effect and the subtree dies painted. */
let setupWasUp = false;
$effect(() => {
const up = nav.half?.kind === "setup";
if (setupWasUp && !up) untrack(() => { void finishSetup(); });
setupWasUp = up;
});
/* ── a library read that failed gets a Try again ──────────────────
The library is the one thing every screen stands on, and a rejected
read left the app empty with a Rust string over it. The retry is wired
HERE, where the toast is drawn, rather than in lib.svelte.js: that
store reaching for the player store would close an import loop —
player.svelte.js already reads lib. */
$effect(() => {
const e = lib.error;
if (!e) return;
untrack(() => {
player.retry = { for: e, fn: () => { player.error = ""; lib.error = ""; player.retry = null; void loadLibrary(); } };
});
});
/* ── the card gets the cover once the thumbnail has landed ───────── */
$effect(() => {
const k = player.album?.art_path;
const u = k ? art[k] : "";
if (u) syncMedia();
});
/* ── the accent follows the same artwork ─────────────────────────── */
let accentReq = 0;
$effect(() => {
const p = groundPath;
/* a streamed cover lands late: read it here so the accent follows */
void (p ? art[p] : null);
const req = ++accentReq;
accentFor(p).then((c) => { if (req === accentReq) applyAccent(c); });
});
/* ── a new screen: scroll to where it was (a pop) or the top (a push) ── */
$effect(() => {
void nav.seq;
scrolled = 0;
tick().then(() => {
if (!screenEl) return;
const s = untrack(() => top().scroll) || 0;
screenEl.scrollTop = s;
scrolled = s;
});
});
/* ── the mini bar's gestures: swipe to skip, drag up to open ─────── */
let miniMoved = false;
const miniDrag = {
start: () => { miniMoved = false; },
move: (dx, dy, el) => {
if (Math.abs(dx) > 8 || Math.abs(dy) > 8) miniMoved = true;
if (Math.abs(dx) > Math.abs(dy)) el.style.transform = `translateX(${dx * 0.6}px)`;
else if (dy < 0) el.style.transform = `translateY(${Math.max(dy, -44) * 0.6}px)`;
},
end: (dx, dy, el) => {
el.style.transform = "";
if (Math.abs(dx) > 56 && Math.abs(dx) > Math.abs(dy)) { if (dx < 0) next(); else prev(); }
else if (dy < -34) openSheet(miniArt);
},
};
function onMiniTap() {
if (miniMoved) { miniMoved = false; return; }
openSheet(miniArt);
}
/* ── when the library landed, for the record ──────────────────────
Home gates its landing screen on lib.loaded, so this is the number a
launch is actually felt as, and it is the one the phone leg prints.
`boot` is a plain object and never a rune, so the write cannot feed
the effect back into itself; untrack says so out loud. */
$effect(() => {
if (!lib.loaded) return;
untrack(() => { if (boot.libMs == null) boot.libMs = Math.round(performance.now() - boot.t0); });
});
onMount(() => {
/* the boot census starts here. Every command the app issues between
this line and the first frame is counted, and the mock walk holds
a budget against the count — the point is to catch a round that
loads the world at boot, not to police milliseconds. */
boot.t0 = performance.now();
try { performance.mark("ss:boot"); } catch { /* a webview without the entry types */ }
loadPrefs();
bootNav();
let unlisten = null;
bindEvents()
.then((u) => { unlisten = u; }, (e) => { player.error = String(e); })
.then(() => restoreSession())
.catch(() => {});
hooks.recentsDirty = refreshRecents;
bindMedia();
/* the first run. The config is read at boot because the notes on
the screens that are empty BECAUSE of it read this store — and
the tour itself is offered only once the library and the saved
account have both answered. Asked any earlier they are `[]` and
`""` on every phone in existence, Kyle's included, and the sheet
that must never open on his would open on his. */
const configRead = loadSetup();
const libRead = loadLibrary();
/* the network: subscribe first, then the saved account signs in on its own */
const netRead = bindSlsk().then(() => connectStored()).catch(() => {});
Promise.all([configRead, libRead, netRead]).then(() => maybeOpenFirstRun()).catch(() => {});
bindNet();
watchLibrary();
bindTransfers();
bindXferNote();
bindHome();
bindImporter();
bindShare();
loadContacts();
/* the desktop's library art prewarm: every cover cut to the cards'
edge, one decode at a time behind the tiles' gate, a moment after
boot so the first screen and a restored track come first */
setTimeout(() => { api.libraryArtPrewarm([360]).catch(() => {}); }, 8000);
/* everything above went out in this one turn. A call that waits on
the one before it can only have issued its first here, so this is
the number that says the boot chain is still going out in one
round — 10 before this round, 23 after it — and the walk holds a
floor against it. */
boot.sync = boot.calls;
/* two frames: the first is the shell, the second is the first one
the app could have drawn anything into. A phone producing no
frames at all (launched straight into the background) seals on
the timer instead, well before the walk's first probe — the
census must never depend on something that might not happen. */
const sealTimer = setTimeout(sealBoot, 1200);
requestAnimationFrame(() => requestAnimationFrame(() => { clearTimeout(sealTimer); sealBoot(); }));
/* the probe handle — the desktop has __soulstream for the same reason:
tests/mobile/smoke.mjs drives the real app through it */
window.__ss = { nav, top, go, push, back, openSheet, closeSheet, openHalf, closeHalf, player, lib, art, catalog, home, loadHome, lyr, loadLyrics, slsk, boot, net, resolver, streamSong, streamCatalogAlbum, sources, api, transfers, xfn: xfernote, pls, notice, importer, artTally, imp: { startFolder, startFile }, setup, openSetup, setupState, wifi: { downloadsAllowed, cellular }, next, prev, toggle, seekSecs, playQueueAt, patch, contacts, loadContacts, say, refreshRooms };
return () => { if (unlisten) unlisten(); };
});
</script>
<div class="app" class:has-mini={!!player.track}>
<!-- the ground: the artwork in play, blurred, under everything -->
<div class="app-bg" aria-hidden="true">
<div class="amb" class:on={lit === "a"} style:background-image={ambA ? `url("${ambA}")` : null}></div>
<div class="amb" class:on={lit === "b"} style:background-image={ambB ? `url("${ambB}")` : null}></div>
<div class="amb-tint"></div>
<div class="grain"></div>
</div>
<div class="pane">
<div class="viewport">
{#if meta.hero && heroArt.src}
<div class="hero-behind" aria-hidden="true" style:--hero-shift="{-scrolled}px">
<i style:background-image={`url("${heroArt.src}")`}></i>
</div>
<div class="hero-wash" aria-hidden="true"></div>
{/if}
{#key nav.seq}
<div
class="screen tr-{nav.tr} stagger"
class:has-chrome={!root && !meta.hero && !meta.flush}
data-title={meta.title}
data-kicker={meta.kicker && meta.kicker !== meta.title ? meta.kicker : ""}
class:has-hero={meta.hero}
bind:this={screenEl}
onscroll={(e) => { scrolled = e.currentTarget.scrollTop; }}
>
{#if entry.n === "home"}
<Home {screenEl} />
{:else if entry.n === "search"}
<Search {entry} {screenEl} />
{:else if entry.n === "library"}
<Library {entry} {screenEl} />
{:else if entry.n === "album"}
<Album id={entry.c.id} {screenEl} />
{:else if entry.n === "artist"}
<Artist name={entry.c.name} seed={entry.c.seed ?? null} {screenEl} />
{:else if entry.n === "disco"}
<Discography {entry} {screenEl} />
{:else if entry.n === "catalogAlbum"}
<CatalogAlbum id={entry.c.id} artist={entry.c.artist} title={entry.c.title} {screenEl} />
{:else if entry.n === "playlist"}
<Playlist id={entry.c.id} />
{:else if entry.n === "import"}
<Import />
{:else if entry.n === "browse"}
<Browse username={entry.c.username} />
{:else if entry.n === "peer"}
<Peer username={entry.c.username} {screenEl} />
{:else if entry.n === "community"}
<Community {entry} {screenEl} />
{:else if entry.n === "room"}
<Room name={entry.c.name} {screenEl} />
{:else if entry.n === "thread"}
<Thread username={entry.c.username} {screenEl} />
{:else if entry.n === "you"}
<You />
{:else if entry.n === "transfers"}
<Transfers />
{:else if entry.n === "diag"}
<Diagnostics />
{:else}
<Stub kind={entry.n} />
{/if}
</div>
{/key}
</div>
<!-- no bar on any screen. A pushed page floats its two controls over
the page instead; the title is the page's own, in 27px type. -->
{#if showBar}
<button class="fchrome back" onclick={back} aria-label="Back"><Icon name="chevron-left" size={24} /></button>
<button class="fchrome more" onclick={() => openHalf("more")} aria-label="More"><Icon name="more" size={21} /></button>
{/if}
</div>
{#if player.track}
<div class="mini" class:hidden={nav.sheet} use:drag={miniDrag} onclick={onMiniTap} oncontextmenu={(e) => { e.preventDefault(); openHalf("track", { album: player.album, track: player.track }); }} role="button" tabindex="0" onkeydown={() => {}} aria-hidden={nav.sheet ? "true" : undefined} aria-label={"Now playing: " + (player.track.title || "") + (player.track.artist || player.album?.artist ? " · " + (player.track.artist || player.album?.artist) : "")}>
<span class="art" bind:this={miniArt}><Cover path={player.album?.art_path} eager size={20} /></span>
<span class="b">
<span class="t">{player.track.title}</span>
{#if resolver.active && !player.track.stream}
<span class="m live">Finding the best source…</span>
{:else if player.track.stream && (player.phase === "hold" || player.phase === "loading")}
<span class="m live">Finding {player.track.stream.username}’s copy…</span>
{:else if player.track.stream}
<span class="m live">{player.track.artist || player.album?.artist || ""} · streaming from {player.track.stream.username}</span>
{:else}
<span class="m">{player.track.artist || player.album?.artist || ""}</span>
{/if}
</span>
<button class="iconbtn sm" onclick={(e) => { e.stopPropagation(); prev(); }} aria-label="Previous"><Icon name="skip-back" size={19} /></button>
<button class="iconbtn sm" onclick={(e) => { e.stopPropagation(); toggle(); }} aria-label={playing ? "Pause" : "Play"}><Icon name={playing ? "pause" : "play"} size={21} /></button>
<button class="iconbtn sm" onclick={(e) => { e.stopPropagation(); next(); }} aria-label="Next"><Icon name="skip-forward" size={19} /></button>
<Lane class="mini-lane" height={16} peaks={player.peaks} heldPeaks={player.heldPeaks} phase={player.phase} coverage={player.coverage} {played} />
</div>
{/if}
<!-- svelte-ignore a11y_no_noninteractive_element_to_interactive_role -- the
five destinations ARE a tab set, and the desktop's own segmented
control says so too; wrapping them in an inner div to keep <nav>'s
implicit role would change the bar's flex layout -->
<nav class="nav" class:gone={nav.sheet} role="tablist" aria-label="Sections" aria-hidden={nav.sheet ? "true" : undefined}>
{#each TABS as [id, icon, label] (id)}
<button class:on={nav.tab === id} onclick={() => go(id)} role="tab" aria-selected={nav.tab === id} aria-label={tabName(id, label)}>
<span class="pill"></span>
<span class="gl">
<Icon name={icon} size={22} />
{#if id === "you" && activeTransfers().length}<span class="nb" aria-hidden="true">{activeTransfers().length}</span>{/if}
{#if id === "community" && unreadTotal()}<span class="nb" aria-hidden="true">{unreadTotal()}</span>{/if}
</span>
<span class="lb">{label}</span>
</button>
{/each}
</nav>
<div class="scrim" class:on={!!nav.half} onclick={closeHalf} aria-hidden="true"></div>
<HalfSheet />
<Sheet />
<Viewer />
<SyncRoom />
<!-- the announcement: one node, always here, so a change of its words
is what gets read out. The toasts below stay where they were and
say nothing on their own — two announcers say everything twice -->
<div class="sr-live" data-live aria-live="polite" aria-atomic="true">{announce}</div>
{#if player.error || lib.error}
{@const msg = player.error || lib.error}
<!-- the notice's shape, in the red: the sentence trouble.js makes of
whatever Rust said, and one action beside it where there is one to
offer — Sign in when nobody is, else the Try again that was armed
for THIS message and no other -->
<div class="toast" onclick={() => { player.error = ""; lib.error = ""; player.retry = null; }} role="button" tabindex="0" onkeydown={() => {}} aria-label="Dismiss this message">
<span class="nt">{say(msg)}</span>
{#if signedOut(msg)}
<button class="na" onclick={(e) => { e.stopPropagation(); player.error = ""; lib.error = ""; player.retry = null; go("community"); }}>Sign in</button>
{:else if player.retry?.for === msg}
<button class="na" onclick={(e) => { e.stopPropagation(); const r = player.retry; player.error = ""; lib.error = ""; player.retry = null; r.fn(); }}>Try again</button>
{/if}
</div>
{/if}
{#if notice.text}
<div class="toast notice">
<span class="nt" aria-hidden="true">{notice.text}</span>
{#if notice.action}<button class="na" onclick={() => { const f = notice.action.fn; clearNote(); f?.(); }}>{notice.action.label}</button>{/if}
</div>
{/if}
</div>