From 45f39a959b233fc33d4bad6eb8c1484e45094ee2 Mon Sep 17 00:00:00 2001 From: khannurien Date: Thu, 8 Oct 2026 21:40:10 +0000 Subject: [PATCH] =?UTF-8?q?v4:=20/settings=20and=20/manage=20take=20after?= =?UTF-8?q?=20/notifications=20=E2=80=94=20the=20three=20pages=20the=20use?= =?UTF-8?q?r=20menu=20and=20the=20bell=20lead=20to=20share=20one=20frame:?= =?UTF-8?q?=20a=20680px=20column=20(PageShell's=20new=20narrow),=20a=20tit?= =?UTF-8?q?le=20with=20its=20icon=20(PageHeader:=20=E2=9A=99=EF=B8=8F,=20?= =?UTF-8?q?=F0=9F=9B=A0=EF=B8=8F,=20=F0=9F=94=94),=20then=20groups=20under?= =?UTF-8?q?=20a=20small=20capitalized=20label=20ruled=20to=20the=20column'?= =?UTF-8?q?s=20end=20(PageGroup,=20the=20notifications'=20day=20label),=20?= =?UTF-8?q?their=20content=20on=20surface=20cards=20(.page-card,=20the=20n?= =?UTF-8?q?otification=20items'=20surface).=20Settings=20drew=20big=20sect?= =?UTF-8?q?ion=20headings=20over=20controls=20laid=20on=20the=20bare=20pag?= =?UTF-8?q?e,=20the=20full=20width=20wide,=20and=20Manage=20the=20same=20a?= =?UTF-8?q?round=20the=20category=20manager;=20each=20section=20is=20now?= =?UTF-8?q?=20a=20card=20whose=20setting=20rows=20are=20ruled=20apart=20an?= =?UTF-8?q?d=20keep=20their=20labels=20lined=20up=20(a=20subgrid=20of=20th?= =?UTF-8?q?e=20card's=20columns).=20The=20notifications'=20column,=20cente?= =?UTF-8?q?red=20by=20auto=20margins=20in=20a=20flex=20column,=20shrank=20?= =?UTF-8?q?to=20its=20content=20(380px=20with=20short=20ones);=20it=20fill?= =?UTF-8?q?s=20its=20width=20now.=20Its=20own=20header,=20group=20and=20be?= =?UTF-8?q?ll=20rules=20gave=20way=20to=20the=20shared=20ones,=20and=20Tim?= =?UTF-8?q?es,=20Brutalist=20and=20GeoCities=20dress=20.page-card=20as=20t?= =?UTF-8?q?hey=20dress=20the=20notification=20items.=20In=20the=20narrower?= =?UTF-8?q?=20column,=20Kanagawa's=20monospace=20no=20longer=20fit=20the?= =?UTF-8?q?=20six=20styles=20on=20one=20row,=20and=20French=20labels=20lea?= =?UTF-8?q?ve=20even=20less=20room:=20a=20segmented=20control=20of=20more?= =?UTF-8?q?=20than=20four=20options=20now=20makes=20two=20even=20rows=20at?= =?UTF-8?q?=20any=20width,=20as=20it=20did=20on=20phones.=20670=20frontend?= =?UTF-8?q?=20tests=20pass,=20and=20tsc,=20eslint,=20deno=20lint,=20deno?= =?UTF-8?q?=20fmt=20and=20doc:lint=20are=20clean;=20checked=20in=20screens?= =?UTF-8?q?hots=20in=20all=20six=20styles=20at=201280=20and=20390px,=20Smo?= =?UTF-8?q?oth=20in=20light=20too,=20with=20the=20account's=20style=20and?= =?UTF-8?q?=20the=20notifications=20mocked=20so=20the=20shared=20test=20ac?= =?UTF-8?q?count=20was=20left=20as=20it=20was.?= 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 | 121 +++++++++++++++++---------- src/components/layout/PageGroup.tsx | 28 +++++++ src/components/layout/PageHeader.tsx | 35 ++++++++ src/components/layout/PageShell.tsx | 11 ++- src/components/shared/Segmented.tsx | 5 +- src/pages/Manage.tsx | 19 ++--- src/pages/Notifications.tsx | 108 ++++++++++++------------ src/pages/Settings.tsx | 41 +++++---- src/themes/brutalist.css | 5 +- src/themes/geocities.css | 1 + src/themes/nyt.css | 15 ++-- 11 files changed, 246 insertions(+), 143 deletions(-) create mode 100644 src/components/layout/PageGroup.tsx create mode 100644 src/components/layout/PageHeader.tsx diff --git a/src/App.css b/src/App.css index 5b57fd17..28cfbbeb 100644 --- a/src/App.css +++ b/src/App.css @@ -825,12 +825,11 @@ white-space: nowrap; } -/* On a phone, many options make two even rows; Segmented sets the columns. */ -@media (max-width: 512px) { - .visibility-toggle--many { - display: grid; - grid-template-columns: repeat(var(--segmented-columns), minmax(0, 1fr)); - } +/* Many options make two even rows, whatever the width, so their fit doesn't + hang on the style's font or the language; Segmented sets the columns. */ +.visibility-toggle--many { + display: grid; + grid-template-columns: repeat(var(--segmented-columns), minmax(0, 1fr)); } .visibility-toggle button.active { @@ -2720,11 +2719,17 @@ body.has-player .chat-fab { gap: 2.5rem; } -/* ── Page header ── - A page that edits nothing (settings, manage, notifications) names itself - with a bare h1, a size above its sections' h2, and keeps whatever goes - with it (a count) at the row's end. The edit forms cap their panels with +/* ── Menu pages ── + The pages the user menu and the bell lead to (settings, manage, + notifications) share one frame: a narrow column, a bare h1 with its icon + (PageHeader), then groups under a small ruled label (PageGroup) whose + content sits on surface cards. The edit forms cap their panels with .form-page-header instead. */ +.page-content--narrow { + max-width: calc(680px + 2.5rem); + gap: 2rem; +} + .page-header { display: flex; align-items: center; @@ -2744,6 +2749,68 @@ body.has-player .chat-fab { overflow-wrap: anywhere; } +.page-title-icon { + font-size: var(--text-xl); +} + +.page-group-label { + font-size: var(--text-2xs); + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--color-text-muted); + margin: 0 0 0.6rem; + display: flex; + align-items: center; + gap: 0.6rem; +} + +.page-group-label::after { + content: ""; + flex: 1; + height: 1px; + background: var(--color-border-subtle); +} + +/* The notification items are cards of their own, with an unread bar. */ +.page-card, +.notification-item { + background: var(--color-surface); + border-radius: var(--radius-xl); + border: 1px solid var(--color-border-subtle); +} + +.page-card { + padding: 1rem; +} + +/* A card of settings rules its rows apart; they keep the grid's columns, so + the labels still line up. */ +.page-card.profile-appearance-grid { + padding-block: 0; + row-gap: 0; +} + +.page-card > .profile-appearance-row { + display: grid; + grid-column: 1 / -1; + grid-template-columns: subgrid; + align-items: center; + justify-items: start; + padding-block: 0.75rem; +} + +.page-card > .profile-appearance-row + .profile-appearance-row { + border-top: 1px solid var(--color-border-subtle); +} + +@media (max-width: 512px) { + .page-card > .profile-appearance-row { + display: flex; + align-items: flex-start; + } +} + .page-content--centered { align-items: center; padding-top: 2.5rem; @@ -6498,17 +6565,6 @@ body.has-player .chat-fab { } /* ── Notifications page ── */ -.notifications-page { - max-width: 680px; - margin: 0 auto; - padding: 1.5rem 1rem 3rem; -} -.notifications-page > .page-header { - margin-bottom: 1.75rem; -} -.notifications-title-bell { - font-size: var(--text-lg); -} .notifications-unread-pill { font-size: var(--text-xs); font-weight: 700; @@ -6540,26 +6596,6 @@ body.has-player .chat-fab { line-height: 1.5; } -.notif-group { - margin-bottom: 2rem; -} -.notif-group-label { - font-size: var(--text-2xs); - font-weight: 700; - letter-spacing: 0.12em; - text-transform: uppercase; - color: var(--color-text-muted); - margin: 0 0 0.6rem; - display: flex; - align-items: center; - gap: 0.6rem; -} -.notif-group-label::after { - content: ""; - flex: 1; - height: 1px; - background: var(--color-border-subtle); -} .notification-list { list-style: none; margin: 0; @@ -6569,9 +6605,6 @@ body.has-player .chat-fab { gap: 1rem; } .notification-item { - background: var(--color-surface); - border-radius: var(--radius-xl); - border: 1px solid var(--color-border-subtle); border-left: 3px solid transparent; transition: background var(--dur-fast), border-color var(--dur-fast); overflow: hidden; diff --git a/src/components/layout/PageGroup.tsx b/src/components/layout/PageGroup.tsx new file mode 100644 index 00000000..429dc787 --- /dev/null +++ b/src/components/layout/PageGroup.tsx @@ -0,0 +1,28 @@ +/** + * Renders one labelled group of a page under a `PageHeader`: a + * settings section, the category manager, a day of notifications. + * + * @module + */ + +import type { ReactNode } from "react"; + +/** Passed to {@link PageGroup} by `Settings`, `Manage` and `Notifications`. */ +export interface PageGroupProps { + /** It shows as a small capitalized label, ruled to the column's end. */ + label: ReactNode; + /** Usually a `.page-card`, or the notifications' list of cards. */ + children: ReactNode; +} + +/** Renders a `
` headed by `label` as its h2. */ +export function PageGroup( + { label, children }: PageGroupProps, +): React.JSX.Element { + return ( +
+

{label}

+ {children} +
+ ); +} diff --git a/src/components/layout/PageHeader.tsx b/src/components/layout/PageHeader.tsx new file mode 100644 index 00000000..82d70b7d --- /dev/null +++ b/src/components/layout/PageHeader.tsx @@ -0,0 +1,35 @@ +/** + * Renders the title row of the pages the user menu and the bell lead to: + * settings, manage and notifications. + * + * @module + */ + +import type { ReactNode } from "react"; + +/** Passed to {@link PageHeader} by `Settings`, `Manage` and `Notifications`. */ +export interface PageHeaderProps { + /** An emoji before the title, hidden from screen readers. */ + icon: string; + title: ReactNode; + /** It goes at the end of the row, such as the notifications' unread count. */ + children?: ReactNode; +} + +/** + * Renders the page's h1 with its icon, then `children`, as the first block of + * a narrow `PageShell`; `PageGroup`s follow it. + */ +export function PageHeader( + { icon, title, children }: PageHeaderProps, +): React.JSX.Element { + return ( +
+

+ + {title} +

+ {children} +
+ ); +} diff --git a/src/components/layout/PageShell.tsx b/src/components/layout/PageShell.tsx index b9217073..15aae13e 100644 --- a/src/components/layout/PageShell.tsx +++ b/src/components/layout/PageShell.tsx @@ -13,17 +13,24 @@ export interface PageShellProps { children: ReactNode; /** Centers the content, for a lone card such as a form or an error. */ centered?: boolean; + /** + * Narrows the content to the column the user menu's and the bell's pages + * share, under a `PageHeader`. + */ + narrow?: boolean; } /** Renders {@link AppHeader}, then the page's content as the `
`. */ export function PageShell( - { children, centered = false }: PageShellProps, + { children, centered = false, narrow = false }: PageShellProps, ): React.JSX.Element { return (
{children}
diff --git a/src/components/shared/Segmented.tsx b/src/components/shared/Segmented.tsx index b17d57a8..d7a2c571 100644 --- a/src/components/shared/Segmented.tsx +++ b/src/components/shared/Segmented.tsx @@ -30,8 +30,9 @@ export interface SegmentedProps { } /** - * Past this many options, a phone lays them out in two even rows rather than - * one row that would wrap unevenly (`.visibility-toggle--many`). + * Past this many options, they lay out in two even rows rather than one row + * that the style's font or the language could make wrap unevenly + * (`.visibility-toggle--many`). */ const MAX_ONE_ROW = 4; diff --git a/src/pages/Manage.tsx b/src/pages/Manage.tsx index 5551e889..20f07e91 100644 --- a/src/pages/Manage.tsx +++ b/src/pages/Manage.tsx @@ -11,7 +11,8 @@ import { can } from "../utils/auth/permissions.ts"; import { useRequiredAuth } from "../hooks/auth/useAuth.ts"; import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts"; import { PageShell } from "../components/layout/PageShell.tsx"; -import { ProfileSection } from "../components/profile/ProfileSection.tsx"; +import { PageHeader } from "../components/layout/PageHeader.tsx"; +import { PageGroup } from "../components/layout/PageGroup.tsx"; import { CategoryManager } from "../components/category/CategoryManager.tsx"; import { NotFound } from "./NotFound.tsx"; @@ -24,15 +25,13 @@ export function Manage(): React.JSX.Element { const { user } = useRequiredAuth(); if (!can(user, "category:manage")) return ; return ( - -
-

- Manage -

-
- Categories}> - - + + Manage} /> + Categories}> +
+ +
+
); } diff --git a/src/pages/Notifications.tsx b/src/pages/Notifications.tsx index 9b68f76c..a37f404b 100644 --- a/src/pages/Notifications.tsx +++ b/src/pages/Notifications.tsx @@ -47,6 +47,8 @@ import { useScrollSave, } from "../hooks/feed/useScrollSave.ts"; import { PageShell } from "../components/layout/PageShell.tsx"; +import { PageHeader } from "../components/layout/PageHeader.tsx"; +import { PageGroup } from "../components/layout/PageGroup.tsx"; import { dumpPath, playlistPath, userPath } from "../../shared/paths.ts"; type NotifIconKind = @@ -488,63 +490,57 @@ export function Notifications(): React.JSX.Element { const totalUnread = items.filter((n) => !n.read).length; return ( - -
-
-

- - Notifications -

- {totalUnread > 0 && ( - - - - )} -
+ + Notifications}> + {totalUnread > 0 && ( + + + + )} + - items.length === 0} - empty={ -
- -

- Nothing here yet. -

-

- - You'll be notified when someone follows your playlists, - upvotes your dumps, or posts new content. - -

-
- } - > - {({ data }) => ( - <> - {groupByDate(items).map((group) => ( -
-

{group.label}

-
    - {group.items.map((n) => ( - - ))} -
-
- ))} - - - )} -
-
+ items.length === 0} + empty={ +
+ +

+ Nothing here yet. +

+

+ + You'll be notified when someone follows your playlists, upvotes + your dumps, or posts new content. + +

+
+ } + > + {({ data }) => ( + <> + {groupByDate(items).map((group) => ( + +
    + {group.items.map((n) => ( + + ))} +
+
+ ))} + + + )} +
); } diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 2f209e7e..d2c054b6 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -22,12 +22,13 @@ import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts"; import { EMAIL_ENABLED } from "../config/instance.ts"; import { previewChime } from "../utils/shared/sound.ts"; import { PageShell } from "../components/layout/PageShell.tsx"; +import { PageHeader } from "../components/layout/PageHeader.tsx"; +import { PageGroup } from "../components/layout/PageGroup.tsx"; import { Segmented, type SegmentedOption, } from "../components/shared/Segmented.tsx"; import { ChangePasswordModal } from "../components/profile/ChangePasswordModal.tsx"; -import { ProfileSection } from "../components/profile/ProfileSection.tsx"; import { SettingRow } from "../components/profile/SettingRow.tsx"; // The styles' names are their own, in every language. @@ -77,14 +78,10 @@ export function Settings(): React.JSX.Element { const [changePasswordOpen, setChangePasswordOpen] = useState(false); return ( - -
-

- Settings -

-
- Account}> -
+ + Settings} /> + Account}> +
Username}> {user.username} @@ -98,9 +95,9 @@ export function Settings(): React.JSX.Element {
- - Appearance}> -
+ + Appearance}> +
Style}>
- - Feeds}> -
+ + Feeds}> +
Default tab}>
- - Notifications}> -
+ + Notifications}> +
Sounds}>
- - Email}> -
+ + Email}> +
}>

)} - + {changePasswordOpen && ( setChangePasswordOpen(false)} /> )} diff --git a/src/themes/brutalist.css b/src/themes/brutalist.css index adcc135d..5a5959ee 100644 --- a/src/themes/brutalist.css +++ b/src/themes/brutalist.css @@ -321,8 +321,9 @@ color: var(--color-text); } -/* ── Notification items ──────────────────────────────────────────── */ -[data-style="brutalist"] .notification-item { +/* ── Notification items and page cards ─────────────────────────── */ +[data-style="brutalist"] .notification-item, +[data-style="brutalist"] .page-card { border: 2px solid var(--color-border); box-shadow: 2px 2px 0 var(--color-border); } diff --git a/src/themes/geocities.css b/src/themes/geocities.css index 5c32376b..2df83d8b 100644 --- a/src/themes/geocities.css +++ b/src/themes/geocities.css @@ -242,6 +242,7 @@ [data-style="geocities"] .dump-post-header, [data-style="geocities"] .auth-card, [data-style="geocities"] .notification-item, +[data-style="geocities"] .page-card, [data-style="geocities"] .user-result-item, [data-style="geocities"] .journal-card { border: 3px ridge var(--color-border); diff --git a/src/themes/nyt.css b/src/themes/nyt.css index ce26e21f..122919c0 100644 --- a/src/themes/nyt.css +++ b/src/themes/nyt.css @@ -154,7 +154,7 @@ [data-style="nyt"] .form-page-eyebrow, [data-style="nyt"] .playlist-badge, [data-style="nyt"] .dump-card-private-badge, -[data-style="nyt"] .notif-group-label, +[data-style="nyt"] .page-group-label, [data-style="nyt"] .follow-btn { text-transform: uppercase; letter-spacing: 0.08em; @@ -233,14 +233,19 @@ box-shadow: none; } -/* Notifications keep their left bar (the unread indicator), lose the box. */ -[data-style="nyt"] .notification-item { +/* Notifications and the cards of the pages beside them lose the box for a + rule underneath; notifications keep their left bar (the unread indicator). */ +[data-style="nyt"] .notification-item, +[data-style="nyt"] .page-card { border: none; - border-left: 3px solid transparent; border-bottom: 1px solid var(--color-border); box-shadow: none; } +[data-style="nyt"] .notification-item { + border-left: 3px solid transparent; +} + /* No box-hover; the headline underline is the affordance. */ [data-style="nyt"] .dump-card:hover, [data-style="nyt"] .playlist-card:hover, @@ -472,7 +477,7 @@ [data-style="nyt"] .notification-bell-icon, [data-style="nyt"] .notif-icon, [data-style="nyt"] .notifications-empty-icon, -[data-style="nyt"] .notifications-title-bell, +[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,