From 6c959231bf9a51953b3297607e3edfc72480d6e4 Mon Sep 17 00:00:00 2001 From: Aaron Reisman Date: Tue, 23 Jun 2026 16:02:22 +0700 Subject: [PATCH 1/4] Add named anatomy parts and bake spec-defined styles into preview-card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds PreviewCardTitle, PreviewCardDescription, and PreviewCardImage as propel-extended anatomy parts (Base UI has no counterparts for these). Their styles encode the "always the same" items from the designer's spec — title/description font hierarchy and image overflow-hidden/object-cover — so consumers use the parts rather than hand-rolling raw spans with inline classNames. Also adds previewCardArrowVariants (text-layer-1) so the caret inherits the popup surface background, matching every other arrow in the system. Both tier stories are updated to use the new named parts. --- .../src/components/preview-card/index.tsx | 6 +++++ .../preview-card/preview-card.stories.tsx | 23 +++++++++++++++---- packages/propel/src/ui/preview-card/index.tsx | 6 +++++ .../ui/preview-card/preview-card-arrow.tsx | 4 +++- .../preview-card/preview-card-description.tsx | 16 +++++++++++++ .../ui/preview-card/preview-card-image.tsx | 17 ++++++++++++++ .../ui/preview-card/preview-card-title.tsx | 16 +++++++++++++ .../ui/preview-card/preview-card.stories.tsx | 10 +++++--- .../propel/src/ui/preview-card/variants.ts | 18 +++++++++++++++ 9 files changed, 107 insertions(+), 9 deletions(-) create mode 100644 packages/propel/src/ui/preview-card/preview-card-description.tsx create mode 100644 packages/propel/src/ui/preview-card/preview-card-image.tsx create mode 100644 packages/propel/src/ui/preview-card/preview-card-title.tsx diff --git a/packages/propel/src/components/preview-card/index.tsx b/packages/propel/src/components/preview-card/index.tsx index db1417f2..c4ebed5e 100644 --- a/packages/propel/src/components/preview-card/index.tsx +++ b/packages/propel/src/components/preview-card/index.tsx @@ -5,6 +5,12 @@ export { type PreviewCardProps, PreviewCardArrow, type PreviewCardArrowProps, + PreviewCardDescription, + type PreviewCardDescriptionProps, + PreviewCardImage, + type PreviewCardImageProps, + PreviewCardTitle, + type PreviewCardTitleProps, PreviewCardTrigger, type PreviewCardTriggerProps, } from "../../ui/preview-card"; diff --git a/packages/propel/src/components/preview-card/preview-card.stories.tsx b/packages/propel/src/components/preview-card/preview-card.stories.tsx index 69fc8617..1de75591 100644 --- a/packages/propel/src/components/preview-card/preview-card.stories.tsx +++ b/packages/propel/src/components/preview-card/preview-card.stories.tsx @@ -1,7 +1,14 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, userEvent, waitFor, within } from "storybook/test"; -import { PreviewCard, PreviewCardArrow, PreviewCardContent, PreviewCardTrigger } from "./index"; +import { + PreviewCard, + PreviewCardArrow, + PreviewCardContent, + PreviewCardDescription, + PreviewCardTitle, + PreviewCardTrigger, +} from "./index"; // Components-tier story: uses the ready-made `PreviewCardContent`, which composes // the portal/backdrop/positioner/popup so a consumer only writes the trigger and @@ -10,7 +17,13 @@ import { PreviewCard, PreviewCardArrow, PreviewCardContent, PreviewCardTrigger } const meta = { title: "Components/PreviewCard", component: PreviewCard, - subcomponents: { PreviewCardTrigger, PreviewCardContent, PreviewCardArrow }, + subcomponents: { + PreviewCardTrigger, + PreviewCardContent, + PreviewCardArrow, + PreviewCardTitle, + PreviewCardDescription, + }, } satisfies Meta; export default meta; @@ -38,10 +51,10 @@ export const Default: Story = {
- Plane - + Plane + Open-source project management for issues, sprints, and roadmaps. - +
diff --git a/packages/propel/src/ui/preview-card/index.tsx b/packages/propel/src/ui/preview-card/index.tsx index 6e1b8c41..795a9c95 100644 --- a/packages/propel/src/ui/preview-card/index.tsx +++ b/packages/propel/src/ui/preview-card/index.tsx @@ -1,8 +1,14 @@ export { PreviewCard, type PreviewCardProps } from "./preview-card"; export { PreviewCardArrow, type PreviewCardArrowProps } from "./preview-card-arrow"; export { PreviewCardBackdrop, type PreviewCardBackdropProps } from "./preview-card-backdrop"; +export { + PreviewCardDescription, + type PreviewCardDescriptionProps, +} from "./preview-card-description"; +export { PreviewCardImage, type PreviewCardImageProps } from "./preview-card-image"; export { PreviewCardPopup, type PreviewCardPopupProps } from "./preview-card-popup"; export { PreviewCardPortal, type PreviewCardPortalProps } from "./preview-card-portal"; export { PreviewCardPositioner, type PreviewCardPositionerProps } from "./preview-card-positioner"; +export { PreviewCardTitle, type PreviewCardTitleProps } from "./preview-card-title"; export { PreviewCardTrigger, type PreviewCardTriggerProps } from "./preview-card-trigger"; export { PreviewCardViewport, type PreviewCardViewportProps } from "./preview-card-viewport"; diff --git a/packages/propel/src/ui/preview-card/preview-card-arrow.tsx b/packages/propel/src/ui/preview-card/preview-card-arrow.tsx index 0d240f8a..ad816c0b 100644 --- a/packages/propel/src/ui/preview-card/preview-card-arrow.tsx +++ b/packages/propel/src/ui/preview-card/preview-card-arrow.tsx @@ -1,8 +1,10 @@ import { PreviewCard as BasePreviewCard } from "@base-ui/react/preview-card"; +import { previewCardArrowVariants } from "./variants"; + export type PreviewCardArrowProps = Omit; /** The optional caret pointing from the popup back to the trigger. Maps 1:1 to `PreviewCard.Arrow`. */ export function PreviewCardArrow(props: PreviewCardArrowProps) { - return ; + return ; } diff --git a/packages/propel/src/ui/preview-card/preview-card-description.tsx b/packages/propel/src/ui/preview-card/preview-card-description.tsx new file mode 100644 index 00000000..fce96979 --- /dev/null +++ b/packages/propel/src/ui/preview-card/preview-card-description.tsx @@ -0,0 +1,16 @@ +import type * as React from "react"; + +import { previewCardDescriptionVariants } from "./variants"; + +export type PreviewCardDescriptionProps = Omit< + React.ComponentPropsWithoutRef<"p">, + "className" | "style" +>; + +/** + * Supporting description text beneath the title. Bakes in the secondary text style — 13px in + * secondary text colour — per the "always the same" items in the design spec. + */ +export function PreviewCardDescription(props: PreviewCardDescriptionProps) { + return

; +} diff --git a/packages/propel/src/ui/preview-card/preview-card-image.tsx b/packages/propel/src/ui/preview-card/preview-card-image.tsx new file mode 100644 index 00000000..5b71c64a --- /dev/null +++ b/packages/propel/src/ui/preview-card/preview-card-image.tsx @@ -0,0 +1,17 @@ +import type * as React from "react"; + +import { previewCardImageVariants } from "./variants"; + +export type PreviewCardImageProps = Omit< + React.ComponentPropsWithoutRef<"img">, + "className" | "style" +>; + +/** + * The thumbnail image shown inside the popup. Bakes in overflow-hidden and object-cover so the + * thumbnail always clips and fills its container — these are "always the same" per the design spec. + * Width and height are set by the consumer's layout. + */ +export function PreviewCardImage(props: PreviewCardImageProps) { + return ; +} diff --git a/packages/propel/src/ui/preview-card/preview-card-title.tsx b/packages/propel/src/ui/preview-card/preview-card-title.tsx new file mode 100644 index 00000000..0755fc20 --- /dev/null +++ b/packages/propel/src/ui/preview-card/preview-card-title.tsx @@ -0,0 +1,16 @@ +import type * as React from "react"; + +import { previewCardTitleVariants } from "./variants"; + +export type PreviewCardTitleProps = Omit< + React.ComponentPropsWithoutRef<"p">, + "className" | "style" +>; + +/** + * The card's primary heading. Bakes in the title font style — semibold 14px in primary text colour + * — per the "always the same" items in the design spec. + */ +export function PreviewCardTitle(props: PreviewCardTitleProps) { + return

; +} diff --git a/packages/propel/src/ui/preview-card/preview-card.stories.tsx b/packages/propel/src/ui/preview-card/preview-card.stories.tsx index 8d000e4c..aa1c4b12 100644 --- a/packages/propel/src/ui/preview-card/preview-card.stories.tsx +++ b/packages/propel/src/ui/preview-card/preview-card.stories.tsx @@ -5,9 +5,11 @@ import { PreviewCard, PreviewCardArrow, PreviewCardBackdrop, + PreviewCardDescription, PreviewCardPopup, PreviewCardPortal, PreviewCardPositioner, + PreviewCardTitle, PreviewCardTrigger, PreviewCardViewport, } from "./index"; @@ -29,6 +31,8 @@ const meta = { PreviewCardViewport, PreviewCardPopup, PreviewCardArrow, + PreviewCardTitle, + PreviewCardDescription, }, } satisfies Meta; @@ -60,10 +64,10 @@ export const Anatomy: Story = {

- Plane - + Plane + Open-source project management for issues, sprints, and roadmaps. - +
diff --git a/packages/propel/src/ui/preview-card/variants.ts b/packages/propel/src/ui/preview-card/variants.ts index 51a502f6..fbb905b1 100644 --- a/packages/propel/src/ui/preview-card/variants.ts +++ b/packages/propel/src/ui/preview-card/variants.ts @@ -4,6 +4,11 @@ import { cva, cx } from "class-variance-authority"; // open state through `data-*` attributes, so there are no styling axes // (variant/tone/magnitude) to expose. The cva pairings below hold the static // chrome for every styled part in one place, with no `className` at the boundary. +// +// The anatomy also includes three propel-extended parts — Image, Title, and +// Description — that have no Base UI counterpart. Their styles below encode the +// "always the same" items from the design spec: image overflow/cover-fit and the +// title/description font hierarchy. export const previewCardPositionerVariants = cva("z-50 outline-none"); @@ -23,3 +28,16 @@ export const previewCardBackdropVariants = cva( "data-ending-style:opacity-0 data-starting-style:opacity-0", ), ); + +// Arrow inherits the popup's background so the caret blends with the card surface. +export const previewCardArrowVariants = cva("text-layer-1"); + +// Image: overflow-hidden + object-cover bake in the "always the same" thumbnail +// treatment from the design spec. Width/height are set by the consumer's layout. +export const previewCardImageVariants = cva("overflow-hidden rounded-md object-cover"); + +// Title: matches the heading hierarchy in the Figma spec. +export const previewCardTitleVariants = cva("text-14 font-semibold text-primary"); + +// Description: secondary supporting text beneath the title. +export const previewCardDescriptionVariants = cva("text-13 text-secondary"); From 680a813db14cf73bf64cd4681e7abfe2b0438ab4 Mon Sep 17 00:00:00 2001 From: Aaron Reisman Date: Tue, 23 Jun 2026 16:33:40 +0700 Subject: [PATCH 2/4] Extract preview-card Body part; keep padding/layout out of compositions The text content area of a preview card is its own anatomy region: per the design spec the content layout (title above description) and the padding around it are always the same. Previously each composition hand-rolled a `
` for it and the popup baked the padding, so a full-bleed image could not sit edge-to-edge above the text. Add `PreviewCardBody` (single element, `flex flex-col gap-1 p-3` in cva), move the padding off the popup onto the body, and let the image round/clip itself so the popup needs no overflow-hidden (which would clip the arrow). Both stories now compose Body and register it in subcomponents. --- .../src/components/preview-card/index.tsx | 2 ++ .../preview-card/preview-card.stories.tsx | 6 +++-- packages/propel/src/ui/preview-card/index.tsx | 1 + .../src/ui/preview-card/preview-card-body.tsx | 15 +++++++++++ .../ui/preview-card/preview-card.stories.tsx | 6 +++-- .../propel/src/ui/preview-card/variants.ts | 26 ++++++++++++++----- 6 files changed, 45 insertions(+), 11 deletions(-) create mode 100644 packages/propel/src/ui/preview-card/preview-card-body.tsx diff --git a/packages/propel/src/components/preview-card/index.tsx b/packages/propel/src/components/preview-card/index.tsx index c4ebed5e..b8e6dbf3 100644 --- a/packages/propel/src/components/preview-card/index.tsx +++ b/packages/propel/src/components/preview-card/index.tsx @@ -5,6 +5,8 @@ export { type PreviewCardProps, PreviewCardArrow, type PreviewCardArrowProps, + PreviewCardBody, + type PreviewCardBodyProps, PreviewCardDescription, type PreviewCardDescriptionProps, PreviewCardImage, diff --git a/packages/propel/src/components/preview-card/preview-card.stories.tsx b/packages/propel/src/components/preview-card/preview-card.stories.tsx index 1de75591..0d6e21cc 100644 --- a/packages/propel/src/components/preview-card/preview-card.stories.tsx +++ b/packages/propel/src/components/preview-card/preview-card.stories.tsx @@ -4,6 +4,7 @@ import { expect, userEvent, waitFor, within } from "storybook/test"; import { PreviewCard, PreviewCardArrow, + PreviewCardBody, PreviewCardContent, PreviewCardDescription, PreviewCardTitle, @@ -21,6 +22,7 @@ const meta = { PreviewCardTrigger, PreviewCardContent, PreviewCardArrow, + PreviewCardBody, PreviewCardTitle, PreviewCardDescription, }, @@ -50,12 +52,12 @@ export const Default: Story = { Plane -
+ Plane Open-source project management for issues, sprints, and roadmaps. -
+
{" "} diff --git a/packages/propel/src/ui/preview-card/index.tsx b/packages/propel/src/ui/preview-card/index.tsx index 795a9c95..be0b08e9 100644 --- a/packages/propel/src/ui/preview-card/index.tsx +++ b/packages/propel/src/ui/preview-card/index.tsx @@ -1,6 +1,7 @@ export { PreviewCard, type PreviewCardProps } from "./preview-card"; export { PreviewCardArrow, type PreviewCardArrowProps } from "./preview-card-arrow"; export { PreviewCardBackdrop, type PreviewCardBackdropProps } from "./preview-card-backdrop"; +export { PreviewCardBody, type PreviewCardBodyProps } from "./preview-card-body"; export { PreviewCardDescription, type PreviewCardDescriptionProps, diff --git a/packages/propel/src/ui/preview-card/preview-card-body.tsx b/packages/propel/src/ui/preview-card/preview-card-body.tsx new file mode 100644 index 00000000..08a70095 --- /dev/null +++ b/packages/propel/src/ui/preview-card/preview-card-body.tsx @@ -0,0 +1,15 @@ +import type * as React from "react"; + +import { previewCardBodyVariants } from "./variants"; + +export type PreviewCardBodyProps = Omit, "className" | "style">; + +/** + * The text content area of the card — typically holds a `PreviewCardTitle` and + * `PreviewCardDescription` stacked in a column. Owns the padding so a full-bleed + * `PreviewCardImage` can sit edge-to-edge above it; both the column layout and the + * padding are "always the same" per the design spec. + */ +export function PreviewCardBody(props: PreviewCardBodyProps) { + return
; +} diff --git a/packages/propel/src/ui/preview-card/preview-card.stories.tsx b/packages/propel/src/ui/preview-card/preview-card.stories.tsx index aa1c4b12..d6f5cdb0 100644 --- a/packages/propel/src/ui/preview-card/preview-card.stories.tsx +++ b/packages/propel/src/ui/preview-card/preview-card.stories.tsx @@ -5,6 +5,7 @@ import { PreviewCard, PreviewCardArrow, PreviewCardBackdrop, + PreviewCardBody, PreviewCardDescription, PreviewCardPopup, PreviewCardPortal, @@ -31,6 +32,7 @@ const meta = { PreviewCardViewport, PreviewCardPopup, PreviewCardArrow, + PreviewCardBody, PreviewCardTitle, PreviewCardDescription, }, @@ -63,12 +65,12 @@ export const Anatomy: Story = { -
+ Plane Open-source project management for issues, sprints, and roadmaps. -
+
diff --git a/packages/propel/src/ui/preview-card/variants.ts b/packages/propel/src/ui/preview-card/variants.ts index fbb905b1..f7611c19 100644 --- a/packages/propel/src/ui/preview-card/variants.ts +++ b/packages/propel/src/ui/preview-card/variants.ts @@ -5,23 +5,33 @@ import { cva, cx } from "class-variance-authority"; // (variant/tone/magnitude) to expose. The cva pairings below hold the static // chrome for every styled part in one place, with no `className` at the boundary. // -// The anatomy also includes three propel-extended parts — Image, Title, and +// The anatomy also includes four propel-extended parts — Body, Image, Title, and // Description — that have no Base UI counterpart. Their styles below encode the -// "always the same" items from the design spec: image overflow/cover-fit and the -// title/description font hierarchy. +// "always the same" items from the design spec: the text content area's layout + +// padding (Body), the image overflow/cover-fit (Image), and the title/description +// font hierarchy (Title/Description). export const previewCardPositionerVariants = cva("z-50 outline-none"); -// Like the shared anchored popup, but with `p-3` and a `max-w-80` to comfortably -// hold the richer link-preview content. +// The card surface. Padding lives on `PreviewCardBody` (per the spec, padding is +// "within the text content area") rather than here, so the popup is the bare +// surface: a `PreviewCardImage` clips itself (it carries its own overflow-hidden + +// rounding) and the body supplies its own padding. The popup keeps no inner padding +// so the arrow is never clipped. `max-w-80` keeps the link-preview content to a +// comfortable width. export const previewCardPopupVariants = cva( cx( - "max-w-80 origin-(--transform-origin) rounded-lg border-sm border-subtle bg-layer-1 p-3 shadow-overlay-100 outline-none", + "max-w-80 origin-(--transform-origin) rounded-lg border-sm border-subtle bg-layer-1 shadow-overlay-100 outline-none", "transition-[opacity,transform] duration-150", "data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:scale-95 data-starting-style:opacity-0", ), ); +// Body: the text content area. Per the spec, the content layout (a column with the +// title above the description) and the padding around it are "always the same", so +// they live here rather than in the consumer's composition. +export const previewCardBodyVariants = cva("flex flex-col gap-1 p-3"); + export const previewCardBackdropVariants = cva( cx( "fixed inset-0 bg-backdrop transition-opacity duration-200", @@ -33,7 +43,9 @@ export const previewCardBackdropVariants = cva( export const previewCardArrowVariants = cva("text-layer-1"); // Image: overflow-hidden + object-cover bake in the "always the same" thumbnail -// treatment from the design spec. Width/height are set by the consumer's layout. +// treatment from the design spec (clip + cover-fit); the image rounds itself so it +// can sit inside the popup without the popup needing overflow-hidden (which would +// clip the arrow). Width/height are set by the consumer's layout. export const previewCardImageVariants = cva("overflow-hidden rounded-md object-cover"); // Title: matches the heading hierarchy in the Figma spec. From f4ca5a22037890845e38e9dcb6240b55d652683b Mon Sep 17 00:00:00 2001 From: Aaron Reisman Date: Tue, 23 Jun 2026 17:35:39 +0700 Subject: [PATCH 3/4] Wrap PreviewCardBody props type to 100-col (match repo oxfmt width) --- packages/propel/src/ui/preview-card/preview-card-body.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/propel/src/ui/preview-card/preview-card-body.tsx b/packages/propel/src/ui/preview-card/preview-card-body.tsx index 08a70095..3356de89 100644 --- a/packages/propel/src/ui/preview-card/preview-card-body.tsx +++ b/packages/propel/src/ui/preview-card/preview-card-body.tsx @@ -2,7 +2,10 @@ import type * as React from "react"; import { previewCardBodyVariants } from "./variants"; -export type PreviewCardBodyProps = Omit, "className" | "style">; +export type PreviewCardBodyProps = Omit< + React.ComponentPropsWithoutRef<"div">, + "className" | "style" +>; /** * The text content area of the card — typically holds a `PreviewCardTitle` and From 1637cf1b37a86f4b08c93b12302a9fc76e337e06 Mon Sep 17 00:00:00 2001 From: Aaron Reisman Date: Tue, 23 Jun 2026 23:15:19 +0700 Subject: [PATCH 4/4] Reformat under pinned toolchain --- packages/propel/src/ui/preview-card/preview-card-body.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/propel/src/ui/preview-card/preview-card-body.tsx b/packages/propel/src/ui/preview-card/preview-card-body.tsx index 3356de89..e64c54c0 100644 --- a/packages/propel/src/ui/preview-card/preview-card-body.tsx +++ b/packages/propel/src/ui/preview-card/preview-card-body.tsx @@ -9,9 +9,9 @@ export type PreviewCardBodyProps = Omit< /** * The text content area of the card — typically holds a `PreviewCardTitle` and - * `PreviewCardDescription` stacked in a column. Owns the padding so a full-bleed - * `PreviewCardImage` can sit edge-to-edge above it; both the column layout and the - * padding are "always the same" per the design spec. + * `PreviewCardDescription` stacked in a column. Owns the padding so a full-bleed `PreviewCardImage` + * can sit edge-to-edge above it; both the column layout and the padding are "always the same" per + * the design spec. */ export function PreviewCardBody(props: PreviewCardBodyProps) { return
;