From b9b9036a34f1b49ce4e10cfb256c52c62df56e91 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Fri, 21 Aug 2026 15:30:37 -0400 Subject: [PATCH] feat(player-profile): signed out, Challenge asks you to sign in The site's own dialog rather than a disabled button, with the challenge stashed across the round trip; a press the API cannot answer says so. --- plan/player-profile.md | 14 ++++--- web/scripts/players.test.mjs | 38 ++++++++++++++++--- web/src/players.ts | 71 ++++++++++++++++++++++++------------ web/src/router.ts | 8 +++- web/src/styles.css | 12 +++--- 5 files changed, 101 insertions(+), 42 deletions(-) diff --git a/plan/player-profile.md b/plan/player-profile.md index 172d7d9..608ddd4 100644 --- a/plan/player-profile.md +++ b/plan/player-profile.md @@ -102,11 +102,15 @@ now, mounted beside the account's and waiting on nothing. - [x] **There is something to do on somebody else's page.** Challenge stashes who it is about and opens a duel lobby with them seated opposite - the press already said who, so the screen that asks is skipped. Signed out - it is inert and says why rather than vanishing: a stranger who arrived - from a post is exactly who it is for, and a control that is missing - tells them nothing. Seating happens on the first seat snapshot, which is - the first one after a scenario is chosen, and once: a seat this arranges - is one anybody in the lobby may then change. + it is still a press: the site's one sign-in dialog goes up and the + challenge is stashed on the way out, so the round trip to the player's + own provider comes back into the fight rather than onto the front page. + That is the daily card's shape through the same modal, and it matters + most here - a stranger who arrived from a post is who this page is for. + Seating happens on the first seat snapshot, which is the first one after + a scenario is chosen, and once: a seat this arranges is one anybody in + the lobby may then change. A press the control plane cannot answer says + so rather than doing nothing. - [x] **The mark says whether you follow them.** One unauthenticated call to the public appview (`app.bsky.graph.getRelationships`), which is a question about the reader rather than about the player, so nothing on diff --git a/web/scripts/players.test.mjs b/web/scripts/players.test.mjs index 455c032..0f94117 100644 --- a/web/scripts/players.test.mjs +++ b/web/scripts/players.test.mjs @@ -188,15 +188,41 @@ test("the challenge opens a duel with them in it, not the Play screen", async () assert.match(fn[0], /socket\?\.inviteHandle\(theirs\.slot/); }); -test("signing out disables Challenge rather than hiding it", async () => { +// Signed out, Challenge is still a press: it opens the site's one sign-in +// dialog and stashes the challenge on the way out, so the round trip to the +// provider comes back into the fight rather than onto the front page. A +// disabled button would be a dead end for the reader this page is most for. +test("signed out, Challenge signs you in and remembers what you pressed", async () => { const page = await readFile(`${src}players.ts`, "utf8"); const fn = page.match(/function challengeButton\([\s\S]*?\n\}/); assert.ok(fn, "challengeButton moved or was renamed"); - assert.match(fn[0], /button\.disabled = true;/); - // The reason goes in the accessible name: a disabled control is skipped by - // a screen reader's own controls list, so a tooltip alone says it to - // nobody who cannot see it. - assert.match(fn[0], /aria-label[\s\S]*sign in/i); + + assert.match( + fn[0], + /openSignIn\(\{ onBeforeConnect: \(\) => stashChallenge\(who\) \}\)/, + ); + // The session is read at press time, not at render time: this page is a + // document somebody can leave open while their session ends. + assert.match(fn[0], /currentSession\(\)/); // And it is absent on your own page, where the answer is nobody. assert.match(fn[0], /session\?\.did === actor\.did\) return null;/); + + // The other half of the round trip: the OAuth callback comes back to the + // bare root with no hash, so something has to recognise the stash as a + // destination or the challenge is lost on the doorstep. + const router = await readFile(`${src}router.ts`, "utf8"); + assert.match( + router, + /if \(hasPendingChallenge\(\)\) return "matches";/, + "a challenge stashed before sign-in has nowhere to land on the way back", + ); +}); + +test("neither Challenge nor the follow tick is drawn about yourself", async () => { + const page = await readFile(`${src}players.ts`, "utf8"); + const mark = page.match(/function accountMark\([\s\S]*?\n\}/); + assert.ok(mark, "accountMark moved or was renamed"); + // "You follow yourself" is not a fact, and there is no relationship to ask + // the appview about. + assert.match(mark[0], /session\.did !== actor\.did/); }); diff --git a/web/src/players.ts b/web/src/players.ts index 52a9fd9..aa3ba28 100644 --- a/web/src/players.ts +++ b/web/src/players.ts @@ -198,19 +198,24 @@ function figure(spec: Figure, actor: Actor, self: boolean): HTMLElement { /** * The one thing to do with somebody else's page. * - * A press stashes who it is about and leaves for Play, where the router - * opens a duel lobby with them seated opposite (challenge-stash.ts, and - * router.ts's "challenge" branch). It is not a link: what it starts is a - * lobby that does not exist yet, and there is no address to give it. + * A press stashes who the page is about and opens a duel lobby with them + * seated opposite (challenge-stash.ts, and router.ts's "matches" branch). It + * is not a link: what it starts is a lobby that does not exist yet, and there + * is no address to give it. * - * Signed out it is inert and says why rather than disappearing - a stranger - * who came from a post is exactly who this is for, and a control that is - * missing tells them nothing. Absent only on your own page, where the answer - * to "challenge" is nobody. + * Signed out it is still a press. It puts up the site's one sign-in dialog + * and stashes the challenge on the way out, so the round trip to the + * player's own provider comes back into the fight they asked for rather than + * onto the front page - the same shape the daily card's Play has, through + * the same modal. A disabled button would have been a dead end for the one + * reader this page is most for: a stranger who arrived from a post. + * + * Absent on your own page, where the answer to "challenge" is nobody. */ function challengeButton( actor: Actor, session: Session | null, + trouble: HTMLElement, ): HTMLElement | null { if (session?.did === actor.did) return null; @@ -220,23 +225,35 @@ function challengeButton( textContent: "Challenge", }); - if (!session) { - button.disabled = true; - button.title = "Sign in to challenge a player."; - // A disabled control is skipped by a screen reader's own controls list, - // so the reason goes in the accessible name rather than only in a - // tooltip a pointer has to find. - button.setAttribute( - "aria-label", - "Challenge — sign in to challenge a player", - ); - return button; - } + const who = actor.handle ? `@${actor.handle}` : actor.did; button.addEventListener("click", () => { - stashChallenge(actor.handle ? `@${actor.handle}` : actor.did); - window.location.assign("/#matches"); + // Read now rather than trusted from render time: this page is a document + // somebody can leave open, and a session can end while it sits there. + button.disabled = true; + trouble.textContent = ""; + void currentSession() + .then(async (live) => { + button.disabled = false; + if (!live) { + const { openSignIn } = await import("./signin-modal"); + openSignIn({ onBeforeConnect: () => stashChallenge(who) }); + return; + } + stashChallenge(who); + window.location.assign("/#matches"); + }) + .catch((error: unknown) => { + // The control plane did not answer. Not an error screen - the rest of + // this page is read from the network the account lives on and is + // still true - but not silence either: a press that appears to do + // nothing is the worst of the three answers. + console.warn("players: the challenge could not be started", error); + button.disabled = false; + trouble.textContent = "A challenge could not be started just now."; + }); }); + return button; } @@ -352,7 +369,14 @@ function profile(actor: Actor, session: Session | null): Node[] { // the action first and where else they are after it, since one of those is // a decision and the other is a footnote. Friending will land here too, // between them. - const actions = [challengeButton(actor, session), accountMark(actor, session)] + // Where a press that could not be started says so. Empty and on the page + // from the first frame, because a live region added and written in the same + // breath announces nothing. + const trouble = el("p", { className: "error", role: "alert" }); + const actions = [ + challengeButton(actor, session, trouble), + accountMark(actor, session), + ] .filter((node) => node !== null) .map((node) => el("li", {}, [node])); @@ -362,6 +386,7 @@ function profile(actor: Actor, session: Session | null): Node[] { el("span", { className: "profile-label", textContent: "Player" }), el("ul", { className: "profile-actions" }, actions), ]), + trouble, el("div", { className: "profile-head" }, [ el("div", { className: "profile-who" }, [ el("h1", { diff --git a/web/src/router.ts b/web/src/router.ts index 235177a..12f6830 100644 --- a/web/src/router.ts +++ b/web/src/router.ts @@ -26,7 +26,7 @@ import { capturePageview } from "./analytics"; import { ApiError, currentSession } from "./api"; import { camoEditor, hasPendingCamo } from "./camo/editor"; import { footer, markCurrent, type Route } from "./chrome"; -import { takePendingChallenge } from "./challenge-stash"; +import { hasPendingChallenge, takePendingChallenge } from "./challenge-stash"; import { hasPendingDailyPlay, takePendingDailyPlay } from "./daily-play-stash"; import { type AboutPageId, @@ -81,6 +81,12 @@ export function routeFromLocation(): Route { // use. if (hasPendingLobbyInvite()) return "lobby"; + // The same return, for a reader who pressed Challenge on somebody's page + // while signed out: the sign-in modal stashed who before the redirect, and + // Play is where that is spent - see start()'s "matches" branch, which + // opens the duel rather than the screen. Peek only, like the two above. + if (hasPendingChallenge()) return "matches"; + // And the third: a reader who pressed Play on the daily card while signed // out. "daily" rather than "lobby" because the slug, not a lobby id, is // what came back — see the "daily" branch in start(), which consumes it and diff --git a/web/src/styles.css b/web/src/styles.css index d713d5b..caa34d6 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -2913,14 +2913,12 @@ footer .debug { opacity: 0.85; } -/* Signed out: inert, and still legible. Not dimmed into the ground - the - reason it cannot be pressed is in its own accessible name, and a control - nobody can read is a control nobody can act on. */ +/* Disabled only while a press is in flight - the session is read before the + lobby opens, and a second press during that read would open two. Signed + out is not this state: that press puts up the sign-in dialog. */ .profile-challenge:disabled { - color: var(--muted); - background: none; - border-color: var(--line); - cursor: not-allowed; + opacity: 0.6; + cursor: default; } .profile-badge { -- 2.51.2