mirror of
https://github.com/block/buzz.git
synced 2026-11-09 02:41:57 +00:00
fix(desktop): continuous blur across inbox header columns
Render a single full-width backdrop strip behind the inbox pane headers instead of a per-pane backdrop-filter box. The blur now samples continuously across the column boundary rather than clipping at each pane edge. Inset headers become transparent and share a measured height via a new CSS variable so the strip spans both columns. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -40,7 +40,12 @@ import { resolveUserLabel } from "@/features/profile/lib/identity";
|
||||
import { deleteMessage, sendChannelMessage } from "@/shared/api/tauri";
|
||||
import type { HomeFeedResponse } from "@/shared/api/types";
|
||||
import { KIND_REACTION } from "@/shared/constants/kinds";
|
||||
import { topChromeInset } from "@/shared/layout/chromeLayout";
|
||||
import {
|
||||
insetHeaderHeightMeasurement,
|
||||
insetHeaderOverlay,
|
||||
topChromeInset,
|
||||
} from "@/shared/layout/chromeLayout";
|
||||
import { useMeasuredCssVariable } from "@/shared/layout/useMeasuredCssVariable";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { resolveMentionNames } from "@/shared/lib/resolveMentionNames";
|
||||
import { useElementWidth } from "@/shared/hooks/use-mobile";
|
||||
@@ -68,6 +73,12 @@ export function HomeView({
|
||||
onRefresh,
|
||||
}: HomeViewProps) {
|
||||
const [homeInboxRef, homeInboxWidthPx] = useElementWidth<HTMLDivElement>();
|
||||
// One pane header drives the shared backdrop strip height so the blur is a
|
||||
// single element spanning both columns instead of clipping per pane.
|
||||
const headerChromeRef = useMeasuredCssVariable({
|
||||
targetRef: homeInboxRef,
|
||||
...insetHeaderHeightMeasurement,
|
||||
});
|
||||
const isNarrowHomeViewport =
|
||||
homeInboxWidthPx > 0 &&
|
||||
homeInboxWidthPx < INBOX_SINGLE_COLUMN_BREAKPOINT_PX;
|
||||
@@ -348,10 +359,13 @@ export function HomeView({
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<div aria-hidden className={insetHeaderOverlay.backdrop} />
|
||||
|
||||
{showListPane ? (
|
||||
<InboxListPane
|
||||
doneSet={effectiveDoneSet}
|
||||
filter={filter}
|
||||
headerChromeRef={headerChromeRef}
|
||||
items={filteredItems}
|
||||
onFilterChange={setFilter}
|
||||
onSelect={(itemId) => {
|
||||
@@ -398,6 +412,7 @@ export function HomeView({
|
||||
contextChannelName={selectedChannel?.name ?? null}
|
||||
currentPubkey={currentPubkey}
|
||||
disabledReplyReason={disabledReplyReason}
|
||||
headerChromeRef={showListPane ? undefined : headerChromeRef}
|
||||
isDeletingMessage={isDeletingMessage}
|
||||
isDone={
|
||||
selectedItem ? effectiveDoneSet.has(selectedItem.id) : false
|
||||
|
||||
@@ -24,6 +24,7 @@ import type { TimelineMessage } from "@/features/messages/types";
|
||||
import { MessageComposer } from "@/features/messages/ui/MessageComposer";
|
||||
import { UpdateIndicator } from "@/features/settings/UpdateIndicator";
|
||||
import type { Channel } from "@/shared/api/types";
|
||||
import { insetHeaderOverlay } from "@/shared/layout/chromeLayout";
|
||||
import { TopChromeInsetHeader } from "@/shared/layout/TopChromeInsetHeader";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
@@ -46,6 +47,8 @@ type InboxDetailPaneProps = {
|
||||
canOpenChannel: boolean;
|
||||
canReply: boolean;
|
||||
disabledReplyReason?: string | null;
|
||||
/** Measured ref wiring the header height to the shared backdrop strip. */
|
||||
headerChromeRef?: React.Ref<HTMLDivElement>;
|
||||
isDone: boolean;
|
||||
isDeletingMessage?: boolean;
|
||||
isSendingReply?: boolean;
|
||||
@@ -80,6 +83,7 @@ export function InboxDetailPane({
|
||||
canOpenChannel,
|
||||
canReply,
|
||||
disabledReplyReason,
|
||||
headerChromeRef,
|
||||
isDone,
|
||||
isDeletingMessage = false,
|
||||
isSendingReply = false,
|
||||
@@ -234,7 +238,11 @@ export function InboxDetailPane({
|
||||
ref={detailPaneRef}
|
||||
>
|
||||
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<TopChromeInsetHeader>
|
||||
<TopChromeInsetHeader
|
||||
className={insetHeaderOverlay.negativeMargin}
|
||||
ref={headerChromeRef}
|
||||
transparent
|
||||
>
|
||||
<div className="px-5 py-1 pr-3">
|
||||
<div className="flex min-w-0 items-center justify-between gap-3">
|
||||
<div
|
||||
@@ -316,7 +324,12 @@ export function InboxDetailPane({
|
||||
</div>
|
||||
</TopChromeInsetHeader>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain pb-32">
|
||||
<div
|
||||
className={cn(
|
||||
"min-h-0 flex-1 overflow-y-auto overscroll-contain pb-32",
|
||||
insetHeaderOverlay.contentPadding,
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
{isThreadContextLoading ? (
|
||||
<div className="px-6 pb-3 text-[11px] text-muted-foreground">
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { ChevronDown, Inbox } from "lucide-react";
|
||||
import type * as React from "react";
|
||||
|
||||
import {
|
||||
formatInboxTypeLabel,
|
||||
type InboxFilter,
|
||||
type InboxItem,
|
||||
} from "@/features/home/lib/inbox";
|
||||
import { topChromeInset } from "@/shared/layout/chromeLayout";
|
||||
import {
|
||||
insetHeaderOverlay,
|
||||
topChromeInset,
|
||||
} from "@/shared/layout/chromeLayout";
|
||||
import { TopChromeInsetHeader } from "@/shared/layout/TopChromeInsetHeader";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
@@ -30,6 +34,8 @@ const FILTER_OPTIONS: Array<{ label: string; value: InboxFilter }> = [
|
||||
type InboxListPaneProps = {
|
||||
doneSet: ReadonlySet<string>;
|
||||
filter: InboxFilter;
|
||||
/** Measured ref wiring the header height to the shared backdrop strip. */
|
||||
headerChromeRef?: React.Ref<HTMLDivElement>;
|
||||
items: InboxItem[];
|
||||
onFilterChange: (filter: InboxFilter) => void;
|
||||
onSelect: (itemId: string) => void;
|
||||
@@ -40,6 +46,7 @@ type InboxListPaneProps = {
|
||||
export function InboxListPane({
|
||||
doneSet,
|
||||
filter,
|
||||
headerChromeRef,
|
||||
items,
|
||||
onFilterChange,
|
||||
onSelect,
|
||||
@@ -55,7 +62,11 @@ export function InboxListPane({
|
||||
showRightDivider && topChromeInset.verticalDivider,
|
||||
)}
|
||||
>
|
||||
<TopChromeInsetHeader>
|
||||
<TopChromeInsetHeader
|
||||
className={insetHeaderOverlay.negativeMargin}
|
||||
ref={headerChromeRef}
|
||||
transparent
|
||||
>
|
||||
<div className="px-5 py-1">
|
||||
<div className="flex min-w-0 items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-[6px]">
|
||||
@@ -99,7 +110,10 @@ export function InboxListPane({
|
||||
</TopChromeInsetHeader>
|
||||
|
||||
<div
|
||||
className="min-h-0 flex-1 overflow-y-auto overscroll-contain"
|
||||
className={cn(
|
||||
"min-h-0 flex-1 overflow-y-auto overscroll-contain",
|
||||
insetHeaderOverlay.contentPadding,
|
||||
)}
|
||||
data-testid="home-inbox-list"
|
||||
>
|
||||
{items.length === 0 ? (
|
||||
|
||||
@@ -3,7 +3,14 @@ import type * as React from "react";
|
||||
import { topChromeInset } from "@/shared/layout/chromeLayout";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
|
||||
type TopChromeInsetHeaderProps = React.ComponentProps<"div">;
|
||||
type TopChromeInsetHeaderProps = React.ComponentProps<"div"> & {
|
||||
/**
|
||||
* Render without its own backdrop, borders, and hairlines — for headers
|
||||
* stacked on a shared `insetHeaderOverlay.backdrop` strip that spans
|
||||
* multiple columns (keeps the blur continuous across pane boundaries).
|
||||
*/
|
||||
transparent?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Flowed header row that clears the global search/drag chrome and draws the
|
||||
@@ -12,14 +19,16 @@ type TopChromeInsetHeaderProps = React.ComponentProps<"div">;
|
||||
export function TopChromeInsetHeader({
|
||||
className,
|
||||
children,
|
||||
transparent = false,
|
||||
...props
|
||||
}: TopChromeInsetHeaderProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
topChromeInset.headerBase,
|
||||
transparent
|
||||
? "relative z-40 shrink-0"
|
||||
: cn(topChromeInset.headerBase, topChromeInset.divider),
|
||||
topChromeInset.padding,
|
||||
topChromeInset.divider,
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
export const TOP_CHROME_HEIGHT_DEFAULT = "2.5rem";
|
||||
export const CHANNEL_CONTENT_TOP_PADDING_DEFAULT = "5.75rem";
|
||||
export const INSET_HEADER_HEIGHT_DEFAULT = "5rem";
|
||||
|
||||
export const chromeCssVars = {
|
||||
topChromeHeight: "--buzz-top-chrome-height",
|
||||
channelContentTopPadding: "--buzz-channel-content-top-padding",
|
||||
insetHeaderHeight: "--buzz-inset-header-height",
|
||||
} as const;
|
||||
|
||||
export const chromeCssVarDefaults = {
|
||||
[chromeCssVars.topChromeHeight]: TOP_CHROME_HEIGHT_DEFAULT,
|
||||
[chromeCssVars.channelContentTopPadding]: CHANNEL_CONTENT_TOP_PADDING_DEFAULT,
|
||||
[chromeCssVars.insetHeaderHeight]: INSET_HEADER_HEIGHT_DEFAULT,
|
||||
} as const;
|
||||
|
||||
export const channelContentTopPaddingMeasurement = {
|
||||
@@ -16,6 +19,31 @@ export const channelContentTopPaddingMeasurement = {
|
||||
resetValue: chromeCssVarDefaults[chromeCssVars.channelContentTopPadding],
|
||||
} as const;
|
||||
|
||||
export const insetHeaderHeightMeasurement = {
|
||||
cssVariable: chromeCssVars.insetHeaderHeight,
|
||||
resetValue: chromeCssVarDefaults[chromeCssVars.insetHeaderHeight],
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Tailwind class fragments for a flowed `TopChromeInsetHeader` that overlays
|
||||
* the scrollable content below it, so the content scrolls under the
|
||||
* translucent blurred header (same treatment as the channel header).
|
||||
*/
|
||||
export const insetHeaderOverlay = {
|
||||
/** Negative bottom margin pulling the next sibling under the header. */
|
||||
negativeMargin: "-mb-(--buzz-inset-header-height,5rem)",
|
||||
/** Padding-top reserving the measured header height inside the scroll area. */
|
||||
contentPadding: "pt-(--buzz-inset-header-height,5rem)",
|
||||
/**
|
||||
* Single full-width backdrop strip drawn behind transparent inset headers.
|
||||
* Rendered once per view so the blur samples continuously across column
|
||||
* boundaries instead of clipping at each pane's backdrop-filter box.
|
||||
* Carries the search-strip hairline (`before:`) and the bottom border.
|
||||
*/
|
||||
backdrop:
|
||||
"pointer-events-none absolute inset-x-0 top-0 z-30 h-(--buzz-inset-header-height,5rem) border-b border-border/35 bg-background/80 backdrop-blur-md supports-backdrop-filter:bg-background/70 dark:bg-background/70 dark:backdrop-blur-xl dark:supports-backdrop-filter:bg-background/55 before:pointer-events-none before:absolute before:inset-x-0 before:top-(--buzz-top-chrome-height,2.5rem) before:h-px before:bg-border/35 before:content-['']",
|
||||
} as const;
|
||||
|
||||
/** Tailwind class fragments for layout under the global top chrome. */
|
||||
export const topChromeInset = {
|
||||
/** Absolute/fixed top offset below the search bar. */
|
||||
@@ -29,7 +57,7 @@ export const topChromeInset = {
|
||||
"before:pointer-events-none before:absolute before:inset-x-0 before:top-(--buzz-top-chrome-height,2.5rem) before:h-px before:bg-border/35 before:content-['']",
|
||||
/** Shared header backdrop and bottom border below the inset row. */
|
||||
headerBase:
|
||||
"relative z-40 shrink-0 border-b border-border/35 bg-background/75 backdrop-blur-md supports-backdrop-filter:bg-background/65 dark:bg-background/45 dark:backdrop-blur-xl dark:supports-backdrop-filter:bg-background/35",
|
||||
"relative z-40 shrink-0 border-b border-border/35 bg-background/80 backdrop-blur-md supports-backdrop-filter:bg-background/70 dark:bg-background/70 dark:backdrop-blur-xl dark:supports-backdrop-filter:bg-background/55",
|
||||
/** Vertical pane divider starting below the global top chrome. */
|
||||
verticalDivider:
|
||||
"after:pointer-events-none after:absolute after:bottom-0 after:right-0 after:top-(--buzz-top-chrome-height,2.5rem) after:z-40 after:w-px after:bg-border/35 after:content-['']",
|
||||
|
||||
Reference in New Issue
Block a user