Repository navigation
Open player profiles from /player/<publicId> links, with sharing - #5775
Conversation
The canonical profile link is now https://<site>/player/<publicId>, the link the site unfurls into a profile card. Opening it, with or without a trailing slash, behind a /w<n> worker prefix, and with an optional ?tab=stats|games|clans|progression, opens that profile on load. The hash form (#modal=profile&publicID=...) keeps working. The modal router learns path routes: while a profile opened from its path stays open the URL keeps the path, with tab changes written to ?tab=; closing it (Back, Escape) returns to the home page and replaces the URL with / so a reload doesn't reopen it. Opening another modal hands the URL back to the hash. On the profile: - Signed-out viewers get a "New to OpenFront?" banner above the card, whose Play button closes the profile onto the home page. - Share buttons under the card: Copy link, Share on X, and Copy for Discord (a short line and the link, which Discord unfurls). Touch devices with a native share sheet get one Share button instead. Copies toast as elsewhere and are announced to screen readers. - Not found now explains itself, shows the link that was opened, and offers Play OpenFront and View leaderboard. The friends box accepts a pasted /player/<id> link. Game server workers serve the app shell at /player/<id> (the master's SPA fallback already did for the unprefixed form), and the dev server does the same for /w<n>/player/<id>. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Play OpenFront and View leaderboard stacked on desktop because the not-found block was capped at max-w-md. The block is wider now (the text keeps its measure), and the buttons sit side by side from sm up and stack on a phone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. 🗂️ Base branches to auto review (1)
Please check the settings in the CodeRabbit UI or the ⚙️ Run configuration
You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
A profile opened from /player/<id> kept that path when another modal was opened from the hash (the nav menu's #modal=account-settings): the router recorded the new modal as current but never left the path, so closing the profile hit syncClosed's "not the active routed modal" early return. The URL stayed /player/<id>, and the next reload or URL change reopened the profile. Opening a modal from the hash now leaves the path, as an in-app open already did, keeping the new #modal= hash. Closing the path-routed modal also leaves its path whichever modal the router last recorded. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The end-of-game popup and the prestige ceremony share a moment with the same three actions the profile has (copy link, post to X, copy for Discord), so profile-share grows what they need instead of a second copy: - layout="menu": one labelled button that opens the three as a menu (arrow keys, Escape and a press outside close it). On a touch device with a share sheet the button opens the sheet straight away, as the row does. - text: the line that goes with the link, in place of the profile's own. - triggerClass: so the menu button can match the buttons beside it. - solid / centered: backed buttons for a busy backdrop, and a centred row. The profile's row is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Once the XP reveal has shown it (or been skipped), a game that reached a milestone level (10/25/50/75/100) or made the player a Legend gets a "Share Level 50" / "Share Legend" button in the popup's footer, between Exit Game and Keep playing. It opens the profile's share menu with the player's profile link naming the moment (/player/<id>?moment=level50 or ?moment=legend) and "I just reached Level 50 on OpenFront!". Only the server's result counts (view.kind "result"), never a provisional figure. A Legend wins over the level, and of several milestones reached in one game the highest is shared. Signed out, or no public ID: no button. The panel tells its host when a result is showing in full (xp-reveal-settled), which is when the button appears. Three labelled buttons don't fit side by side on a phone: below md (and beside a ranked game's requeue button) the share button takes a row of its own above the other two. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When the ceremony ends, a share row sits between the title and Continue: the player's profile link with ?moment=prestige<N> and "I just reached Prestige N on OpenFront!". The ceremony skips on any click, so the row keeps its clicks to itself, and its buttons are backed to read over the honeycomb. The account page passes the player's public ID; without one there is no row. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-out visitors now see the same profile as everyone else. Removes the banner, the sign-in check that drove it, its two en.json strings and its tests. The not-found screen keeps its own Play button. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A quick yellow ring (full strength at once, held 80 ms, out over 320 ms while it grows to 1.04) on mouse hover or :focus-visible, once per hover: a hover while it still plays is ignored. Only with a fine hover pointer, never on touch, and not under reduced motion. The ring is an element ProfileShare renders itself, never a pseudo-element on the button: the menu's trigger takes its classes from the host, and WinModal's .win-action::after is already its light streak. Beside the menu trigger (which clips its content) in a box the trigger's size; inside each row button. Its radius is the button's plus 4. The prestige ceremony's share row no longer rises in: it is simply there. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…gend ceremony Keeps both sides in WinModal (the share button's settled state and public id, and the Legend ceremony's), keeps the prestige share row while taking the shared Ceremony.ts honeycomb and the base's renderStyles, and folds the two GameXpPanel updated() hooks (xp-reveal-settled and the Legend announcement) into one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Celant
left a comment
There was a problem hiding this comment.
Coordinated review
Same batch, reviewed with the backend repo available locally. CI review was stale and has been re-run.
On the og:image v question I raised on the backend side: not applicable here, and the answer is good. This client never composes the card URL — it emits the page link (${ClientEnv.shareOrigin()}/player/<id> plus an optional ?moment=). The card URL is built by the backend's site worker, whose cardUrl() produces ?v=${progress.prestige}-${progress.level}, so profile links do unfurl with the 6-hour variant rather than the 5-minute one. The only og:image in this repo's src/ is the pre-existing game-preview route. Worth knowing the flip side: because the client has no hand in the card URL, it also has no lever on card freshness.
moment bounds match the server. gameShareMoment() yields only legend or a level drawn from MILESTONE_LEVELS (10/25/50/75/100, all inside the server's 2..100), and momentSlug() emits exactly legend/level<N>/prestige<N>, matching parseMoment's regex and bounds in both the API's card route and the site worker's copy. One soft spot below.
Input validation is clean: publicId from the URL is validated by PUBLIC_ID = /^[A-Za-z0-9_-]{1,22}$/ before any use — matching the API's own regex — and every outbound use re-encodes it. ProfileShare sends nothing but a constructed intent URL to window.open(..., "noopener,noreferrer"). I chased the share menu's capture-phase outside-click handler as a possible shadow-DOM retargeting hazard; it isn't one, because WinModal, BaseModal, LegendCeremony, PrestigeFlow and ProfileShare all override createRenderRoot() to render into light DOM.
Two intended semantics worth being aware of, both confirmed against backend comments: the server draws a level<N> moment only within the player's current run, so a shared level50 link reverts to the plain card once that player prestiges ("the card never claims what isn't so"); and the in-app view ignores ?moment= entirely, so a recipient clicking through sees the plain profile while the unfurl celebrated the moment.
3 finding(s): 3 low
Findings whose anchor falls outside the diff:
[low] The prestige share slug uses the server's raw rank, unclamped
src/client/components/PrestigeFlow.ts
renderShare(rank) is reached with rank = this.outcome?.progress.prestige ?? this.nextRank(before), and momentShareUrl → momentSlug interpolates it straight into prestige${moment.rank}. Every other consumer of that number goes through clampPrestige() or nextRank(); this one takes PrestigeResponse.progress.prestige as a bare z.number().
Failure scenario: a 0, an 11, or a non-integer yields prestige0/prestige11/prestige1.5, all of which the card route answers with badRequest({ resource: "moment" }) — so the shared link unfurls with no image at all, on the single most celebratory surface in the feature. Only reachable if the server returns a rank outside 1..10, which it doesn't today.
Suggested fix: clampPrestige(rank) before building the slug, and/or have momentSlug return null for an out-of-range value so the share row drops rather than emitting a 400.
Worth a table test over {level: 1, 2, 100, 101} and {prestige: 0, 1, 10, 11} asserting the slug is either refused or inside the range the card route accepts — nothing currently pins that seam.
[low] The address bar and the not-found panel's "link you opened" can name the wrong player
src/client/PlayerProfileModal.ts
BaseModal.open() returns before modalRouter.syncOpened() when wasOpen is true; PlayerProfileModal.onOpen() then snapshots `${window.location.host}${window.location.pathname}` into openedLink.
So re-opening the profile modal for a different publicID while it's already open never calls syncOpened → never calls leavePath() → the URL keeps /player/<old-id>, isPathRouted("profile") stays true, and openedLink is captured from the stale path. syncTab then writes ?tab= onto the wrong player's path, and a reload opens the wrong profile.
Latent today — the only open()-while-open path is returnToGames(), which re-opens the same player. It becomes live the moment anything opens one profile from inside another (a clan roster, a friends-list entry rendered in the profile).
Suggested fix: derive openedLink from the id the modal is actually about rather than from window.location, and/or let syncOpened run for an already-open modal whose args changed.
[low] The dev-server rewrite only covers the worker-prefixed profile path
vite.config.ts
/^\/w\d+\/(?:game|player)\/[^/]+/ — but PROFILE_PATH and src/server/PlayerProfileRoute.ts both accept a bare /player/<id>. Only the /w<n>/ form gets the dev bypass, so npm run dev plus a plain profile link relies on Vite's SPA fallback instead.
Suggested fix: cosmetic — match the two regexes so dev behaves like production.
…n with the profile - momentSlug/momentShareUrl return null for a level outside 2..100 or a prestige rank outside 1..10 (or a non-integer), and the prestige ceremony, Legend ceremony and end-of-game popup drop their share row then, rather than sharing a link that unfurls with no card. - Opening another player's profile over an open one now hands the URL to the new player (BaseModal only synced on a first open), so the old /player/<id> path or #modal= hash no longer outlives it, and the not-found panel only names the opened link while the path is this player's. - Note in vite.config.ts why only the /w<n> proxies need the dev bypass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Resolves #5733 Stacked on #5726 (levels and the end-of-game XP reveal). The base is that PR's branch, so this diff shows only this change. GitHub moves the base to `main` once #5726 merges. ## Description: Profile cards for every player, and the prestige flow for players at level 100: a confirmation screen you hold to confirm, then a full-screen ceremony. As with #5726, nothing shows unless the API returns progress. The prestige endpoint (`POST /users/@me/prestige`) is being built in the infra repo. ### Profile cards - **Full card:** heads the Stats tab of your account and of public player profiles. It shows the badge, clan tag, name, prestige and level, a segmented XP bar and lifetime XP. Games and wins stay in the stats below it, not repeated on the card. - **Compact card:** a single row at the top of the Account tab, above the existing account settings and creator code. - **Prestige button:** at level 100 your own card shows a pulsing Prestige button. It never shows on someone else's profile. - **Public XP bar:** the public card draws the XP bar from two new optional fields on the public progress response (`xpInLevel`, `xpForNext`). Without them it shows the level and leaves the bar out. ### Prestige confirmation - **Layout:** full screen on the menu's world map, with the honeycomb shimmering, rather than a dialog over the page: - "ENTER PRESTIGE N" over the new rank's emblem; - the prestige track (P1 to P10, then Legend): ranks you've earned are lit, the one you're entering is raised, the rest are dimmed; - unlock tiles: the new emblem, the Caps bonus, and an exclusive cosmetic at ranks 1, 5 and 10; - one line on what's kept ("You go back to level 1. You keep your lifetime stats, XP and cosmetics.") and "This can't be undone". - **Hold to prestige:** the button fills over 1.5 seconds while held, and the label changes to "Keep holding…". Letting go early empties it and sends nothing; a click does nothing. Space and Enter work the same way, and a long press on mobile doesn't open the context menu. - **Idempotent:** each opening of the confirmation gets its own `Idempotency-Key`. A retry after a failure reuses it, and a second hold while the first is in flight sends nothing. - **Errors:** a failed request leaves the player where they were, with "Couldn't prestige. Nothing has changed, so try again." ### Ceremony - The confirmation cuts straight into it on the same background. The cyan honeycomb, traced onto the background image's own hex lines, pulses in toward the level-100 badge as it charges and shakes. - The badge shatters with a flash and shockwave, and the honeycomb bursts outward. - The new emblem forms out of the flash, "PRESTIGE N" slams in, then Continue. - Clicking anywhere skips to the end. Reduced motion goes straight there. - The account page updates behind it: the new rank, any rewards granted, and the cached `/users/@me` dropped. ### Media **Prestige, start to finish (1920×1080).** Account tab at P3 level 100 → Prestige → hold to confirm → ceremony → back on the Account tab at P4 level 1. https://github.com/user-attachments/assets/df99b662-bca6-46fd-a1fc-b1e9e572f10a **Account tab: the compact card.** <img width="960" alt="Account tab with the compact profile card" src="https://github.com/user-attachments/assets/5534f8fe-bef6-4c7c-a494-36a3810ab417" /> **Stats tab: the full card above the stats.** <img width="960" alt="Stats tab with the full profile card" src="https://github.com/user-attachments/assets/4a1a5a03-52f3-474d-a0ec-da5f63013e76" /> **A public profile.** <img width="960" alt="Public profile with the full profile card" src="https://github.com/user-attachments/assets/770abeda-421e-40c2-b2b6-09833afae2fe" /> **The confirmation on mobile (P4 → P5, with the cosmetic tile).** <img width="300" alt="Prestige confirmation on mobile" src="https://github.com/user-attachments/assets/93a52423-a1b2-4059-a9ba-77874c48b5e9" /> The progress values and the prestige response are sample data fed to the page; the API is not deployed yet. ### Testing - **New tests** cover: - the profile card in both variants: no card without progress, the XP bar and its absence, and the Prestige button only on your own card at level 100; - the confirmation: title, unlock tiles (and the cosmetic only at ranks 1, 5 and 10), the track's earned, next and locked ranks, and no lifetime XP figure; - holding: a click and a short press do nothing and empty the bar, a full hold prestiges, the keyboard works, and two holds send one request; - failure and retry with the same idempotency key; - the ceremony's beats, skipping, and Continue; - the account and profile modals with the cards. - **Full suite:** 7,089 passed. - The only failures are the `UpdateRegister` tests, which shell out to `update.sh` and fail the same way on Windows without this change. - **Checks:** `tsc`, oxlint, eslint and prettier are clean. ### Added (2026-10-04): real prestige rewards, a smoother prestige, profile card pulses and the Legend ceremony All of these were approved from mockups and prototype clips. This also merges #5726's latest work: the past-game XP card and the narrow-bar fix. - **The confirm screen shows the real rewards:** - The Caps tile reads `prestige.caps` from `GET /public/progression/config` (2,500). - An exclusive-cosmetic tile ("Solar Corona · Nuke explosion effect · P5 only") appears only when the config's `flares[]` has a prestige reward for the next rank. This replaces the hard-coded `COSMETIC_RANKS`. - Without a config, the tiles fall back as before. - `ProgressionConfigSchema` gains `levels[].caps/plutonium`, `levelRewards`, `prestige.caps` and `flares[]`. All are tolerant, so an older API still parses. - **You hold your own badge, then the new rank appears:** - The confirm screen's emblem is the player's current badge, e.g. P4 at level 100. - While held, it grows, brightens and glows, through a registered `--hold` property, and trembles past 75%. - When the hold completes, the confirm UI slides away, and the same badge glides to the centre still charging. The tremble becomes the ceremony's shake, and the honeycomb carries on without restarting. - At the flash, the old badge shatters and the new rank's emblem is revealed. The flash lands at hold completion + 2.2 s, or when the server answers, whichever is later. - On failure the move reverses and the error shows. Under reduced motion it crossfades. - **Prestige titles are white for every rank** ("ENTER PRESTIGE 5", and the "PRESTIGE 5" slam). The rank colour stays on the glow, shockwave and shards. - **Profile card opening** (full card only, once per open): - The badge settles, the bar fills, and a sheen crosses the card. - Two rings of blue light run through the card's own hex pattern, clipped to the card. Only a mask animates, and the layer is removed afterwards. - It's off under reduced motion, on a hidden page and inside an iframe. - **A Legend's profile card:** - a thin gold edge and a gold pattern; - a gentle gold ring every 3.6 s, about 30 s in total, then removed; - the Legend line shimmers once. - **Full-screen Legend ceremony** (`LegendCeremony.ts`), played when the confirmed end-of-game result (never the provisional one) makes a player a Legend: - **The sequence:** - dim; - the P10 emblem charges, with gold rays and the honeycomb pulsing gold; - it implodes; - the crown drops: flash, shockwaves, a gold wave across the honeycomb; - "LEGEND" slams in; - "The top of the track · {xp} lifetime XP · {date}"; - Continue. - **Controls:** any click or key skips to the end, and Continue returns to the end-of-game popup. - **Once per account and reduced motion:** it plays once per account (a `localStorage` flag). Reduced motion shows the final state. - **Shared code:** the honeycomb, backdrop, flash, shockwave and slam now live in `Ceremony.ts`, shared by both ceremonies. - **The Legend share row** comes with the merge into #5775, where `<profile-share>` lives. - **The Prestige button's glow** is now an opacity animation on a glow layer, replacing an endless `box-shadow` repaint. **New `en.json` keys:** - `legend_ceremony.continue` - `legend_ceremony.label` - `legend_ceremony.lifetime_xp` - `legend_ceremony.top_of_track` - `prestige.caps` - `prestige.exclusive` - `prestige.exclusive_untyped` `prestige.gain_cosmetic` is removed. **Screenshots and clips** (sample data, Wonder / wonder01). The confirm screen and the hold clip were captured from this branch merged with #5786, which carries the new per-rank emblems. On this branch alone, the badges use the older art. <img width="1000" alt="Prestige confirm going to P5 with the Caps and exclusive tiles" src="https://github.com/user-attachments/assets/84de953a-52a5-488d-b769-92cb1a69cec2" /> The hold, handoff, shatter and P5 reveal: https://github.com/user-attachments/assets/fead94dd-708a-4337-9f96-cfa498eb10aa The profile card opening with the blue pulse: https://github.com/user-attachments/assets/143e9895-bb5b-4e48-b310-b6814ecf7b7b A Legend's profile card, then its gold pulse: <img width="700" alt="Legend profile card, desktop" src="https://github.com/user-attachments/assets/2eedd285-eabc-47ef-8e34-4fab35058dff" /> <img width="300" alt="Legend profile card, phone" src="https://github.com/user-attachments/assets/bb3ea005-cde7-4426-ba0b-8189312d34e6" /> https://github.com/user-attachments/assets/4a99b4a0-dd69-463d-8726-09205b9cca7d The Legend ceremony, from the end-of-game reveal reaching level 100: https://github.com/user-attachments/assets/9fb5fed1-2835-4735-8866-bf10a8084676 **Testing for these additions:** - `PrestigeFlow.test.ts`: - tiles with and without a prestige flare, and Caps from the config; - which badge is held and which is revealed; - the handoff stages, a slow answer, failure reversal and reduced motion; - white titles. - `ProfileCard.test.ts`: the pulse only on the full card, not under reduced motion or on a hidden page, removed after it plays, once per open, gold for a Legend. - `LegendCeremony.test.ts`: the beats, skipping, Escape, Continue, reduced motion and the seen flag. - `WinModalXp.test.ts`: the ceremony only on a confirmed Legend result, once per account. - `FlareCosmetic.test.ts` and `ProgressionSchemas.test.ts`. **Results:** `tsc` and lint are clean, and `tests/client` passes (270 files, 3,508 tests). ### Performance: load the levels screens on demand (2026-10-05) Benchmarks against main showed the levels UI added 9.4% (67 KB gzipped) to the startup download. Screens most sessions never open now load when they're needed: - **Prestige flow** (and the shared `Ceremony` code): downloads as soon as the Prestige button appears. If the download fails, pressing it shows an alert (`prestige.load_failed`) and the next press retries. - **Legend ceremony:** downloads at game end, only for players on their last prestige run. If it isn't ready by the level-100 moment, the in-card Legend pop plays, and the account isn't marked as having seen it. - **End-of-game XP section:** downloads when the game starts, so the reveal never waits. - **Past-game XP card:** downloads with its XP record. - **Reward track** (#5774): downloads when the Progression tab first opens, in parallel with that tab's data. If it fails, the tab shows without the track. Two build fixes go with this: - `FlareCosmetic.ts` now imports `Cosmetics` / `CosmeticPresentation` / `CosmeticPreview` statically. They were in the startup bundle anyway, and this removes three `INEFFECTIVE_DYNAMIC_IMPORT` warnings. - `vite.config.ts` keeps everything on the startup import chain in the entry chunk, so the bundler no longer splits extra startup files. On main it builds byte-identical chunks. **Results:** | Startup JS + CSS | main | before | after | |---|---|---|---| | gzip | 709 KB | 775 KB (+9.4%) | 741 KB (+4.6%) | | Startup files | 4 | 5 | 4 | | Play ready at 20 Mbps / 40 ms | 1,122 ms | 1,137 ms | 1,130 ms | Unthrottled page load is within noise. The prestige, Legend, level-up, Claim and reward-track scenarios pass on the lazy production build. **Tests:** - `WinModalXp`: the ceremony fallback, and downloading only for possible Legends. - `AccountModalPrestige`: the load on press, and the failure alert. - `RewardTrack`: no track when it fails to load, and the parallel download. - `tsc`, lint and `tests/client` pass. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory ## Please put your Discord username so you can be contacted if a bug or regression is found: Iamlewis 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Brings in the explicit-tab fix for the profile modal. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Folded into #5774, which now carries the Progression tab, this PR's profile links and sharing, and #5786's end-of-game rewards as one PR (GitHub shows this one as merged because its commits are now in #5774's branch). Nothing from this PR was dropped; review continues on #5774. 🤖 Generated with Claude Code |
🤖 Claude Code ReviewSkipped: this pull request is already merged (state MERGED), so no review was performed. |
…ing, and end-of-game rewards (#5774) Resolves #5772 Resolves #5773 Part of #5785 and #5760. This PR now combines three that were stacked on each other, so they can be reviewed and merged as one: the Progression tab and reward track (was this PR), profile links and sharing (was #5775), and the end-of-game rewards and XP polish (was #5786, minus its prestige emblems; see below). Every part has been reviewed in its own PR already; the history is kept, with each part's commits as they were. ## What was dropped while combining - **The per-rank prestige emblems** from #5786 (a colour and outline for every rank, P1 bronze to P10 prismatic, winged P5/P10). The designer has replaced that direction with a new unit-icon design, which will come as its own PR. Badges here are main's current ones: `LevelBadge.ts` and its tests are identical to `main`. The cached-template speed-up that only existed to offset those emblems' drawing cost went with them. - **Kept for the rollout:** the reward track still hides itself when the config has no per-level amounts, and the owner still sees the visitor view when level rewards don't say their level. This client can reach players before the API that sends those fields is deployed (2419c2b restores both after an earlier cleanup removed them). - **Duplicates:** one shared `claimRewards()` (`RewardClaims.ts`) now does the claiming for both the reward track's Claim all and the end-of-game rewards row; one reward-amount parser; one set of section styles. - **Stale bits:** a comment saying search visibility wasn't supported yet (it is, in #5771), and a reward-track test fixture still using the old Plutonium cadence (now every 25 levels). --- # Part 1: Progression tab and reward track A **Progression** tab on the player profile: prestige history, and the date each milestone level was first reached in every prestige run. #### The data `GET /public/player/:publicId/progress` gains two optional fields on the backend: - `prestigeHistory: [{ rank, at }]`, ordered by rank; - `milestones: [{ prestige, level, at }]`, for levels 10/25/50/75/100, the first time reached per prestige run. Both are added to `PublicProgressSchema` as `.optional().catch(undefined)`, so older responses still parse and a malformed list reads as absent rather than failing the whole profile. #### The tab (`<profile-progression>`, `PlayerProfileModal.ts`) - **Where:** after Clans, **only when the player has progress**. - If it's asked for before progress arrives (a link or the last tab used), the profile stays on Stats and switches once progress loads. - With no progress it stays on Stats. - **Prestige history:** - one tile per rank from 1 to the current prestige: a 36px badge (level 1 at that rank), "Prestige N" and the date; - tiles are built from the current prestige, so a missing or partial history still lists every rank, just without dates; - at prestige 0: "Hasn't prestiged yet". - **Milestones:** grouped by run, newest first. - The current run ("Current run · Prestige N", or "Current run" at prestige 0) shows all five levels. Unreached ones are faded with "Not yet", and a level passed without a record shows as reached without a date. - Earlier runs show what they reached; a first run at prestige 0 is "First run". - Entries above the current prestige and duplicates are dropped. Any level the API records is shown, even one the client doesn't list, so a milestone added server-side appears without a client change; the client's list only adds the current run's "Not yet" slots. - Legend: "Legend · Prestige 10", all five reached, and level 100 drawn as the Legend badge. - **Dates:** the client's usual `toLocaleDateString(undefined, { year, month: "short", day })`. An unreadable date isn't shown. - The XP each past game earned already shows in `GameStatsModal` (from #5726), so nothing was needed there. #### Popup tabs on narrow screens (`baseComponents/Modal.ts`) With a fourth tab, the shared tab row wrapped onto a second line on phones. Now: - it stays on one row that **scrolls sideways when it doesn't fit**; - it's still centred when it does fit: auto margins on the first and last tab, which, unlike `justify-center`, never push a tab off the left edge out of reach; - the scrollbar is hidden; - the active tab is scrolled into view when it changes. This is shared, so every popup with tabs gets it; wide screens look the same as before. ### Screenshots From this branch's stack (the profile page PR on top), opened from a real `/player/<id>` link at 2x. The profile is sample data given to the popup, because the dev API has no such player. **Desktop, Progression tab:** prestige history, then milestones per run (the current run shows "Not yet" for 50/75/100). <img width="1000" alt="Progression tab on desktop" src="https://github.com/user-attachments/assets/c90abe7a-f94a-40d7-8331-a8f821ed6cc8" /> **Phone:** the tab row stays on one line and scrolls sideways, with Progression scrolled into view. <img width="320" alt="Progression tab on a phone, tab row scrolled" src="https://github.com/user-attachments/assets/ea5907fd-fa6e-4ab1-bce0-bda97b6fbcbe" /> ### Testing - **Schema:** parsing with, without and with malformed `prestigeHistory`/`milestones`. - **Tab:** shown only with progress; the deferred switch when progress arrives late. - **History:** tiles, partial history, the prestige-0 empty state. - **Milestones:** grouping by run, newest first; the current run's "Not yet" slots; reached-without-record; dropped entries; dates; Legend. - **Tab row:** one scrollable row (`flex-nowrap overflow-x-auto`, non-shrinking tabs), and the active tab scrolled into view on change. - **Results:** `tsc`, `npm run lint` and the translation tests pass. In the full suite the only failures were the known `InventoryModal` / `MainInitialize` timeouts, which pass on their own. #### Added (2026-10-04): the reward track A "Reward track · Prestige N" section now heads the Progression tab, above prestige history and milestones. It shows what each level of the current run pays. Approved from mockups ("Layout A", with the next rank's reward inside the end card). - **Run strip:** levels 1–100. - A tick every 10 levels, green dots for Plutonium levels and a purple diamond for cosmetic levels. - A gold "You · 47" knob, and "100 → P{n+1}" (or "100 → Legend") at the end. Past level 85 the two labels merge. - A highlighted window shows the stretch in view. Tapping or dragging the strip jumps the track there. - **Track:** about 15 levels in view on desktop and about 6 on phone. - Arrow buttons sit in their own gutters, never over a level. - Gold fill runs up to your level. Your level is the large badge with "YOU". - Each level shows its Caps and a status: claimed, ready to claim, or locked. - Plutonium levels carry a "+25" chip. - **Cosmetic tags:** each cosmetic level gets a compact tag on its level: preview, name, type, plus "+25" if it also pays Plutonium. The data comes from the progression config's `flares[]`: this run's level rewards, plus every-run ones. - **End card:** "After level 100 · Prestige {n+1} · 2,500 Caps". - If the next rank has an exclusive cosmetic, its tag sits inside the card. - On a P10 run the card is Legend, "The top of the track", with the Legend reward tag. - There's no tag when the next rank has no exclusive. - **Claim bar** (your own profile only): "{n} level rewards to claim · +{caps} Caps · +{plutonium} Plutonium [Claim all]". The Plutonium part only shows when it's above 0. - **What it claims:** only this run's level rewards, never subscription dailies or win Caps. - **Afterwards:** `/users/@me` is refreshed and `rewards-changed` is sent. - **Where the status comes from:** `/users/@me` rewards, which now say which level and prestige run they paid for (openfrontio/infra#907). A passed level with no reward row counts as claimed. - **Visitors** see the track without statuses or the claim bar, and the strip reads "Level 47". - **Pop-in:** everything in the tab (track, history and milestones) pops in, one item after another, about 55 ms apart. Nothing shows before its turn, and the next milestone gets a breathing dashed ring. - It plays once per opening; switching back to the tab shows it at rest. - It's off under reduced motion. - **Code:** - New `RewardTrack.ts` (the model functions and `<reward-track>`). - The client `RewardSchema` gains optional `level` / `prestige`. - `FlareCosmetic.ts` can show a flare's own name and image until the cosmetics catalog answers. - The profile modal's opening counter is shared by every profile modal, so no two openings share a key. **New `en.json` keys:** all under `reward_track`: - `after_level_100`, `caps_amount`, `claim_caps`, `claim_count`, `claim_plutonium`; - `earlier`, `later`; - `key_claimed`, `key_locked`, `key_ready`; - `legend_reward`, `legend_sub`, `levels_label`, `plus`, `prestige_reward`; - `strip_end_legend`, `strip_end_prestige`, `strip_label`, `strip_level`, `strip_start`, `strip_value`, `strip_you`; - `title`, `title_first_run`, `you`. **Screenshots and clip** (built UI at 2x, sample data Wonder / wonder01): Your own profile at level 47, then scrolled to the end for P4 → P5 with the Solar Corona tag. The 34px Solar Corona preview is the catalog's own animated effect, caught between bursts: <img width="1000" alt="Reward track at level 47 with the claim bar" src="https://github.com/user-attachments/assets/dbc28ec4-c341-42b5-ab0c-68b4aa749b32" /> <img width="1000" alt="End of the track with the Prestige 5 card and its reward tag" src="https://github.com/user-attachments/assets/8f82db84-ad69-4b0d-930d-65b4a22a1fcd" /> A P10 run ending in Legend, then P3 → P4 with no exclusive: <img width="1000" alt="End of a P10 run with the Legend card" src="https://github.com/user-attachments/assets/9d8fa4e9-4b89-496c-8cb2-91bd40e53447" /> <img width="1000" alt="End card with no reward tag" src="https://github.com/user-attachments/assets/48d10af3-fa6e-4305-8dfb-b0d8966e5529" /> Phone, then a visitor's view. The visitor still predates a last fix that removed an empty status row, so the live track is about 16px shorter: <img width="300" alt="Reward track on a phone" src="https://github.com/user-attachments/assets/5b0b7325-ed67-493f-9ac9-8399233175b7" /> <img width="700" alt="Reward track as a visitor sees it" src="https://github.com/user-attachments/assets/d7cd4c35-c433-4203-a192-323c74621d5a" /> The pop-in, then a switch to Stats and back, which shows the tab at rest: https://github.com/user-attachments/assets/ec336d4b-ff99-49c1-8bad-0356dfd992e3 **Testing for these additions:** - `RewardTrack.test.ts` (39 tests): - the strip markers, knob and merged label; - level status from the reward fields; - tags from run-specific and every-run flares; - the end card with an exclusive, without one, and the Legend card; - Claim all claims only this run's level rewards, plus the failure path; - Plutonium hidden at 0; - the visitor view; - the removed text absent; - the pop-in order, fill-mode `both`, and no replay on the same opening. - `PlayerProfileProgressionTab.test.ts`: the tab pops in once per opening, not when switching back. **Results:** - `tests/client`, plus the translation and `ApiSchemas` tests: 277 files, 3,726 passed. - `tsc` and lint are clean. #### Performance: load the levels screens on demand (2026-10-05) Benchmarks against main showed the levels UI added 9.4% (67 KB gzipped) to the startup download. Screens most sessions never open now load when they're needed: - **Prestige flow** (and the shared `Ceremony` code): downloads as soon as the Prestige button appears. If the download fails, pressing it shows an alert (`prestige.load_failed`) and the next press retries. - **Legend ceremony:** downloads at game end, only for players on their last prestige run. If it isn't ready by the level-100 moment, the in-card Legend pop plays, and the account isn't marked as having seen it. - **End-of-game XP section:** downloads when the game starts, so the reveal never waits. - **Past-game XP card:** downloads with its XP record. - **Reward track** (#5774): downloads when the Progression tab first opens, in parallel with that tab's data. If it fails, the tab shows without the track. Two build fixes go with this: - `FlareCosmetic.ts` now imports `Cosmetics` / `CosmeticPresentation` / `CosmeticPreview` statically. They were in the startup bundle anyway, and this removes three `INEFFECTIVE_DYNAMIC_IMPORT` warnings. - `vite.config.ts` keeps everything on the startup import chain in the entry chunk, so the bundler no longer splits extra startup files. On main it builds byte-identical chunks. **Results:** | Startup JS + CSS | main | before | after | |---|---|---|---| | gzip | 709 KB | 775 KB (+9.4%) | 741 KB (+4.6%) | | Startup files | 4 | 5 | 4 | | Play ready at 20 Mbps / 40 ms | 1,122 ms | 1,137 ms | 1,130 ms | Unthrottled page load is within noise. The prestige, Legend, level-up, Claim and reward-track scenarios pass on the lazy production build. **Tests:** - `WinModalXp`: the ceremony fallback, and downloading only for possible Legends. - `AccountModalPrestige`: the load on press, and the failure alert. - `RewardTrack`: no track when it fails to load, and the parallel download. - `tsc`, lint and `tests/client` pass. --- # Part 2: Profile links and sharing `openfront.io/player/<publicId>` now opens that player's profile directly, so a shared link lands on a real page. The backend's link previews make the same URL unfurl in Discord, X and WhatsApp with the player's level and card. #### Opening from a link (`ModalRouter.ts`, `Main.ts`) - `ModalRouter` gains `registerPath()` and `routeFromPath()`; `Main.handleUrl` tries the path just before the hash. - **Accepted:** `/player/<id>`, with or without a trailing slash, behind `/w<n>/` and `/v/<commit>/`, with an optional `?tab=stats|games|clans|progression`. - Ids must match `[A-Za-z0-9_-]{1,22}`; anything else is ignored. The hash form (`#modal=profile&publicID=…`) still works. - **While open from a path,** the URL keeps the path and tab changes are written to `?tab=`. - **Closing** (Back or Escape) goes home and replaces the URL with `/` (or `/v/<pin>/` on a version-pinned page), so a reload doesn't reopen it. Opening another popup in-app hands the URL back to the hash form. #### Links (`utilities/PlayerProfileUrl.ts`) - `playerProfileUrl()` is now `shareOrigin()/player/<id>`: the page's own origin on the web, and the site origin in the desktop shell, the same as lobby invite links. - Callers: the profile header chip, the account "Share Profile" button, and the nav account menu's copy action. - The friends box also accepts a pasted `/player/<id>` link. #### Share row (`components/ProfileShare.ts`) Under the profile card: - **Copy link:** copies the canonical URL, shows the app's "Copied!" toast, and announces it through an sr-only `aria-live` status, since the toast isn't a live region. - **Share on X:** opens `https://x.com/intent/post?url=…&text=…` with `noopener,noreferrer`. The desktop shell already sends https links to the system browser. - **Copy for Discord:** "Check out {name}'s profile on OpenFront", then the link on the next line, so it pastes as a ready message; Discord unfurls the link. - **On touch devices with `navigator.share`:** one **Share** button opens the native share sheet instead of the three. It's gated on touch because desktop Chrome on Windows also exposes `navigator.share`. A dismissed sheet does nothing; any other failure falls back to copying the link. #### "Player not found" Replaces the old 📊 line with the approved design: - a person-with-question-mark icon in a circle; - "Player not found" and "We couldn't find a player at this link. It may be mistyped, or the account may have been deleted."; - the opened link, shown faintly, only when opened from a path; - **Play OpenFront** (home) and **View leaderboard** (closes the profile and opens the leaderboard): side by side from `sm` up, stacked on a phone. The old `player_profile.not_found` key is removed; the translation test fails on unused keys. #### Server (`src/server/PlayerProfileRoute.ts`) - The master's SPA fallback already serves the app shell at a bare `/player/<id>`. - `/w<n>/player/<id>` reaches a worker, which returned 404, so `registerPlayerProfileRoute` serves the shell there, in the `GamePreviewRoute` style. - The dev proxy bypass covers `/w<n>/player/` as well as `/w<n>/game/`. ### Screenshots From this branch at 2x, opened from real `/player/<id>` links. The profile is sample data given to the popup, because the dev API has no such player. The not-found shots are the real failure path. **Desktop:** the profile card and the share row. Signed-in and signed-out viewers see the same page (the "New to OpenFront?" banner was removed in a18d74e). <img width="1000" alt="Profile page Stats tab with share buttons" src="https://github.com/user-attachments/assets/57331d4c-cabd-4520-84cd-947ea8fd772c" /> **Desktop, Progression** (from #5774), opened with `?tab=progression`: <img width="1000" alt="Profile page Progression tab" src="https://github.com/user-attachments/assets/c90abe7a-f94a-40d7-8331-a8f821ed6cc8" /> **Player not found** (the real failure path, from `/player/aB3dE5fX`): <img width="1000" alt="Player not found screen on desktop" src="https://github.com/user-attachments/assets/1a4137ac-efaf-430c-9c5b-6b434761a793" /> **Phone:** Stats (the three share buttons collapse to a native Share button on touch devices with share support; headless Chrome has none, so the three show here), Progression, and Player not found with the buttons stacked. <img width="300" alt="Profile page on a phone" src="https://github.com/user-attachments/assets/a978817d-5b2c-43ee-9081-a8b9d812d224" /> <img width="300" alt="Progression tab on a phone" src="https://github.com/user-attachments/assets/ea5907fd-fa6e-4ab1-bce0-bda97b6fbcbe" /> <img width="300" alt="Player not found on a phone" src="https://github.com/user-attachments/assets/f6e3c7f5-288b-41bd-87ba-f0c34065348e" /> #### Added: sharing level-up moments Players can now share a milestone, Legend or a new prestige rank, using the same Copy link / Share on X / Copy for Discord options as the profile. The link is the profile with the moment, e.g. `/player/wonder01?moment=level50`. openfrontio/infra#910 unfurls it as that moment's celebration card. - **End-of-game popup:** - A "Share Level 50" or "Share Legend" button sits in the footer, between Exit Game and Keep playing. - It opens the three options as a menu above it. On a touch device with a share sheet, it opens the share sheet directly. - On phones it takes its own full-width row above the other two buttons. - **When it shows:** - only for the server's confirmed result, never the early estimate shown when a player is eliminated; - only once the reveal has finished or been skipped; - only for a signed-in player with a public ID; - only when the game reached a milestone (10, 25, 50, 75 or 100) or made the player a Legend. With several milestones in one game it shares the highest, and Legend wins over any level. - **Prestige ceremony:** a share row with a dark backing sits between the title and Continue. Clicks on it don't close the ceremony. - **Code:** - `<profile-share>` defines its actions once and gains a `menu` layout. The profile's own row works as before. - `MomentShare.ts` works out what to share. - `GameXpPanel` fires `xp-reveal-settled` when the reveal ends. - `PrestigeFlow` takes a `publicId`. - **New `en.json` keys:** `progression.share_moment_level`, `share_moment_legend`, `share_text_level`, `share_text_legend`, `prestige.share_text`. Screenshots from this branch at 2x. The XP results are sample data given to the popup. In dev the link starts with the local origin; in production it's `https://openfront.io`. **Level 50, closed and open, and Legend:** <img width="700" alt="Share Level 50 button in the end-of-game footer" src="https://github.com/user-attachments/assets/4571dc04-eb2a-4d61-bca7-2e04fb589265" /> <img width="700" alt="Share menu open above the button" src="https://github.com/user-attachments/assets/227a8b31-dfa2-4430-9e60-92cca0275ca8" /> <img width="700" alt="Share Legend with the menu open" src="https://github.com/user-attachments/assets/42f5b09e-187f-4653-9640-b998a9bb1b50" /> **Phone, closed and open:** <img width="300" alt="Share button on its own row on a phone" src="https://github.com/user-attachments/assets/482570aa-51cd-42ad-8ff3-2aa29780211e" /> <img width="300" alt="Share menu open on a phone" src="https://github.com/user-attachments/assets/7a891971-5042-411e-bef5-9b80805ac0a0" /> **Prestige ceremony, desktop and phone:** <img width="700" alt="Prestige ceremony with share row" src="https://github.com/user-attachments/assets/3c05a8f9-cb7f-4e74-90ad-16abfb40bb28" /> <img width="300" alt="Prestige ceremony share row on a phone" src="https://github.com/user-attachments/assets/496faa57-48ba-4ab4-b5e6-28c5f2d2b9c1" /> **Testing for these additions:** - `MomentShare.test.ts`, plus 7 menu tests in `ProfileShare`, 8 in `WinModalXp` and 3 in `PrestigeFlow`. They cover: - result-only, never for the early estimate; - hidden mid-reveal, signed out, or with no public ID; - the highest milestone chosen, and Legend over a level; - the link and text built correctly; - clicks on the ceremony's share row not closing it. - All 3,528 client tests pass. `tsc` and lint are clean. #### Added (2026-10-04): share ring on hover, and a share row on the Legend ceremony - **Share ring:** - **The flash:** a quick yellow ring flashes around a share button when the mouse moves onto it, or on keyboard focus (`:focus-visible`). It's at full strength instantly, held for 80 ms, then fades over 320 ms while growing to 1.04×. - **When it plays:** once per hover; a hover while it's still playing is ignored. It's never shown on touch devices (`(hover: hover) and (pointer: fine)` only) or under reduced motion. - **How it's drawn:** `ProfileShare` renders its own ring element and plays it with `ring.animate(...)`. It's not a pseudo-element on the trigger, because the trigger's `.win-action::after` is already its hover streak. - **Where it appears:** the end-of-game footer button, the prestige ceremony's row and the profile page's row. The share buttons no longer rise in; they're simply there. - **The Legend ceremony** (from #5734) now has the share row between "The top of the track…" and Continue, linking `?moment=legend` the same way the prestige ceremony does. - It stops clicks from reaching the ceremony, and doesn't show without a public ID. - Tab now cycles through the share buttons and Continue inside the ceremony, with Continue still focused first. **Built UI, sample data:** The share button in the end-of-game footer, hovered twice: https://github.com/user-attachments/assets/ff34f99d-9e14-4b07-9929-0b23f29d7bf3 The Legend ceremony's final screen with the share row: <img width="1000" alt="Legend ceremony final screen with Copy link, Share on X and Copy for Discord" src="https://github.com/user-attachments/assets/222e5512-937f-43d0-ae36-ce23aeea49a5" /> **Tests:** - `ProfileShare.test.ts`: the ring element, one `animate` per hover with the exact keyframes, a hover during playback ignored, focus-visible, touch and pen ignored, nothing under reduced motion or a coarse pointer. - `LegendCeremony.test.ts`: the row only at the end, between the line and Continue; URL and text; clicks kept; no row without an ID; Tab cycling. - `WinModalXp.test.ts`: the `?moment=legend` URL. **Results:** `tsc` and lint are clean, and `tests/client` passes (3,654). ### Testing - **New:** - `PlayerProfilePage.test.ts`: path parsing in every form, invalid ids ignored, opening on load, `?tab=` sync, closing resets the URL, the not-found state; - `PlayerProfileUrl.test.ts`; - `ProfileShare.test.ts`: copy calls the clipboard, X URL encoding, the native share path and its fallback; - `tests/server/PlayerProfileRoute.test.ts`. - **Updated:** `ShareBase`, `NavAccountMenu`, `FriendsListPublicIdInput`, and the profile tests' mocks. - **Results:** 63 tests pass in the 8 targeted files, plus the translation tests. `tsc` and `npm run lint` are clean. In the full suite the only failures were the known `InventoryModal` / `MainInitialize` timeouts, which pass on their own. --- # Part 3: End-of-game level rewards and XP polish Approved from mockups and prototype clips. ### 1. The XP bar's leading edge glows while it fills While the bar fills, its front edge glows and a sheen runs along the fill. It uses `::after` / `::before`, moved by transform only. It shows only while `barAnimate` is on, and it's off under reduced motion. ### 2. A game's level rewards, shown and claimed in the reveal When the confirmed result (never the provisional one) levels you up, a row shows what those levels paid. It reads `rewards` / `flares` from `GET /users/@me/xp/:gameId` (openfrontio/infra#907, #911): - **One level:** "Level 47 reward +100 Caps". - **Several levels:** "Levels 45–47 +300 Caps (3 × 100)". - **A milestone:** "Level 50 rewards +100 Caps +25 Plutonium". - **A cosmetic unlocked:** "Unlocked · Firebird Trail · Boat trail · in your Locker" wipes into the milestone card. On phones it wraps under it. How the row behaves: - **Arrival:** it rises in after the level-up lands, and its amounts count up. - **Claim:** it claims only this game's reward ids. It shows "Claiming", then "✓ Claimed" with the balance counting up. - On failure: an inline error, and a retry claims only what didn't go through. - If the rewards were already claimed, it opens in the claimed state. - **Account refresh:** after a game that unlocked a cosmetic, `/users/@me` is refreshed so the Locker shows it. - **Reduced motion:** the final state, without animation. - **Code:** `GameRewards.ts` (sums, claiming, unlock lookup) and `GameXpRewardSchema` / `GameXpFlareSchema`. The new fields are optional, so an older API still parses. ### 3. The nav's XP bar catches up after a game When a refreshed `/users/@me` shows more XP, the account menu's bar fills to the end, the badge pops as the new level lands, the bar resets, then it grows to the new progress. - **When it animates:** only when XP went up, the menu is on screen, the page is visible and reduced motion is off. First load, a prestige and anything else snap straight to the new value. - **Overlap fix:** the badge and avatar now sit in their own row, with the bar only under them. The avatar is 28px while there's a bar, and the email badge moved to the icon's top corner. The 32px avatar used to reach 3px down into the bar. That overlap was already on #5726. ### 4. Reduced motion on the "calculating" spinner The reveal's spinner now has `motion-reduce:animate-none`. #### New `en.json` keys All under `progression`: - `rewards_balance` - `rewards_balance_caps` - `rewards_claimed` - `rewards_claiming` - `rewards_each` - `rewards_level_many` - `rewards_level_one` - `rewards_levels` - `unlock_in_locker` - `unlocked` - `unlocked_cosmetic` #### Screenshots From the branch at 2x with sample data (Wonder / wonder01). **These were taken before the prestige emblems were dropped**, so any badge at prestige 1 or above in them shows the old per-rank emblem; the shipped badges are main's. Nothing else in them changed. **The bar's leading edge while it fills:** https://github.com/user-attachments/assets/88d2005b-98b5-481b-8aa0-b25d4132af0f **Rewards: one level, several levels, a milestone with an unlock, and claimed:** <img width="700" alt="Level 47 reward row" src="https://github.com/user-attachments/assets/4868b4f4-122c-48e3-91fb-f69db14137a9" /> <img width="700" alt="Levels 45 to 47 reward row" src="https://github.com/user-attachments/assets/4adccdaa-c699-4a74-8de1-914237dfca3a" /> <img width="700" alt="Level 50 milestone with an unlocked cosmetic and rewards" src="https://github.com/user-attachments/assets/acecab8c-6cb2-4252-af4c-bf75a3a54cad" /> <img width="700" alt="Rewards claimed with the new balance" src="https://github.com/user-attachments/assets/185c60e4-f23a-42ce-b63c-86b389d02b4b" /> **The full reveal to level 50, the unlock, the rewards row and a Claim:** https://github.com/user-attachments/assets/5fac69a5-4a22-4bee-bc2d-e8a5e29a7964 **Phone, milestone:** <img width="300" alt="Milestone rewards on a phone" src="https://github.com/user-attachments/assets/35481bec-8da1-415e-8ba7-6c62b20dc268" /> **Nav menu at rest, then catching up after a game:** <img width="500" alt="Nav account menu with the XP bar under the badge and avatar" src="https://github.com/user-attachments/assets/9c4ed6f5-8495-46c7-902a-02e6c59ea0c4" /> https://github.com/user-attachments/assets/b5d63de2-43d6-4e3a-88d3-9efa565acfb3 #### Testing **New:** - `GameXpRewards.test.ts` (18 tests): - result only; - one, several and milestone; - the unlock; - Claim calls only this game's ids; - claimed, failure and retry, and already claimed; - mid-reveal clicks; - reduced motion. - `NavAccountCatchUp.test.ts`: when it animates or snaps, the fill → pop → reset → grow sequence, the fallback, and the layout (bar inside the badge-and-avatar row; the avatar node is kept). **Updated:** - `GameXpPanelReveal.test.ts`: the bar edge class and the spinner. - `ProgressionSchemas.test.ts`. - `WinModalXp.test.ts`. **Results:** `npx tsc --noEmit` and `npm run lint` are clean. The translation tests and all of `tests/client` (277 files, 3,574 tests) pass. --- ## Checks after combining - `npm run typecheck` and `npm run lint` pass. - Full `npx vitest --run`: 8,200 passed; the only failures are the Windows-only `UpdateRegister` tests, which fail on `main` too. - New `tests/client/RewardClaims.test.ts` (6 tests) for the shared claim helper, including a reward the API refuses as held (#5879). ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory ## Please put your Discord username so you can be contacted if a bug or regression is found: Iamlewis 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Resolves #5773
Stacked on #5774 (the Progression tab), which is stacked on #5734. The base is that PR's branch, so this diff shows only this change. Part of #5760.
Description:
openfront.io/player/<publicId>now opens that player's profile directly, so a shared link lands on a real page. The backend's link previews make the same URL unfurl in Discord, X and WhatsApp with the player's level and card.Opening from a link (
ModalRouter.ts,Main.ts)ModalRoutergainsregisterPath()androuteFromPath();Main.handleUrltries the path just before the hash./player/<id>, with or without a trailing slash, behind/w<n>/and/v/<commit>/, with an optional?tab=stats|games|clans|progression.[A-Za-z0-9_-]{1,22}; anything else is ignored. The hash form (#modal=profile&publicID=…) still works.?tab=./(or/v/<pin>/on a version-pinned page), so a reload doesn't reopen it. Opening another popup in-app hands the URL back to the hash form.Links (
utilities/PlayerProfileUrl.ts)playerProfileUrl()is nowshareOrigin()/player/<id>: the page's own origin on the web, and the site origin in the desktop shell, the same as lobby invite links./player/<id>link.Share row (
components/ProfileShare.ts)Under the profile card:
aria-livestatus, since the toast isn't a live region.https://x.com/intent/post?url=…&text=…withnoopener,noreferrer. The desktop shell already sends https links to the system browser.navigator.share: one Share button opens the native share sheet instead of the three. It's gated on touch because desktop Chrome on Windows also exposesnavigator.share. A dismissed sheet does nothing; any other failure falls back to copying the link."Player not found"
Replaces the old 📊 line with the approved design:
smup, stacked on a phone.The old
player_profile.not_foundkey is removed; the translation test fails on unused keys.Server (
src/server/PlayerProfileRoute.ts)/player/<id>./w<n>/player/<id>reaches a worker, which returned 404, soregisterPlayerProfileRouteserves the shell there, in theGamePreviewRoutestyle./w<n>/player/as well as/w<n>/game/.Not in this PR
The "Show my profile in search engines" setting needs a backend flag and the site Worker's
noindex. A code comment inrenderProfilemarks where it will connect.Screenshots
From this branch at 2x, opened from real
/player/<id>links. The profile is sample data given to the popup, because the dev API has no such player. The not-found shots are the real failure path.Desktop: the profile card and the share row. Signed-in and signed-out viewers see the same page (the "New to OpenFront?" banner was removed in a18d74e).
Desktop, Progression (from #5774), opened with
?tab=progression:Player not found (the real failure path, from
/player/aB3dE5fX):Phone: Stats (the three share buttons collapse to a native Share button on touch devices with share support; headless Chrome has none, so the three show here), Progression, and Player not found with the buttons stacked.
Added: sharing level-up moments
Players can now share a milestone, Legend or a new prestige rank, using the same Copy link / Share on X / Copy for Discord options as the profile. The link is the profile with the moment, e.g.
/player/wonder01?moment=level50. openfrontio/infra#910 unfurls it as that moment's celebration card.End-of-game popup:
When it shows:
With several milestones in one game it shares the highest, and Legend wins over any level.
Prestige ceremony: a share row with a dark backing sits between the title and Continue. Clicks on it don't close the ceremony.
Code:
<profile-share>defines its actions once and gains amenulayout. The profile's own row works as before.MomentShare.tsworks out what to share.GameXpPanelfiresxp-reveal-settledwhen the reveal ends.PrestigeFlowtakes apublicId.New
en.jsonkeys:progression.share_moment_level,share_moment_legend,share_text_level,share_text_legend,prestige.share_text.Screenshots from this branch at 2x. The XP results are sample data given to the popup. In dev the link starts with the local origin; in production it's
https://openfront.io.Level 50, closed and open, and Legend:
Phone, closed and open:
Prestige ceremony, desktop and phone:
Testing for these additions:
MomentShare.test.ts, plus 7 menu tests inProfileShare, 8 inWinModalXpand 3 inPrestigeFlow. They cover:tscand lint are clean.Added (2026-10-04): share ring on hover, and a share row on the Legend ceremony
:focus-visible). It's at full strength instantly, held for 80 ms, then fades over 320 ms while growing to 1.04×.(hover: hover) and (pointer: fine)only) or under reduced motion.ProfileSharerenders its own ring element and plays it withring.animate(...). It's not a pseudo-element on the trigger, because the trigger's.win-action::afteris already its hover streak.?moment=legendthe same way the prestige ceremony does.17a3aa555) and Add profile cards and the prestige flow #5734's latest (c6383aeb6).Built UI, sample data:
The share button in the end-of-game footer, hovered twice:
P6-share-ring-footer-hover.mp4
The Legend ceremony's final screen with the share row:
Tests:
ProfileShare.test.ts: the ring element, oneanimateper hover with the exact keyframes, a hover during playback ignored, focus-visible, touch and pen ignored, nothing under reduced motion or a coarse pointer.LegendCeremony.test.ts: the row only at the end, between the line and Continue; URL and text; clicks kept; no row without an ID; Tab cycling.WinModalXp.test.ts: the?moment=legendURL.Results:
tscand lint are clean, andtests/clientpasses (3,654).Testing
PlayerProfilePage.test.ts: path parsing in every form, invalid ids ignored, opening on load,?tab=sync, closing resets the URL, the not-found state;PlayerProfileUrl.test.ts;ProfileShare.test.ts: copy calls the clipboard, X URL encoding, the native share path and its fallback;tests/server/PlayerProfileRoute.test.ts.ShareBase,NavAccountMenu,FriendsListPublicIdInput, and the profile tests' mocks.tscandnpm run lintare clean. In the full suite the only failures were the knownInventoryModal/MainInitializetimeouts, which pass on their own.Please complete the following:
Please put your Discord username so you can be contacted if a bug or regression is found:
Iamlewis
🤖 Generated with Claude Code