Fix case-study share chrome, hero lockup width, and copy confirmation in the share dialog.

Marketing pages omitted create-flow nav copy, so Share rendered as a translation key; the case-study banner squeezed hero text to the thumbnail width; Copy link had no hover or copied state.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
adilallo
2026-09-09 22:00:19 -06:00
co-authored by Cursor
parent ddf7b0ef7a
commit ce581a42c8
17 changed files with 225 additions and 52 deletions
@@ -96,7 +96,7 @@ export function useCompletedRuleShareExport({
}: {
setActionBanner: (_: CompletedFlowActionBanner | null) => void;
}): {
copyPublishedRuleLink: () => Promise<void>;
copyPublishedRuleLink: () => Promise<boolean>;
mailtoPublishedRule: () => void;
sharePublishedRuleViaSignal: () => Promise<void>;
sharePublishedRuleViaSlack: () => Promise<void>;
@@ -137,32 +137,34 @@ export function useCompletedRuleShareExport({
url: string,
banner?: () => void,
options?: { suppressFailureWhenDocumentNotFocused?: boolean },
) => {
): Promise<boolean> => {
try {
await navigator.clipboard.writeText(url);
(banner ?? bannerCopied)();
return true;
} catch {
if (
options?.suppressFailureWhenDocumentNotFocused === true &&
typeof window !== "undefined" &&
shouldSkipShareClipboardFallback(window)
) {
return;
return true;
}
bannerCopyFailed();
return false;
}
},
[bannerCopied, bannerCopyFailed],
);
const copyPublishedRuleLink = useCallback(async () => {
if (typeof window === "undefined") return;
if (typeof window === "undefined") return false;
const ctx = resolvePublishedRuleShareContext(window);
if (!ctx) {
bannerNoRule();
return;
return false;
}
await copyUrlToClipboard(ctx.url);
return copyUrlToClipboard(ctx.url);
}, [bannerNoRule, copyUrlToClipboard]);
const mailtoPublishedRule = useCallback(() => {
@@ -34,7 +34,7 @@ export function useUseCaseCompletedRuleActions({
const [duplicateBusy, setDuplicateBusy] = useState(false);
const copyPageLink = useCallback(async () => {
if (typeof window === "undefined") return;
if (typeof window === "undefined") return false;
try {
await navigator.clipboard.writeText(window.location.href);
setActionBanner({
@@ -43,6 +43,7 @@ export function useUseCaseCompletedRuleActions({
title: t("shareLinkCopiedTitle"),
description: t("shareLinkCopiedDescription"),
});
return true;
} catch {
setActionBanner({
key: "shareCopyFailed",
@@ -50,6 +51,7 @@ export function useUseCaseCompletedRuleActions({
title: t("shareCopyFailedTitle"),
description: t("shareCopyFailedDescription"),
});
return false;
}
}, [setActionBanner, t]);
@@ -16,7 +16,7 @@ import type { ContentContainerProps } from "./ContentContainer.types";
const ContentContainerContainer = memo<ContentContainerProps>(
({
post,
width = "200px",
width: widthProp,
size: sizeProp = "responsive",
tone: toneProp = "inverse",
leadingImageSrc,
@@ -26,6 +26,8 @@ const ContentContainerContainer = memo<ContentContainerProps>(
const size = sizeProp;
const tone = toneProp;
const showLeadingImage = showLeadingImageProp;
const width =
widthProp ?? (size === "useCase" ? "100%" : "200px");
const onLight = tone === "onLight";
const titleColor = onLight
? "text-[var(--color-content-default-primary)] group-hover:text-[var(--color-content-default-brand-primary)]"
@@ -57,39 +59,55 @@ const ContentContainerContainer = memo<ContentContainerProps>(
},
);
const isUseCase = size === "useCase";
const containerClasses =
size === "xs"
? "relative z-20 flex h-full flex-col gap-[var(--measures-spacing-012)]"
: isUseCase
? "relative z-20 flex h-full w-full min-w-0 flex-col gap-[var(--measures-spacing-024)]"
: "relative z-20 h-full flex flex-col gap-[var(--measures-spacing-012)] sm:gap-[var(--measures-spacing-016)] md:gap-[18px] lg:gap-[var(--measures-spacing-024)]";
const contentGapClasses =
size === "xs"
? "flex flex-col gap-[var(--measures-spacing-008)]"
: isUseCase
? "flex w-full min-w-0 flex-col gap-[var(--measures-spacing-016)]"
: "flex flex-col gap-[var(--measures-spacing-008)] sm:gap-[var(--measures-spacing-012)] md:gap-[var(--measures-spacing-008)] lg:gap-[var(--measures-spacing-016)] xl:gap-[var(--measures-spacing-004)]";
const textGapClasses =
size === "xs"
? "flex flex-col gap-[var(--measures-spacing-004)]"
: isUseCase
? "flex w-full min-w-0 flex-col gap-[var(--measures-spacing-004)]"
: "flex flex-col gap-[var(--measures-spacing-004)] md:gap-[var(--measures-spacing-002)] lg:gap-[var(--measures-spacing-004)]";
const titleClasses =
size === "xs"
? `font-bricolage-grotesque font-medium text-[18px] leading-[22px] transition-colors ${titleColor}`
: isUseCase
? `w-full font-bricolage-grotesque font-medium text-[32px] leading-[110%] lg:text-medium-display transition-colors ${titleColor}`
: `font-bricolage-grotesque font-medium text-xx-small-display sm:text-x-small-display md:text-[32px] md:leading-[110%] lg:text-medium-display xl:text-x-large-display transition-colors ${titleColor}`;
const descriptionClasses =
size === "xs"
? `text-x-small-paragraph max-w-md ${bodyColor}`
: isUseCase
? `w-full text-small-paragraph lg:text-large-paragraph ${bodyColor}`
: `text-x-small-paragraph sm:text-small-paragraph md:text-small-paragraph lg:text-large-paragraph xl:text-x-large-paragraph ${bodyColor}`;
const authorClasses =
size === "xs"
? `overflow-hidden text-ellipsis whitespace-nowrap text-xx-small-paragraph ${bodyColor}`
: isUseCase
? `overflow-hidden text-ellipsis whitespace-nowrap text-x-small-paragraph lg:text-small-paragraph ${bodyColor}`
: `text-xx-small-paragraph md:text-x-small-paragraph lg:text-small-paragraph xl:text-large-paragraph ${bodyColor}`;
const dateClasses =
size === "xs"
? `overflow-hidden text-ellipsis whitespace-nowrap text-xx-small-paragraph ${bodyColor}`
: isUseCase
? `overflow-hidden text-ellipsis whitespace-nowrap text-x-small-paragraph lg:text-small-paragraph ${bodyColor}`
: `text-xx-small-paragraph md:text-x-small-paragraph lg:text-small-paragraph xl:text-large-paragraph ${bodyColor}`;
return (
@@ -1,6 +1,5 @@
import type { BlogPost } from "../../../../lib/content";
export type ContentContainerSizeValue = "xs" | "responsive";
import type { ContentContainerSizeValue } from "../../../../lib/propNormalization";
/** `inverse` — blog hero on imagery; `onLight` — marketing pages on default surface. */
export type ContentContainerToneValue = "inverse" | "onLight";
@@ -9,7 +8,8 @@ export interface ContentContainerProps {
post: BlogPost;
width?: string;
/**
* Content container size.
* `xs` — catalog thumbnail. `responsive` — article banner (scales through xl).
* `useCase` — case-study ContentBanner lockup (Figma 365px / medium-display).
*/
size?: ContentContainerSizeValue;
/**
@@ -27,7 +27,7 @@ export interface ContentContainerProps {
export interface ContentContainerViewProps {
post: BlogPost;
width: string;
size: "xs" | "responsive";
size: ContentContainerSizeValue;
tone: ContentContainerToneValue;
iconImage: string;
iconAlt: string;
@@ -3,8 +3,9 @@
/**
* Figma: Community Rule System — "Modal / Share"
* https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22073-30884
* Copy-link hover / Copied! : 23769:27494 / 23769:27443
*/
import { memo, useId, useRef } from "react";
import { memo, useCallback, useEffect, useId, useRef, useState } from "react";
import { useTranslation } from "../../../contexts/MessagesContext";
import { useCreateModalA11y } from "../Create/useCreateModalA11y";
import { ShareView } from "./Share.view";
@@ -15,9 +16,27 @@ const ShareContainer = memo<ShareProps>((props) => {
const overlayRef = useRef<HTMLDivElement>(null);
const titleId = useId();
const t = useTranslation("modals.share");
const [linkCopied, setLinkCopied] = useState(false);
useCreateModalA11y(props.isOpen, props.onClose, dialogRef);
useEffect(() => {
if (!props.isOpen) {
setLinkCopied(false);
}
}, [props.isOpen]);
const onCopyLinkClick = useCallback(async () => {
try {
const result = await props.onCopyLink();
if (result !== false) {
setLinkCopied(true);
}
} catch {
setLinkCopied(false);
}
}, [props.onCopyLink]);
return (
<ShareView
{...props}
@@ -27,6 +46,10 @@ const ShareContainer = memo<ShareProps>((props) => {
title={t("title")}
description={t("description")}
copyLinkLabel={t("copyLink")}
copiedLabel={t("copied")}
copiedLive={t("copiedLive")}
linkCopied={linkCopied}
onCopyLinkClick={onCopyLinkClick}
signalLabel={t("signal")}
slackLabel={t("slack")}
discordLabel={t("discord")}
+7 -1
View File
@@ -4,7 +4,8 @@ import type { CreateModalBackdropVariant } from "../Create/CreateModalFrame.view
export type ShareProps = {
isOpen: boolean;
onClose: () => void;
onCopyLink: () => void | Promise<void>;
/** Return `false` when the clipboard write did not succeed. */
onCopyLink: () => void | boolean | Promise<void | boolean>;
onEmailShare: () => void;
onSignalShare: () => void | Promise<void>;
onSlackShare: () => void | Promise<void>;
@@ -20,6 +21,10 @@ export type ShareViewProps = ShareProps & {
title: string;
description: string;
copyLinkLabel: string;
copiedLabel: string;
copiedLive: string;
linkCopied: boolean;
onCopyLinkClick: () => void | Promise<void>;
signalLabel: string;
slackLabel: string;
discordLabel: string;
@@ -34,4 +39,5 @@ export type ShareChannelTileProps = {
onClick: () => void | Promise<void>;
circleClassName: string;
icon: ReactNode;
copied?: boolean;
};
+43 -13
View File
@@ -34,19 +34,32 @@ function ShareAssetIcon(props: {
);
}
function ShareChannelTile({ label, onClick, circleClassName, icon }: ShareChannelTileProps) {
function ShareChannelTile({
label,
onClick,
circleClassName,
icon,
copied = false,
}: ShareChannelTileProps) {
return (
<button
type="button"
onClick={() => void onClick()}
className="flex w-16 shrink-0 flex-col items-center gap-2 rounded-md focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-invert-primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface-default-primary)]"
aria-pressed={copied || undefined}
className="group flex w-16 shrink-0 flex-col items-center gap-2 rounded-md focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-invert-primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface-default-primary)]"
>
<div
className={`flex h-[60px] w-[60px] items-center justify-center rounded-full border border-solid ${circleClassName}`}
className={`flex h-[60px] w-[60px] items-center justify-center rounded-full border border-solid transition-[transform,background-color,border-color,filter] duration-150 ease-out group-active:scale-90 ${circleClassName}`}
>
{icon}
</div>
<span className="max-w-[4.5rem] text-center text-x-small-label text-[var(--color-content-default-tertiary)]">
<span
className={`max-w-[4.5rem] text-center text-x-small-label ${
copied
? "text-[var(--color-border-default-positive-primary)]"
: "text-[var(--color-content-default-tertiary)]"
}`}
>
{label}
</span>
</button>
@@ -56,7 +69,6 @@ function ShareChannelTile({ label, onClick, circleClassName, icon }: ShareChanne
export const ShareView = memo(function ShareView({
isOpen,
onClose,
onCopyLink,
onEmailShare,
onSignalShare,
onSlackShare,
@@ -69,6 +81,10 @@ export const ShareView = memo(function ShareView({
title,
description,
copyLinkLabel,
copiedLabel,
copiedLive,
linkCopied,
onCopyLinkClick,
signalLabel,
slackLabel,
discordLabel,
@@ -109,36 +125,50 @@ export const ShareView = memo(function ShareView({
{/* Channel circle hexes are third-party brand colors (copy/link, Signal, Slack, Discord), not DS tokens. */}
<div className="flex flex-wrap gap-4">
<ShareChannelTile
label={copyLinkLabel}
onClick={onCopyLink}
circleClassName="border-[#444444] bg-[#333333]"
icon={<ShareAssetIcon name="link" width={24} height={24} />}
label={linkCopied ? copiedLabel : copyLinkLabel}
onClick={onCopyLinkClick}
copied={linkCopied}
circleClassName={
linkCopied
? "border-2 border-[#444444] bg-[#333333]"
: "border-[#444444] bg-[#333333] group-hover:border-2 group-hover:border-[var(--color-border-default-positive-primary)] group-hover:bg-[#444444]"
}
icon={
<ShareAssetIcon
name={linkCopied ? "check" : "link"}
width={24}
height={24}
/>
}
/>
<ShareChannelTile
label={signalLabel}
onClick={onSignalShare}
circleClassName="border-[#3a76f0] bg-[#3a76f0]"
circleClassName="border-[#3a76f0] bg-[#3a76f0] group-hover:brightness-110"
icon={<ShareAssetIcon name="signal" width={26} height={26} />}
/>
<ShareChannelTile
label={slackLabel}
onClick={onSlackShare}
circleClassName="border-[#4a154b] bg-[#4a154b]"
circleClassName="border-[#4a154b] bg-[#4a154b] group-hover:brightness-110"
icon={<ShareAssetIcon name="slack" width={26} height={26} />}
/>
<ShareChannelTile
label={discordLabel}
onClick={onDiscordShare}
circleClassName="border-[#5865f2] bg-[#5865f2]"
circleClassName="border-[#5865f2] bg-[#5865f2] group-hover:brightness-110"
icon={<ShareAssetIcon name="discord" width={30} height={30} />}
/>
<ShareChannelTile
label={emailLabel}
onClick={onEmailShare}
circleClassName="border-[var(--color-surface-default-brand-kiwi)] bg-[var(--color-surface-default-brand-kiwi)]"
circleClassName="border-[var(--color-surface-default-brand-kiwi)] bg-[var(--color-surface-default-brand-kiwi)] group-hover:brightness-110"
icon={<ShareAssetIcon name="mail" width={24} height={24} />}
/>
</div>
<p className="sr-only" aria-live="polite">
{linkCopied ? copiedLive : ""}
</p>
</div>
<ModalFooter
@@ -193,7 +193,7 @@ function ContentBannerUseCaseView({
>
<ContentContainer
post={post}
size="responsive"
size="useCase"
tone={contentTone}
showLeadingImage={false}
leadingImageSrc={leadingImageSrc}
+7 -1
View File
@@ -86,7 +86,13 @@ export function partnerLogoPath(slug: string): string {
}
/** Share modal glyphs in `public/assets/share/`. */
export type ShareIconName = "discord" | "link" | "mail" | "signal" | "slack";
export type ShareIconName =
| "check"
| "discord"
| "link"
| "mail"
| "signal"
| "slack";
export function shareIconPath(name: ShareIconName): string {
return `assets/share/${name}.svg`;
+5 -1
View File
@@ -119,7 +119,11 @@ export type HeaderLockupPaletteValue =
export const TEXT_INPUT_SIZE_OPTIONS = ["small", "medium"] as const;
export type TextInputSizeValue = (typeof TEXT_INPUT_SIZE_OPTIONS)[number];
export const CONTENT_CONTAINER_SIZE_OPTIONS = ["xs", "responsive"] as const;
export const CONTENT_CONTAINER_SIZE_OPTIONS = [
"xs",
"responsive",
"useCase",
] as const;
export type ContentContainerSizeValue =
(typeof CONTENT_CONTAINER_SIZE_OPTIONS)[number];
+14 -13
View File
@@ -1,17 +1,15 @@
/**
* Marketing-scoped message bundle: every namespace from `./index` EXCEPT the
* `create.*` subtree. The `create` namespace is ~41 KB gzipped — the largest
* single contributor to per-route HTML size — and is only used inside
* `(app)/create/*`. Excluding it from the `(marketing)` group's
* `MessagesProvider` removes the embed from every marketing HTML response.
* bulk of the `create.*` subtree. That subtree is ~41 KB gzipped — the
* largest single contributor to per-route HTML size — and wizard copy is
* only used inside `(app)/create/*`.
*
* The type stays compatible with `typeof import("./index").default` because
* we satisfy the same key shape (modulo `create`); marketing client
* components only read keys that exist here. If a future change reaches into
* `messages.create.*` from a marketing surface, `getTranslation` will return
* the dotted key as the fallback — visible immediately at runtime.
* Case-study completed-rule chrome reuses `CreateFlowTopNav`, so this bundle
* includes `create.topNav` only. Other `create.*` keys still fall back to the
* dotted path via `getTranslation` if a marketing surface reaches them.
*
* Keep this in sync with new entries added to `./index` (excluding `create/`).
* Keep this in sync with new entries added to `./index` (excluding wizard
* `create/` files other than `create/topNav.json`).
* See `docs/perf/next16-eval.md` for measurement context.
*/
import common from "./common.json";
@@ -53,6 +51,7 @@ import metadata from "./metadata.json";
import modalsShare from "./modals/share.json";
import modalsPopoverExport from "./modals/popoverExport.json";
import modalsAskOrganizerInquiry from "./modals/askOrganizerInquiry.json";
import createTopNav from "./create/topNav.json";
import type messages from "./index";
const marketingMessages = {
@@ -99,11 +98,13 @@ const marketingMessages = {
popoverExport: modalsPopoverExport,
askOrganizerInquiry: modalsAskOrganizerInquiry,
},
create: {
topNav: createTopNav,
},
};
// Cast to the full shape so it satisfies `typeof import("./index").default`
// at the MessagesProvider boundary. Reads of `messages.create.*` from a
// marketing surface are a code smell and will return the dotted key (the
// runtime `getTranslation` fallback) — visible immediately.
// at the MessagesProvider boundary. Reads of wizard `create.*` keys other
// than `create.topNav` still return the dotted key at runtime.
export default marketingMessages as typeof messages;
+2
View File
@@ -2,6 +2,8 @@
"title": "Share this CommunityRule",
"description": "Anyone with the link can view this rule.",
"copyLink": "Copy link",
"copied": "Copied!",
"copiedLive": "Link copied to clipboard",
"signal": "Signal",
"slack": "Slack",
"discord": "Discord",
+3
View File
@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20 6L9 17L4 12" stroke="#45EA97" stroke-width="2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 188 B

+4 -1
View File
@@ -157,11 +157,14 @@ describe("ContentBanner", () => {
const title = screen.getByRole("heading", { name: "Test Article" });
expect(title).toBeInTheDocument();
expect(title).toHaveClass("sm:text-x-small-display", "md:text-[32px]");
expect(title).toHaveClass("text-[32px]", "lg:text-medium-display");
expect(title).not.toHaveClass("xl:text-x-large-display");
expect(screen.getByText("Sample Operating Manual")).toBeInTheDocument();
const copyColumn = container.querySelector('[data-node-id="19189:9171"]');
expect(copyColumn).toHaveClass("lg:max-w-[365px]");
expect(copyColumn).not.toHaveClass("max-w-[365px]");
const copyLockup = title.closest("div.relative.z-20");
expect(copyLockup).toHaveStyle({ width: "100%" });
const bannerRow = container.querySelector(
'[data-figma-node="22015:42621"]',
);
+37
View File
@@ -70,6 +70,43 @@ describe("Share modal", () => {
expect(onClose).toHaveBeenCalledTimes(1);
});
it("confirms Copy link with Copied! when the handler succeeds", async () => {
const user = userEvent.setup();
const onCopyLink = vi.fn().mockResolvedValue(true);
render(
<Share
isOpen={true}
onClose={vi.fn()}
{...noopHandlers}
onCopyLink={onCopyLink}
/>,
);
await user.click(screen.getByRole("button", { name: "Copy link" }));
expect(onCopyLink).toHaveBeenCalledTimes(1);
expect(screen.getByRole("button", { name: "Copied!" })).toHaveAttribute(
"aria-pressed",
"true",
);
expect(screen.getByText("Link copied to clipboard")).toBeInTheDocument();
});
it("keeps Copy link when the handler reports failure", async () => {
const user = userEvent.setup();
render(
<Share
isOpen={true}
onClose={vi.fn()}
{...noopHandlers}
onCopyLink={vi.fn().mockResolvedValue(false)}
/>,
);
await user.click(screen.getByRole("button", { name: "Copy link" }));
expect(screen.getByRole("button", { name: "Copy link" })).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "Copied!" }),
).not.toBeInTheDocument();
});
it("calls onClose when header overflow (more) is activated, matching modal chrome parity", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
+26 -1
View File
@@ -1,9 +1,12 @@
import { describe, test, expect, vi } from "vitest";
import { screen } from "@testing-library/react";
import { screen, render as rtlRender } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderWithProviders as render } from "../utils/test-utils";
import { MessagesProvider } from "../../app/contexts/MessagesContext";
import UseCaseCompletedRulePage from "../../app/(marketing-case-study)/use-cases/[slug]/rule/page";
import messages from "../../messages/en/index";
import marketingMessages from "../../messages/en/marketing";
import { getTranslation } from "../../lib/i18n/getTranslation";
import { USE_CASE_DETAIL_SLUGS } from "../../lib/useCaseSyntheticPost";
const mockPush = vi.fn();
@@ -83,9 +86,31 @@ describe("UseCaseCompletedRulePage", () => {
name: messages.pages.useCasesCompletedRule.topNav.duplicateAriaLabel,
}),
).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Share" })).toBeInTheDocument();
},
);
test("marketing message bundle resolves Share (not the dotted create.topNav key)", () => {
expect(getTranslation(marketingMessages, "create.topNav.share")).toBe(
"Share",
);
});
test("Share button reads Share under the marketing case-study bundle", async () => {
rtlRender(
<MessagesProvider messages={marketingMessages}>
{await UseCaseCompletedRulePage({
params: Promise.resolve({ slug: "mutual-aid-colorado" }),
})}
</MessagesProvider>,
);
expect(screen.getByRole("button", { name: "Share" })).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "create.topNav.share" }),
).not.toBeInTheDocument();
});
test("Duplicate opens login when signed out", async () => {
const user = userEvent.setup();
mockOpenLogin.mockClear();
+11
View File
@@ -111,6 +111,17 @@ describe("ContentContainer", () => {
expect(container).toHaveStyle("width: 200px");
});
it("applies full width and case-study type scale for useCase size", () => {
render(<ContentContainer post={mockPost} size="useCase" />);
const container = document.querySelector("div[class*='relative z-20']");
expect(container).toHaveStyle("width: 100%");
const title = screen.getByText("Test Article Title");
expect(title).toHaveClass("text-[32px]", "lg:text-medium-display");
expect(title).not.toHaveClass("xl:text-x-large-display");
});
it("has proper spacing between icon and text", () => {
render(<ContentContainer post={mockPost} />);