fix(client): style the user profile popup

The popup rendered unstyled: it appeared at the bottom of the page and
pushed the rest of the app up, with the avatar drawn as a full-width bar.

app.css carried a complete Discord-shaped card under `.user-popup` /
`.up-*`, but nothing in the codebase renders those classes — the
component emits `.upp-*`. The component had been rewritten with a new
prefix and the stylesheet was left pointing at a DOM that no longer
existed. With no rule matching, the card stayed `position: static`, so
the left/top it computes were discarded and both it and its overlay laid
out as ordinary blocks at the end of <body>.

Replace the orphaned block with rules for the classes actually rendered,
following the same anatomy: banner strip, avatar straddling the
banner/body seam inside a ring punched from the card background, panel
sections, action row. Everything routes through existing tokens, so the
card follows the theme contract.

Two latent bugs fixed while there:

  - Placement guessed a 300px card height and clamped only the top edge,
    so a member clicked low in the list opened a card that ran off the
    bottom of the window. Measure the card and clamp both edges.
  - The avatar has to hang off the body's top edge, but the body scrolls,
    and `overflow-y: auto` clips horizontally too. Make it a child of the
    card rather than the body.

The fade+scale moves from inline styles into CSS so a
`prefers-reduced-motion` override can drop it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
J3vb
2026-08-04 17:43:13 +02:00
co-authored by Claude Opus 5
parent b77c2790b0
commit a308f81fb4
2 changed files with 191 additions and 90 deletions
@@ -3,8 +3,9 @@
* in the chat or member list. Shows avatar, username, role badge, status dot,
* about section, join date, and Message/Call action buttons.
*
* Position: anchored to click point, flips if <100px from viewport edge.
* Animation: fade+scale 100ms.
* Position: anchored to the click point, flipped to the other side and clamped
* against the measured card height so it always lands fully on screen.
* Animation: fade+scale, defined in CSS so reduced-motion can drop it.
* Close: outside click or Escape.
* A11y: role="dialog", aria-label, focus trap, return focus on close.
*/
@@ -57,8 +58,10 @@ export type UserProfilePopupComponent = MountableComponent & {
// ---------------------------------------------------------------------------
const POPUP_WIDTH = 300;
const EDGE_THRESHOLD = 100;
const ANIMATION_DURATION_MS = 100;
/** Keeps the card clear of the window edges on both axes. */
const VIEWPORT_MARGIN = 8;
/** Breathing room between the click point and the card. */
const ANCHOR_GAP = 8;
const STATUS_COLORS: Record<UserStatus, string> = {
online: "#3ba55d",
@@ -110,28 +113,37 @@ export function createUserProfilePopup(
}
}
function computePosition(anchorX: number, anchorY: number): { left: number; top: number } {
/**
* Place the card beside the anchor, flipping and clamping so it always lands
* fully on screen — Discord opens its popout away from whichever edge the
* clicked row is nearest.
*
* The height is measured rather than assumed. The previous version guessed
* 300px and only clamped the top edge, so a member clicked low in the list
* opened a card that ran off the bottom of the window.
*/
function position(el: HTMLElement, anchorX: number, anchorY: number): void {
const vw = window.innerWidth;
const vh = window.innerHeight;
const height = el.offsetHeight;
let left = anchorX;
// Prefer the right of the anchor and flip left when there is no room. The
// member list sits against the right edge, so flipping is the usual case.
let left = anchorX + ANCHOR_GAP;
if (left + POPUP_WIDTH > vw - VIEWPORT_MARGIN) {
left = anchorX - POPUP_WIDTH - ANCHOR_GAP;
}
left = Math.max(VIEWPORT_MARGIN, Math.min(left, vw - POPUP_WIDTH - VIEWPORT_MARGIN));
// Align the top with the click, then lift the card just enough to fit.
let top = anchorY;
// Flip horizontally if too close to right edge
if (vw - anchorX < EDGE_THRESHOLD) {
left = anchorX - POPUP_WIDTH;
if (top + height > vh - VIEWPORT_MARGIN) {
top = vh - height - VIEWPORT_MARGIN;
}
top = Math.max(VIEWPORT_MARGIN, top);
// Flip vertically if too close to bottom edge
if (vh - anchorY < EDGE_THRESHOLD) {
top = anchorY - 300; // approximate popup height
}
// Clamp to viewport
left = Math.max(8, Math.min(left, vw - POPUP_WIDTH - 8));
top = Math.max(8, top);
return { left, top };
el.style.left = `${left}px`;
el.style.top = `${top}px`;
}
function buildAvatar(user: UserProfileData): HTMLDivElement {
@@ -182,17 +194,8 @@ export function createUserProfilePopup(
"data-testid": "user-profile-popup",
});
// Position the popup
const pos = computePosition(options.anchorX, options.anchorY);
popup.style.left = `${pos.left}px`;
popup.style.top = `${pos.top}px`;
popup.style.width = `${POPUP_WIDTH}px`;
// Animation: fade + scale
popup.style.opacity = "0";
popup.style.transform = "scale(0.95)";
popup.style.transition = `opacity ${ANIMATION_DURATION_MS}ms ease, transform ${ANIMATION_DURATION_MS}ms ease`;
// --- Content ---
// Avatar
@@ -298,10 +301,12 @@ export function createUserProfilePopup(
actions.appendChild(callBtn);
}
// Assemble popup
// Assemble the card: a banner strip and a body, with the avatar straddling
// the seam between them the way Discord's popout does.
const banner = createElement("div", { class: "upp-banner" });
const body = createElement("div", { class: "upp-body" });
appendChildren(
popup,
avatar,
body,
nameEl,
handleEl,
customStatusEl,
@@ -311,17 +316,24 @@ export function createUserProfilePopup(
joinSection,
);
if (actions.childElementCount > 0) {
appendChildren(popup, divider, actions);
appendChildren(body, divider, actions);
}
// The avatar hangs off the body's top edge, so it is a child of the card
// rather than the body — the body scrolls, and a scroll container clips.
// Appending it last puts it over the banner without needing a z-index.
appendChildren(popup, banner, body, avatar);
overlay.appendChild(popup);
container.appendChild(overlay);
// Trigger animation
// Measure, then place: the card has to be in the document before it has a
// height to clamp against.
position(popup, options.anchorX, options.anchorY);
// The fade+scale itself lives in CSS so `prefers-reduced-motion` can drop it.
requestAnimationFrame(() => {
if (popup !== null) {
popup.style.opacity = "1";
popup.style.transform = "scale(1)";
popup.classList.add("open");
}
});
+143 -54
View File
@@ -2559,90 +2559,179 @@ ul.md-list-nested {
filter: saturate(0.3);
}
/* ── User Profile Popup ── */
.user-popup {
/* ── User Profile Popup ──
Discord's popout shape: a floating card anchored to the click, a banner strip
across the top, and the avatar straddling the banner/body seam inside a ring
punched out of the card background.
`position: fixed` on both layers is load-bearing. These rules replaced an
earlier `.up-*` set that no longer matched the component's markup, so the
card rendered unstyled: `static` positioning discarded the computed
left/top and both layers laid out in normal flow at the end of <body>,
pushing the whole app up the page. */
.upp-overlay {
position: fixed;
inset: 0;
z-index: 200;
}
.upp-popup {
position: fixed;
display: flex;
flex-direction: column;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: var(--radius-md);
width: 300px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
display: none;
box-shadow: var(--elevation-high);
overflow: hidden;
/* A long "about me" scrolls inside the card instead of growing it off the
bottom of the screen. */
max-height: calc(100vh - 16px);
opacity: 0;
transform: scale(0.95);
transform-origin: top left;
transition:
opacity var(--transition-fast),
transform var(--transition-fast);
}
.user-popup.open {
display: block;
.upp-popup.open {
opacity: 1;
transform: scale(1);
}
.up-banner {
.upp-banner {
height: 60px;
flex-shrink: 0;
background: var(--accent-gradient);
}
.up-body {
padding: 36px 16px 16px;
.upp-body {
position: relative;
padding: 44px 16px 16px;
overflow-y: auto;
}
.up-avatar {
width: 64px;
height: 64px;
border-radius: var(--radius-circle);
/* Positioned against the card, not the body. The body scrolls, and an
`overflow-y: auto` box also clips horizontally, so an avatar overhanging its
top edge would be sliced in half. Offset = banner height minus half the
avatar, so it straddles the seam. */
.upp-avatar {
position: absolute;
top: -32px;
top: 24px;
left: 16px;
box-sizing: border-box;
width: 72px;
height: 72px;
border-radius: var(--radius-circle);
/* The ring is the card background rather than a border colour — that is what
makes the avatar read as punched through the banner. */
border: 6px solid var(--bg-primary);
display: flex;
align-items: center;
justify-content: center;
font-size: var(--font-size-xxl);
font-weight: 700;
font-size: 24px;
color: white;
color: #fff;
}
.upp-status-dot {
position: absolute;
right: -2px;
bottom: -2px;
box-sizing: border-box;
width: 20px;
height: 20px;
border-radius: var(--radius-circle);
border: 4px solid var(--bg-primary);
}
.up-name {
font-size: 18px;
.upp-username {
font-size: var(--font-size-xl);
font-weight: 700;
color: white;
line-height: 1.2;
color: var(--header-primary);
overflow-wrap: anywhere;
}
.up-role {
font-size: 12px;
margin-top: 2px;
}
.up-section {
margin-top: 12px;
background: var(--bg-secondary);
border-radius: var(--radius-md);
padding: 12px;
}
.up-section-title {
font-size: 11px;
font-weight: 700;
color: var(--text-faint);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 6px;
}
.up-section-text {
font-size: 13px;
color: var(--text-normal);
}
.up-roles {
display: flex;
gap: 4px;
flex-wrap: wrap;
margin-top: 8px;
}
.up-role-tag {
display: flex;
.upp-role-badge {
display: inline-flex;
align-items: center;
gap: 4px;
gap: 6px;
margin-top: 8px;
padding: 2px 8px;
border-radius: var(--radius-pill);
background: var(--bg-hover);
font-size: 11px;
font-size: var(--font-size-xs);
color: var(--text-normal);
}
.up-role-dot {
.upp-status-line {
display: flex;
align-items: center;
gap: 6px;
margin-top: 8px;
font-size: var(--font-size-sm);
color: var(--text-muted);
}
.upp-role-dot,
.upp-status-dot-inline {
flex-shrink: 0;
width: 8px;
height: 8px;
border-radius: 50%;
border-radius: var(--radius-circle);
}
/* Both sections are always built and left empty when the user has no data for
them, so they have to collapse rather than render as blank panels. */
.upp-about,
.upp-join-date {
margin-top: 12px;
padding: 12px;
border-radius: var(--radius-md);
background: var(--bg-secondary);
}
.upp-about:empty,
.upp-join-date:empty {
display: none;
}
.upp-section-title {
margin-bottom: 6px;
font-size: var(--font-size-xxs);
font-weight: 700;
letter-spacing: 0.5px;
text-transform: uppercase;
color: var(--text-faint);
}
.upp-about-text,
.upp-join-text {
font-size: var(--font-size-sm);
color: var(--text-normal);
overflow-wrap: anywhere;
}
.upp-divider {
height: 1px;
margin: 12px 0;
background: var(--border);
}
.upp-actions {
display: flex;
gap: 8px;
}
.upp-action-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px 12px;
border: none;
border-radius: var(--radius-sm);
background: var(--accent);
color: #fff;
font-family: inherit;
font-size: var(--font-size-sm);
font-weight: 500;
cursor: pointer;
transition: background var(--transition-fast);
}
.upp-action-btn:hover {
background: var(--accent-hover);
}
@media (prefers-reduced-motion: reduce) {
.upp-popup {
transition: none;
}
}
/* ── Emoji Picker ── */