diff --git a/index.html b/index.html index 85a361fa..26b41954 100644 --- a/index.html +++ b/index.html @@ -4288,7 +4288,7 @@ .dr-opts { display: flex; flex-wrap: wrap; gap: 6px; } .dr-card .opt-btn { min-width: 0; width: auto; padding: 6px 10px; font-size: 12.5px; } .dr-card .opt-btn[data-locked], #opt-outfit-row .opt-btn[data-locked] { opacity: .45; } - .opt-btn[data-locked]::before { content: 'F512A0'; } + .opt-btn[data-locked]::before { content: '\1F512\A0'; } .dr-actions { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; } .dr-hint { margin: 0; text-align: center; } .dr-hint:empty { display: none; } diff --git a/js/dressing-room.js b/js/dressing-room.js index ceffb950..07516eea 100644 --- a/js/dressing-room.js +++ b/js/dressing-room.js @@ -26,6 +26,7 @@ // (js/seat-accounts.js). The lobby supplies the row's state through `accounts`. import { ManualInput } from '../shared/ui/inputs.js'; +import { personTag } from '../shared/ui/elements/uf-person.js'; import { RowFocusController as FocusController } from './row-focus.js'; import { playerLooks, BODIES, LED_COLOURS, lookColourway, withColourway, seatLook, ledRgb } from './player-look.js'; import { GOOSE_OUTFITS } from './goose-outfits.js'; @@ -268,7 +269,7 @@ export class DressingRoom { const stickHint = c.el.querySelector('.dr-stick-hint'); if (stickHint) stickHint.hidden = !(c.type === 'gamepad' && this._preview); // The same P# badge as the seat cards (uf-seat badge), then the player's name (#617). - c.el.querySelector('.dr-who').innerHTML = `P${c.n}${esc(this._name(c))}`; + c.el.querySelector('.dr-who').innerHTML = this._whoHtml(c); this._renderAccount(c, false); c.el.querySelector('.dr-summary').textContent = this._summary(look); c.el.querySelector('.dr-rows-wrap').innerHTML = c.state === 'editing' ? this._rows(c, look) : ''; @@ -282,6 +283,13 @@ export class DressingRoom { this._refocus(c, c.state === 'editing' ? c.el.querySelector('.dr-rows .opt-btn.active') : c.el.querySelector('.dr-edit')); } + /** P# then the player: picture, sign-in badge and name once signed in (#612), else the seat's label. */ + _whoHtml(c) { + const v = this._accounts ? this._accounts.view(c.n) : null; + const who = v && v.state === 'signed-in' && v.person ? personTag(v.person) : esc(this._name(c)); + return `P${c.n}${who}`; + } + /** The card's name: the signed-in account's, else the seat's label. */ _name(c) { const v = this._accounts ? this._accounts.view(c.n) : null; @@ -327,7 +335,8 @@ export class DressingRoom { } if (box.innerHTML !== html) box.innerHTML = html; const who = c.el.querySelector('.dr-who'); - if (who) who.innerHTML = `P${c.n}${esc(this._name(c))}`; + const whoHtml = this._whoHtml(c); + if (who && who.innerHTML !== whoHtml) who.innerHTML = whoHtml; if (refocus && c.focus) { const next = box.querySelector('button:not([disabled])') || (c.state === 'editing' ? c.el.querySelector('.dr-rows .opt-btn.active') : c.el.querySelector('.dr-edit')); c.focus.setSpatial(this._items(c), next || null); diff --git a/js/lobby.js b/js/lobby.js index c25ccc72..1c2c91c7 100644 --- a/js/lobby.js +++ b/js/lobby.js @@ -942,14 +942,15 @@ export class Lobby { const connected = on && !!this.net.connected; const partner = this._roomPartnerName && this._roomPartnerName !== 'LEFT' ? this._roomPartnerName : ''; const user = this.auth && this.auth.isLoggedIn() ? this.auth.getUser() : null; - const me = { who: (user && user.name) || 'You', dev: 'This screen', state: 'joined' }; + const mePerson = user ? this.seatPerson(1) : null; + const me = { who: (mePerson && mePerson.name) || (user && user.name) || 'You', pic: mePerson ? mePerson.pic : '', source: mePerson ? mePerson.source : '', dev: 'This screen', state: 'joined' }; const left = this._roomPartnerName === 'LEFT'; const them = left ? { who: 'Partner left', dev: 'Disconnected', state: 'open' } : connected ? { who: partner || 'Partner', dev: 'Online · connected', state: 'joined' } : partner ? { who: partner, dev: 'Reconnecting…', state: 'open' } : { who: 'Waiting for partner…', dev: '', state: 'open' }; const code = on ? (this.net.roomCode || '') : ''; - const key = on ? [role, code, me.who, them.who, them.dev, them.state].join('|') : 'off'; + const key = on ? [role, code, me.who, me.pic, me.source, them.who, them.dev, them.state].join('|') : 'off'; if (key === this._levelRoomKey) return; this._levelRoomKey = key; team.hidden = !on; @@ -959,7 +960,7 @@ export class Lobby { const sto = /** @type {any} */ (document.getElementById('level-room-stoker')); const [mine, theirs] = role === 'captain' ? [cap, sto] : [sto, cap]; if (mine) Object.assign(mine, me); - if (theirs) Object.assign(theirs, them); + if (theirs) Object.assign(theirs, { pic: '', source: '' }, them); } /** Both room seats from current state: me in my seat, the partner's seat open until they arrive. */ @@ -2333,6 +2334,8 @@ export class Lobby { }; const updateUI = (user) => { + // P1's seat card shows their picture and name once signed in (#612). + queueMicrotask(() => { try { this._refreshHostNext(); } catch (e) {} }); if (user) { this.toggleProfile.classList.add('active'); this.toggleLeaderboard.classList.add('active'); @@ -3338,7 +3341,8 @@ export class Lobby { if (!sheet || !sheet.open) return; const seat = document.getElementById('controls-seat'); if (seat) { - seat.who = id ? id.shortName : 'No controller yet'; + const me = id ? this.seatPerson(1) : null; + Object.assign(seat, { who: me ? me.name : id ? id.shortName : 'No controller yet', pic: me ? me.pic : '', source: me ? me.source : '' }); seat.dev = id ? `Player 1 · ${id.name}` : 'Press a button on a controller'; seat.state = id ? 'joined' : 'open'; seat.badge = id ? 'P1' : ''; @@ -3360,7 +3364,7 @@ export class Lobby { roster.rows = this.controllerManager.controllerRoster(pads, { seatOf: (slotId) => (slotId === 'P1' ? { player: 1, seat: { role: 'player', label: 'P1 · you', tone: 'captain' } } : null), keyboard: true, - }).map(fixRosterRow); + }).map(fixRosterRow).map((r) => (r.player === 1 ? { ...r, person: this.seatPerson(1) } : r)); } } @@ -5074,9 +5078,9 @@ export class Lobby { const sto = /** @type {any} */ (document.getElementById('host-seat-stoker')); if (sto) { if (!sto._openHint) sto._openHint = sto.hint; - if (online) Object.assign(sto, { who: this._roomPartnerName || 'Partner', dev: 'Online', state: 'joined', hint: '' }); - else if (local) Object.assign(sto, { who: this._localP2Name || 'Player 2', dev: '', state: 'joined', hint: '' }); - else Object.assign(sto, { who: 'Waiting for partner…', dev: '', state: 'open', hint: sto._openHint || '' }); + if (online) Object.assign(sto, { who: this._roomPartnerName || 'Partner', dev: 'Online', state: 'joined', hint: '', pic: '', source: '' }); + else if (local) Object.assign(sto, { dev: '', state: 'joined', hint: '' }, this._seatWho(2, this._localP2NameBeforeAccount || this._localP2Name || 'Player 2')); + else Object.assign(sto, { who: 'Waiting for partner…', dev: '', state: 'open', hint: sto._openHint || '', pic: '', source: '' }); // P2 in the corner once someone sits there, like the captain's P1. sto.badge = online || local ? 'P2' : ''; } @@ -6425,19 +6429,20 @@ export class Lobby { const p2Slot = p2Im && p2Im._slot ? p2Im._slot.id : null; const stokerSeat = { team: 'coop', role: 'stoker', label: 'Stoker', tone: 'stoker' }; const key = (p1 ? p1.shortName : '') + '§' + candidates.map(c => c.key).join('|') + - '§' + (online ? 'online:' + (this._roomPartnerName || '') : '') + (p2Im ? 'p2:' + (p2Slot || this._localP2Type) : ''); + '§' + (online ? 'online:' + (this._roomPartnerName || '') : '') + (p2Im ? 'p2:' + (p2Slot || this._localP2Type) : '') + + '§' + this._personKey(1) + '§' + (p2Im ? this._personKey(2) : ''); if (key === this._hostSeatsKey) return; this._hostSeatsKey = key; - cap.who = p1 ? p1.shortName : 'You'; + Object.assign(cap, { dev: '' }, this._seatWho(1, p1 ? p1.shortName : 'You')); const rows = []; - if (p1) rows.push({ key: 'p1', name: p1.name, shortName: p1.shortName, glyphFamily: p1.glyphFamily, swapAB: p1.swapAB, state: 'seated', player: 1, seat: { team: 'coop', role: 'captain', label: 'Captain', tone: 'captain' } }); + if (p1) rows.push({ key: 'p1', name: p1.name, shortName: p1.shortName, glyphFamily: p1.glyphFamily, swapAB: p1.swapAB, state: 'seated', player: 1, person: this.seatPerson(1), seat: { team: 'coop', role: 'captain', label: 'Captain', tone: 'captain' } }); let p2Listed = false; for (const c of candidates) { const id = this._versusIdentity(c); const isP2 = !!p2Slot && c.slotId === p2Slot; if (isP2) p2Listed = true; rows.push({ key: c.key, name: id.name, shortName: id.shortName, glyphFamily: id.glyphFamily, swapAB: id.swapAB, - state: isP2 ? 'seated' : 'connected', player: isP2 ? 2 : null, seat: isP2 ? stokerSeat : null }); + state: isP2 ? 'seated' : 'connected', player: isP2 ? 2 : null, seat: isP2 ? stokerSeat : null, person: isP2 ? this.seatPerson(2) : null }); } if (p2Im && !p2Listed) { // A seated pad drops out of the join candidates; name and glyph come from @@ -6445,8 +6450,10 @@ export class Lobby { const mgr = this.controllerManager; const p2SlotObj = p2Im._slot && p2Im._slot.state === 'claimed' ? p2Im._slot : null; const pid = this._localP2Type !== 'keyboard' && p2SlotObj && mgr ? slotIdentityHidFirst(p2SlotObj, (s) => mgr.slotIdentity(s)) : null; - const n = this._localP2Name || (this._localP2Type === 'keyboard' ? 'Keyboard' : (pid ? pid.shortName : 'Player 2')); - rows.push({ key: 'p2', name: this._localP2Name || (pid ? pid.name : n), shortName: n, + const p2 = this.seatPerson(2); + const own = p2 ? this._localP2NameBeforeAccount : this._localP2Name; // the seat's own name, not the account's + const n = own || (this._localP2Type === 'keyboard' ? 'Keyboard' : (pid ? pid.shortName : 'Player 2')); + rows.push({ key: 'p2', name: own || (pid ? pid.name : n), shortName: n, person: p2, glyphFamily: this._localP2Type === 'keyboard' ? 'keyboard' : (pid ? pid.glyphFamily : 'generic'), swapAB: pid ? !!pid.swapAB : false, state: 'seated', player: 2, seat: stokerSeat }); } @@ -7035,7 +7042,7 @@ export class Lobby { */ _renderVersusCards() { const candidates = this._versusCandidates || []; - const key = this._versusPlayers.map(p => `${p.n}:${p.team}:${p.name}:${p.slotId || ''}`).join('|') + const key = this._versusPlayers.map(p => `${p.n}:${p.team}:${p.name}:${p.slotId || ''}:${p.type === 'bot' ? '' : this._personKey(p.n)}`).join('|') + '§' + candidates.map(c => `${c.key}:${c.name}`).join('|'); if (key === this._versusLastCardsKey) return; this._versusLastCardsKey = key; @@ -7064,7 +7071,7 @@ export class Lobby { for (const p of this._versusPlayers.filter(pl => pl.team === team)) { const id = this._versusIdentity(p); const seat = document.createElement('uf-seat'); - Object.assign(seat, { seat: `Player ${p.n}`, tone, who: id.shortName, state: 'joined', badge: `P${p.n}` }); + Object.assign(seat, { seat: `Player ${p.n}`, tone, state: 'joined', badge: `P${p.n}` }, p.type === 'bot' ? { who: id.shortName } : this._seatWho(p.n, id.shortName)); seats.push(seat); } for (const c of joinFor[team]) { @@ -7139,7 +7146,7 @@ export class Lobby { const id = this._versusIdentity(p); rows.push({ key: 'p' + p.n, name: id.name, shortName: id.shortName, glyphFamily: id.glyphFamily, swapAB: id.swapAB, - state: 'seated', player: p.n, connection: connection(p), + state: 'seated', player: p.n, connection: connection(p), person: p.type === 'bot' ? null : this.seatPerson(p.n), seat: { team: p.team === 'A' ? 'a' : 'b', role: 'player', label: `P${p.n}`, tone: p.team === 'A' ? 'team-a' : 'team-b' }, }); } @@ -8015,6 +8022,34 @@ export class Lobby { return this._seatAchievements; } + /** + * #612 · who sits in seat n, for the picture and name next to their controller: Player 1 is + * the game's own account (its Steam sign-in first in the Steam app, else Discord), seats 2–4 + * their own Discord sign-ins. The name is the account's (what the rest of the game calls them). + * { name, pic, source } or null when nobody is signed in. + */ + seatPerson(n) { + let user = null; + if (n === 1) user = this.auth && this.auth.isLoggedIn() ? this.auth.getUser() : null; + else { try { const a = this.seatAccounts.get(n); user = a && a.user; } catch (e) {} } + if (!user) return null; + const ids = user.identities || {}; + const order = n === 1 && this._isSteam ? ['steam', 'discord'] : ['discord', 'steam']; + for (const p of order) { + if (ids[p] && (ids[p].avatar || ids[p].name)) return { name: user.name || ids[p].name || 'Player ' + n, pic: ids[p].avatar || '', source: p }; + } + const src = user.provider === 'steam' || user.provider === 'discord' ? user.provider : ''; + return { name: user.name || 'Player ' + n, pic: user.avatar || '', source: src }; + } + + /** A seat card's fields for seat n: the person's name and picture over their controller, else the controller. */ + _seatWho(n, controllerName) { + const p = this.seatPerson(n); + return p ? { who: p.name, pic: p.pic, source: p.source, dev: controllerName || '' } : { who: controllerName, pic: '', source: '' }; + } + + _personKey(n) { const p = this.seatPerson(n); return p ? [p.name, p.pic, p.source].join(',') : ''; } + /** The signed-in name for seat n (2–4), or null. */ seatAccountName(n) { const a = n > 1 ? this.seatAccounts.get(n) : null; @@ -8049,7 +8084,7 @@ export class Lobby { if (this.auth.isLoggedIn()) { const u = this.auth.getUser(); return { - state: 'signed-in', name: u.name, pics: identityPictures(u), + state: 'signed-in', name: u.name, pics: identityPictures(u), person: this.seatPerson(1), linkable: !this.auth.hasIdentity('discord'), canSignOut: false, }; } @@ -8074,7 +8109,7 @@ export class Lobby { const dev = seats.pending(n); if (dev) return { state: 'pending', code: dev.code, url: dev.url, status: dev.status }; const a = seats.get(n); - if (a) return withWait(n, { state: 'signed-in', name: a.user.name, pics: identityPictures(a.user), canSignOut: true }); + if (a) return withWait(n, { state: 'signed-in', name: a.user.name, pics: identityPictures(a.user), person: this.seatPerson(n), canSignOut: true }); const err = seats.lastError && seats.lastError.n === n ? seats.lastError.error : null; return withWait(n, { state: 'signed-out', error: err === 'cancelled' ? 'Cancelled.' : err === 'expired' ? 'That code expired. Try again.' : err }); }, @@ -8103,8 +8138,9 @@ export class Lobby { } if (seats.lastError && seats.lastError.n === n) seats.lastError = null; try { - const dev = await seats.startSignIn(n); - openBrowser(dev && dev.url); + // Seats 2–4 use the QR only (Pete, 2026-11-07). This PC's browser is signed in to + // P1's Discord, so opening it here would sign P2's seat in as P1. + await seats.startSignIn(n); } catch (e) { seats.lastError = { n, error: e.message || 'Discord sign-in is unavailable' }; emit(n); } }, signOut: (n) => { if (n > 1) seats.signOut(n); }, diff --git a/scripts/smoke-seat-signin.mjs b/scripts/smoke-seat-signin.mjs index c87a0ae1..7fe5078e 100644 --- a/scripts/smoke-seat-signin.mjs +++ b/scripts/smoke-seat-signin.mjs @@ -65,6 +65,12 @@ const card = (page, n) => page.evaluate((k) => { disabled: [...(acc?.querySelectorAll('button[disabled]') || [])].map((b) => b.textContent.trim()), }; }, n); +// The (picture + sign-in badge + name) inside a selector, or null. +const person = (page, sel) => page.evaluate((s) => { + const p = document.querySelector(s + ' uf-person'); + return p ? { name: p.getAttribute('name'), source: p.getAttribute('source'), pic: !!p.querySelector('img'), badge: p.querySelector('.uf-person__src')?.getAttribute('aria-label') || '' } : null; +}, sel); + const cardClick = (page, n, sel) => page.evaluate(([k, s]) => { const b = document.querySelector(`#dr-cards .dr-card[data-n="${k}"] ${s}`); if (!b) return false; b.click(); return true; }, [n, sel]); // ── SS-01..04 · web, local co-op ── @@ -95,8 +101,13 @@ const cardClick = (page, n, sel) => page.evaluate(([k, s]) => { const b = docume check(reach, "SS-01: P2's own pad can reach their Sign in button"); await h.shot(page, 'ss01-cards'); + // A desktop-app spy for the click only: P2's sign-in must never open this PC's browser + // (it is signed in to P1's Discord). Removed right after, so the web run stays the web. + await page.evaluate(() => { window.__opened = []; window.__hadApp = !!window.electronApp; window.electronApp = window.electronApp || {}; window.__oldOpen = window.electronApp.openSignIn; window.electronApp.openSignIn = (u) => { window.__opened.push(u); return Promise.resolve(true); }; }); await cardClick(page, 2, '[data-act="acc-signin"]'); await until(page, () => document.querySelector('#dr-cards .dr-card[data-n="2"] .dr-acc-code')?.textContent === 'Q4MZ'); + const p2Opened = await page.evaluate(() => { const o = window.__opened.slice(); if (window.__hadApp) window.electronApp.openSignIn = window.__oldOpen; else delete window.electronApp; return o; }); + check(p2Opened.length === 0, "SS-01: P2's Sign in shows only the QR, never opens this PC's browser", p2Opened); c1 = await card(page, 1); c2 = await card(page, 2); check(c2.state === 'pending' && c2.qr && c2.code === 'Q4MZ', "SS-02: P2's card shows its own QR + code", c2); check(c1.state === 'signed-out' && !c1.qr, "SS-02: P1's card is untouched", c1); @@ -112,9 +123,20 @@ const cardClick = (page, n, sel) => page.evaluate(([k, s]) => { const b = docume check((await card(page, 1)).disabled.length === 0, "SS-02: once P2 is in, P1's Sign in is free again", await card(page, 1)); check(c2.state === 'signed-in' && c2.pics.join() === 'discord' && c2.buttons.includes('Not me') && /Sam/.test(c2.who), "SS-02: P2's card: Sam's picture, name and Not me", c2); check(!(await page.evaluate(() => window._game.lobby.auth.isLoggedIn())), 'SS-02: Player 1 is still signed out'); + const drP2 = await person(page, '#dr-cards .dr-card[data-n="2"] .dr-who'); + check(drP2 && drP2.name === 'Sam' && drP2.source === 'discord' && drP2.pic && drP2.badge === 'Discord', "SS-02: P2's card title: Sam's picture with the Discord badge", drP2); + check(!(await person(page, '#dr-cards .dr-card[data-n="1"] .dr-who')), "SS-02: signed-out P1's card title has no picture"); await h.shot(page, 'ss02-p2-signed-in'); await page.evaluate(() => window._game.lobby._room.close('smoke')); check(await until(page, () => document.getElementById('host-seat-stoker')?.who === 'Sam'), 'SS-02: the stoker seat card says Sam', await page.evaluate(() => document.getElementById('host-seat-stoker')?.who)); + const stoSeat = await person(page, '#host-seat-stoker'); + const stoDev = await page.evaluate(() => document.getElementById('host-seat-stoker')?.dev); + check(stoSeat && stoSeat.name === 'Sam' && stoSeat.source === 'discord' && stoSeat.pic && /dualsense/i.test(stoDev || ''), "SS-02: the stoker seat: Sam's picture + Discord badge over the controller", { stoSeat, stoDev }); + await until(page, () => !!document.querySelector('#host-roster uf-person')); + const chip = await page.evaluate(() => [...document.querySelectorAll('#host-roster .ucr-chip')].map((c) => ({ person: c.querySelector('uf-person')?.getAttribute('name') || null, source: c.querySelector('uf-person')?.getAttribute('source') || null, text: c.textContent }))); + check(chip.some((c) => c.person === 'Sam' && c.source === 'discord' && /dualsense/i.test(c.text)) && chip.filter((c) => c.person).length === 1, "SS-02: the controller strip: Sam's chip has the picture and the DualSense; P1's has none", chip); + check(!(await person(page, '#host-seat-captain')), "SS-02: signed-out P1's seat is the controller alone"); + await h.shot(page, 'ss02-host-seats'); // SS-03 · the couch ride's submit (the finish + submit, with a stub ride). const sub = await page.evaluate(async () => { @@ -187,6 +209,17 @@ const cardClick = (page, n, sel) => page.evaluate(([k, s]) => { const b = docume await until(page, () => !!document.querySelector('#dr-cards .dr-card[data-n="1"] .dr-account')); const c1 = await card(page, 1); check(c1.state === 'signed-in' && c1.pics.join() === 'steam' && c1.buttons.includes('Link Discord') && !c1.buttons.includes('Not me'), "SS-05: P1's card on Steam: Steam picture + Link Discord (no Not me)", c1); + const drP1 = await person(page, '#dr-cards .dr-card[data-n="1"] .dr-who'); + const p1Name = await page.evaluate(() => window._game.lobby.auth.getUser().name); + check(drP1 && drP1.name === p1Name && drP1.source === 'steam' && drP1.badge === 'Steam', "SS-05: P1's card title on Steam: the Steam picture and badge, under the account's name", { drP1, p1Name }); + // Steam + Discord both linked: the Steam app still leads with Steam; the web would lead with Discord. + const lead = await page.evaluate(() => { + const l = window._game.lobby; const u = l.auth.user; const was = u.identities; + u.identities = { ...was, discord: { name: 'pete_dc', avatar: 'x' } }; + const steamApp = l.seatPerson(1).source; l._isSteam = false; const web = l.seatPerson(1).source; l._isSteam = true; + u.identities = was; return { steamApp, web }; + }); + check(lead.steamApp === 'steam' && lead.web === 'discord', 'SS-05: P1 leads with Steam in the Steam app, Discord elsewhere', lead); await h.shot(page, 'ss05-p1-steam-card'); await page.evaluate(() => window._game.lobby._room.close('smoke')); diff --git a/shared/ui/UI_VERSION.json b/shared/ui/UI_VERSION.json index 68b0aa42..8e1c381a 100644 --- a/shared/ui/UI_VERSION.json +++ b/shared/ui/UI_VERSION.json @@ -1,7 +1,7 @@ { "package": "@usersfirst/ui", - "version": "0.4.0", - "sourceCommit": "15027a8", - "sourceRef": "pr-139-1-g15027a8", - "syncedAt": "2026-11-01" + "version": "0.6.0", + "sourceCommit": "2690e06", + "sourceRef": "ui-v0.5.0-1-g2690e06", + "syncedAt": "2026-11-07" } diff --git a/shared/ui/elements.d.ts b/shared/ui/elements.d.ts index 7846102a..ea77dd32 100644 --- a/shared/ui/elements.d.ts +++ b/shared/ui/elements.d.ts @@ -4,6 +4,9 @@ // Maintained by tools/ui-kit/new-element.mjs — one entry per element. // uf:elements-imports +import type { UfAchievementSheet } from './elements/uf-achievement-sheet.js'; +import type { UfAchievementDetail } from './elements/uf-achievement-detail.js'; +import type { UfAchievementGrid } from './elements/uf-achievement-grid.js'; import type { UfPips } from './elements/uf-pips.js'; import type { UfPlayerCard } from './elements/uf-player-card.js'; import type { UfAchievement } from './elements/uf-achievement.js'; @@ -21,6 +24,7 @@ import type { UfTeam } from './elements/uf-team.js'; import type { UfControllerGlyph } from './elements/uf-controller-glyph.js'; import type { UfChip } from './elements/uf-chip.js'; import type { UfSeat } from './elements/uf-seat.js'; +import type { UfPerson } from './elements/uf-person.js'; import type { UfFlow } from './elements/uf-flow.js'; import type { UfStep } from './elements/uf-step.js'; import type { UfButton } from './elements/uf-button.js'; @@ -28,6 +32,9 @@ import type { UfButton } from './elements/uf-button.js'; declare global { interface HTMLElementTagNameMap { // uf:elements-map + 'uf-achievement-sheet': UfAchievementSheet; + 'uf-achievement-detail': UfAchievementDetail; + 'uf-achievement-grid': UfAchievementGrid; 'uf-pips': UfPips; 'uf-player-card': UfPlayerCard; 'uf-achievement': UfAchievement; @@ -45,6 +52,7 @@ declare global { 'uf-controller-glyph': UfControllerGlyph; 'uf-chip': UfChip; 'uf-seat': UfSeat; + 'uf-person': UfPerson; 'uf-flow': UfFlow; 'uf-step': UfStep; 'uf-button': UfButton; diff --git a/shared/ui/elements/uf-achievement-detail.js b/shared/ui/elements/uf-achievement-detail.js new file mode 100644 index 00000000..4b4d5459 --- /dev/null +++ b/shared/ui/elements/uf-achievement-detail.js @@ -0,0 +1,138 @@ +// @ts-check +// ============================================================ +// — one achievement's detail card. +// ============================================================ +// +// const card = document.createElement('uf-achievement-detail'); +// card.achievement = { id: 'snake_eyes', name: 'Snake Eyes', description: 'Roll double 1s', icon: '🐍', earnedAt: '2026-11-07 15:00:00' }; +// card.number = 5; card.total = 22; // "No. 5 of 22" (omit number to hide it) +// +// The badge large in a ring, a kicker over it (Achievement unlocked · Locked · +// In progress · Hidden · or the item's `unavailable` reason), the name, what +// to do, the status (Unlocked November 7, 2026 · Not yet · 3 / 7 days with a +// bar), its number, and its ID in small mono text (show-id="false" hides it). +// `extraHtml` adds a game's own trusted line under the description +// (Tandemonium's medal times); it is not escaped. +// +// Replaces: Tandemonium's #ach-detail-card markup (.ach-detail-*) built in lobby.js. + +import { UfElement, prop, esc } from '../uf-element.js'; +import { isEarned } from './uf-achievement-grid.js'; + +/** @typedef {import('./uf-achievement-grid.js').AchievementItem} AchievementItem */ + +/** "November 1, 2026" from ms, an ISO date, or SQLite "2026-11-01 15:00:00" (UTC); '' if unreadable. @param {unknown} when */ +export function longDate(when) { + try { + let v = /** @type {any} */ (when); + if (typeof v === 'string' && /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}/.test(v)) v = v.replace(' ', 'T') + 'Z'; + if (v === true || v == null) return ''; + const d = new Date(v); + if (isNaN(d.getTime())) return ''; + return d.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }); + } catch { return ''; } +} + +const fmt = (/** @type {number} */ v) => Math.floor(v).toLocaleString('en-US'); + +/** + * The card's state, kicker and status line for an achievement. + * @param {AchievementItem} a + * @returns {{ state: 'earned'|'secret'|'full'|'progress'|'locked', kicker: string, status: string }} + */ +export function detailState(a) { + if (isEarned(a)) { + const date = longDate(a.earnedAt); + return { state: 'earned', kicker: 'Achievement unlocked', status: date ? 'Unlocked ' + date : 'Unlocked' }; + } + if (a.secret) return { state: 'secret', kicker: 'Hidden', status: 'Revealed when you earn it' }; + if (a.unavailable) return { state: 'full', kicker: a.unavailable, status: a.unavailableNote || 'Not available here' }; + if (a.progress && a.progress[0] > 0) { + return { state: 'progress', kicker: 'In progress', status: fmt(a.progress[0]) + ' / ' + fmt(a.progress[1]) + (a.unit ? ' ' + a.unit : '') }; + } + return { state: 'locked', kicker: 'Locked', status: 'Not yet' }; +} + +export class UfAchievementDetail extends UfElement { + /** @override */ static tag = 'uf-achievement-detail'; + + /** @override */ static props = { + showId: prop.boolean(true), + number: prop.number(0), + total: prop.number(0), + }; + + /** @override */ static styles = ` + uf-achievement-detail { display: flex; flex-direction: column; align-items: center; text-align: center; + padding: var(--uf-space-2) 0 var(--uf-space-3); font-size: var(--uf-size-md); font-family: var(--uf-font-ui); color: var(--uf-color-fg); } + uf-achievement-detail .uad-kicker { font-size: var(--uf-size-sm); font-weight: 700; letter-spacing: .28em; text-transform: uppercase; color: var(--uf-color-fg-muted); } + uf-achievement-detail[data-state="earned"] .uad-kicker { color: var(--uf-color-accent-2); } + uf-achievement-detail .uad-badge { width: 6em; height: 6em; margin: .9em 0 .7em; border-radius: 50%; display: grid; place-items: center; font-size: var(--uf-size-xl); + background: radial-gradient(circle at 50% 40%, color-mix(in srgb, var(--uf-color-surface) 70%, #6a5a90) 0%, var(--uf-color-surface) 70%); + border: .18em solid color-mix(in srgb, var(--uf-color-fg) 25%, transparent); box-shadow: 0 .6em 2em rgba(0,0,0,.5); + animation: uad-pop var(--uf-motion-base) var(--uf-ease); } + uf-achievement-detail .uad-badge span { font-size: 3.2em; line-height: 1; } + uf-achievement-detail[data-state="earned"] .uad-badge { border-color: var(--uf-color-accent-2); + box-shadow: 0 0 0 .4em color-mix(in srgb, var(--uf-color-accent-2) 18%, transparent), 0 .6em 2em rgba(0,0,0,.55); } + uf-achievement-detail[data-state="locked"] .uad-badge, uf-achievement-detail[data-state="progress"] .uad-badge, + uf-achievement-detail[data-state="full"] .uad-badge { border-style: dashed; } + uf-achievement-detail[data-state="locked"] .uad-badge span, uf-achievement-detail[data-state="full"] .uad-badge span { filter: grayscale(1); opacity: .6; } + uf-achievement-detail[data-state="secret"] .uad-badge span { opacity: .7; } + uf-achievement-detail .uad-name { font-size: var(--uf-size-2xl); font-weight: 800; line-height: 1.1; color: var(--uf-color-fg); } + uf-achievement-detail[data-state="earned"] .uad-name { color: var(--uf-color-accent-2); } + uf-achievement-detail .uad-desc { margin-top: .4em; font-size: var(--uf-size-lg); font-style: italic; color: color-mix(in srgb, var(--uf-color-fg) 85%, transparent); max-width: 24em; } + uf-achievement-detail .uad-desc:empty { display: none; } + uf-achievement-detail .uad-status { margin-top: 1em; font-size: var(--uf-size-md); font-weight: 600; color: color-mix(in srgb, var(--uf-color-fg) 70%, transparent); } + uf-achievement-detail[data-state="earned"] .uad-status { color: var(--uf-color-accent); } + uf-achievement-detail[data-state="progress"] .uad-status { color: var(--uf-color-accent-2); } + uf-achievement-detail .uad-bar { width: min(16em, 80%); height: .45em; margin-top: .5em; border-radius: var(--uf-radius-pill); background: color-mix(in srgb, var(--uf-color-fg) 12%, transparent); overflow: hidden; } + uf-achievement-detail .uad-bar i { display: block; height: 100%; background: var(--uf-color-accent-2); } + uf-achievement-detail .uad-count { margin-top: 1em; font-size: var(--uf-size-sm); letter-spacing: .12em; color: color-mix(in srgb, var(--uf-color-fg) 45%, transparent); } + uf-achievement-detail .uad-id { margin-top: .35em; font: var(--uf-size-xs)/1.3 var(--uf-font-mono); letter-spacing: .02em; color: color-mix(in srgb, var(--uf-color-fg) 40%, transparent); + user-select: all; overflow-wrap: anywhere; } + @keyframes uad-pop { from { transform: scale(.6); opacity: 0; } to { transform: scale(1); opacity: 1; } } + @media (prefers-reduced-motion: reduce) { uf-achievement-detail .uad-badge { animation: none; } } + `; + + /** @type {AchievementItem|null} */ #achievement = null; + #extraHtml = ''; + + /** @returns {boolean} */ get showId() { return this.get('showId'); } + /** @param {boolean} v */ set showId(v) { this.set('showId', v); } + /** Its number in the full list (1-based); 0 hides "No. n of N". @returns {number} */ get number() { return this.get('number'); } + /** @param {number} v */ set number(v) { this.set('number', v); } + /** @returns {number} */ get total() { return this.get('total'); } + /** @param {number} v */ set total(v) { this.set('total', v); } + + /** @returns {AchievementItem|null} */ get achievement() { return this.#achievement; } + /** @param {AchievementItem|null} v */ set achievement(v) { this.#achievement = v && typeof v.id === 'string' ? v : null; this.requestUpdate(); } + + /** A trusted HTML line under the description (not escaped). @returns {string} */ get extraHtml() { return this.#extraHtml; } + /** @param {string} v */ set extraHtml(v) { this.#extraHtml = String(v || ''); this.requestUpdate(); } + + /** @override */ + render() { + const a = this.#achievement; + if (!a) { delete this.dataset.state; return ''; } + const { state, kicker, status } = detailState(a); + this.dataset.state = state; + const bar = state === 'progress' && a.progress + ? `
` : ''; + return `
${esc(kicker)}
` + + `` + + `
${esc(a.name)}
` + + `
${esc(a.description || '')}
` + + this.#extraHtml + + `
${esc(status)}
` + bar + + (this.number > 0 ? `
No. ${this.number}${this.total > 0 ? ' of ' + this.total : ''}
` : '') + + (this.showId ? `
${esc(a.id)}
` : ''); + } + + /** @override @param {Set} _changed */ + afterRender(_changed) { + const a = this.#achievement; + if (a) this.setAttribute('aria-label', `${a.name}: ${detailState(a).status}`); + } +} + +UfAchievementDetail.define(); diff --git a/shared/ui/elements/uf-achievement-grid.js b/shared/ui/elements/uf-achievement-grid.js new file mode 100644 index 00000000..237f80df --- /dev/null +++ b/shared/ui/elements/uf-achievement-grid.js @@ -0,0 +1,170 @@ +// @ts-check +// ============================================================ +// — every achievement as a grid of buttons, by section. +// ============================================================ +// +// const grid = document.createElement('uf-achievement-grid'); +// grid.sections = [{ id: 'firsts', title: 'Firsts' }, { id: 'streaks', title: 'Streaks' }]; +// grid.achievements = [ +// { id: 'first_roll', name: 'First Roll', description: 'Roll the dice', icon: '🎲', section: 'firsts', earnedAt: '2026-11-07 15:00:00' }, +// { id: 'hot_hand', name: 'Hot Hand', description: 'Score 3 in a row', icon: '🔥', section: 'streaks', progress: [1, 3] }, +// ]; +// grid.addEventListener('uf-achievement-grid:select', (e) => openCard(e.detail.id)); +// +// The count on top ("2 / 22 Unlocked"), then one titled block per section with +// its own count, each badge a '; + } + html += ''; + } + return html; + } + + /** Items grouped by section, in `sections` order; the rest in one untitled group at the end. */ + #groups() { + const all = this.#achievements; + if (!this.#sections.length) return [{ id: '', title: '', items: all }]; + const known = new Set(this.#sections.map((s) => s.id)); + const groups = this.#sections.map((s) => ({ id: s.id, title: s.title, items: all.filter((a) => a.section === s.id) })).filter((g) => g.items.length); + const rest = all.filter((a) => !a.section || !known.has(a.section)); + if (rest.length) groups.push({ id: '', title: '', items: rest }); + return groups; + } +} + +UfAchievementGrid.define(); diff --git a/shared/ui/elements/uf-achievement-sheet.js b/shared/ui/elements/uf-achievement-sheet.js new file mode 100644 index 00000000..89e45f36 --- /dev/null +++ b/shared/ui/elements/uf-achievement-sheet.js @@ -0,0 +1,265 @@ +// @ts-check +// ============================================================ +// — the achievements sheet: the grid, and a detail card per badge. +// ============================================================ +// +// +// const sheet = document.getElementById('ach'); +// sheet.sections = [...]; sheet.achievements = [...]; // AchievementItem[] (uf-achievement-grid.js) +// sheet.show(); // the grid +// sheet.showDetail('snake_eyes'); // straight to one card +// sheet.attachFocus(focusController); // optional: a controller drives it (A opens, B goes back) +// +// Two s it builds itself: the grid (size lg) and, over it, one +// badge's card (size md, headed with the badge's section). Picking a badge +// lifts it off the grid toward the middle of the screen while the grid dims, +// then the card shows. Back on the card (B, Esc, the scrim, its Close) +// returns to the grid with focus on the same badge; Back on the grid closes +// everything and focus returns to whatever opened it. +// +// Events: uf-achievement-sheet:select ({ id }) when a card opens, +// uf-achievement-sheet:close when the whole sheet closes. +// +// Replaces: Tandemonium's #achievements-modal + #ach-detail sheets and their +// open/close/lift/focus handling in lobby.js. + +import { UfElement, prop } from '../uf-element.js'; +import './uf-sheet.js'; +import './uf-achievement-grid.js'; +import './uf-achievement-detail.js'; + +/** @typedef {import('./uf-achievement-grid.js').AchievementItem} AchievementItem */ +/** @typedef {import('./uf-achievement-grid.js').AchievementSection} AchievementSection */ + +export class UfAchievementSheet extends UfElement { + /** @override */ static tag = 'uf-achievement-sheet'; + + /** @override */ static props = { + heading: prop.string('Achievements'), + // Shown as the card's heading when a badge has no section title. + detailHeading: prop.string('Achievement'), + size: prop.enum(/** @type {const} */ (['md', 'tv']), 'md'), + }; + + /** @override */ static styles = ` + uf-achievement-sheet { display: contents; } + uf-achievement-sheet .uas-list .uf-sheet__body { transition: filter 420ms ease; scrollbar-width: thin; + scrollbar-color: color-mix(in srgb, var(--uf-color-fg) 22%, transparent) transparent; scrollbar-gutter: stable; } + uf-achievement-sheet .uas-list .uf-sheet__body.uas-dim { filter: blur(6px) brightness(.5); } + uf-achievement-sheet .uag-item.uas-lifting { visibility: hidden; } + .uag-host .uas-lift.uag-item { position: fixed; z-index: calc(var(--uf-z-modal) + 2); margin: 0; box-sizing: border-box; pointer-events: none; opacity: 1; + background: color-mix(in srgb, var(--uf-color-surface) 92%, var(--uf-color-fg)); border-color: var(--uf-color-accent-2) !important; + box-shadow: 0 0 30px color-mix(in srgb, var(--uf-color-accent-2) 45%, transparent), 0 30px 60px rgba(0,0,0,.6); transform-origin: 50% 50%; } + .uag-host .uas-lift .uag-name { color: color-mix(in srgb, var(--uf-color-fg) 92%, transparent) !important; } + `; + + /** @type {AchievementItem[]} */ #achievements = []; + /** @type {AchievementSection[]} */ #sections = []; + /** @type {any} */ #list = null; + /** @type {any} */ #detail = null; + /** @type {any} */ #grid = null; + /** @type {any} */ #card = null; + /** @type {string|null} */ #detailId = null; + /** @type {HTMLElement|null} */ #opener = null; + /** @type {import('../focus-controller.js').FocusController|null} */ #focus = null; + #lifting = false; + + /** @returns {string} */ get heading() { return this.get('heading'); } + /** @param {string} v */ set heading(v) { this.set('heading', v); } + /** @returns {string} */ get detailHeading() { return this.get('detailHeading'); } + /** @param {string} v */ set detailHeading(v) { this.set('detailHeading', v); } + /** @returns {'md' | 'tv'} */ get size() { return this.get('size'); } + /** @param {'md' | 'tv'} v */ set size(v) { this.set('size', v); } + + /** @returns {AchievementItem[]} */ get achievements() { return this.#achievements; } + /** @param {AchievementItem[]} v */ + set achievements(v) { this.#achievements = Array.isArray(v) ? v : []; this.#build(); this.#grid.achievements = this.#achievements; this.#refreshCard(); } + /** @returns {AchievementSection[]} */ get sections() { return this.#sections; } + /** @param {AchievementSection[]} v */ + set sections(v) { this.#sections = Array.isArray(v) ? v : []; this.#build(); this.#grid.sections = this.#sections; } + + /** The grid's sheet is open. */ + get open() { return !!(this.#list && this.#list.open); } + /** A card is open. */ + get detailOpen() { return !!(this.#detail && this.#detail.open); } + /** The id the card shows (null when closed). */ + get detailId() { return this.#detailId; } + /** The inner elements, for a page that wants to style or extend them. */ + get gridElement() { this.#build(); return this.#grid; } + get detailElement() { this.#build(); return this.#card; } + + /** Open the grid. Focus returns to the element that had it when the sheet closes. */ + show() { + this.#build(); + const active = /** @type {HTMLElement|null} */ (document.activeElement); + this.#opener = active && active !== document.body ? active : null; + this.#list.show(); + if (this.#list.body) this.#list.body.scrollTop = 0; + const first = this.#grid.focusables()[0]; + if (first && !this.#focus) requestAnimationFrame(() => first.focus({ preventScroll: true })); + } + + /** Close everything. */ + close() { + if (this.detailOpen) this.#detail.close(); + if (this.open) this.#list.close(); + } + + /** Open one badge's card (from the grid, or straight from elsewhere). @param {string} id */ + showDetail(id) { + this.#build(); + const a = this.#achievements.find((x) => x.id === id); + if (!a || this.detailOpen || this.#lifting) return; + this.#detailId = id; + this.#renderCard(a); + const tile = this.open ? this.#grid.tile(id) : null; + const show = () => { + const sec = this.#sections.find((s) => s.id === a.section); + this.#detail.heading = sec ? sec.title : this.detailHeading; + // Esc on the card goes back to the grid, not out of both. + this.#list.dismissible = false; + this.#detail.show(); + if (!this.#focus) requestAnimationFrame(() => /** @type {HTMLElement|null} */ (this.#detail.querySelector('.uf-sheet__close'))?.focus({ preventScroll: true })); + this.emit('uf-achievement-sheet:select', { id }); + }; + this.#lift(tile, show); + } + + /** + * Let a FocusController drive the sheet: the badges and Close are its scope, + * A opens a card, B goes back (card → grid → closed). + * @param {import('../focus-controller.js').FocusController | null} controller + */ + attachFocus(controller) { + this.#build(); + this.#focus = controller; + this.#list.attachFocus(controller); + this.#detail.attachFocus(controller); + return this; + } + + /** @override */ + render() { return null; } + + /** @override */ + onConnect() { this.#build(); } + + /** @override @param {Set} changed */ + afterRender(changed) { + this.#build(); + if (changed.has('heading')) this.#list.heading = this.heading; + if (changed.has('size')) this.#grid.size = this.size; + } + + #build() { + if (this.#list) return; + const list = /** @type {any} */ (document.createElement('uf-sheet')); + list.className = 'uas-list'; + list.setAttribute('size', 'lg'); + list.heading = this.heading; + const grid = /** @type {any} */ (document.createElement('uf-achievement-grid')); + grid.size = this.size; + list.append(grid); + const detail = /** @type {any} */ (document.createElement('uf-sheet')); + detail.className = 'uas-detail'; + detail.setAttribute('size', 'md'); + detail.heading = this.detailHeading; + const card = /** @type {any} */ (document.createElement('uf-achievement-detail')); + detail.append(card); + this.append(list, detail); + this.#list = list; this.#grid = grid; this.#detail = detail; this.#card = card; + + grid.addEventListener('uf-achievement-grid:select', (/** @type {CustomEvent} */ e) => { e.stopPropagation(); this.showDetail(e.detail.id); }); + detail.addEventListener('uf-sheet:close', () => this.#onDetailClosed()); + list.addEventListener('uf-sheet:close', () => this.#onListClosed()); + // A uf-sheet:open/close from the inner sheets is this element's business, not the page's. + for (const s of [list, detail]) { + s.addEventListener('uf-sheet:open', (/** @type {Event} */ e) => e.stopPropagation()); + s.addEventListener('uf-sheet:close', (/** @type {Event} */ e) => e.stopPropagation()); + } + } + + /** @param {AchievementItem} a */ + #renderCard(a) { + this.#card.total = this.#achievements.length; + this.#card.number = this.#achievements.indexOf(a) + 1; + this.#card.achievement = a; + } + + #refreshCard() { + if (!this.#detailId) return; + const a = this.#achievements.find((x) => x.id === this.#detailId); + if (a) this.#renderCard(a); + } + + #onDetailClosed() { + this.#undim(); + this.#list.dismissible = true; + this.querySelectorAll('.uas-lift-host').forEach((el) => el.remove()); + this.querySelectorAll('.uas-lifting').forEach((el) => el.classList.remove('uas-lifting')); + const id = this.#detailId; + this.#detailId = null; + if (!this.open) return; + const tile = id ? this.#grid.tile(id) : null; + // Back to the grid with focus on the same badge (the controller's scope too). + requestAnimationFrame(() => { + if (this.#focus && this.open) this.#focus.setSpatial(this.#list.focusables(), tile); + if (tile) tile.focus({ preventScroll: true }); + }); + } + + #onListClosed() { + if (this.detailOpen) this.#detail.close(); + this.#undim(); + this.#list.dismissible = true; + const opener = this.#opener; + this.#opener = null; + if (opener && opener.isConnected && !this.#focus) opener.focus({ preventScroll: true }); + this.emit('uf-achievement-sheet:close'); + } + + #undim() { + const body = this.#list && this.#list.body; + if (!body) return; + body.getAnimations?.().forEach((/** @type {Animation} */ a) => a.cancel()); + body.classList.remove('uas-dim'); + } + + /** + * The badge lifts off the grid toward the middle of the screen (the grid + * dims), then `done` shows the card. Skipped without a tile or with reduced motion. + * @param {HTMLElement|null} tile @param {() => void} done + */ + #lift(tile, done) { + const reduce = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches; + const r = tile ? tile.getBoundingClientRect() : null; + if (!tile || !r || !r.width || reduce || typeof tile.animate !== 'function') { done(); return; } + this.querySelectorAll('.uas-lift-host').forEach((el) => el.remove()); + const c = /** @type {HTMLElement} */ (tile.cloneNode(true)); + c.classList.add('uas-lift'); + c.classList.remove('gamepad-focus'); + c.removeAttribute('data-ach'); + Object.assign(c.style, { left: r.left + 'px', top: r.top + 'px', width: r.width + 'px', height: r.height + 'px' }); + // Outside the grid (its body blurs while the card opens), in a host the badge styles match. + const host = document.createElement('div'); + host.className = 'uag-host uas-lift-host'; + host.append(c); + this.append(host); + tile.classList.add('uas-lifting'); + const W = innerWidth, H = innerHeight; + const S = Math.min(0.6 * W / r.width, 0.45 * H / r.height); + const dx = W / 2 - (r.left + r.width / 2), dy = H / 2 - (r.top + r.height / 2); + const anim = c.animate([ + { transform: 'translate(0, 0) scale(1)', opacity: 1 }, + { transform: `translate(${dx}px, ${dy}px) scale(${S})`, opacity: 0.35 }, + ], { duration: 420, easing: 'cubic-bezier(0.65, 0, 0.35, 1)', fill: 'forwards' }); + this.#list.body?.classList.add('uas-dim'); + this.#lifting = true; + let fired = false; + const finish = () => { if (fired) return; fired = true; this.#lifting = false; host.remove(); tile.classList.remove('uas-lifting'); done(); }; + anim.finished.then(finish, finish); + setTimeout(finish, 700); // a dropped animation never leaves the badge in limbo + } +} + +UfAchievementSheet.define(); diff --git a/shared/ui/elements/uf-controller-roster.js b/shared/ui/elements/uf-controller-roster.js index 6d3c02eb..8bece56e 100644 --- a/shared/ui/elements/uf-controller-roster.js +++ b/shared/ui/elements/uf-controller-roster.js @@ -25,6 +25,7 @@ // its Controllers panel rows (docs/UI-KIT-TV-COMPONENTS.md §7). import { UfElement, prop, esc } from '../uf-element.js'; +import { personTag } from './uf-person.js'; /** * @typedef {object} RosterRow (the fields this element reads; core's RosterEntry has them all) @@ -39,6 +40,7 @@ import { UfElement, prop, esc } from '../uf-element.js'; * @property {string} [connection] * @property {boolean} [active] * @property {string} [detail] list variant: a second line (ids, vid:pid, serial…) + * @property {{ name: string, pic?: string, source?: string }|null} [person] the signed-in player on this controller: shown before the controller's name */ const CONNECTION = { usb: 'USB', bluetooth: 'Bluetooth', puck: 'Puck', webhid: 'WebHID', gamepad: 'Gamepad', keyboard: 'Keys' }; @@ -118,7 +120,7 @@ export class UfControllerRoster extends UfElement { /** @param {RosterRow[]} v */ set rows(v) { this.#rows = Array.isArray(v) ? v : []; - const sig = this.#rows.map((r) => [r.key, r.shortName || r.name, r.state, r.player, r.seat && r.seat.team, r.seat && r.seat.role, r.glyphFamily, r.swapAB, r.connection, r.detail].join('|')).join('~'); + const sig = this.#rows.map((r) => [r.key, r.shortName || r.name, r.state, r.player, r.seat && r.seat.team, r.seat && r.seat.role, r.glyphFamily, r.swapAB, r.connection, r.detail, r.person && [r.person.name, r.person.pic, r.person.source].join(',')].join('|')).join('~'); if (sig !== this.#sig) { this.#sig = sig; this.requestUpdate(); } else this.#patchDots(); } @@ -168,7 +170,8 @@ export class UfControllerRoster extends UfElement { const chips = ordered.map((r) => { const name = esc(r.shortName || r.name || 'Controller'); if (isSeated(r)) { - return `${esc(seatBadge(r))}${name}` + + const who = r.person ? personTag(r.person, 'sm') + `${name}` : name; + return `${esc(seatBadge(r))}${who}` + (r.state === 'ready' ? `ready` : '') + ``; } const glyph = ``; @@ -187,7 +190,7 @@ export class UfControllerRoster extends UfElement { const seat = isSeated(r) ? `${seatBadge(r)} · ` : (r.state === 'joined' && r.player != null ? `P${r.player} · ` : ''); return `
` + `` + - `
${esc(r.name || r.shortName || 'Controller')}${r.detail ? `${esc(seat)}${esc(r.detail)}` : (seat ? `${esc(seat.replace(/ · $/, ''))}` : '')}
` + + `
${r.person ? personTag(r.person, 'sm') + ' · ' : ''}${esc(r.name || r.shortName || 'Controller')}${r.detail ? `${esc(seat)}${esc(r.detail)}` : (seat ? `${esc(seat.replace(/ · $/, ''))}` : '')}
` + `${esc(CONNECTION[/** @type {keyof typeof CONNECTION} */ (r.connection)] || r.connection || '')}` + `${tags.join('')}
`; }).join(''); diff --git a/shared/ui/elements/uf-person.js b/shared/ui/elements/uf-person.js new file mode 100644 index 00000000..5210c5fa --- /dev/null +++ b/shared/ui/elements/uf-person.js @@ -0,0 +1,70 @@ +// @ts-check +// ============================================================ +// — who is playing: their picture, a small badge for where the +// sign-in came from (Discord, Steam), and their name. +// ============================================================ +// +// +// (no picture: the badge alone) +// +// Seats and controller rosters use it next to the controller's name, so a +// signed-in player shows as a person rather than a pad (#612). + +import { UfElement, prop, esc } from '../uf-element.js'; + +/** The sign-in sources this element knows a badge for (24×24 brand marks). */ +export const PERSON_SOURCES = { + discord: { label: 'Discord', color: '#5865f2', path: 'M20.317 4.37a19.79 19.79 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.865-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.74 19.74 0 0 0 3.677 4.37a.07.07 0 0 0-.032.028C.533 9.046-.32 13.58.099 18.058a.082.082 0 0 0 .031.056 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.1 13.1 0 0 1-1.872-.892.077.077 0 0 1-.008-.128c.126-.094.252-.192.372-.291a.074.074 0 0 1 .078-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.009c.12.1.246.198.373.292a.077.077 0 0 1-.006.127 12.3 12.3 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.029 19.84 19.84 0 0 0 6.002-3.03.077.077 0 0 0 .032-.055c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.029zM8.02 15.331c-1.182 0-2.157-1.086-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.332-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.086-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.332-.946 2.418-2.157 2.418z' }, + steam: { label: 'Steam', color: '#1b2838', path: 'M11.979 0C5.678 0 .511 4.86.022 11.037l6.432 2.658c.545-.371 1.203-.59 1.912-.59.063 0 .125.004.188.006l2.861-4.142V8.91c0-2.495 2.028-4.524 4.524-4.524 2.494 0 4.524 2.031 4.524 4.527s-2.03 4.525-4.524 4.525h-.105l-4.076 2.911c0 .052.004.105.004.159 0 1.875-1.515 3.396-3.39 3.396-1.635 0-3.016-1.173-3.331-2.727L.436 15.27C1.862 20.307 6.486 24 11.979 24c6.627 0 11.999-5.373 11.999-12S18.605 0 11.979 0zM7.54 18.21l-1.473-.61c.262.543.714.999 1.314 1.25 1.297.539 2.793-.076 3.332-1.375.263-.63.264-1.319.005-1.949s-.75-1.121-1.377-1.383c-.624-.26-1.29-.249-1.878-.03l1.523.63c.956.4 1.409 1.5 1.009 2.455-.397.957-1.497 1.41-2.454 1.012H7.54zm11.415-9.303c0-1.662-1.353-3.015-3.015-3.015-1.665 0-3.015 1.353-3.015 3.015 0 1.665 1.35 3.015 3.015 3.015 1.663 0 3.015-1.35 3.015-3.015zm-5.273-.005c0-1.252 1.013-2.266 2.265-2.266 1.249 0 2.266 1.014 2.266 2.266 0 1.251-1.017 2.265-2.266 2.265-1.253 0-2.265-1.014-2.265-2.265z' }, +}; + +export class UfPerson extends UfElement { + /** @override */ static tag = 'uf-person'; + + /** @override */ static props = { + name: prop.string(''), + pic: prop.string(''), + source: prop.string(''), + size: prop.enum(/** @type {const} */ (['sm', 'md']), 'md'), + }; + + /** @override */ static styles = ` + uf-person { display: inline-flex; align-items: center; gap: .4em; min-width: 0; vertical-align: middle; } + uf-person .uf-person__pic { position: relative; flex: none; width: 1.6em; height: 1.6em; } + uf-person[size="sm"] .uf-person__pic { width: 1.3em; height: 1.3em; } + uf-person .uf-person__pic img { width: 100%; height: 100%; border-radius: 50%; object-fit: cover; display: block; box-shadow: 0 0 0 2px var(--_src, var(--uf-color-line-soft)); background: var(--uf-color-surface-3); } + uf-person .uf-person__src { position: absolute; right: -.25em; bottom: -.2em; width: .8em; height: .8em; padding: .1em; border-radius: 50%; background: var(--_src); color: #fff; display: block; } + uf-person .uf-person__pic.uf-person__pic--bare .uf-person__src { position: static; width: 100%; height: 100%; padding: .22em; box-sizing: border-box; } + uf-person .uf-person__src svg { width: 100%; height: 100%; display: block; fill: currentColor; } + uf-person .uf-person__name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; } + `; + + /** @returns {string} */ get name() { return this.get('name'); } + /** @param {string} v */ set name(v) { this.set('name', v); } + /** Picture URL ('' = the source badge alone). @returns {string} */ get pic() { return this.get('pic'); } + /** @param {string} v */ set pic(v) { this.set('pic', v); } + /** 'discord' | 'steam' | '' @returns {string} */ get source() { return this.get('source'); } + /** @param {string} v */ set source(v) { this.set('source', v); } + /** @returns {'sm' | 'md'} */ get size() { return this.get('size'); } + /** @param {'sm' | 'md'} v */ set size(v) { this.set('size', v); } + + /** @override */ + render() { + const src = PERSON_SOURCES[/** @type {keyof typeof PERSON_SOURCES} */ (this.source)] || null; + this.style.setProperty('--_src', src ? src.color : ''); + this.title = src ? `${this.name} · ${src.label}` : this.name; + const badge = src ? `` : ''; + const pic = this.pic + ? `${badge}` + : (badge ? `${badge}` : ''); + return pic + `${esc(this.name)}`; + } +} + +/** Markup for a (for elements that render strings). @param {{ name?: string, pic?: string, source?: string }|null|undefined} p @param {'sm'|'md'} [size] */ +export function personTag(p, size = 'md') { + if (!p) return ''; + return ``; +} + +UfPerson.define(); diff --git a/shared/ui/elements/uf-seat.js b/shared/ui/elements/uf-seat.js index 455f92a0..32d55d6b 100644 --- a/shared/ui/elements/uf-seat.js +++ b/shared/ui/elements/uf-seat.js @@ -22,6 +22,7 @@ // "JOIN AS PLAYER 2" buttons (docs/UI-KIT-TV-COMPONENTS.md §5). import { UfElement, prop, esc } from '../uf-element.js'; +import { personTag } from './uf-person.js'; export class UfSeat extends UfElement { /** @override */ static tag = 'uf-seat'; @@ -34,6 +35,8 @@ export class UfSeat extends UfElement { state: prop.enum(/** @type {const} */ (['open', 'candidate', 'joined', 'ready', 'locked']), 'open'), tone: prop.enum(/** @type {const} */ (['captain', 'stoker', 'team-a', 'team-b']), 'captain'), badge: prop.string(''), + pic: prop.string(''), // the player's picture: `who` then shows as a + source: prop.string(''), // where their sign-in came from: 'discord' | 'steam' }; /** @override */ static styles = ` @@ -57,6 +60,7 @@ export class UfSeat extends UfElement { uf-seat .uf-seat__role { font: 700 var(--uf-size-xs)/1.3 var(--uf-font-mono); letter-spacing: 1.4px; color: var(--_tone); text-transform: uppercase; } uf-seat .uf-seat__who { font: 700 var(--uf-size-lg)/1.2 var(--uf-font-ui); letter-spacing: .3px; } uf-seat[state="joined"] .uf-seat__who, uf-seat[state="ready"] .uf-seat__who { color: var(--_tone); } + uf-seat .uf-seat__who uf-person { max-width: 100%; } uf-seat .uf-seat__dev { font: var(--uf-size-sm)/1.3 var(--uf-font-ui); color: var(--uf-color-fg-muted); } uf-seat .uf-seat__dev uf-controller-glyph { margin: 0 .15em; } uf-seat .uf-seat__badge { position: absolute; top: 6px; right: 8px; font: 800 var(--uf-size-xs)/1 var(--uf-font-mono); padding: 3px 6px; border-radius: var(--uf-radius-sm); background: var(--_tone); color: var(--uf-color-bg); } @@ -89,6 +93,12 @@ export class UfSeat extends UfElement { /** @returns {string} */ get badge() { return this.get('badge'); } /** @param {string} v */ set badge(v) { this.set('badge', v); } + /** The signed-in player's picture URL. @returns {string} */ get pic() { return this.get('pic'); } + /** @param {string} v */ set pic(v) { this.set('pic', v); } + + /** 'discord' | 'steam' | '' @returns {string} */ get source() { return this.get('source'); } + /** @param {string} v */ set source(v) { this.set('source', v); } + /** Open and candidate seats take focus; the rest are read-only. */ #syncFocusable() { const focusable = this.state === 'open' || this.state === 'candidate'; @@ -106,7 +116,7 @@ export class UfSeat extends UfElement { return (this.badge ? `${esc(this.badge)}` : '') + (this.state === 'ready' ? `✓ READY` : '') + `${esc(this.seat)}` + - `${esc(this.who)}` + sub; + `${this.pic || this.source ? personTag({ name: this.who, pic: this.pic, source: this.source }) : esc(this.who)}` + sub; } /** @override @param {Set} _changed */ diff --git a/shared/ui/index.js b/shared/ui/index.js index 92de0a04..35124897 100644 --- a/shared/ui/index.js +++ b/shared/ui/index.js @@ -7,6 +7,9 @@ export { UfElement, prop, coerce, esc, kebab } from './uf-element.js'; export { FocusController } from './focus-controller.js'; export { GamepadInput, ManualInput } from './inputs.js'; // uf:elements — the scaffolder appends one line per element below this marker. +export { UfAchievementSheet } from './elements/uf-achievement-sheet.js'; +export { UfAchievementDetail } from './elements/uf-achievement-detail.js'; +export { UfAchievementGrid } from './elements/uf-achievement-grid.js'; export { UfPips } from './elements/uf-pips.js'; export { UfPlayerCard } from './elements/uf-player-card.js'; export { UfAchievement } from './elements/uf-achievement.js'; @@ -27,3 +30,4 @@ export { UfSeat } from './elements/uf-seat.js'; export { UfFlow } from './elements/uf-flow.js'; export { UfStep } from './elements/uf-step.js'; export { UfButton } from './elements/uf-button.js'; +export { UfPerson, personTag, PERSON_SOURCES } from './elements/uf-person.js';