diff --git a/src/App.css b/src/App.css
index 155f0e3f..bd1d83e7 100644
--- a/src/App.css
+++ b/src/App.css
@@ -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);
diff --git a/src/components/layout/AppHeader.tsx b/src/components/layout/AppHeader.tsx
index 90bb809c..f78275ac 100644
--- a/src/components/layout/AppHeader.tsx
+++ b/src/components/layout/AppHeader.tsx
@@ -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(
diff --git a/src/components/layout/ChatButton.tsx b/src/components/layout/ChatButton.tsx
index 0ca50e74..a521b82d 100644
--- a/src/components/layout/ChatButton.tsx
+++ b/src/components/layout/ChatButton.tsx
@@ -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 (
);
diff --git a/src/components/layout/ChatFab.tsx b/src/components/layout/ChatFab.tsx
index 834c038c..56645468 100644
--- a/src/components/layout/ChatFab.tsx
+++ b/src/components/layout/ChatFab.tsx
@@ -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}
>
-
+
{unreadChatCount > 0 && (
{unreadChatCount > 99 ? "99+" : unreadChatCount}
diff --git a/src/components/layout/NavIcons.tsx b/src/components/layout/NavIcons.tsx
new file mode 100644
index 00000000..0015c75d
--- /dev/null
+++ b/src/components/layout/NavIcons.tsx
@@ -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 (
+
+ );
+}
+
+/** Renders a magnifier, for the header's search toggle and the search bar. */
+export const IconSearch = (props: NavIconProps): React.JSX.Element => (
+
+
+
+
+);
+
+/** Renders the ✕ the search toggle turns into while the search is open. */
+export const IconClose = (props: NavIconProps): React.JSX.Element => (
+
+
+
+);
+
+/** Renders a speech bubble, for `ChatButton` and `ChatFab`. */
+export const IconChat = (props: NavIconProps): React.JSX.Element => (
+
+
+
+);
+
+/** Renders a bell, for `NotificationBell`. */
+export const IconBell = (props: NavIconProps): React.JSX.Element => (
+
+
+
+
+);
diff --git a/src/components/layout/NotificationBell.tsx b/src/components/layout/NotificationBell.tsx
index ac30444c..d97ba6b3 100644
--- a/src/components/layout/NotificationBell.tsx
+++ b/src/components/layout/NotificationBell.tsx
@@ -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 {
);
diff --git a/src/components/layout/SearchBar.tsx b/src/components/layout/SearchBar.tsx
index 83d0bfb4..9c1e8050 100644
--- a/src/components/layout/SearchBar.tsx
+++ b/src/components/layout/SearchBar.tsx
@@ -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}
>
- 🔍
+
);
diff --git a/src/components/layout/UserMenu.tsx b/src/components/layout/UserMenu.tsx
index 7cb03b1c..6c90e0c1 100644
--- a/src/components/layout/UserMenu.tsx
+++ b/src/components/layout/UserMenu.tsx
@@ -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}
/>
{open && (
diff --git a/src/test/components/layout/ChatButton.test.tsx b/src/test/components/layout/ChatButton.test.tsx
index c7270e40..e5ee7a1a 100644
--- a/src/test/components/layout/ChatButton.test.tsx
+++ b/src/test/components/layout/ChatButton.test.tsx
@@ -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", () => {
,
{ 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());
});
});
diff --git a/src/test/components/layout/NotificationBell.test.tsx b/src/test/components/layout/NotificationBell.test.tsx
index dcb4a584..05475c6c 100644
--- a/src/test/components/layout/NotificationBell.test.tsx
+++ b/src/test/components/layout/NotificationBell.test.tsx
@@ -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(, {
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");
});
diff --git a/src/themes/brutalist.css b/src/themes/brutalist.css
index 5a5959ee..aa7a1f7b 100644
--- a/src/themes/brutalist.css
+++ b/src/themes/brutalist.css
@@ -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),
diff --git a/src/themes/geocities.css b/src/themes/geocities.css
index 2df83d8b..d384286b 100644
--- a/src/themes/geocities.css
+++ b/src/themes/geocities.css
@@ -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,
diff --git a/src/themes/nyt.css b/src/themes/nyt.css
index 122919c0..565dbf43 100644
--- a/src/themes/nyt.css
+++ b/src/themes/nyt.css
@@ -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,
diff --git a/src/tokens.css b/src/tokens.css
index 97078579..8974211f 100644
--- a/src/tokens.css
+++ b/src/tokens.css
@@ -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