From 7531be300e32e4977caa20dae0a6b445c84e8108 Mon Sep 17 00:00:00 2001 From: khannurien Date: Thu, 8 Oct 2026 21:56:19 +0000 Subject: [PATCH] =?UTF-8?q?v4:=20the=20header's=20controls=20are=20line=20?= =?UTF-8?q?icons,=20and=20the=20avatar=20leads=20them=20=E2=80=94=20?= =?UTF-8?q?=F0=9F=94=8D,=20=E2=9C=95,=20=F0=9F=92=AC=20and=20=F0=9F=94=94?= =?UTF-8?q?=20were=20four=20emoji=20in=20four=20palettes=20beside=20the=20?= =?UTF-8?q?avatar=20and=20the=20"+",=20none=20of=20them=20the=20theme's.?= =?UTF-8?q?=20They=20are=20now=20stroked=20icons=20(NavIcons.tsx,=20which?= =?UTF-8?q?=20the=20search=20bar's=20submit=20and=20ChatFab=20share)=20in?= =?UTF-8?q?=20the=20text=20colour,=20their=20bodies=20washed=20with=20the?= =?UTF-8?q?=20accent;=20the=20wash=20deepens=20on=20hover,=20and=20fills?= =?UTF-8?q?=20while=20something=20is=20unread,=20with=20a=20small=20dot=20?= =?UTF-8?q?in=20place=20of=20the=20red=20count=20(the=20count=20stays=20in?= =?UTF-8?q?=20the=20button's=20label=20and=20the=20tab's=20title,=20and=20?= =?UTF-8?q?ChatFab=20keeps=20its=20own).=20Themes=20set=20the=20line=20thr?= =?UTF-8?q?ough=20--nav-icon-stroke,=20-linecap,=20-linejoin=20and=20-tint?= =?UTF-8?q?-strength:=20Brutalist=20heavy=20and=20square-cut,=20Times=20ha?= =?UTF-8?q?irline=20in=20ink=20(its=20grayscale=20filter=20no=20longer=20n?= =?UTF-8?q?eeded=20on=20them),=20GeoCities=20fat=20and=20soaked.=20The=20s?= =?UTF-8?q?earch=20toggle=20was=20a=20ghost=20button=20with=20its=20own=20?= =?UTF-8?q?hover;=20it=20now=20shares=20the=20chat=20button's=20and=20the?= =?UTF-8?q?=20bell's=20rule,=20background=20and=20borders=20in=20every=20t?= =?UTF-8?q?heme.=20The=20avatar=20sat=20among=20the=20icons,=20its=20hover?= =?UTF-8?q?=20box=20larger=20than=20theirs=20and=201.5px=20higher=20(a=20b?= =?UTF-8?q?lock=20.user-menu=20left=20a=20line-box=20gap=20under=20the=20i?= =?UTF-8?q?nline-flex=20trigger):=20it=20now=20leads=20the=20row,=20where?= =?UTF-8?q?=20the=20name=20and=20Playlists=20links=20stand=20on=20wide=20s?= =?UTF-8?q?creens,=20so=20the=20icons=20run=20unbroken=20to=20the=20"+";?= =?UTF-8?q?=20it=20is=2032px,=20and=20hover=20rings=20it=20with=20the=20ac?= =?UTF-8?q?cent=20instead=20of=20a=20box.=20669=20frontend=20tests=20pass,?= =?UTF-8?q?=20and=20tsc,=20eslint=20and=20deno=20fmt=20are=20clean;=20chec?= =?UTF-8?q?ked=20in=20screenshots=20in=20all=20six=20styles,=20light=20and?= =?UTF-8?q?=20dark,=20at=20390px,=20at=20rest=20and=20unread,=20and=20Smoo?= =?UTF-8?q?th=20and=20Brutalist=20at=201280px.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/App.css | 104 +++++++++++------- src/components/layout/AppHeader.tsx | 57 +++++----- src/components/layout/ChatButton.tsx | 16 +-- src/components/layout/ChatFab.tsx | 14 +-- src/components/layout/NavIcons.tsx | 65 +++++++++++ src/components/layout/NotificationBell.tsx | 17 +-- src/components/layout/SearchBar.tsx | 3 +- src/components/layout/UserMenu.tsx | 2 +- .../components/layout/ChatButton.test.tsx | 7 +- .../layout/NotificationBell.test.tsx | 4 +- src/themes/brutalist.css | 7 +- src/themes/geocities.css | 5 +- src/themes/nyt.css | 6 +- src/tokens.css | 6 + 14 files changed, 203 insertions(+), 110 deletions(-) create mode 100644 src/components/layout/NavIcons.tsx 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