v4: the header's controls are line icons, and the avatar leads them — 🔍, ✕, 💬 and 🔔 were four emoji in four palettes beside the avatar and the "+", none of them the theme's. They are now stroked icons (NavIcons.tsx, which the search bar's submit and ChatFab share) in the text colour, their bodies washed with the accent; the wash deepens on hover, and fills while something is unread, with a small dot in place of the red count (the count stays in the button's label and the tab's title, and ChatFab keeps its own). Themes set the line through --nav-icon-stroke, -linecap, -linejoin and -tint-strength: Brutalist heavy and square-cut, Times hairline in ink (its grayscale filter no longer needed on them), GeoCities fat and soaked. The search toggle was a ghost button with its own hover; it now shares the chat button's and the bell's rule, background and borders in every theme. The avatar sat among the icons, its hover box larger than theirs and 1.5px higher (a block .user-menu left a line-box gap under the inline-flex trigger): it now leads the row, where the name and Playlists links stand on wide screens, so the icons run unbroken to the "+"; it is 32px, and hover rings it with the accent instead of a box. 669 frontend tests pass, and tsc, eslint and deno fmt are clean; checked in screenshots in all six styles, light and dark, at 390px, at rest and unread, and Smooth and Brutalist at 1280px.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-10-08 21:56:19 +00:00
parent a0c8b1d509
commit 7531be300e
14 changed files with 203 additions and 110 deletions

View File

@@ -1775,7 +1775,7 @@ body.has-fab .page-content {
top 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
.chat-fab-icon {
.chat-fab .chat-fab-icon {
width: 1.6rem;
height: 1.6rem;
}
@@ -3075,23 +3075,38 @@ body.has-player .chat-fab {
justify-content: center;
}
/* Ghost search button — opens the search (in the center column, or the row
under the header on small viewports) and turns into its ✕ */
.nav-search-btn {
display: flex;
align-items: center;
padding: 0.35rem;
background: transparent;
border: none;
color: inherit;
text-decoration: none;
font-size: var(--text-base);
cursor: pointer;
border-radius: var(--radius-md);
/* Line icons (NavIcons.tsx), stroked in the text colour. */
.nav-icon {
width: 1.25rem;
height: 1.25rem;
flex-shrink: 0;
fill: none;
stroke: currentColor;
stroke-width: var(--nav-icon-stroke);
stroke-linecap: var(--nav-icon-linecap);
stroke-linejoin: var(--nav-icon-linejoin);
}
.nav-search-btn:hover {
background: var(--color-surface);
/* Two-tone in the header and its search: the icons' bodies take a wash of
the accent, which deepens on hover and fills while something is unread.
The floating chat button sits on the accent, so its bubble stays hollow. */
.app-header .nav-icon-tint,
.search-bar-btn .nav-icon-tint {
fill: color-mix(in srgb, var(--color-accent) var(--nav-icon-tint-strength),
transparent);
transition: fill var(--dur-base);
}
.nav-search-btn:hover,
.chat-button:hover,
.notification-bell:hover,
.search-bar-btn:hover {
--nav-icon-tint-strength: 45%;
}
.chat-button--unread,
.notification-bell--unread {
--nav-icon-tint-strength: 100%;
}
/* Text links — visible only at wide viewports */
@@ -3137,8 +3152,11 @@ body.has-player .chat-fab {
}
/* ── User menu (compact nav) ── */
/* 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 {
position: relative;
display: flex;
}
.user-menu-trigger {
@@ -3152,8 +3170,15 @@ body.has-player .chat-fab {
transition: background var(--dur-base);
}
.user-menu-trigger:hover {
background: var(--color-header-user-bg-hover);
/* The avatar fills its button, so a hover background would read as a box
bigger than the icons' ones: an accent ring shows instead. */
.user-menu-trigger > * {
transition: box-shadow var(--dur-base);
}
.user-menu-trigger:hover > *,
.user-menu-trigger[aria-expanded="true"] > * {
box-shadow: 0 0 0 2px var(--color-accent);
}
.user-menu-dropdown {
@@ -6484,24 +6509,28 @@ body.has-player .chat-fab {
}
}
/* ── Header icon buttons ──
Search (which opens the search, in the center column or the row under the
header on small viewports, and turns into its ✕), chat and the bell. */
.nav-search-btn,
.chat-button,
.notification-bell {
position: relative;
background: var(--color-header-user-bg);
color: inherit;
border: none;
cursor: pointer;
font-size: var(--text-md);
font-weight: 600;
padding: 0.35rem 0.85rem;
border-radius: var(--radius-lg);
transition: background var(--dur-base);
display: inline-flex;
align-items: center;
}
.nav-search-btn:hover,
.chat-button:hover,
.notification-bell:hover {
background: var(--color-header-user-bg-hover);
}
.notification-bell-icon {
display: inline-block;
transform-origin: 50% 10%;
}
.notification-bell--ringing .notification-bell-icon {
@@ -6527,25 +6556,16 @@ body.has-player .chat-fab {
box-shadow: 0 0 0 2px var(--color-bg);
}
/* ── Header chat button ── */
.chat-button {
position: relative;
background: var(--color-header-user-bg);
border: none;
cursor: pointer;
font-size: var(--text-md);
font-weight: 600;
padding: 0.35rem 0.85rem;
border-radius: var(--radius-lg);
transition: background var(--dur-base);
display: inline-flex;
align-items: center;
}
.chat-button:hover {
background: var(--color-header-user-bg-hover);
}
.chat-button-icon {
display: inline-block;
/* The header's buttons fill their icon while something is unread, so a dot
is enough; the count is in their label. */
.notification-badge--dot {
top: 6px;
right: 6px;
min-width: 0;
width: 8px;
height: 8px;
padding: 0;
background: var(--color-accent);
}
/* ── Notifications page ── */
@@ -7067,6 +7087,8 @@ body.has-player .chat-fab {
}
.search-bar-btn {
display: flex;
align-items: center;
padding: 0.3rem 0.55rem;
background: transparent;
border: var(--border-width) solid var(--color-border-subtle);

View File

@@ -15,6 +15,7 @@ import { NotificationBell } from "./NotificationBell.tsx";
import { ChatButton } from "./ChatButton.tsx";
import { UserMenu } from "./UserMenu.tsx";
import { SearchBar } from "./SearchBar.tsx";
import { IconClose, IconSearch } from "./NavIcons.tsx";
import { SITE_EMOJI, SITE_NAME } from "../../hooks/shared/useDocumentTitle.ts";
import { searchPath, userPath } from "../../../shared/paths.ts";
@@ -156,6 +157,32 @@ export function AppHeader(
</div>
<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. */
}
{user && (
<>
<div className="nav-links">
<Link
to={userPath(user.username)}
className="app-header-user"
>
{user.username}
</Link>
<Link
to={userPath(user.username, "playlists")}
className="app-header-user"
>
<Trans>Playlists</Trans>
</Link>
</div>
<div className="nav-compact">
<UserMenu user={user} />
</div>
</>
)}
<button
ref={searchToggleRef}
type="button"
@@ -164,31 +191,11 @@ export function AppHeader(
aria-expanded={searchExpanded}
onClick={handleSearchToggle}
>
{searchExpanded ? "✕" : "🔍"}
{searchExpanded ? <IconClose /> : <IconSearch />}
</button>
{user
? (
<>
<div className="nav-links">
<Link
to={userPath(user.username)}
className="app-header-user"
>
{user.username}
</Link>
<Link
to={userPath(user.username, "playlists")}
className="app-header-user"
>
<Trans>Playlists</Trans>
</Link>
</div>
{/* CSS shows either the links above or this menu, by width. */}
<div className="nav-compact">
<UserMenu user={user} />
</div>
<ChatButton />
<NotificationBell />
<button
@@ -205,11 +212,9 @@ export function AppHeader(
</>
)
: (
<>
<button type="button" onClick={() => navigate("/login")}>
<Trans>Log in</Trans>
</button>
</>
<button type="button" onClick={() => navigate("/login")}>
<Trans>Log in</Trans>
</button>
)}
</nav>
</header>

View File

@@ -7,10 +7,12 @@
import { t } from "@lingui/core/macro";
import { useWSChat } from "../../hooks/ws/useWS.ts";
import { useChat } from "../../hooks/chat/useChat.ts";
import { IconChat } from "./NavIcons.tsx";
/**
* Renders the button that opens the chat, with the socket's count of unread
* messages. `ChatFab` does the same once the header scrolls away.
* Renders the button that opens the chat, filled with the accent and dotted
* while the socket counts unread messages; the count itself is in its label.
* `ChatFab` takes over, with the count, once the header scrolls away.
*/
export function ChatButton(): React.JSX.Element {
const { unreadChatCount } = useWSChat();
@@ -19,17 +21,17 @@ export function ChatButton(): React.JSX.Element {
return (
<button
type="button"
className="chat-button"
className={`chat-button${
unreadChatCount > 0 ? " chat-button--unread" : ""
}`}
onClick={openChat}
aria-label={unreadChatCount > 0
? t`Chat (${unreadChatCount} unread)`
: t`Chat`}
>
<span className="chat-button-icon">💬</span>
<IconChat />
{unreadChatCount > 0 && (
<span className="notification-badge">
{unreadChatCount > 99 ? "99+" : unreadChatCount}
</span>
<span className="notification-badge notification-badge--dot" />
)}
</button>
);

View File

@@ -10,6 +10,7 @@ import { t } from "@lingui/core/macro";
import { useAuth } from "../../hooks/auth/useAuth.ts";
import { useWSChat } from "../../hooks/ws/useWS.ts";
import { useChat } from "../../hooks/chat/useChat.ts";
import { IconChat } from "./NavIcons.tsx";
/**
* Renders the chat button that floats above `DumpFab` once the header, and its
@@ -56,18 +57,7 @@ export function ChatFab(): React.JSX.Element | null {
tabIndex={headerHidden ? 0 : -1}
onClick={openChat}
>
<svg
className="chat-fab-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M21 11.5a8.38 8.38 0 0 1-8.5 8.5 8.5 8.5 0 0 1-3.8-.9L3 21l1.9-5.7a8.5 8.5 0 0 1-.9-3.8A8.38 8.38 0 0 1 12.5 3a8.38 8.38 0 0 1 8.5 8.5z" />
</svg>
<IconChat className="chat-fab-icon" />
{unreadChatCount > 0 && (
<span className="notification-badge">
{unreadChatCount > 99 ? "99+" : unreadChatCount}

View File

@@ -0,0 +1,65 @@
/**
* Draws the line icons of the header's controls (search, its ✕, chat and the
* bell) and of `ChatFab`. They stroke in the text colour; the shapes marked
* `nav-icon-tint` also fill with `--nav-icon-tint`, which the header sets to a
* wash of the accent and the floating button leaves unset. Themes set the
* line's weight and ends with `--nav-icon-stroke` and `--nav-icon-linecap`.
*
* @module
*/
/** Passed to every icon, so a caller can add its own class (the bell rings). */
export interface NavIconProps {
className?: string;
}
/** Wraps an icon's shapes in the shared 24-unit frame. */
function NavIcon(
{ className, children }: NavIconProps & { children: React.ReactNode },
): React.JSX.Element {
return (
<svg
className={className ? `nav-icon ${className}` : "nav-icon"}
viewBox="0 0 24 24"
aria-hidden="true"
>
{children}
</svg>
);
}
/** Renders a magnifier, for the header's search toggle and the search bar. */
export const IconSearch = (props: NavIconProps): React.JSX.Element => (
<NavIcon {...props}>
<circle className="nav-icon-tint" cx="10.5" cy="10.5" r="6.5" />
<path d="M15.5 15.5 20.5 20.5" />
</NavIcon>
);
/** Renders the ✕ the search toggle turns into while the search is open. */
export const IconClose = (props: NavIconProps): React.JSX.Element => (
<NavIcon {...props}>
<path d="M6 6 18 18M18 6 6 18" />
</NavIcon>
);
/** Renders a speech bubble, for `ChatButton` and `ChatFab`. */
export const IconChat = (props: NavIconProps): React.JSX.Element => (
<NavIcon {...props}>
<path
className="nav-icon-tint"
d="M20.5 11.5a8 8 0 0 1-8 8 8.1 8.1 0 0 1-3.6-.85L3.5 20.5l1.85-5.4a8.1 8.1 0 0 1-.85-3.6 8 8 0 0 1 8-8 8 8 0 0 1 8 8z"
/>
</NavIcon>
);
/** Renders a bell, for `NotificationBell`. */
export const IconBell = (props: NavIconProps): React.JSX.Element => (
<NavIcon {...props}>
<path
className="nav-icon-tint"
d="M6 9.5a6 6 0 0 1 12 0c0 5.5 2.5 7.5 2.5 7.5h-17S6 15 6 9.5z"
/>
<path d="M10.25 20.5a2 2 0 0 0 3.5 0" />
</NavIcon>
);

View File

@@ -8,10 +8,13 @@ import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router";
import { t } from "@lingui/core/macro";
import { useWS } from "../../hooks/ws/useWS.ts";
import { IconBell } from "./NavIcons.tsx";
/**
* Renders the bell that leads to `/notifications`, with the socket's count of
* unread notifications. It rings when the socket pushes a new one.
* Renders the bell that leads to `/notifications`. While the socket counts
* unread notifications it fills with the accent and wears a dot; the count
* itself is in its label and the tab's title. It rings when the socket pushes
* a new one.
*/
export function NotificationBell(): React.JSX.Element {
const { unreadNotificationCount, lastNotification } = useWS();
@@ -46,18 +49,16 @@ export function NotificationBell(): React.JSX.Element {
<button
type="button"
className={`notification-bell${
ringing ? " notification-bell--ringing" : ""
}`}
unreadNotificationCount > 0 ? " notification-bell--unread" : ""
}${ringing ? " notification-bell--ringing" : ""}`}
onClick={() => navigate("/notifications")}
aria-label={unreadNotificationCount > 0
? t`Notifications (${unreadNotificationCount} unread)`
: t`Notifications`}
>
<span className="notification-bell-icon">🔔</span>
<IconBell className="notification-bell-icon" />
{unreadNotificationCount > 0 && (
<span className="notification-badge">
{unreadNotificationCount > 99 ? "99+" : unreadNotificationCount}
</span>
<span className="notification-badge notification-badge--dot" />
)}
</button>
);

View File

@@ -6,6 +6,7 @@
import { useEffect, useRef } from "react";
import { t } from "@lingui/core/macro";
import { IconSearch } from "./NavIcons.tsx";
/**
* Passed to {@link SearchBar} by `AppHeader`, which holds the query and decides
@@ -88,7 +89,7 @@ export function SearchBar(
aria-label={t`Submit search`}
tabIndex={expanded ? 0 : -1}
>
🔍
<IconSearch />
</button>
</form>
);

View File

@@ -59,7 +59,7 @@ export function UserMenu({ user }: UserMenuProps): React.JSX.Element {
userId={user.id}
username={user.username}
hasAvatar={!!user.avatarMime}
size={28}
size={32}
/>
</button>
{open && (

View File

@@ -1,5 +1,5 @@
import { describe, expect, test, vi } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import { screen } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { ChatButton } from "../../../components/layout/ChatButton.tsx";
import { ChatContext } from "../../../contexts/chat/ChatContext.ts";
@@ -17,8 +17,9 @@ describe("ChatButton", () => {
</ChatContext.Provider>,
{ ws: { unreadChatCount: 4 } },
);
await user.click(screen.getByRole("button", { name: "Chat (4 unread)" }));
const chat = screen.getByRole("button", { name: "Chat (4 unread)" });
expect(chat).toHaveClass("chat-button--unread");
await user.click(chat);
expect(openChat).toHaveBeenCalled();
await waitFor(() => expect(screen.getByText("4")).toBeInTheDocument());
});
});

View File

@@ -7,7 +7,7 @@ import { currentPath, renderWithContexts } from "../../render.tsx";
import { defaultWSValue, WSContext } from "../../../contexts/ws/WSContext.ts";
describe("NotificationBell", () => {
test("the bell shows the unread count and goes to notifications", async () => {
test("the bell shows unread notifications and goes to them", async () => {
const user = userEvent.setup();
renderWithContexts(<NotificationBell />, {
ws: { unreadNotificationCount: 120 },
@@ -15,7 +15,7 @@ describe("NotificationBell", () => {
const bell = screen.getByRole("button", {
name: "Notifications (120 unread)",
});
expect(bell).toHaveTextContent("99+");
expect(bell).toHaveClass("notification-bell--unread");
await user.click(bell);
expect(currentPath()).toBe("/notifications");
});

View File

@@ -45,6 +45,10 @@
--radius-lg: 0;
--radius-xl: 0;
--radius-full: 0;
/* Heavy, square-cut icon lines, like the borders. */
--nav-icon-stroke: 2.5;
--nav-icon-linecap: square;
--nav-icon-linejoin: miter;
font-family: var(--font-body);
font-weight: 500;
}
@@ -102,7 +106,6 @@
[data-style="brutalist"] .audio-player-vol-btn,
[data-style="brutalist"] .rich-content-thumbnail-btn,
[data-style="brutalist"] .user-menu-trigger,
[data-style="brutalist"] .nav-search-btn,
[data-style="brutalist"] .auth-link-btn,
[data-style="brutalist"] .form-cancel,
[data-style="brutalist"] .form-label-action {
@@ -112,8 +115,6 @@
[data-style="brutalist"] .user-menu-trigger:hover:not(:disabled),
[data-style="brutalist"] .user-menu-trigger:active,
[data-style="brutalist"] .nav-search-btn:hover:not(:disabled),
[data-style="brutalist"] .nav-search-btn: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

@@ -101,6 +101,9 @@
"Marker Felt", cursive;
font-family: var(--font-body);
font-weight: 400;
/* Fat icons, soaked in the accent. */
--nav-icon-stroke: 2.25;
--nav-icon-tint-strength: 55%;
}
/* ── Tiled wallpaper, fixed so content scrolls over it ────────────── */
@@ -132,7 +135,6 @@
[data-style="geocities"] .index-presence-avatar,
[data-style="geocities"] .app-header-brand,
[data-style="geocities"] .app-header-user,
[data-style="geocities"] .nav-search-btn,
[data-style="geocities"] .user-menu-item,
[data-style="geocities"] .user-list-popover-item {
text-decoration: none;
@@ -215,7 +217,6 @@
[data-style="geocities"] .audio-player-vol-btn,
[data-style="geocities"] .rich-content-thumbnail-btn,
[data-style="geocities"] .user-menu-trigger,
[data-style="geocities"] .nav-search-btn,
[data-style="geocities"] .auth-link-btn,
[data-style="geocities"] .form-cancel,
[data-style="geocities"] .form-label-action,

View File

@@ -69,6 +69,8 @@
smeared faux bold or spindly faux small caps give the typeface away.
Hierarchy comes from size, italics and capitals instead. */
font-synthesis: none;
/* Hairline icons, drawn in ink like the rules. */
--nav-icon-stroke: 1.25;
}
/* This theme squares everything off — opt the FAB into a square too (it keeps
@@ -472,13 +474,9 @@
type icons, card glyphs…). Real content — avatars, uploaded thumbnails,
media — is left in colour. grayscale() also greys the notif icon tints. */
[data-style="nyt"] .app-header-brand,
[data-style="nyt"] .nav-search-btn,
[data-style="nyt"] .search-bar-btn,
[data-style="nyt"] .notification-bell-icon,
[data-style="nyt"] .notif-icon,
[data-style="nyt"] .notifications-empty-icon,
[data-style="nyt"] .page-title-icon,
[data-style="nyt"] .chat-button-icon,
[data-style="nyt"] .dump-card-preview-icon,
[data-style="nyt"] .playlist-card-icon,
[data-style="nyt"] .rich-content-compact-icon,

View File

@@ -138,6 +138,12 @@
--tab-active-fg: var(--color-on-accent);
--tab-text-transform: uppercase;
--tab-tracking: 0.04em;
/* The header's line icons (NavIcons.tsx): their weight and ends, and how
much of the accent washes their bodies at rest. */
--nav-icon-stroke: 1.75;
--nav-icon-linecap: round;
--nav-icon-linejoin: round;
--nav-icon-tint-strength: 22%;
--btn-primary-shadow: 0 2px 8px
color-mix(in srgb, var(--color-accent) 40%, transparent);
--btn-primary-shadow-hover: 0 4px 14px