v4: phone layouts hold down to 300px in every style, Kanagawa's wide monospace included, and the lint is clean — the floating chat and "+" buttons hid the end of every page (Save on the dump edit page, the last setting), since the page kept room for one button and they stack two; it keeps room for both now, read from one --fab-size. A 1fr page grid grew to its widest unbreakable content, and the edit forms' action bars didn't wrap, so at 320px the dump edit page scrolled sideways and the playlist edit's Save stuck out of its card; the grids take minmax(0, 1fr) and .form-actions wraps. The profile header's info column was a hidden horizontal scroller under a fade, which cut the username, the inviter, the email and the Log out and Invite buttons; it wraps instead (long names and emails break anywhere), and the avatar shrinks to 70% on phones. TabBar pins its leading content (the feed's category switcher) at the row's start while the tabs scroll under it, where scrolling to the active tab used to push it out of view; its edge fades show only while tabs are hidden past that edge, so the last tab is no longer dimmed once scrolled to, and a left fade marks a scrolled row. Tabs are narrower on phones, which lets Kanagawa's search tabs fit at 375px. The global player puts its track controls on a row of their own on phones, where they left the title three letters, and an audio bar narrower than 420px drops its volume slider for the waveform. The profile bio lost the margins it stacked on the page's gap. A dump card is now a container and lays out by its own width rather than the viewport's, since a playlist's is narrowed by its drag handle and remove button and titles broke every few letters: under 472px it is a grid where the vote ends the date line, as on the journal's cards, instead of hanging under the thumb, with a spacer row keeping it off the title; under 300px the thumb takes the top row and the text the full width. The lint errors that predated this are fixed: a useless fragment and two async callbacks without await in tests (they now await advanceTimersByTimeAsync), EmailKit's renderer gets the kit as an argument instead of aliasing this, its figure regex drops the literal no-break spaces \s already matches, digest-service's highlightOf takes NotificationData narrowed per case instead of any, and eslint ignores coverage/. 254 backend and 670 frontend tests pass, and tsc, deno check, deno lint, deno fmt, eslint and doc:lint are clean; every page was checked in screenshots at 300 to 480px in Kanagawa and Smooth.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -107,90 +107,88 @@ export function ProfileHeader(
|
||||
)}
|
||||
</div>
|
||||
<div className="profile-info" ref={infoRef}>
|
||||
<div className="profile-info-scroll">
|
||||
<div className="profile-username-row">
|
||||
<h1 className="profile-username">{user.username}</h1>
|
||||
<RoleChip role={user.role} />
|
||||
</div>
|
||||
{user.invitedByUsername
|
||||
<div className="profile-username-row">
|
||||
<h1 className="profile-username">{user.username}</h1>
|
||||
<RoleChip role={user.role} />
|
||||
</div>
|
||||
{user.invitedByUsername
|
||||
? (
|
||||
<p className="profile-invited-by">
|
||||
<Trans>invited by</Trans>{" "}
|
||||
<Link
|
||||
to={userPath(user.invitedByUsername)}
|
||||
className="profile-invited-by-link"
|
||||
>
|
||||
@{user.invitedByUsername}
|
||||
</Link>
|
||||
</p>
|
||||
)
|
||||
: (
|
||||
<p className="profile-invited-by profile-invited-by--founding">
|
||||
O.G.
|
||||
</p>
|
||||
)}
|
||||
{isOwnProfile && (
|
||||
emailEditing
|
||||
? (
|
||||
<p className="profile-invited-by">
|
||||
<Trans>invited by</Trans>{" "}
|
||||
<Link
|
||||
to={userPath(user.invitedByUsername)}
|
||||
className="profile-invited-by-link"
|
||||
>
|
||||
@{user.invitedByUsername}
|
||||
</Link>
|
||||
</p>
|
||||
<EmailEditor
|
||||
initialEmail={me?.email ?? ""}
|
||||
onCancel={() => setEmailEditing(false)}
|
||||
onSaved={handleEmailSaved}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<p className="profile-invited-by profile-invited-by--founding">
|
||||
O.G.
|
||||
</p>
|
||||
)}
|
||||
{isOwnProfile && (
|
||||
emailEditing
|
||||
? (
|
||||
<EmailEditor
|
||||
initialEmail={me?.email ?? ""}
|
||||
onCancel={() => setEmailEditing(false)}
|
||||
onSaved={handleEmailSaved}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<p
|
||||
className="profile-email-display"
|
||||
onClick={() => setEmailEditing(true)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setEmailEditing(true);
|
||||
}
|
||||
}}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
title={t`Edit email`}
|
||||
>
|
||||
{me?.email ?? t`Add email…`}
|
||||
<span className="profile-description-edit-btn" aria-hidden>
|
||||
✎
|
||||
</span>
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
{avatarError && (
|
||||
<ErrorCard
|
||||
title={t`Failed to update avatar`}
|
||||
message={avatarError}
|
||||
/>
|
||||
)}
|
||||
{!isOwnProfile && (
|
||||
<FollowUserButton
|
||||
targetUserId={user.id}
|
||||
targetUsername={user.username}
|
||||
/>
|
||||
)}
|
||||
{isOwnProfile && (
|
||||
<ProfileOwnActions>
|
||||
<Link to="/settings" className="btn-border">
|
||||
<Trans>Settings</Trans>
|
||||
</Link>
|
||||
{can(me, "category:manage") && (
|
||||
<Link to="/manage" className="btn-border">
|
||||
<Trans>Manage</Trans>
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn-border btn-border--danger"
|
||||
onClick={logout}
|
||||
<p
|
||||
className="profile-email-display"
|
||||
onClick={() => setEmailEditing(true)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setEmailEditing(true);
|
||||
}
|
||||
}}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
title={t`Edit email`}
|
||||
>
|
||||
<Trans>Log out</Trans>
|
||||
</button>
|
||||
</ProfileOwnActions>
|
||||
)}
|
||||
</div>
|
||||
{me?.email ?? t`Add email…`}
|
||||
<span className="profile-description-edit-btn" aria-hidden>
|
||||
✎
|
||||
</span>
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
{avatarError && (
|
||||
<ErrorCard
|
||||
title={t`Failed to update avatar`}
|
||||
message={avatarError}
|
||||
/>
|
||||
)}
|
||||
{!isOwnProfile && (
|
||||
<FollowUserButton
|
||||
targetUserId={user.id}
|
||||
targetUsername={user.username}
|
||||
/>
|
||||
)}
|
||||
{isOwnProfile && (
|
||||
<ProfileOwnActions>
|
||||
<Link to="/settings" className="btn-border">
|
||||
<Trans>Settings</Trans>
|
||||
</Link>
|
||||
{can(me, "category:manage") && (
|
||||
<Link to="/manage" className="btn-border">
|
||||
<Trans>Manage</Trans>
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn-border btn-border--danger"
|
||||
onClick={logout}
|
||||
>
|
||||
<Trans>Log out</Trans>
|
||||
</button>
|
||||
</ProfileOwnActions>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -28,7 +28,8 @@ export interface TabBarProps<T extends string> {
|
||||
innerClassName?: string;
|
||||
/**
|
||||
* Optional content rendered inside the row, before the tabs (e.g. a scope
|
||||
* selector). Sits in the same flex line so it scrolls and aligns with them.
|
||||
* selector). Sits in the same flex line, aligned with them, and stays pinned
|
||||
* at the row's start while they scroll under it.
|
||||
*/
|
||||
leading?: ReactNode;
|
||||
}
|
||||
@@ -42,23 +43,35 @@ export function TabBar<T extends string>(
|
||||
TabBarProps<T>,
|
||||
): React.JSX.Element {
|
||||
const rowRef = useRef<HTMLDivElement>(null);
|
||||
const leadingRef = useRef<HTMLDivElement>(null);
|
||||
const [overflowing, setOverflowing] = useState(false);
|
||||
// Tabs hidden past either edge, which the stylesheet fades only then, so a
|
||||
// fade never dims a tab that is fully in view.
|
||||
const [moreLeft, setMoreLeft] = useState(false);
|
||||
const [moreRight, setMoreRight] = useState(false);
|
||||
|
||||
// Flags a row whose tabs don't all fit, for places that fade the edge only
|
||||
// then (the app header) rather than always.
|
||||
useLayoutEffect(() => {
|
||||
const row = rowRef.current;
|
||||
if (!row) return;
|
||||
const measure = () => setOverflowing(row.scrollWidth > row.clientWidth);
|
||||
const measure = () => {
|
||||
setOverflowing(row.scrollWidth > row.clientWidth);
|
||||
setMoreLeft(row.scrollLeft > 1);
|
||||
setMoreRight(row.scrollLeft + row.clientWidth < row.scrollWidth - 1);
|
||||
};
|
||||
const resize = new ResizeObserver(measure);
|
||||
resize.observe(row);
|
||||
for (const child of row.children) resize.observe(child);
|
||||
row.addEventListener("scroll", measure, { passive: true });
|
||||
measure();
|
||||
return () => resize.disconnect();
|
||||
return () => {
|
||||
resize.disconnect();
|
||||
row.removeEventListener("scroll", measure);
|
||||
};
|
||||
}, [tabs.length]);
|
||||
|
||||
// When they don't all fit, keep the active tab in view, clear of the fade
|
||||
// over the row's right edge — scrolling the row alone, never the page.
|
||||
// When they don't all fit, keep the active tab in view, between the leading
|
||||
// content pinned over the row's left edge and the fade over its right edge,
|
||||
// scrolling the row alone, never the page.
|
||||
useLayoutEffect(() => {
|
||||
const row = rowRef.current;
|
||||
const active = row?.querySelector<HTMLElement>(".feed-sort-btn.active");
|
||||
@@ -67,7 +80,8 @@ export function TabBar<T extends string>(
|
||||
const fade =
|
||||
parseFloat(getComputedStyle(document.documentElement).fontSize) * 2;
|
||||
const r = row.getBoundingClientRect(), b = active.getBoundingClientRect();
|
||||
if (b.left < r.left) row.scrollLeft -= r.left - b.left;
|
||||
const left = r.left + (leadingRef.current?.offsetWidth ?? 0);
|
||||
if (b.left < left) row.scrollLeft -= left - b.left;
|
||||
else if (b.right > r.right - fade) {
|
||||
row.scrollLeft += b.right - r.right + fade;
|
||||
}
|
||||
@@ -76,14 +90,18 @@ export function TabBar<T extends string>(
|
||||
return (
|
||||
<div
|
||||
className={`feed-sort-scroller${
|
||||
overflowing ? " feed-sort-scroller--overflowing" : ""
|
||||
}${className ? ` ${className}` : ""}`}
|
||||
moreLeft ? " feed-sort-scroller--more-left" : ""
|
||||
}${moreRight ? " feed-sort-scroller--more" : ""}${
|
||||
className ? ` ${className}` : ""
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
ref={rowRef}
|
||||
className={`feed-sort${innerClassName ? ` ${innerClassName}` : ""}`}
|
||||
>
|
||||
{leading}
|
||||
{leading && (
|
||||
<div ref={leadingRef} className="feed-sort-leading">{leading}</div>
|
||||
)}
|
||||
{tabs.map(({ key, label }) => (
|
||||
<button
|
||||
key={key}
|
||||
|
||||
Reference in New Issue
Block a user