From a308f81fb419dc62d0acea9456f726d647f9814c Mon Sep 17 00:00:00 2001
From: J3vb <192430104+J3vb@users.noreply.github.com>
Date: Sun, 2 Aug 2026 09:32:10 +0200
Subject: [PATCH] fix(client): style the user profile popup
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
.
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)
---
.../src/components/UserProfilePopup.ts | 84 ++++----
Client/tauri-client/src/styles/app.css | 197 +++++++++++++-----
2 files changed, 191 insertions(+), 90 deletions(-)
diff --git a/Client/tauri-client/src/components/UserProfilePopup.ts b/Client/tauri-client/src/components/UserProfilePopup.ts
index 00423a34..3f577b74 100644
--- a/Client/tauri-client/src/components/UserProfilePopup.ts
+++ b/Client/tauri-client/src/components/UserProfilePopup.ts
@@ -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 = {
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");
}
});
diff --git a/Client/tauri-client/src/styles/app.css b/Client/tauri-client/src/styles/app.css
index 4bb197cb..d9c9f442 100644
--- a/Client/tauri-client/src/styles/app.css
+++ b/Client/tauri-client/src/styles/app.css
@@ -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 ,
+ 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 ── */