fix(desktop): match compact link preview thumbnail corners to card shell (#5711)

## Problem

In compact link preview cards with an image, the thumbnail's corners
looked inconsistent — the flush left side and the interior right side
read as different shapes.

## Cause

The `Attachment` shell rounds its corners with a **smooth-corner
(squircle) clip path** via `useSmoothCorners`, not a plain
`border-radius`. In compact image mode the shell has `p-0`, so the
thumbnail sits flush against its left, top, and bottom edges.

That means:

- **Left corners** are carved by the shell's smoothed clip path.
- **Right corners** are drawn by the thumbnail's own plain
`border-radius`.

A circular arc and a smoothed corner of the *same* radius are different
shapes (at 16px the smoothed curve starts 25.6px along the edge instead
of 16px). So the two sides could never match by picking a radius value —
the thumbnail's class has no effect on its left corners at all.

## Fix

Give the thumbnail the same `useSmoothCorners` treatment as the shell,
so both sides share one curve.

- Radius token unchanged: `rounded-2xl` (16px).
- The shell and the shared `Attachment` component are untouched, so no
other `Attachment` consumer changes.

Verified on a rendered card — thumbnail vs shell now agree on all three:
arc radius (16), smoothing (0.6), and curve start (25.6px).

## Hardening

The underlying issue is an invariant that lived nowhere: **a child flush
against a smooth-cornered parent must share its corner treatment.** This
is why the bug was easy to introduce and hard to diagnose.

- Documented the invariant in `smoothCorners.ts`, where anyone reaching
for the hook will see it.
- Added an `expectSmoothCorners()` guard to the existing compact-preview
e2e test. Confirmed it **fails** when the fix is reverted, so it
genuinely bites.

Note: this cannot be a lint rule — "flush" is a runtime layout fact, not
visible in the source.

## Known follow-up (not in this PR)

The composer link preview (`useComposerLinkPreviews.tsx`) has the same
latent issue: a flush thumbnail with a hand-copied `rounded-l-2xl` that
happens to match the shell's current 16px. It is correct today only by
coincidence of two literals agreeing. Left for a separate PR rather than
expanding scope here.

## Screenshots

The same compact card and content before and after the change.

| Before | After |
| --- | --- |
| Original `rounded-xl` (12px) thumbnail: left corners are clipped by
the card’s 16px smooth silhouette while the right corners keep the
thumbnail’s smaller plain radius | `rounded-2xl` (16px) thumbnail with
the same smooth-corner treatment as the card |
| ![Before: compact link preview with the original 12px thumbnail
corners](https://raw.githubusercontent.com/block/buzz/9911631a0698cae25df4d41471dde69faa4169dc/pr-5711--before.png)
| ![After: compact link preview with matching 16px smooth thumbnail
corners](https://raw.githubusercontent.com/block/buzz/9911631a0698cae25df4d41471dde69faa4169dc/pr-5711--after.png)
|

## Verification

- `pnpm exec biome check` on all three touched files
- `pnpm exec tsc --noEmit`
- `node --test src/shared/ui/smoothCorners.test.mjs` — 3 passed
- All 18 link-preview e2e tests pass
- Guard verified to fail without the fix, then pass with it

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
This commit is contained in:
morgmart
2026-08-13 20:42:24 -05:00
committed by GitHub
parent 068a83b097
commit eedcd886a0
3 changed files with 56 additions and 2 deletions
@@ -1,5 +1,5 @@
import { ImageOff } from "lucide-react";
import { useState } from "react";
import { useRef, useState } from "react";
import type { ResolvedLinkPreview } from "@/shared/lib/useResolvedLinkPreviews";
import { cn } from "@/shared/lib/cn";
@@ -13,6 +13,7 @@ import {
} from "@/shared/ui/attachment";
import { LinkPreviewControls } from "@/shared/ui/link-preview-controls";
import { BuzzMark } from "@/shared/ui/buzz-logo/BuzzMark";
import { useSmoothCorners } from "@/shared/ui/smoothCorners";
function getHostname(preview: ResolvedLinkPreview): string {
if (preview.href.startsWith("buzz://")) return preview.provider;
@@ -64,6 +65,13 @@ export function CompactLinkPreviewAttachment({
const imageSrc =
preview.imageState === "image" ? (preview.imageDataUrl ?? null) : null;
const [failedImageSrc, setFailedImageSrc] = useState<string | null>(null);
// The shell clips its corners with a smooth-corner (squircle) path, and the
// thumbnail sits flush against its left edge — so the thumbnail's left
// corners are carved by that path while its right corners are its own.
// A plain border-radius can never match a squircle of the same radius, so
// give the thumbnail the same treatment: both sides then share one curve.
const thumbnailRef = useRef<HTMLDivElement | null>(null);
useSmoothCorners(thumbnailRef);
const showImage = Boolean(imageSrc && failedImageSrc !== imageSrc);
const showFallback =
preview.imageState === "fallback" || Boolean(imageSrc && !showImage);
@@ -88,8 +96,9 @@ export function CompactLinkPreviewAttachment({
>
{reserveImage ? (
<AttachmentMedia
ref={thumbnailRef}
aria-hidden={showImage ? undefined : "true"}
className="aspect-auto h-16 w-26 rounded-xl bg-muted"
className="aspect-auto h-16 w-26 rounded-2xl bg-muted"
data-link-preview-thumbnail=""
variant="image"
>
+33
View File
@@ -1,3 +1,36 @@
/**
* Corner smoothing ("squircle") for media surfaces.
*
* CSS `border-radius` can only draw a circular arc. This helper reads an
* element's computed radius and replaces the shape with an equivalent
* *smoothed* corner, applied at runtime as an inline `clip-path`. The radius
* token stays the source of truth — `rounded-2xl` still means a 16px corner,
* it just renders as a smoothed 16px corner (the curve starts 1.6x further
* along the edge, so it reads wider and softer than a plain arc).
*
* INVARIANT — flush children must share the parent's corner treatment.
*
* A smooth-cornered parent clips its subtree. Any child that sits *flush*
* against a clipped edge therefore has those corners carved by the parent's
* smoothed path, while its remaining corners are drawn by its own
* `border-radius`. A plain arc and a smoothed corner of the *same* radius are
* different shapes, so such a child renders visibly mismatched corners — the
* number matching is not enough.
*
* So when a child is flush to a smooth-cornered parent (typically because the
* parent zeroes its padding, e.g. `p-0`), either:
* - give the child `useSmoothCorners` too, so both sides share one curve
* (see `compact-link-preview-attachment.tsx`), or
* - give the child no radius on the flush side and let the parent's clip own
* that silhouette entirely.
*
* Inset children are unaffected: they never share a corner with the clip.
*
* This cannot be caught by a lint rule — "flush" is a runtime layout fact, not
* something visible in the source. Guard it with `expectSmoothCorners()` from
* `desktop/tests/helpers/css.ts` instead.
*/
import * as React from "react";
export const SMOOTH_CORNER_SMOOTHING = 0.6;
+12
View File
@@ -531,6 +531,18 @@ test("sent link preview media uses the authenticated proxy in compact and rich c
.poll(() => compactThumbnail.evaluate((image) => image.naturalWidth))
.toBe(40);
// The compact thumbnail sits flush against the card shell's left edge, so the
// shell's smooth-corner clip carves those corners. It must therefore carry
// the same treatment itself, or its right corners render as a plain arc
// against the shell's smoothed left corners. See the invariant in
// `shared/ui/smoothCorners.ts`.
const compactThumbnailFrame = compactPreview
.locator("[data-link-preview-thumbnail]")
.first();
await expectCornerRadiusPx(compactPreview, 16);
await expectCornerRadiusPx(compactThumbnailFrame, 16);
await expectSmoothCorners(compactThumbnailFrame);
await openSettings(page, "appearance");
await page.getByTestId("link-preview-style-trigger").click();
await page.getByTestId("link-preview-style-rich").click();