From d64a60f2aa27101e1252bcb81f1a9d1f2222e93a Mon Sep 17 00:00:00 2001 From: brandonmcconnell Date: Thu, 13 Aug 2026 19:50:00 -0700 Subject: [PATCH 1/7] feat: add Mention component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a new inline `Mention` component that renders a page or user reference as a small icon + label pill — similar to @mentions in Mintlify and Notion. - `path` prop → page mention, renders as linking to the page - `user` prop → user mention, renders as - `icon` accepts an FA/Lucide icon name, an image URL (rendered as circular avatar), or any React node - `color` variants: neutral, info, success, warning, feature, error - Full dark mode support via CSS custom properties - Zero runtime JS — pure Tailwind styling - Storybook stories covering all variants and inline prose usage Closes ENG-10574 Co-authored-by: Cursor --- packages/components/src/components/index.ts | 1 + .../src/components/mention/index.ts | 2 + .../components/mention/mention.stories.tsx | 300 ++++++++++++++++++ .../src/components/mention/mention.tsx | 160 ++++++++++ .../components/src/constants/selectors.ts | 1 + 5 files changed, 464 insertions(+) create mode 100644 packages/components/src/components/mention/index.ts create mode 100644 packages/components/src/components/mention/mention.stories.tsx create mode 100644 packages/components/src/components/mention/mention.tsx diff --git a/packages/components/src/components/index.ts b/packages/components/src/components/index.ts index 755742f6..7777f95e 100644 --- a/packages/components/src/components/index.ts +++ b/packages/components/src/components/index.ts @@ -9,6 +9,7 @@ export * from "./columns"; export * from "./expandable"; export * from "./frame"; export * from "./icon"; +export * from "./mention"; export * from "./mermaid"; export * from "./panel"; export * from "./property"; diff --git a/packages/components/src/components/mention/index.ts b/packages/components/src/components/mention/index.ts new file mode 100644 index 00000000..8eb10f97 --- /dev/null +++ b/packages/components/src/components/mention/index.ts @@ -0,0 +1,2 @@ +export type { MentionColor, MentionProps } from "./mention"; +export { MENTION_COLORS, Mention, mentionColorVariants } from "./mention"; diff --git a/packages/components/src/components/mention/mention.stories.tsx b/packages/components/src/components/mention/mention.stories.tsx new file mode 100644 index 00000000..7d802031 --- /dev/null +++ b/packages/components/src/components/mention/mention.stories.tsx @@ -0,0 +1,300 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Mention } from "./mention"; + +const meta: Meta = { + title: "Components/Mention", + component: Mention, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + argTypes: { + color: { + control: "select", + options: ["neutral", "info", "success", "warning", "feature", "error"], + }, + iconType: { + control: "select", + options: [ + "regular", + "solid", + "light", + "duotone", + "thin", + "brands", + "sharp-solid", + ], + }, + iconLibrary: { + control: "select", + options: ["fontawesome", "lucide"], + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + children: "Getting Started", + path: "/getting-started", + color: "neutral", + }, +}; + +// ─── Page mentions ───────────────────────────────────────────────────────── + +export const PageMention: Story = { + name: "Page mention (default icon)", + render: () => ( +
+ Getting Started + API Reference + Changelog +
+ ), +}; + +export const PageMentionCustomIcon: Story = { + name: "Page mention (custom icon)", + render: () => ( +
+ + Travel + + + Accommodation & Meals + + + Sustainability + + + Warnings + +
+ ), +}; + +// ─── User mentions ────────────────────────────────────────────────────────── + +export const UserMention: Story = { + name: "User mention (default icon)", + render: () => ( +
+ + James Baduor + + Alex Chen + + Sara Kim + +
+ ), +}; + +export const UserMentionWithAvatar: Story = { + name: "User mention (avatar URL)", + render: () => ( +
+ + James Baduor + + + Alex Chen + +
+ ), +}; + +// ─── Color variants ───────────────────────────────────────────────────────── + +export const Colors: Story = { + render: () => ( +
+ + Neutral + + + Info + + + Success + + + Warning + + + Feature + + + Error + +
+ ), +}; + +export const ColorsWithUser: Story = { + name: "Colors (user mentions)", + render: () => ( +
+ + Neutral + + + Info + + + Success + + + Warning + + + Feature + + + Error + +
+ ), +}; + +// ─── In-context usage ─────────────────────────────────────────────────────── + +export const InlineInProse: Story = { + name: "Inline in prose (checklist context)", + render: () => ( +
+ + + + +
+ ), +}; + +// ─── Icon library ─────────────────────────────────────────────────────────── + +export const LucideIcons: Story = { + name: "Lucide icon library", + render: () => ( +
+ + Travel + + + Eco + + + New + +
+ ), +}; + +// ─── All badge variants (design reference) ────────────────────────────────── + +export const DesignReference: Story = { + name: "Design reference — all variants", + render: () => ( +
+
+

