Skip to content

Player profile: Progression tab, reward track, profile links and sharing, and end-of-game rewards - #5774

Merged
evanpelle merged 120 commits into
mainfrom
profile-progression
Oct 12, 2026
Merged

evanpelle merged 120 commits into
mainfrom
profile-progression

Conversation

@iiamlewis

@iiamlewis iiamlewis commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

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 Distinct prestige rank emblems, level-up rewards in the reveal, and XP bar polish #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 Privacy settings: hide my level, and keep my profile out of search engines #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 Show player levels and the XP each game earns #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).

Progression tab on desktop

Phone: the tab row stays on one line and scrolls sideways, with Progression scrolled into view.

Progression tab on a phone, tab row scrolled

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:

Reward track at level 47 with the claim bar End of the track with the Prestige 5 card and its reward tag

A P10 run ending in Legend, then P3 → P4 with no exclusive:

End of a P10 run with the Legend card End card with no reward tag

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:

Reward track on a phone Reward track as a visitor sees it

The pop-in, then a switch to Stats and back, which shows the tab at rest:

7-progression-tab-pop-in-60fps.mp4

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 (Player profile: Progression tab, reward track, profile links and sharing, and end-of-game rewards #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).

Profile page Stats tab with share buttons

Desktop, Progression (from #5774), opened with ?tab=progression:

Profile page Progression tab

Player not found (the real failure path, from /player/aB3dE5fX):

Player not found screen on desktop

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.

Profile page on a phone Progression tab on a phone Player not found on a phone

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:

Share Level 50 button in the end-of-game footer Share menu open above the button Share Legend with the menu open

Phone, closed and open:

Share button on its own row on a phone Share menu open on a phone

Prestige ceremony, desktop and phone:

Prestige ceremony with share row Prestige ceremony share row on a phone

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 Add profile cards and the prestige flow #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:

P6-share-ring-footer-hover.mp4

The Legend ceremony's final screen with the share row:

Legend ceremony final screen with Copy link, Share on X and Copy for Discord

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 Show player levels and the XP each game earns #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:

clip-bar-edge.mp4

Rewards: one level, several levels, a milestone with an unlock, and claimed:

Level 47 reward row Levels 45 to 47 reward row Level 50 milestone with an unlocked cosmetic and rewards Rewards claimed with the new balance

The full reveal to level 50, the unlock, the rewards row and a Claim:

clip-rewards-claim.mp4

Phone, milestone:

Milestone rewards on a phone

Nav menu at rest, then catching up after a game:

Nav account menu with the XP bar under the badge and avatar
clip-nav-catchup-v2.mp4

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 (Rewards: show level Plutonium held until the account is trusted #5879).

Please complete the following:

  • I have added screenshots for all UI updates
  • I process any text displayed to the user through translateText() and I've added it to the en.json file
  • 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

iiamlewis and others added 20 commits October 1, 2026 00:16
A level badge (level bands, prestige emblems, the Legend crown) and an XP
bar for the account menu, player profiles and the game stats modal.

On the end-of-game screen, the game's XP is revealed a source at a time:
a caption per source rolls in over a segmented bar that climbs, pausing
with an animation at each level reached, while each source's card lands
below. Multipliers then wipe across the cards in the bonus's colours, a
subscription tier's own (Sovereign, Warlord, Vanguard), and a milestone
or Legend gets its own card. Tapping skips to the end. The modal's
buttons get the store's texture and a hover.

Every API state is handled (signed out, calculating, ineligible, result),
and nothing shows while the API reports progression is off.

Resolves #5725

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CodeQL flags the regex (incomplete multi-character sanitization). These
tests only compare SVG markup, but removing the comment nodes through the
DOM is simpler to read and leaves nothing to flag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A profile card heads the Stats tab and public profiles, with a compact
row on the Account tab. At level 100 your own card offers Prestige: a
full-screen confirmation (the new emblem, the prestige track, what it
unlocks), held down to confirm, then a ceremony on the menu background.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- End a hold when the button loses focus, the window blurs or the tab is
  hidden, so a key released elsewhere can't let it run to a prestige.
- Ignore open() while the flow is up, focus the hold button on open, keep
  Tab inside the confirmation, and take Escape before the page behind does
  (cancel the confirmation; skip, then close, the ceremony).
- Keep the idempotency key per rank until a prestige succeeds, so reopening
  after a lost answer gets that prestige back instead of a 409. A 409 now
  reloads the account; any other failure no longer claims nothing changed.
- Give each profile card its own hex pattern id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Parse a malformed /users/@me progress object as absent instead of
  failing the whole profile (which made the player look signed out).
- Decide "signed out" for the XP panel from auth state: a /users/@me
  failure with a live session now hides the panel rather than telling a
  signed-in player to sign in. The rule lives in ProgressionAccount and
  is shared by WinModal and GameStatsModal, so both accept a CrazyGames
  session.
- Show a player who was already a Legend with the Legend frame for the
  whole reveal.
- Remove each poll wait's abort listener when its timer fires.
- Load the profile's level independently of the profile itself, still
  dropping a response for a superseded load.
- Add a keyboard-reachable skip button for the reveal, and give the XP
  progress bars an accessible name and value text.
- Translate the prestige tab's "P{prestige}" label.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On this branch the public profile shows the level on <profile-card>, not
the summary #5726's tests look for.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Re-read /users/@me after a prestige and broadcast it, so the header's
  account menu shows the new rank (the purchase flows' pattern). A
  replayed answer's rewards are listed once.
- Keep focus in the overlay through every stage: on the dialog while the
  request is out, on the ceremony while it plays, then on Continue. Tab
  is trapped throughout.
- prestigeMe reports why it failed: refused (409), signed out (no session,
  401/403) or failed. A signed-out player is told to sign in, and a 409
  now says the account was refreshed (or asks for a reload).
- Draw the hold's fill straight onto its element, so a hold no longer
  re-renders the whole overlay every frame.
- celebrate() is documented as the preview entry point and ignored while
  the flow is up.
- Direct tests for prestigeMe's header and status mapping, and for the
  account page's handling of prestiged and prestige-stale.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, abortable XP fetch

- Explain games recorded ineligible as "unverified" (stats the votes did
  not agree on) instead of the generic line.
- Only play the XP reveal while the win modal is on screen: a result that
  lands after it is dismissed shows in its final state, and dismissing it
  mid-reveal jumps to the end. A panel removed mid-reveal also ends it, so
  it never comes back half-revealed and aria-busy.
- Pass pollGameXp's abort signal into the request itself (combined with its
  timeout via AbortSignal.any), so aborting cancels a request in flight.
- Default the breakdown's per-source fields and leftEarly, so XP rows
  scored under the first XP rules (no `played`/`leftEarly`) still parse.
- Read your own profile's level from the public endpoint, not the
  /users/@me copy cached at page load.
- Put the level in the nav account button's accessible name, which its
  aria-label otherwise hid from screen readers.
- Drop xpBar's unused animate/durationMs options and fix the milestone
  comment that mentioned a share button.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	src/client/PlayerProfileModal.ts
#	tests/client/ProgressionModals.test.ts
The API now pays +100 XP for the first eligible public game each UTC day,
win or lose, instead of +200 for the first win, and reports it as
`firstGame` in the XP breakdown. Rename the schema field, the line key and
its label ("First game of the day"). Feats stay in the breakdown but are
always 0 for now; zero lines are already left off the cards.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The subscription tiers' XP multipliers move from 1.2x / 1.3x / 1.5x to
1.25x / 1.5x / 2x, so the client now recognises Vanguard at 1250,
Warlord at 1500 and Sovereign at 2000 permille. The bonus line's percent
is still derived from the multiplier ("+100% XP (Sovereign bonus)"), and
any other value, the old ones included, shows as a plain subscriber bonus.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The public progress response can now carry the player's prestige history
(when each rank was entered) and milestones (the first time each of levels
10/25/50/75/100 was reached in each prestige run). Both are optional, and a
malformed list reads as absent rather than failing the whole response.

The profile gains a Progression tab after Clans, only for a player with a
level:
- Prestige history: one tile per rank entered with its badge and date, or
  "Hasn't prestiged yet".
- Milestones: grouped by run, newest first. The current run shows all five
  milestones, the ones still ahead faded as "Not yet"; earlier runs show
  only what they reached. A Legend's run is named for it, and its level 100
  wears the Legend badge.

Dates use the viewer's locale in the same day/short-month/year format as
the other dates in the client. A link (or the tab last left open) asking
for Progression selects it once the progress arrives, and stays on Stats
when there is none. The reward track will join the tab later.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With a fourth tab (Progression) the tab row wrapped onto a second line at
phone width. It now stays on one row that scrolls sideways when it doesn't
fit, still centred when it does, and the active tab is scrolled into view.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
@iiamlewis iiamlewis added this to the v35 milestone Oct 3, 2026
@iiamlewis iiamlewis added UI/UX UI/UX changes including assets, menus, QoL, etc. Feature area:client Best-guess area: client (rendering, UI, web components) labels Oct 3, 2026
@iiamlewis iiamlewis self-assigned this Oct 3, 2026
@coderabbitai

coderabbitai Bot commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: de3fc11d-0cb8-40f5-b120-763ce22f08e0

📥 Commits

Reviewing files that changed from the base of the PR and between d8b1144 and 2d2f79b.


📒 Files selected for processing (47)
  • packages/shared/src/ApiSchemas.ts
  • resources/lang/en.json
  • src/client/AccountModal.ts
  • src/client/GameRewards.ts
  • src/client/Main.ts
  • src/client/ModalRouter.ts
  • src/client/MomentShare.ts
  • src/client/PlayerProfileModal.ts
  • src/client/Progression.ts
  • src/client/RewardClaims.ts
  • src/client/components/FriendsList.ts
  • src/client/components/GameXpPanel.ts
  • src/client/components/LegendCeremony.ts
  • src/client/components/NavAccountMenu.ts
  • src/client/components/PrestigeFlow.ts
  • src/client/components/ProfileProgression.ts
  • src/client/components/ProfileShare.ts
  • src/client/components/ProgressionSection.ts
  • src/client/components/RewardTrack.ts
  • src/client/components/XpBar.ts
  • src/client/hud/layers/WinModal.ts
  • src/client/utilities/PlayerProfileUrl.ts
  • src/server/PlayerProfileRoute.ts
  • src/server/Worker.ts
  • tests/client/AccountGameStatsNavigation.test.ts
  • tests/client/GameXpPanelReveal.test.ts
  • tests/client/GameXpRewards.test.ts
  • tests/client/LegendCeremony.test.ts
  • tests/client/MomentShare.test.ts
  • tests/client/NavAccountCatchUp.test.ts
  • tests/client/NavAccountMenu.test.ts
  • tests/client/PlayerProfileClansTab.test.ts
  • tests/client/PlayerProfileModal.test.ts
  • tests/client/PlayerProfilePage.test.ts
  • tests/client/PlayerProfileProgressionTab.test.ts
  • tests/client/PlayerProfileUrl.test.ts
  • tests/client/PrestigeFlow.test.ts
  • tests/client/ProfileGameStatsNavigation.test.ts
  • tests/client/ProfileShare.test.ts
  • tests/client/ProgressionSchemas.test.ts
  • tests/client/RewardClaims.test.ts
  • tests/client/RewardTrack.test.ts
  • tests/client/ShareBase.test.ts
  • tests/client/components/FriendsListPublicIdInput.test.ts
  • tests/client/graphics/layers/WinModalXp.test.ts
  • tests/server/PlayerProfileRoute.test.ts
  • vite.config.ts

🚧 Files skipped from review as they are similar to previous changes (1)
  • resources/lang/en.json

Included review availability: This review used your included allowance. Your plan provides up to 8 included reviews per hour; 7 remain after this review.



Priority: ⬇️ Low

Change: Feature

Merge Risk: ⚪ Minimal · up to 2d2f7

The Progression tab, reward track, profile links, and sharing features have no open defects in this review. The earlier tab-selection and reward-track concerns appear addressed, so the change is ready to merge after normal checks.

Pre-merge checks | Passed 3 | Failed 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Out of Scope Changes check Warning The PR includes changes not connected to [#5772]. These changes add profile URL routing and sharing, player-not-found actions, end-of-game reward reveals, XP-bar catch-up, prestige and Legend ceremony… Split the unrelated profile-sharing, end-of-game reward, ceremony, server-route, and startup-bundle changes into separate PRs, or provide active directly linked issue requirements that justify each group.
Docstring Coverage Warning Docstring coverage is 53.13% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 96 functions across 63 files. (1 skipped:… Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Linked Issues check Passed The PR meets the coding requirements in [#5772]. It adds the conditional Progression tab, prestige history, milestone runs, dates, current-run “Not yet” entries, optional tolerant schemas, and one-row…
Title check Passed The title clearly summarizes the pull request's main changes: the profile Progression tab and reward track, profile links and sharing, and end-of-game rewards.
Description check Passed The description is directly related to the changeset and provides detailed objectives, implementation notes, screenshots, testing results, and scope for all combined parts.

Full details: Out of Scope Changes check

Explanation

The PR includes changes not connected to [#5772]. These changes add profile URL routing and sharing, player-not-found actions, end-of-game reward reveals, XP-bar catch-up, prestige and Legend ceremony sharing, server profile routes, and startup lazy-loading changes. The supplied linked-issue evidence does not provide active coding requirements that connect these features to [#5772].


Full details: Docstring Coverage

Explanation

Docstring coverage is 53.13% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 96 functions across 63 files. (1 skipped: 1 unsupported.)


  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

- The profile's search-visibility comment described an API that has since
  shipped; removed.
- The reward track no longer hides itself for a config without per-level
  amounts (trackHasRewards), nor shows the owner the visitor view when
  level rewards lack level/prestige: the API always sends both now. A level
  reward missing them is simply left off the track.
- The Claim all on the profile and the end-of-game Claim share one loop,
  claimRewards() in RewardClaims.ts: not_found counts as claimed, any other
  result than the success shape stops the run, and /users/@me is dropped
  once anything was claimed (the reveal now does so after a partial claim
  too).
- One reward-amount parser (rewardAmount in Progression.ts) and one set of
  Progression-tab section classes (ProgressionSection.ts).
- RewardTrack tests use the current Plutonium cadence (every 25 levels).
- File-local types and constants are no longer exported.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@iiamlewis iiamlewis changed the title Show prestige history and milestone dates on the player profile Player profile: Progression tab, reward track, profile links and sharing, and end-of-game rewards Oct 12, 2026
@iiamlewis

Copy link
Copy Markdown
Contributor Author

@Celant heads-up: this PR now combines #5774, #5775 and #5786 (approved by the designer to cut down the PR count). All three were reviewed separately and their commits are unchanged; what's new since your last look is two commits on top: 67b202d drops #5786's per-rank prestige emblems (superseded by a new design; LevelBadge.ts is back to main's), and 2d2f79b removes old-API fallbacks in the reward track, merges the two claim loops into one claimRewards() helper (RewardClaims.ts) that already treats a #5879-style held refusal as not claimed, and fixes the reward-track fixture to the every-25-levels Plutonium cadence. The description covers all three parts.

🤖 Generated with Claude Code

…el rewards yet

The client can reach players before the API that sends the per-level
amounts and stamps level rewards with their level and run. Until then:

- trackHasRewards(): the profile leaves the track out when the config has
  no per-level Caps/Plutonium amounts (nor level flares).
- statuses: when a level reward lacks level/prestige, the owner sees the
  track as a visitor does (no claim status, no Claim all, no key), with a
  one-time console warning, rather than every passed level reading as
  claimed.

Restores the tests for both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

🤖 Claude Code Review

Verdict: No issues found. Findings: 0 critical, 0 major, 0 minor.

No issues found. Checked for bugs and CLAUDE.md compliance.

Scope covered: homepage-chunk and lazy-modal import rules (Main.ts, ModalRouter.ts, PlayerProfileUrl.ts), package layer boundaries, i18n (every translateText() key resolves in en.json, and no other lang files changed), the /player/:publicId server route (no request data reaches the HTML), path and deep-link routing, and the shared claimRewards() flow used by the reward track and the end-of-game rewards.

iiamlewis and others added 2 commits October 12, 2026 15:15
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Now that main has isRewardClaimable() and `held` (#5879):

- claimRewards() takes the rewards themselves and never claims a held one;
  a `{ held }` answer still counts as not claimed.
- Reward track: Claim all and its totals leave held rewards out, and a
  level whose only unclaimed rewards are held no longer reads as ready (it
  shows no claim status, as a passed level does for a visitor).
- End-of-game rewards row: a held reward is listed apart with the rewards
  panel's "Trusted accounts only" label (or its generic note for another
  hold), is never claimed nor counted in what was claimed, and the row
  offers no Claim when every reward is held.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Celant

Celant commented Oct 12, 2026

Copy link
Copy Markdown
Member

Conflicts with main after today's merges — rebase needed before this can go in.

main is at 54427abd2; #5751, #5769, #5879 and #5764 all landed in the last hour, and #5879 and
#5764 both reworked packages/shared/src/ApiSchemas.ts, which is where this collides.

Verified with git merge-tree --write-tree origin/main <head> rather than GitHub's status, so these
are real, not the transient DIRTY you get right after a base moves:

  • packages/shared/src/ApiSchemas.ts
  • tests/ApiSchemas.test.ts
  • tests/client/ProgressionSchemas.test.ts

Everything else on this PR is ready: the CI Claude review is clean at the current head, all required
checks pass, the milestone is set, and it is approved. The rebase is the only thing left.

Worth being careful with ApiSchemas.ts specifically — #5879 landed isRewardClaimable() plus the
fail-closed held transform, and #5764 landed the leaderboard badge fields. Both want to survive
the merge intact.

iiamlewis and others added 4 commits October 12, 2026 15:47
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>
When this client's copy of the account is stale, the API can refuse a
claim with { held } (held until the account is trusted). The claim run
treated that as a failure and stopped, so every retry stopped on the same
reward and the rest were never claimed until /users/@me was re-read.

claimRewards now notes such a reward in ClaimRun.held (id and hold),
moves on, and drops the cached /users/@me when anything was claimed or
refused as held. The end-of-game row shows a refused reward as held from
then on (same label, not claimed again, not counted), and the reward
track's Claim all marks it held and re-reads the account.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@iiamlewis

Copy link
Copy Markdown
Contributor Author

Merged main (#5879, #5764, #5771 and the rest), merge rather than rebase, so the reviewed commits stay as they were. #5879's isRewardClaimable() and fail-closed held, #5764's RowLevelFields and #5771's privacy schema and own-profile fallback all came through intact. The conflicts were in ApiSchemas.ts, its two schema test files and PlayerProfileModal.ts.

Two commits for #5879, now that it's on main:

  • 50a7e71: held rewards are skipped. The reward track's Claim all and totals leave them out, and a level whose only unclaimed reward is held no longer reads as ready. The end-of-game rewards row lists them with Rewards: show level Plutonium held until the account is trusted #5879's "Trusted accounts only" label and never claims or counts them. No new text.
  • dff0ea6: if stale data meets a { held } refusal, claimRewards() records it and moves on to the rest instead of stopping (it used to fail the same way on every retry). The refused reward then shows as held in the end-of-game row, and Claim all re-reads the account.

One test from #5879 changed: ProgressionSchemas.test.ts asserted the post-game XP response carries no rewards. This PR adds that list, so the test now checks that a held reward in it keeps held and isn't claimable. Worth a glance.

Typecheck and lint pass. The full suite passes apart from the Windows-only shell-script tests and load timeouts, which pass when run alone.

🤖 Addressed by Claude Code

iiamlewis and others added 2 commits October 12, 2026 16:33
Keep both reworks of the end-of-game XP panel: #5759's provisional and
reconciled states, celebration mode and status/reconcile notes, and this
branch's level rewards row, unlocks, glowing bar edge and moment share.
Rewards and unlocks show (and are claimed) only for the server's confirmed
result; a celebration that reaches no new level brings the rewards row in
at its start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…evels

A link to a profile's Progression tab, routed while another profile is
open on Games or Clans, now holds on Stats and switches to Progression
once that player's progress arrives, rather than staying put: an explicit
tab request wins on a re-open, as it already did on a first open.

On your own profile the reward track's progress can lag a game behind the
freshly read /users/@me rewards the claim bar counts. A level this run has
an unclaimed level reward for now counts as reached, so it shows ready
rather than locked and the track and claim bar always agree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@iiamlewis

Copy link
Copy Markdown
Contributor Author

Merged main after #5759 landed (a80c2aa), and fixed the two display nits from the review (cbad5d3).

Merge: en.json, GameXpPanel.ts and WinModal.ts conflicted, and both sides are kept in full. #5759's provisional figure, the revealedProvisional handling, the higher-result-from-live-frame branch, reconciliation and the aborted-poll retry are all intact, and so are this PR's rewards row, held rewards, share button and bar edge. The rewards row and share are now explicitly confirmed-only: rewardsOf(), unlocks() and the reward scheduling return nothing unless the view is a result, so they never depend on a provisional figure happening to carry no rewards. A confirmed result that reaches no new level beyond the provisional celebration brings the row in at the start of that celebration. New tests cover a provisional figure followed by a higher confirmed result, and the no-new-level case.

Nit 1, linked tab: a /player/<id>?tab=progression link routed while another profile is open on Games or Clans now switches to Progression once that player's progress arrives. Tests cover routing from Games, from Clans, and from a profile with no Progression tab.

Nit 2, track vs claim bar: a level this run has an unclaimed level reward for (claimable or held) now counts as reached, so the track and the claim bar read the same rewards. A level just reached shows ready, not locked, and the claim bar counts it once. One side effect: the "You" marker can sit a level ahead of the rest of the profile until the public progress catches up.

Typecheck and lint pass. Full suite: 8,750 passed, and the only failures are the Windows-only UpdateRegister shell tests.

🤖 Addressed by Claude Code

@evanpelle
evanpelle merged commit f7ba27e into main Oct 12, 2026
15 checks passed
@evanpelle
evanpelle deleted the profile-progression branch October 12, 2026 21:24

This branch was successfully deployed

2 active deployments
backport — f89228fc Deployed Oct 12, 2026 by evanpelle via Cherry-pick #284
staging — f89228fc Deployed Oct 12, 2026 by openfrontio[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:client Best-guess area: client (rendering, UI, web components) Feature UI/UX UI/UX changes including assets, menus, QoL, etc.

Projects

Status: Complete

Development

Successfully merging this pull request may close these issues.

Open player profiles from /player/<publicId> links, with sharing Show prestige history and milestone dates on the player profile

3 participants