v4: /settings and /manage take after /notifications — the three pages the user menu and the bell lead to share one frame: a 680px column (PageShell's new narrow), a title with its icon (PageHeader: ⚙️, 🛠️, 🔔), then groups under a small capitalized label ruled to the column's end (PageGroup, the notifications' day label), their content on surface cards (.page-card, the notification items' surface). Settings drew big section headings over controls laid on the bare page, the full width wide, and Manage the same around the category manager; each section is now a card whose setting rows are ruled apart and keep their labels lined up (a subgrid of the card's columns). The notifications' column, centered by auto margins in a flex column, shrank to its content (380px with short ones); it fills its width now. Its own header, group and bell rules gave way to the shared ones, and Times, Brutalist and GeoCities dress .page-card as they dress the notification items. In the narrower column, Kanagawa's monospace no longer fit the six styles on one row, and French labels leave even less room: a segmented control of more than four options now makes two even rows at any width, as it did on phones. 670 frontend tests pass, and tsc, eslint, deno lint, deno fmt and doc:lint are clean; checked in screenshots in all six styles at 1280 and 390px, Smooth in light too, with the account's style and the notifications mocked so the shared test account was left as it was.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-10-08 21:40:10 +00:00
parent 1fda26a399
commit 45f39a959b
11 changed files with 246 additions and 143 deletions

View File

@@ -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;

View File

@@ -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 `<section>` headed by `label` as its h2. */
export function PageGroup(
{ label, children }: PageGroupProps,
): React.JSX.Element {
return (
<section className="page-group">
<h2 className="page-group-label">{label}</h2>
{children}
</section>
);
}

View File

@@ -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 (
<div className="page-header">
<h1 className="page-title">
<span className="page-title-icon" aria-hidden="true">{icon}</span>
{title}
</h1>
{children}
</div>
);
}

View File

@@ -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 `<main>`. */
export function PageShell(
{ children, centered = false }: PageShellProps,
{ children, centered = false, narrow = false }: PageShellProps,
): React.JSX.Element {
return (
<div className="page-shell">
<AppHeader />
<main
className={`page-content${centered ? " page-content--centered" : ""}`}
className={`page-content${centered ? " page-content--centered" : ""}${
narrow ? " page-content--narrow" : ""
}`}
>
{children}
</main>

View File

@@ -30,8 +30,9 @@ export interface SegmentedProps<V> {
}
/**
* 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;

View File

@@ -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 <NotFound />;
return (
<PageShell>
<div className="page-header">
<h1 className="page-title">
<Trans>Manage</Trans>
</h1>
</div>
<ProfileSection title={<Trans>Categories</Trans>}>
<CategoryManager />
</ProfileSection>
<PageShell narrow>
<PageHeader icon="🛠️" title={<Trans>Manage</Trans>} />
<PageGroup label={<Trans>Categories</Trans>}>
<div className="page-card">
<CategoryManager />
</div>
</PageGroup>
</PageShell>
);
}

View File

@@ -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 (
<PageShell>
<div className="notifications-page">
<div className="page-header">
<h1 className="page-title">
<span className="notifications-title-bell" aria-hidden="true">
🔔
</span>
<Trans>Notifications</Trans>
</h1>
{totalUnread > 0 && (
<span className="notifications-unread-pill">
<Plural value={totalUnread} one="# new" other="# new" />
</span>
)}
</div>
<PageShell narrow>
<PageHeader icon="🔔" title={<Trans>Notifications</Trans>}>
{totalUnread > 0 && (
<span className="notifications-unread-pill">
<Plural value={totalUnread} one="# new" other="# new" />
</span>
)}
</PageHeader>
<ListState
state={state}
isEmpty={() => items.length === 0}
empty={
<div className="notifications-empty">
<span className="notifications-empty-icon" aria-hidden="true">
🔕
</span>
<p>
<Trans>Nothing here yet.</Trans>
</p>
<p className="notifications-empty-hint">
<Trans>
You'll be notified when someone follows your playlists,
upvotes your dumps, or posts new content.
</Trans>
</p>
</div>
}
>
{({ data }) => (
<>
{groupByDate(items).map((group) => (
<section key={group.label} className="notif-group">
<h2 className="notif-group-label">{group.label}</h2>
<ul className="notification-list">
{group.items.map((n) => (
<NotificationItem key={n.id} n={n} />
))}
</ul>
</section>
))}
<FeedFooter
hasMore={data.hasMore}
loadingMore={data.loadingMore}
sentinelRef={sentinelRef}
/>
</>
)}
</ListState>
</div>
<ListState
state={state}
isEmpty={() => items.length === 0}
empty={
<div className="notifications-empty">
<span className="notifications-empty-icon" aria-hidden="true">
🔕
</span>
<p>
<Trans>Nothing here yet.</Trans>
</p>
<p className="notifications-empty-hint">
<Trans>
You'll be notified when someone follows your playlists, upvotes
your dumps, or posts new content.
</Trans>
</p>
</div>
}
>
{({ data }) => (
<>
{groupByDate(items).map((group) => (
<PageGroup key={group.label} label={group.label}>
<ul className="notification-list">
{group.items.map((n) => (
<NotificationItem
key={n.id}
n={n}
/>
))}
</ul>
</PageGroup>
))}
<FeedFooter
hasMore={data.hasMore}
loadingMore={data.loadingMore}
sentinelRef={sentinelRef}
/>
</>
)}
</ListState>
</PageShell>
);
}

View File

@@ -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 (
<PageShell>
<div className="page-header">
<h1 className="page-title">
<Trans>Settings</Trans>
</h1>
</div>
<ProfileSection title={<Trans>Account</Trans>}>
<div className="profile-appearance-grid">
<PageShell narrow>
<PageHeader icon="⚙️" title={<Trans>Settings</Trans>} />
<PageGroup label={<Trans>Account</Trans>}>
<div className="page-card profile-appearance-grid">
<SettingRow label={<Trans>Username</Trans>}>
<span className="profile-appearance-value">{user.username}</span>
</SettingRow>
@@ -98,9 +95,9 @@ export function Settings(): React.JSX.Element {
</button>
</SettingRow>
</div>
</ProfileSection>
<ProfileSection title={<Trans>Appearance</Trans>}>
<div className="profile-appearance-grid">
</PageGroup>
<PageGroup label={<Trans>Appearance</Trans>}>
<div className="page-card profile-appearance-grid">
<SettingRow label={<Trans>Style</Trans>}>
<Segmented
options={STYLE_OPTIONS}
@@ -116,9 +113,9 @@ export function Settings(): React.JSX.Element {
/>
</SettingRow>
</div>
</ProfileSection>
<ProfileSection title={<Trans>Feeds</Trans>}>
<div className="profile-appearance-grid">
</PageGroup>
<PageGroup label={<Trans>Feeds</Trans>}>
<div className="page-card profile-appearance-grid">
<SettingRow label={<Trans>Default tab</Trans>}>
<Segmented
options={FEED_TAB_OPTIONS}
@@ -127,9 +124,9 @@ export function Settings(): React.JSX.Element {
/>
</SettingRow>
</div>
</ProfileSection>
<ProfileSection title={<Trans>Notifications</Trans>}>
<div className="profile-appearance-grid">
</PageGroup>
<PageGroup label={<Trans>Notifications</Trans>}>
<div className="page-card profile-appearance-grid">
<SettingRow label={<Trans>Sounds</Trans>}>
<Segmented
options={ON_OFF_OPTIONS}
@@ -142,9 +139,9 @@ export function Settings(): React.JSX.Element {
/>
</SettingRow>
</div>
</ProfileSection>
<ProfileSection title={<Trans>Email</Trans>}>
<div className="profile-appearance-grid">
</PageGroup>
<PageGroup label={<Trans>Email</Trans>}>
<div className="page-card profile-appearance-grid">
<SettingRow label={<Message id="digest.weekly" />}>
<Segmented
options={ON_OFF_OPTIONS}
@@ -171,7 +168,7 @@ export function Settings(): React.JSX.Element {
</Trans>
</p>
)}
</ProfileSection>
</PageGroup>
{changePasswordOpen && (
<ChangePasswordModal onClose={() => setChangePasswordOpen(false)} />
)}

View File

@@ -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);
}

View File

@@ -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);

View File

@@ -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,