+ Page mentions +

+
+ + Page + + + Page + + + Page + + + Page + + + Page + + + Page + +
+
+
+

+ Person mentions +

+
+ + Person + + + Person + + + Person + + + Person + + + Person + + + Person + +
+
+
+ ), +}; diff --git a/packages/components/src/components/mention/mention.tsx b/packages/components/src/components/mention/mention.tsx new file mode 100644 index 00000000..5e29c1be --- /dev/null +++ b/packages/components/src/components/mention/mention.tsx @@ -0,0 +1,160 @@ +import type React from "react"; +import { Icon } from "@/components/icon"; +import { cn } from "@/utils/cn"; +import type { IconLibrary, IconType } from "@/utils/icon-utils"; + +const MENTION_COLORS = [ + "neutral", + "info", + "success", + "warning", + "feature", + "error", +] as const; + +type MentionColor = (typeof MENTION_COLORS)[number]; + +const colorVariants: Record = { + neutral: + "[--mention-bg:#F5F5F4] dark:[--mention-bg:#292524] [--mention-text:#57534E] dark:[--mention-text:#A8A29E]", + info: "[--mention-bg:#EFF6FF] dark:[--mention-bg:#172554] [--mention-text:#1D4ED8] dark:[--mention-text:#93C5FD]", + success: + "[--mention-bg:#F0FDF4] dark:[--mention-bg:#052E16] [--mention-text:#15803D] dark:[--mention-text:#86EFAC]", + warning: + "[--mention-bg:#FFF7ED] dark:[--mention-bg:#431407] [--mention-text:#C2410C] dark:[--mention-text:#FDBA74]", + feature: + "[--mention-bg:#FAF5FF] dark:[--mention-bg:#3B0764] [--mention-text:#7E22CE] dark:[--mention-text:#D8B4FE]", + error: + "[--mention-bg:#FEF2F2] dark:[--mention-bg:#450A0A] [--mention-text:#B91C1C] dark:[--mention-text:#FCA5A5]", +}; + +type MentionProps = { + children: React.ReactNode; + /** + * Page path for a page mention. When provided, the component renders as an + * anchor tag linking to the page and displays the page icon by default. + */ + path?: string; + /** + * Username or identifier for a user mention. When provided, the component + * displays the user icon by default. + */ + user?: string; + /** + * Optional icon override. Accepts: + * - A FontAwesome or Lucide icon name string (e.g. `"plane"`, `"bed"`) + * - An image URL string, rendered as a circular avatar + * - Any React node for fully custom icon content + */ + icon?: React.ReactNode | string; + /** Icon type for FontAwesome icons. */ + iconType?: IconType; + /** Icon library to use. Defaults to `"fontawesome"`. */ + iconLibrary?: IconLibrary; + /** Color variant. Defaults to `"neutral"`. */ + color?: MentionColor; + className?: string; +}; + +const DEFAULT_PAGE_ICON = "file"; +const DEFAULT_USER_ICON = "circle-user"; + +const isUrl = (s: string) => + s.startsWith("http://") || + s.startsWith("https://") || + s.startsWith("/") || + s.startsWith("data:"); + +const ICON_NAME_REGEX = /^[\w-]+$/; +const isIconName = (s: string) => ICON_NAME_REGEX.test(s); + +const Mention = ({ + children, + path, + user, + icon, + iconType, + iconLibrary = "fontawesome", + color = "neutral", + className, +}: MentionProps) => { + const isUser = !!user; + const defaultIconName = isUser ? DEFAULT_USER_ICON : DEFAULT_PAGE_ICON; + const resolvedIcon = icon ?? defaultIconName; + + const renderIcon = () => { + if (typeof resolvedIcon !== "string") { + return ( + + ); + } + + if (isUrl(resolvedIcon)) { + return ( + + ); + } + + if (isIconName(resolvedIcon)) { + return ( + + ); + } + + // Emoji or other non-icon text + return ( + + ); + }; + + const isLink = !!path && !user; + + const sharedClassName = cn( + "mention", + "inline-flex items-center gap-1 rounded-md px-1.5 py-0.5", + "whitespace-nowrap font-medium text-xs leading-none", + "bg-(--mention-bg) text-(--mention-text)", + '[&_[data-component-part="icon-svg"]]:bg-(--mention-text)', + '[&_[data-component-part="icon-svg"]]:size-3', + colorVariants[color], + isLink && "cursor-pointer no-underline transition-opacity hover:opacity-80", + className + ); + + if (isLink) { + return ( +
+ {renderIcon()} + {children} + + ); + } + + return ( + + {renderIcon()} + {children} + + ); +}; + +export { Mention, MENTION_COLORS, colorVariants as mentionColorVariants }; +export type { MentionProps, MentionColor }; diff --git a/packages/components/src/constants/selectors.ts b/packages/components/src/constants/selectors.ts index 49989598..7122946a 100644 --- a/packages/components/src/constants/selectors.ts +++ b/packages/components/src/constants/selectors.ts @@ -11,6 +11,7 @@ const _classes = { Field: "field", Frame: "frame", Icon: "icon", + Mention: "mention", Mermaid: "mermaid", Step: "step", Steps: "steps", From 9b8bda4fbe727e96dcb746377fd3f188e93f661c Mon Sep 17 00:00:00 2001 From: brandonmcconnell Date: Thu, 13 Aug 2026 20:02:31 -0700 Subject: [PATCH 2/7] chore: improve Mention Storybook playground controls - Add descriptions to all argTypes (children, path, user, icon, color, iconType, iconLibrary) so the autodocs table is self-documenting - Expose all iconType variants as select options - Guard against empty-string icon prop falling through to emoji branch - Add inline comment in Default story explaining path vs user and icon formats Co-authored-by: Cursor --- .../components/mention/mention.stories.tsx | 34 +++++++++++++++++++ .../src/components/mention/mention.tsx | 3 +- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/packages/components/src/components/mention/mention.stories.tsx b/packages/components/src/components/mention/mention.stories.tsx index 7d802031..87f0a2c8 100644 --- a/packages/components/src/components/mention/mention.stories.tsx +++ b/packages/components/src/components/mention/mention.stories.tsx @@ -9,9 +9,29 @@ const meta: Meta = { }, tags: ["autodocs"], argTypes: { + children: { + control: "text", + description: "The display label shown inside the mention pill.", + }, + path: { + control: "text", + description: + "Page path for a **page mention**. When set, the component renders as an `` link and shows the page icon by default. Use either `path` or `user`, not both.", + }, + user: { + control: "text", + description: + "Username or identifier for a **user mention**. When set, the component shows the person icon by default. Use either `path` or `user`, not both.", + }, + icon: { + control: "text", + description: + "Optional icon override. Accepts a **FontAwesome or Lucide icon name** (e.g. `plane`, `bed`, `circle-check`), an **image URL** rendered as a circular avatar, or any **React node** for fully custom icon content. Omit to use the default page or person icon.", + }, color: { control: "select", options: ["neutral", "info", "success", "warning", "feature", "error"], + description: "Color variant. Defaults to `neutral`.", }, iconType: { control: "select", @@ -23,11 +43,19 @@ const meta: Meta = { "thin", "brands", "sharp-solid", + "sharp-light", + "sharp-regular", + "sharp-thin", + "sharp-duotone-solid", ], + description: + "FontAwesome icon style. Only applies when `icon` is a string icon name and `iconLibrary` is `fontawesome`. Defaults to `regular`.", }, iconLibrary: { control: "select", options: ["fontawesome", "lucide"], + description: + "Icon library used to resolve the `icon` string. Defaults to `fontawesome`. Pass a React node to `icon` to bypass this entirely.", }, }, }; @@ -35,6 +63,12 @@ const meta: Meta = { export default meta; type Story = StoryObj; +// ─── Interactive playground ───────────────────────────────────────────────── +// Use the Controls panel (below) to experiment with every prop. +// • Set `path` for a page mention, `user` for a user mention (not both). +// • `icon` accepts a FontAwesome name (e.g. "plane"), a Lucide name with +// iconLibrary="lucide", or a full image URL for an avatar. + export const Default: Story = { args: { children: "Getting Started", diff --git a/packages/components/src/components/mention/mention.tsx b/packages/components/src/components/mention/mention.tsx index 5e29c1be..21675475 100644 --- a/packages/components/src/components/mention/mention.tsx +++ b/packages/components/src/components/mention/mention.tsx @@ -80,7 +80,8 @@ const Mention = ({ }: MentionProps) => { const isUser = !!user; const defaultIconName = isUser ? DEFAULT_USER_ICON : DEFAULT_PAGE_ICON; - const resolvedIcon = icon ?? defaultIconName; + // Treat an empty string the same as omitted — fall back to the default icon. + const resolvedIcon = (icon === "" ? undefined : icon) ?? defaultIconName; const renderIcon = () => { if (typeof resolvedIcon !== "string") { From 8a3240700572d184f9847314fea1d9fc4dad741b Mon Sep 17 00:00:00 2001 From: brandonmcconnell Date: Thu, 13 Aug 2026 20:12:45 -0700 Subject: [PATCH 3/7] fix: increase Mention padding and allow label wrapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - py-0.5 → py-1, px-1.5 → px-2 (matches 20px Paper artboard height) - Remove whitespace-nowrap so long labels can wrap naturally - Add LongLabelWrapping story covering narrow container, forced
, and wrapping label inline in prose Co-authored-by: Cursor --- .../components/mention/mention.stories.tsx | 53 +++++++++++++++++++ .../src/components/mention/mention.tsx | 4 +- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/packages/components/src/components/mention/mention.stories.tsx b/packages/components/src/components/mention/mention.stories.tsx index 87f0a2c8..dd0c00ba 100644 --- a/packages/components/src/components/mention/mention.stories.tsx +++ b/packages/components/src/components/mention/mention.stories.tsx @@ -273,6 +273,59 @@ export const LucideIcons: Story = { ), }; +// ─── Wrapping ─────────────────────────────────────────────────────────────── + +export const LongLabelWrapping: Story = { + name: "Long label (wrapping)", + render: () => ( +
+
+

