v4: the avatar menu shows at every width and holds Log out, so a profile keeps only Invite; the profile's dump cards lay out again — your own profile had four buttons in a row, Invite someone, Settings, Manage and Log out, Invite taller than the rest, and on wide screens it was the only way to reach /settings or /manage. The avatar menu now leads the header's icon row at every width, after the name and Playlists text links on wide screens, and ends with Log out under a separator, muted until hovered. The profile keeps Invite someone alone. The menu's button outweighs .app-header-nav button and the themes' button rules (Brutalist and GeoCities list it among their flat buttons), so it reads as one of the menu's links. Since b9ff9c4, a card under 472px is laid out by the narrow-card container query, but the profile columns' dense-card rules still placed the thumb, body and vote themselves, more specifically: the thumb lost its row span, so the title fell under it and every card was a tall, mostly empty box. Those rules now only make the card denser (a smaller thumb, a 2-line title, smaller type) and leave placement to the container query. 670 frontend tests pass, and tsc, eslint, deno fmt, doc:lint and i18n:compile --strict are clean; checked in screenshots in Smooth dark at 1400 and 390px, Brutalist light at 390px, GeoCities light and Kanagawa dark at 1400px, in French.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-10-08 22:22:15 +00:00
parent 10c9cf4269
commit 886216749e
11 changed files with 179 additions and 219 deletions

View File

