diff --git a/plan/player-profile.md b/plan/player-profile.md index ff27951..2163c64 100644 --- a/plan/player-profile.md +++ b/plan/player-profile.md @@ -36,10 +36,16 @@ dash. Those two are the only part of this still waiting. and the camo already answer for anybody on the network, because they are read from the network; the match history is what needs [appview](appview.md). -- [ ] **The preview says almost nothing about the player.** One figure, and - it is the camo count. It is worth revisiting once a profile has - anything on it that a stranger would stop scrolling for — which is - Wins, Kills and a streak, so it lands with them rather than before. +- [ ] **Three of the five figures have no source.** Rating waits on + [leaderboard](leaderboard.md), K/D on the per-unit attribution in + [match-records](match-records.md), and Forces on [forces](forces.md). + They are drawn as em dashes on purpose: the instrument column is the + one shape where that reads as pending rather than as broken. +- [ ] **Room is held for three things nothing fills.** A bio, medals under + the picture, and a faction mark ghosted behind the card. The drawing + takes all three when they arrive and closes up when they have not — the + instruments come up to meet the name on a card with no bio, so held-open + space never reads as a hole. - [ ] **Nothing links to a profile yet except your own menu.** A lineup chip, a seat select and the report page's forces all name another player, and none of them leads anywhere. That is the next thing worth doing here. @@ -100,6 +106,24 @@ now, mounted beside the account's and waiting on nothing. unconfirmed handle is dropped rather than shown, because a handle is rented and the account that used to hold one is not the account that holds it now. The DID form is canonical for the same reason. +- [x] **The page and the card are two things that look like one.** A reader + arriving from a post sees both within a second of each other, so they + share a vocabulary - a tracked label in the accent colour with a rule + running out of it, the picture under corner ticks, a standing read as + instruments and counts read as a ruled row - and they are not the same + object: the card is one fixed 1200x630 drawing and the page reflows, + links onwards and has a match history coming. `players.test.mjs` holds + the two to the same figures in the same two kinds, since nothing else + links a TypeScript screen to a Rust drawing. +- [x] **A figure is one of two kinds, and is drawn as what it is.** A rating + and a kill ratio are read against other players; matches, forces and + patterns are counts of what somebody has done. Drawing all five the + same way made the two kinds look like one kind. +- [x] **The card carries the account's own picture**, decoded from PNG or + JPEG - a Bluesky client uploads JPEG, so PNG alone was a hole in most + cards - with a monogram where there is none. +- [x] **The DID is off both.** It is how a bug report names an account, not + how a person does. What it was taking is the bio's room. - [x] **A shared link previews as the player, not as the site.** An unfurler runs no JavaScript, so one shell would preview as one generic card for everybody. `services/api/src/unfurl.rs` answers `/unfurl/players/<...>` diff --git a/web/scripts/players.test.mjs b/web/scripts/players.test.mjs index bebafa0..9a4221a 100644 --- a/web/scripts/players.test.mjs +++ b/web/scripts/players.test.mjs @@ -80,3 +80,46 @@ test("the page reads the address rather than being built per player", async () = "a page per player cannot be built: there is no list of players", ); }); + +// The page and the card are two drawings of one claim, in two languages, in +// two repositories' worth of code. Nothing links them, and the ways they can +// drift are all quiet: a figure renamed on one, a figure that is a score here +// and a count there, or the card saying "camo" while the page says +// "patterns". Somebody reading a post and then the page it points at sees +// both within a second of each other. +test("the page and the card count the same things, in the same two kinds", async () => { + const page = await readFile(`${src}players.ts`, "utf8"); + const card = await readFile( + fileURLToPath(new URL("../../services/api/src/unfurl.rs", import.meta.url)), + "utf8", + ); + + // Only what is declared as a label: the blocks also name a module to + // import and a figure to read, and neither is what is printed. + const labels = (source, block, { pattern, closes }) => { + const start = source.indexOf(block); + assert.ok(start !== -1, `${block} is gone`); + const end = source.indexOf(closes, start); + assert.ok(end !== -1, `${block} does not close`); + return [...source.slice(start, end).matchAll(pattern)].map((m) => m[1]); + }; + + // A TypeScript array of specs, and a Rust vec! of pairs. Each ends where + // its own language ends it. + const written = { pattern: /label:\s*"([^"]+)"/g, closes: "\n];" }; + const owned = { pattern: /"([^"]+)"\.to_owned\(\)/g, closes: "\n }" }; + + assert.deepEqual(labels(page, "const SCORES", written), ["Rating", "K/D"]); + assert.deepEqual(labels(page, "const COUNTS", written), [ + "Matches", + "Forces", + "Patterns", + ]); + // The card builds its own from two functions rather than two constants. + assert.deepEqual(labels(card, "fn scores(&self)", owned), ["Rating", "K/D"]); + assert.deepEqual(labels(card, "fn activity(&self)", owned), [ + "Matches", + "Forces", + "Patterns", + ]); +}); diff --git a/web/scripts/soon-stamp.test.mjs b/web/scripts/soon-stamp.test.mjs index 269b1ae..881eb33 100644 --- a/web/scripts/soon-stamp.test.mjs +++ b/web/scripts/soon-stamp.test.mjs @@ -159,28 +159,12 @@ test("a Mode with nothing behind it is stamped, and a live one is not", () => { test("a stat with no reader is stamped, and one with a reader is not", () => { assert.match(pilot, /soonStamp\("tile"\)/, "the unread stats lost the stamp"); - // The stamp goes on inside the guard. Outside it, every tile on the card - // would carry one, including the two that count something real. - const guard = pilot.indexOf("if (!stat.read || withheld)"); - assert.ok(guard !== -1, "the placeholder guard has been renamed"); - const guarded = pilot.slice(guard, pilot.indexOf("stat\n .read(subject)")); + // The stamp goes on inside `if (!stat.read)`. Outside it, every tile on the + // card would carry one, including the two that count something real. + const guarded = pilot.slice( + pilot.indexOf("if (!stat.read)"), + pilot.indexOf("return box;", pilot.indexOf("if (!stat.read)")), + ); assert.match(guarded, /soonStamp\("tile"\)/, "the stamp is not guarded"); assert.match(guarded, /notReadyNote\(\)/, "the caption carries no note"); }); - -// A figure that is recorded and is not ours to show is a dash too, and it is -// not the same dash: "coming soon" over another player's match count would -// say the site cannot count it yet, which is untrue. -test("a figure withheld about somebody else is not stamped as unbuilt", () => { - const withheld = pilot.slice( - pilot.indexOf("if (withheld) {"), - pilot.indexOf("return box;", pilot.indexOf("if (withheld) {")), - ); - assert.ok(withheld, "the withheld branch has gone"); - assert.doesNotMatch( - withheld, - /soonStamp/, - "a withheld figure wears the soon stamp", - ); - assert.match(withheld, /not public/, "nothing tells a screen reader why"); -}); diff --git a/web/src/players.ts b/web/src/players.ts index 5df3fd0..39570a9 100644 --- a/web/src/players.ts +++ b/web/src/players.ts @@ -20,13 +20,12 @@ * match count, and it is only ever your own. */ -import { currentSession } from "./api"; +import { currentSession, listMatches } from "./api"; import { footer, pageHead } from "./chrome"; import { el, render } from "./dom"; import { actorFromPath, playerHref } from "./destinations"; import { resolveActor, type Actor } from "./identity"; -import { accountName } from "./profile"; -import { identityBlock, statGrid } from "./screens/pilot"; +import { avatarUrl, monogram } from "./profile"; /** * What the page says while it is finding out who it is about. @@ -72,18 +71,140 @@ function nobody(actor: string): Node[] { ]; } +/** + * What the page counts, and which kind of figure each one is. + * + * The same split the card draws, because they are the same claim made twice: + * a rating and a kill ratio are read against other players, so they are + * instruments with a leader running out to them; matches, forces and patterns + * are counts of what somebody has done, read one at a time, so they are a + * ruled row. The page is not the card - it is wider, it is responsive, it + * links onwards and it will hold a match history - but a reader arriving from + * a post should recognise it as the same thing. + * + * `read` is what makes a figure real. Without one it is an em dash, with the + * reason on the tile: `leaderboard` owes the rating, `match-records` owes the + * kill attribution behind K/D, and `forces` owes the count of saved lists. + */ +type Figure = { + readonly label: string; + readonly read?: (actor: Actor) => Promise; + /** Why the figure is missing. Placeholders only. */ + readonly pending?: string; + /** Readable about yourself and about nobody else, with the reason. */ + readonly selfOnly?: string; +}; + +const SCORES: readonly Figure[] = [ + { label: "Rating", pending: "No rating is computed yet." }, + { label: "K/D", pending: "A match result does not record kills yet." }, +]; + +const COUNTS: readonly Figure[] = [ + { + label: "Matches", + // Ours rather than the network's, and only about you: the list is + // session-scoped until a finished match is a record in the player's own + // repository. See match-records. + read: async () => (await listMatches()).length, + selfOnly: "Only the player can see their matches for now.", + }, + { label: "Forces", pending: "Nothing saves a force yet." }, + { + // Not "camo": that is what the editor is called, not what somebody has + // eight of. + label: "Patterns", + // Read straight from the player's own repository, so this costs + // headquarters nothing and answers for anybody on the network. + read: async (actor) => { + const repo = await import("./camo/repo"); + return (await repo.list(actor.did)).length; + }, + }, +]; + +/** The picture, or the letter it stands in for. */ +function face(actor: Actor): HTMLElement { + const box = el("span", { className: "profile-face" }, [ + el("span", { + className: "profile-monogram", + textContent: monogram(actor), + }), + ]); + + void avatarUrl(actor.did) + .then((src) => { + if (!src) return; + const image = el("img", { + className: "profile-portrait", + src, + // Decorative: the handle is beside it. + alt: "", + decoding: "async", + }); + image.addEventListener("load", () => box.replaceChildren(image)); + }) + .catch((error: unknown) => { + console.warn("players: the picture could not be found", error); + }); + + return box; +} + +/** + * One figure, filled in when its own read lands. + * + * Every one starts as an em dash, so the page is complete and readable from + * the first frame and nothing on it moves as the numbers arrive. + */ +function figure(spec: Figure, actor: Actor, self: boolean): HTMLElement { + const value = el("span", { className: "figure-value", textContent: "—" }); + const label = el("span", { + className: "figure-label", + textContent: spec.label, + }); + const row = el("div", { className: "figure" }, [label, value]); + + const withheld = spec.selfOnly && !self ? spec.selfOnly : null; + if (!spec.read || withheld) { + row.classList.add("figure-empty"); + row.title = withheld ?? spec.pending ?? "Not recorded yet."; + label.append( + el("span", { + className: "visually-hidden", + textContent: withheld ? " (not public)" : " (not recorded yet)", + }), + ); + return row; + } + + spec + .read(actor) + .then((count) => { + value.textContent = String(count); + }) + .catch((error: unknown) => { + // A figure that cannot be read stays an em dash. Nothing on this page is + // worth an error screen, and the rest of it may have arrived. + console.warn(`players: ${spec.label} could not be counted`, error); + row.title = "This could not be read just now."; + }); + + return row; +} + /** The page, once the account behind the address is known. */ -function profile(subject: Actor & { self: boolean }): HTMLElement { - const actions = el("div", { className: "pilot-actions" }, [ +function profile(actor: Actor, self: boolean): Node[] { + const links = el("div", { className: "profile-links" }, [ el("a", { className: "pilot-action", - href: `https://bsky.app/profile/${subject.handle ?? subject.did}`, + href: `https://bsky.app/profile/${actor.handle ?? actor.did}`, textContent: "On Bluesky", rel: "noopener", }), ]); - if (subject.self) { - actions.prepend( + if (self) { + links.prepend( el("a", { className: "pilot-action", href: "/#matches", @@ -97,15 +218,32 @@ function profile(subject: Actor & { self: boolean }): HTMLElement { ); } - return el("section", { className: "pilot" }, [ - el("h1", { - className: "visually-hidden", - textContent: accountName(subject), - }), - identityBlock(subject), - statGrid(subject), - actions, - ]); + return [ + el("section", { className: "profile" }, [ + el("p", { className: "profile-eyebrow", textContent: "Player" }), + el("div", { className: "profile-head" }, [ + el("div", { className: "profile-who" }, [ + el("h1", { + className: "profile-name", + textContent: actor.handle ? `@${actor.handle}` : actor.did, + }), + links, + ]), + el("div", { className: "profile-plate" }, [face(actor)]), + ]), + el( + "div", + { className: "profile-scores" }, + SCORES.map((spec) => figure(spec, actor, self)), + ), + el( + "div", + { className: "profile-counts" }, + COUNTS.map((spec) => figure(spec, actor, self)), + ), + ]), + footer(), + ]; } /** @@ -160,8 +298,8 @@ async function start(): Promise { } markCanonical(actor.did); - document.title = `${accountName(actor)} · lance.blue`; - render(profile({ ...actor, self: session?.did === actor.did }), footer()); + document.title = `${actor.handle ? `@${actor.handle}` : actor.did} · lance.blue`; + render(...profile(actor, session?.did === actor.did)); app?.removeAttribute("aria-busy"); } diff --git a/web/src/screens/pilot.ts b/web/src/screens/pilot.ts index dd72ac7..54cdae1 100644 --- a/web/src/screens/pilot.ts +++ b/web/src/screens/pilot.ts @@ -18,20 +18,7 @@ import { listMatches, type Session } from "../api"; import { el } from "../dom"; import { notReadyNote, soonStamp } from "../soon-stamp"; -import { accountName, avatarUrl, monogram, type Named } from "../profile"; - -/** - * Who a figure is being read about. - * - * The same tiles stand on Home, where the subject is the signed-in account, - * and on a player's page, where it may be anybody. `self` is not a - * convenience: a figure can be public about you and unreadable about somebody - * else, and a tile has to say which of those it is rather than showing a dash - * that looks like nothing was ever recorded. - */ -export interface Subject extends Named { - readonly self: boolean; -} +import { accountName, avatarUrl, monogram } from "../profile"; /** * What the card counts, in the order it shows them. @@ -46,17 +33,9 @@ export interface Subject extends Named { type Stat = { readonly label: string; /** Absent means nothing records this yet. */ - readonly read?: (subject: Subject) => Promise; + readonly read?: (session: Session) => Promise; /** Why the figure is missing. Placeholders only. */ readonly placeholder?: string; - /** - * Readable about yourself and about nobody else, with the reason. - * - * Set where the figure exists but is not public, which is a different - * thing from `placeholder`'s "nothing records this yet" and has to read - * differently on the tile. - */ - readonly selfOnly?: string; }; export const STATS: readonly Stat[] = [ @@ -64,19 +43,15 @@ export const STATS: readonly Stat[] = [ label: "Camo", // Real, and read straight from the player's own repository — the same // public listing the editor does, so this costs headquarters nothing. - read: async (subject) => { + read: async (session) => { const repo = await import("../camo/repo"); - return (await repo.list(subject.did)).length; + return (await repo.list(session.did)).length; }, }, { label: "Matches", - // Real, as far as the control plane's own record of them goes - and only - // about you: the list is session-scoped, and a match another player - // fought is not ours to hand out until it is a record in their own - // repository. See match-records. + // Real, as far as the control plane's own record of them goes. read: async () => (await listMatches()).length, - selfOnly: "Only the player can see their matches for now.", }, // Nothing reports a result at the end of a match yet, so neither of these // has a source. They are on the card because the shape of the card is the @@ -94,18 +69,23 @@ export const STATS: readonly Stat[] = [ * moves as the numbers arrive. */ export function pilotCard(session: Session): HTMLElement { - const subject: Subject = { - did: session.did, - handle: session.handle, - self: true, - }; + const name = accountName(session); + + const identity = el("div", { className: "pilot-identity" }, [ + avatar(session), + el("div", { className: "pilot-who" }, [ + el("p", { className: "pilot-name", textContent: name }), + // The DID under the handle, because the handle is rented and this is not. + el("p", { className: "pilot-did", textContent: session.did }), + ]), + ]); return el("section", { className: "pilot" }, [ // The card names itself for the document outline. On screen the avatar and // the handle say the same thing louder, so the heading is not repeated. el("h2", { className: "visually-hidden", textContent: "Your pilot card" }), - identityBlock(subject), - statGrid(subject), + identity, + statGrid(session), el("div", { className: "pilot-actions" }, [ el("a", { className: "pilot-action", href: "/#matches" }, [ "Your matches", @@ -115,35 +95,17 @@ export function pilotCard(session: Session): HTMLElement { ]); } -/** - * Who the card is about: the picture, the name, and the DID under it. - * - * Exported because a player's own page shows the same block about somebody - * else, and two drawings of one identity is how a handle ends up rendered - * one way here and another way there. - */ -export function identityBlock(subject: Subject): HTMLElement { - return el("div", { className: "pilot-identity" }, [ - avatar(subject), - el("div", { className: "pilot-who" }, [ - el("p", { className: "pilot-name", textContent: accountName(subject) }), - // The DID under the handle, because the handle is rented and this is not. - el("p", { className: "pilot-did", textContent: subject.did }), - ]), - ]); -} - -/** The figures, in the order STATS declares them. */ -export function statGrid(subject: Subject): HTMLElement { - return el( +function statGrid(session: Session): HTMLElement { + const grid = el( "div", { className: "pilot-stats" }, - STATS.map((stat) => tile(stat, subject)), + STATS.map((stat) => tile(stat, session)), ); + return grid; } /** One figure. Em dash until its read lands, and an em dash for good if it never does. */ -function tile(stat: Stat, subject: Subject): HTMLElement { +function tile(stat: Stat, session: Session): HTMLElement { const value = el("span", { className: "pilot-value", textContent: "—" }); const label = el("span", { className: "pilot-label", @@ -151,30 +113,11 @@ function tile(stat: Stat, subject: Subject): HTMLElement { }); const box = el("div", { className: "pilot-stat" }, [value, label]); - // Two different reasons a figure is a dash, and the tile says which: there - // is no such record yet, or there is one and it is not ours to show. - const withheld = stat.selfOnly && !subject.self ? stat.selfOnly : null; - - if (!stat.read || withheld) { + if (!stat.read) { box.classList.add("pilot-stat-soon"); // The reason belongs on the tile, not in a legend under the card: a player // reading a dash wants to know why that one is a dash. - box.title = withheld ?? stat.placeholder ?? "Not recorded yet."; - - if (withheld) { - // Not the same dash, and not marked with the same stamp: this figure is - // recorded and is somebody else's to show. "Coming soon" over it would - // say the site cannot count it yet, which is untrue and is the wrong - // thing to promise. - label.append( - el("span", { - className: "visually-hidden", - textContent: " (not public)", - }), - ); - return box; - } - + box.title = stat.placeholder ?? "Not recorded yet."; // The site's one mark for a thing that is named and not built, rather than // the word "soon" in the brand teal that used to be here. Same mark as the // front page's facts and Play's unbuilt Modes — a dash on this tile means @@ -186,7 +129,7 @@ function tile(stat: Stat, subject: Subject): HTMLElement { } stat - .read(subject) + .read(session) .then((count) => { value.textContent = String(count); }) @@ -207,15 +150,15 @@ function tile(stat: Stat, subject: Subject): HTMLElement { * decoded — because it is the same full-size blob off the same PDS, and here * it is drawn four times as large. */ -function avatar(subject: Named): HTMLElement { +function avatar(session: Session): HTMLElement { const box = el("span", { className: "avatar pilot-avatar" }, [ el("span", { className: "avatar-monogram", - textContent: monogram(subject), + textContent: monogram(session), }), ]); - void avatarUrl(subject.did) + void avatarUrl(session.did) .then((src) => { if (!src) return; const image = el("img", { diff --git a/web/src/styles.css b/web/src/styles.css index 258c2cd..53d3c20 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -2783,6 +2783,210 @@ footer .debug { font-size: 0.7rem; } +/* A player's page. + ------------------------------------------------------------------------- + The same vocabulary as the card a link to it unfurls with - a tracked label + in the brand colour, a rule running out from it, the picture under corner + ticks, a standing read as instruments and counts read as a ruled row - and + not the same object. The card is one fixed 1200x630 drawing for a timeline; + this is a page that reflows, links onwards, and has a match history coming. + A reader arriving from a post should recognise it, not feel they clicked an + image. */ +.profile { + display: flex; + flex-direction: column; + gap: 1.6rem; +} + +/* The label, and the rule that turns it into a header rather than a caption - + the card draws the same line from the same place. */ +.profile-eyebrow { + display: flex; + align-items: center; + gap: 0.9rem; + margin: 0; + font-family: var(--font-labels); + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.34em; + text-transform: uppercase; + color: var(--accent); +} + +.profile-eyebrow::after { + content: ""; + flex: 1; + height: 1px; + background: var(--line); +} + +.profile-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1.5rem; +} + +.profile-who { + display: flex; + flex-direction: column; + gap: 0.9rem; + min-width: 0; +} + +/* A handle is a domain and can be long, so it sizes off the column rather + than off the window, and it breaks rather than pushing the page wide. */ +.profile-name { + margin: 0; + font-size: clamp(1.6rem, 6cqw, 2.6rem); + line-height: 1.05; + overflow-wrap: anywhere; +} + +.profile-links { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; +} + +/* The picture, under the ticks the card marks it with. */ +.profile-plate { + position: relative; + flex: none; + padding: 0.55rem; +} + +.profile-plate::before, +.profile-plate::after { + content: ""; + position: absolute; + width: 1.1rem; + height: 1.1rem; + border: 2px solid var(--accent); + opacity: 0.75; +} + +.profile-plate::before { + top: 0; + left: 0; + border-right: 0; + border-bottom: 0; +} + +.profile-plate::after { + right: 0; + bottom: 0; + border-left: 0; + border-top: 0; +} + +.profile-face { + display: grid; + place-items: center; + width: 8.5rem; + height: 8.5rem; + border: 1px solid var(--line); + background: var(--panel); + overflow: hidden; +} + +.profile-portrait { + width: 100%; + height: 100%; + object-fit: cover; +} + +.profile-monogram { + font-family: var(--font-labels); + font-size: 3rem; + font-weight: 600; + color: var(--muted); +} + +/* A standing: the word, a leader, the figure. The leader is what makes an em + dash read as a gauge at rest rather than as a number somebody forgot. */ +.profile-scores { + display: flex; + flex-direction: column; + gap: 0.9rem; +} + +.profile-scores .figure { + display: flex; + align-items: baseline; + gap: 0.9rem; +} + +.profile-scores .figure::after { + content: ""; + order: 1; + flex: 1; + height: 1px; + background: var(--line); +} + +.profile-scores .figure-value { + order: 2; + font-family: var(--font-labels); + font-size: 1.7rem; + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +/* Counts: read one at a time, so they are ruled apart rather than stacked. */ +.profile-counts { + display: flex; + flex-wrap: wrap; + gap: 1.25rem 2.5rem; + padding-top: 1.1rem; + border-top: 1px solid var(--line); +} + +.profile-counts .figure { + display: flex; + flex-direction: column-reverse; + gap: 0.15rem; + min-width: 5.5rem; +} + +.profile-counts .figure-value { + font-family: var(--font-labels); + font-size: 1.9rem; + font-weight: 600; + font-variant-numeric: tabular-nums; + line-height: 1.1; +} + +.figure-label { + font-family: var(--font-labels); + font-size: 0.7rem; + font-weight: 600; + letter-spacing: 0.2em; + text-transform: uppercase; + color: var(--muted); +} + +/* A figure with no source yet, or one that is not ours to show. Dimmed rather + than stamped: the reason is on the row, and the page is not the place for + the site's rubber stamp on four rows out of five. */ +.figure-empty .figure-value { + color: var(--muted); +} + +/* Phone: the picture goes above the name rather than beside it, because a + handle in a column 4rem wide breaks into a stack of syllables. */ +@media (max-width: 34rem) { + .profile-head { + flex-direction: column-reverse; + align-items: stretch; + } + + .profile-face { + width: 6.5rem; + height: 6.5rem; + } +} + /* The challenge screen's opponent choices. */ .choice { display: flex;