Unlink: POST /user/link/unlink (telegram|vk) via account.RemoveIdentity, refusing
the last identity; email is never unlinked. New fbs LinkUnlinkRequest + gateway
link.unlink op, returning the refreshed profile.
Change-email: purposeChange confirm-codes (RequestChangeCode/ConfirmChange) that
atomically replace the account's confirmed email (account.replaceEmailIdentity);
a new address owned by another account is refused without disclosure, never merged.
The one-tap deeplink handles purposeChange too. Reuses the LinkEmail* fbs tables;
gateway link.email.change.{request,confirm} ops + backendclient methods; branded
ru/en change-email copy.
Two regressions from the previous banner pass:
- Fade (#2): the manual-opacity fade could paint opacity 0 and 1 in one frame and
skip the transition — most visible for a single (default) campaign message,
whose only fade is the first show. Revert the fade to Svelte transition:fade
(which forces the from-state, so even the first/only message fades), keeping it
on its own {#if} layer independent of the scroll. A freshly-mounted view onto a
running cycle still renders the live message instantly (inFade duration 0 once),
so navigation does not replay the fade. Verified by opacity sampling: advances
fade, navigation stays at opacity 1.
- Profile update (#3): the banner block was attached only to GET /profile, so a
profile.update (e.g. a language switch) returned a profile without it and the
banner vanished until reload. A shared profileResponse() now attaches the banner
to GET, PUT and the link/merge profile responses. Regression test added
(TestBannerSurvivesProfileUpdate).
Still open: the scroll position is not preserved across navigation (the view
remounts); discussed separately.