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