+ Natural wrap inside a narrow container +

+
+ + Accommodation & Meals Planning Guide + +
+
+ +
+

+ Forced line break via <br /> +

+
+ + Q4 Product Roadmap +
+ Planning & Review +
+ + Sustainability +
+ Guidelines +
+
+
+ +
+

+ Inline in prose with wrapping label +

+

+ Please review the{" "} + + Compliance & Legal +
+ Documentation +
{" "} + before proceeding. +

+
+
+ ), +}; + // ─── All badge variants (design reference) ────────────────────────────────── export const DesignReference: Story = { diff --git a/packages/components/src/components/mention/mention.tsx b/packages/components/src/components/mention/mention.tsx index 21675475..e9cb8721 100644 --- a/packages/components/src/components/mention/mention.tsx +++ b/packages/components/src/components/mention/mention.tsx @@ -130,8 +130,8 @@ const Mention = ({ const sharedClassName = cn( "mention", - "inline-flex items-center gap-1 rounded-md px-1.5 py-0.5", - "whitespace-nowrap font-medium text-xs leading-none", + "inline-flex items-center gap-1 rounded-md px-2 py-1", + "font-medium text-xs leading-none", "bg-(--mention-bg) text-(--mention-text)", '[&_[data-component-part="icon-svg"]]:bg-(--mention-text)', '[&_[data-component-part="icon-svg"]]:size-3', From 4b130702507eff1cc3bdd7c8210d479ad91f7056 Mon Sep 17 00:00:00 2001 From: brandonmcconnell Date: Thu, 13 Aug 2026 21:04:26 -0700 Subject: [PATCH 4/7] fix: correct Mention pill sizing to match Paper artboard dimensions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous py-0.5 looked cramped because leading-none was overriding text-xs's natural 16px line height down to 12px (font-size), producing a 16px tall pill instead of the intended 20px. Removing leading-none restores the correct 16px line height. Combined with py-0.5 (2px top + 2px bottom), the total height is exactly 20px — matching the badge artboard dimensions in the Paper design file. Horizontal padding reverted to px-1.5 (6px each side), consistent with the sm Badge variant and the artboard width calculations. Co-authored-by: Cursor --- packages/components/src/components/mention/mention.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/components/src/components/mention/mention.tsx b/packages/components/src/components/mention/mention.tsx index e9cb8721..f1599a60 100644 --- a/packages/components/src/components/mention/mention.tsx +++ b/packages/components/src/components/mention/mention.tsx @@ -130,8 +130,8 @@ const Mention = ({ const sharedClassName = cn( "mention", - "inline-flex items-center gap-1 rounded-md px-2 py-1", - "font-medium text-xs leading-none", + "inline-flex items-center gap-1 rounded-md px-1.5 py-0.5", + "font-medium text-xs", "bg-(--mention-bg) text-(--mention-text)", '[&_[data-component-part="icon-svg"]]:bg-(--mention-text)', '[&_[data-component-part="icon-svg"]]:size-3', From 3fe37ee413106e9555db4d32fb956d60661db4ca Mon Sep 17 00:00:00 2001 From: brandonmcconnell Date: Thu, 13 Aug 2026 21:37:53 -0700 Subject: [PATCH 5/7] fix: asymmetric padding so icon is equally inset on three sides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The icon (12px) centered in the 20px pill creates a 4px gap on each vertical side — the left padding (pl-1 = 4px) now matches that exactly, so the icon appears equidistant from the left edge, top, and bottom, matching the Paper design. The right side keeps pr-2 (8px) to give the label text room to breathe. When no icon is shown (icon={null}) both sides become px-2 — symmetric and matching the right-side value. Co-authored-by: Cursor --- .../src/components/mention/mention.tsx | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/packages/components/src/components/mention/mention.tsx b/packages/components/src/components/mention/mention.tsx index f1599a60..cc6a69b5 100644 --- a/packages/components/src/components/mention/mention.tsx +++ b/packages/components/src/components/mention/mention.tsx @@ -80,10 +80,17 @@ const Mention = ({ }: MentionProps) => { const isUser = !!user; const defaultIconName = isUser ? DEFAULT_USER_ICON : DEFAULT_PAGE_ICON; - // Treat an empty string the same as omitted — fall back to the default icon. - const resolvedIcon = (icon === "" ? undefined : icon) ?? defaultIconName; + // null → explicitly hide the icon. "" → treat as omitted, fall back to default. + const hideIcon = icon === null; + const resolvedIcon = hideIcon + ? null + : ((icon === "" ? undefined : icon) ?? defaultIconName); const renderIcon = () => { + if (resolvedIcon === null) { + return null; + } + if (typeof resolvedIcon !== "string") { return (