@@ -2186,54 +2186,28 @@ body.has-player .chat-fab {
}
/* ── Dense dump cards in the two-column profile view ──
The default card is proportioned for the 860px feed. Dropped into a ~400px
column it falls apart: the title wraps four lines deep, the meta row stacks,
every category chip takes a line of its own and the vote button floats in
the middle of the resulting void. Below is the same card re-proportioned for
the column — smaller thumb, title clamped to two lines, no teaser, and meta
plus chips sharing the line under the title. Scoped to the width where the
columns actually split; narrower than that the feed is full-width again and
the regular (then the phone) layout applies. */
A ~400px column is narrow enough for the narrow card's grid (the
`@container dump-card` rules), which places the thumb, the text and the
vote; placing them here as well, more specifically, broke that grid. This
only makes the card denser for a column of a dozen: a smaller thumb, the
title clamped to two lines and smaller type. Scoped to the width where the
columns split; narrower than that the feed is full-width again. */
@media (min-width: 900px) {
.profile-columns .dump-feed {
padding: 0.75rem 0 0;
gap: 0.5rem;
}
/* preview | body | vote, all top-aligned so the count sits on the title
line instead of drifting to the card's vertical middle. */
.profile-columns .dump-card-inner {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: start;
gap: 0.55rem;
padding: 0.5rem 0.6rem;
}
.profile-columns .dump-card-preview {
grid-area: 1 / 1;
width: 88px;
height: 50px;
}
.profile-columns .dump-card-vote {
grid-area: 1 / 3;
align-self: start;
}
/* Row layout with a full-width title: the title takes a line to itself, then
meta and chips flow onto the next one and only wrap when they must. */
.profile-columns .dump-card-body {
grid-area: 1 / 2;
margin-left: 0;
flex-direction: row;
flex-wrap: wrap;
align-items: baseline;
gap: 0.15rem 0.45rem;
}
.profile-columns .dump-card-title {
flex: 1 0 100%;
font-size: var(--text-md);
line-height: 1.3;
display: -webkit-box;
@@ -2243,22 +2217,6 @@ body.has-player .chat-fab {
overflow: hidden;
}
/* The teaser is the first thing to go, the same call the phone layout makes. */
.profile-columns .dump-card-comment {
display: none;
}
.profile-columns .dump-card-meta,
.profile-columns .dump-card-categories {
margin-top: 0;
}
.profile-columns .dump-card-meta {
gap: 0.4rem;
/* Keeps "3 weeks ago" and "1 comment" on one line each. */
white-space: nowrap;
}
.profile-columns .dump-card-date,
.profile-columns .dump-card-comment-count {
font-size: var(--text-2xs);
@@ -2341,10 +2299,6 @@ body.has-player .chat-fab {
background: var(--color-success);
}
.profile-header .btn-border {
margin-top: 0.5rem;
}
.profile-header .follow-btn {
margin-top: 0.5rem;
}
@@ -2476,8 +2430,8 @@ body.has-player .chat-fab {
color: var(--color-accent);
}
/* Grid sized by the button row; the invite panel below only fills that
width (width: 0 + min-width: 100% keeps it out of the sizing). */
/* Grid sized by the button; the invite panel below only fills that width
(width: 0 + min-width: 100% keeps it out of the sizing). */
.profile-own-actions {
display: grid;
width: fit-content;
@@ -2485,15 +2439,6 @@ body.has-player .chat-fab {
gap: 0.5rem;
margin-top: 0.5rem;
}
.profile-own-actions-row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.profile-own-actions .btn-border {
margin-top: 0;
}
.invite-btn {
padding: 0.3rem 0.9rem;
@@ -3121,18 +3066,6 @@ body.has-player .chat-fab {
}
}
/* Avatar menu — visible only below the text-links breakpoint */
.nav-compact {
display: flex;
align-items: center;
}
@media (min-width: 1150px) {
.nav-compact {
display: none;
}
}
.app-header-user {
display: inline-flex;
align-items: center;
@@ -3150,7 +3083,7 @@ body.has-player .chat-fab {
background: var(--color-header-user-bg-hover);
}
/* ── User menu (compact nav) ── */
/* ── User menu ── */
/* A flex box, so the inline-flex trigger leaves no line-box gap under it
that would sit it higher than the buttons beside it. */
.user-menu {
@@ -3206,6 +3139,7 @@ body.has-player .chat-fab {
color: var(--color-text);
font-size: var(--text-md);
font-weight: 600;
white-space: nowrap;
transition: background var(--dur-fast);
}
@@ -3213,6 +3147,33 @@ body.has-player .chat-fab {
background: var(--color-header-user-bg-hover);
}
.user-menu-separator {
margin: 0.15rem 0.35rem;
border-top: var(--border-width) solid var(--color-border-subtle);
}
/* Log out is a button among links: it drops the button's own border,
background and weight, and is muted until hovered, as ending the session
should be. Two classes, to outweigh `.app-header-nav button`, which
centres it, and the themes' `[data-style] button`. */
.user-menu .user-menu-item--logout {
display: block;
border: none;
border-radius: var(--radius-md);
background: transparent;
color: var(--color-text-muted);
font-family: inherit;
font-size: var(--text-md);
font-weight: 600;
text-align: left;
line-height: inherit;
}
.user-menu .user-menu-item--logout:hover {
background: var(--color-header-user-bg-hover);
color: var(--color-danger);
}
.user-list-popover {
/* position/top/bottom/left/max-height set inline — anchored to the
trigger's viewport rect, flipping above it when there isn't room below */

View File

@@ -159,8 +159,8 @@ export function AppHeader(
<nav className="app-header-nav">
{
/* The user's own links lead the row, so the icons after them run
unbroken to the "+". CSS shows the text links or the avatar's
menu, by width. */
unbroken to the "+": the text links on wide screens, then the
avatar's menu at every width. */
}
{user && (
<>
@@ -178,9 +178,7 @@ export function AppHeader(
<Trans>Playlists</Trans>
</Link>
</div>
<div className="nav-compact">
<UserMenu user={user} />
</div>
<UserMenu user={user} />
</>
)}
<button

View File

@@ -1,6 +1,7 @@
/**
* Renders the avatar menu that replaces the header's profile and playlists
* links on small screens, and leads to the settings and the site's tools.
* Renders the avatar menu that leads the header's icon row at every width: it
* holds the profile and playlists links (on small screens, the only ones), the
* settings, the site's tools, and logging out.
*
* @module
*/
@@ -13,6 +14,7 @@ import { Avatar } from "../shared/Avatar.tsx";
import type { SelfUser } from "../../../shared/user.ts";
import { can } from "../../utils/auth/permissions.ts";
import { userPath } from "../../../shared/paths.ts";
import { useAuth } from "../../hooks/auth/useAuth.ts";
/** Passed to {@link UserMenu} by `AppHeader`, for a signed-in user. */
export interface UserMenuProps {
@@ -21,10 +23,12 @@ export interface UserMenuProps {
/**
* Renders the signed-in user's avatar, which opens a menu with links to their
* profile, their playlists and `/settings`, and to `/manage` for those who
* hold `category:manage`. A click outside or Escape closes it.
* profile, their playlists and `/settings`, to `/manage` for those who hold
* `category:manage`, and a Log out button. A click outside or Escape closes
* it.
*/
export function UserMenu({ user }: UserMenuProps): React.JSX.Element {
const { logout } = useAuth();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
@@ -98,6 +102,18 @@ export function UserMenu({ user }: UserMenuProps): React.JSX.Element {
<Trans>Manage</Trans>
</Link>
)}
<div className="user-menu-separator" role="separator" />
<button
type="button"
className="user-menu-item user-menu-item--logout"
role="menuitem"
onClick={() => {
setOpen(false);
logout();
}}
>
<Trans>Log out</Trans>
</button>
</div>
)}
</div>

View File

@@ -7,7 +7,6 @@
import { useRef, useState } from "react";
import { Link } from "react-router";
import { can } from "../../utils/auth/permissions.ts";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { API_URL } from "../../config/api.ts";
@@ -36,13 +35,13 @@ export interface ProfileHeaderProps {
/**
* Renders the avatar, username, role and inviter. Visitors get a follow
* button. On their own profile, users change their avatar
* (`PUT /api/me/avatar`) and email, invite someone, reach their settings (and
* the site's tools, with `category:manage`) and log out.
* (`PUT /api/me/avatar`) and email, and invite someone; their settings, the
* site's tools and logging out are in the header's avatar menu.
*/
export function ProfileHeader(
{ user, isOwnProfile, onUserChange }: ProfileHeaderProps,
): React.JSX.Element {
const { user: me, authFetch, updateSession, logout } = useAuth();
const { user: me, authFetch, updateSession } = useAuth();
const [uploading, setUploading] = useState(false);
const [avatarError, setAvatarError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -170,25 +169,7 @@ export function ProfileHeader(
targetUsername={user.username}
/>
)}
{isOwnProfile && (
<ProfileOwnActions>
<Link to="/settings" className="btn-border">
<Trans>Settings</Trans>
</Link>
{can(me, "category:manage") && (
<Link to="/manage" className="btn-border">
<Trans>Manage</Trans>
</Link>
)}
<button
type="button"
className="btn-border btn-border--danger"
onClick={logout}
>
<Trans>Log out</Trans>
</button>
</ProfileOwnActions>
)}
{isOwnProfile && <ProfileOwnActions />}
</div>
</div>
);

View File

@@ -1,6 +1,6 @@
/**
* Renders the actions of the signed-in user's own profile header, which invite
* someone, beside the log out button the header adds.
* Renders the action of the signed-in user's own profile header, which invites
* someone.
*
* @module
*/
@@ -14,13 +14,10 @@ import { useHeightTransition } from "../../hooks/shared/useHeightTransition.ts";
import { ErrorCard } from "../shared/ErrorCard.tsx";
/**
* Renders the "Invite someone" button, then `children`, the log out button.
* Each opening of the panel creates a new single-use invite link, for
* `/register`.
* Renders the "Invite someone" button, which opens a panel with an invite link.
* Each opening of the panel creates a new single-use link, for `/register`.
*/
export function ProfileOwnActions(
{ children }: { children: React.ReactNode },
): React.JSX.Element {
export function ProfileOwnActions(): React.JSX.Element {
const { authFetch } = useAuth();
const [inviteUrl, setInviteUrl] = useState<string | null>(null);
const [pending, setPending] = useState(false);
@@ -69,18 +66,15 @@ export function ProfileOwnActions(
return (
<div className="profile-own-actions" ref={ref}>
<div className="profile-own-actions-row">
<button
type="button"
className="invite-btn"
onClick={toggle}
disabled={pending}
aria-expanded={inviteUrl !== null}
>
<Trans>+ Invite someone</Trans>
</button>
{children}
</div>
<button
type="button"
className="invite-btn"
onClick={toggle}
disabled={pending}
aria-expanded={inviteUrl !== null}
>
<Trans>+ Invite someone</Trans>
</button>
{inviteUrl && (
<div
className="invite-panel animate-in"

View File

@@ -3,7 +3,8 @@ import { screen } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { UserMenu } from "../../../components/layout/UserMenu.tsx";
import { makeSession } from "../../fixtures.ts";
import { currentPath, renderWithContexts } from "../../render.tsx";
import { currentPath, renderApp, renderWithContexts } from "../../render.tsx";
import { AUTH_STORAGE_KEY } from "../../../utils/auth/storage.ts";
describe("UserMenu", () => {
test("opens, links to profile and playlists, closes on Escape and outside", async () => {
@@ -41,4 +42,26 @@ describe("UserMenu", () => {
expect(currentPath()).toBe("/@ann");
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
});
test("links to /manage for those who hold category:manage", async () => {
const user = userEvent.setup();
renderWithContexts(
<UserMenu user={makeSession({ role: "admin" }).user} />,
);
await user.click(screen.getByRole("button", { name: "User menu" }));
expect(screen.getByRole("menuitem", { name: "Manage" })).toHaveAttribute(
"href",
"/manage",
);
});
test("logs out", async () => {
const session = makeSession({ username: "ann" });
renderApp(<UserMenu user={session.user} />, { session });
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "User menu" }));
await user.click(screen.getByRole("menuitem", { name: "Log out" }));
expect(localStorage.getItem(AUTH_STORAGE_KEY)).toBeNull();
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
});
});

View File

@@ -75,8 +75,6 @@ describe("UserPublicProfile", () => {
expect(dumpsAll).toHaveAttribute("href", "/@ann/dumps");
expect(votesAll).toHaveAttribute("href", "/@ann/upvoted");
await waitFor(() => expect(document.title).toMatch(/^ann · /));
expect(screen.queryByRole("link", { name: "Settings" })).not
.toBeInTheDocument();
// The lists are read by the user's id, which a rename doesn't change.
expect(calls("GET /api/users/:u/dumps")[0].path).toBe(
"/api/users/ann/dumps",
@@ -275,11 +273,11 @@ describe("UserPublicProfile", () => {
expect(within(bob).getByRole("link", { name: /@cy/ })).toBeInTheDocument();
});
test("visitors can follow; a settings link is only on your own profile", async () => {
test("visitors can follow; inviting is only on your own profile", async () => {
open({ session: makeSession({ id: "bob" }) });
expect(await screen.findByRole("button", { name: "Follow ann" }))
.toBeInTheDocument();
expect(screen.queryByRole("link", { name: "Settings" })).not
expect(screen.queryByRole("button", { name: "+ Invite someone" })).not
.toBeInTheDocument();
});
@@ -414,29 +412,18 @@ describe("UserPublicProfile", () => {
}
});
test("your settings, and the site's tools for an admin, are a link away", async () => {
test("an admin's own profile has no manage tab: that's for others'", async () => {
open({
session: { ...annSession, user: { ...annSession.user, role: "admin" } },
user: { ...ann, role: "admin" },
});
expect(await screen.findByRole("link", { name: "Settings" }))
.toHaveAttribute("href", "/settings");
expect(screen.getByRole("link", { name: "Manage" })).toHaveAttribute(
"href",
"/manage",
);
// Their own profile has no manage tab: that's for others'.
expect(await screen.findByRole("button", { name: "+ Invite someone" }))
.toBeInTheDocument();
expect(
within(document.querySelector(".profile-tabs") as HTMLElement)
.queryByRole("button", { name: "Manage" }),
).not.toBeInTheDocument();
});
test("log out", async () => {
const { user } = open({ session: annSession });
await user.click(await screen.findByRole("button", { name: "Log out" }));
expect(localStorage.getItem(AUTH_STORAGE_KEY)).toBeNull();
});
});
describe("manage", () => {

View File

@@ -106,6 +106,7 @@
[data-style="brutalist"] .audio-player-vol-btn,
[data-style="brutalist"] .rich-content-thumbnail-btn,
[data-style="brutalist"] .user-menu-trigger,
[data-style="brutalist"] .user-menu-item,
[data-style="brutalist"] .auth-link-btn,
[data-style="brutalist"] .form-cancel,
[data-style="brutalist"] .form-label-action {
@@ -115,6 +116,8 @@
[data-style="brutalist"] .user-menu-trigger:hover:not(:disabled),
[data-style="brutalist"] .user-menu-trigger:active,
[data-style="brutalist"] .user-menu-item:hover:not(:disabled),
[data-style="brutalist"] .user-menu-item:active,
[data-style="brutalist"] .auth-link-btn:hover:not(:disabled),
[data-style="brutalist"] .auth-link-btn:active,
[data-style="brutalist"] .form-cancel:hover:not(:disabled),

View File

@@ -217,6 +217,7 @@
[data-style="geocities"] .audio-player-vol-btn,
[data-style="geocities"] .rich-content-thumbnail-btn,
[data-style="geocities"] .user-menu-trigger,
[data-style="geocities"] .user-menu-item,
[data-style="geocities"] .auth-link-btn,
[data-style="geocities"] .form-cancel,
[data-style="geocities"] .form-label-action,