Pin the review card description under the sections and tint the card from the uploaded logo.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
adilalloandCursor committed 2026-09-28 15:46:45 -06:00
1 parent 2653ae4602
commit 56adcd59fd
10 files changed
+508 -42

No files matched your search

@@ -0,0 +1,41 @@
"use client";
import { useEffect, useState } from "react";
import {
dominantColorFromImageUrl,
randomPreviewSurfaceColor,
} from "../../../../lib/create/previewSurfaceColor";
/**
* Fill for the review preview card. An uploaded logo contributes its most
* common color; no image uses a random light color. Unset until mount so the
* server and client share the fallback class.
*
* @param imageUrl Uploaded logo URL, or `null` when the user skipped the photo.
* @returns A CSS color, or `undefined` before the client picks one.
*/
export function usePreviewSurfaceColor(
imageUrl: string | null,
): string | undefined {
const [color, setColor] = useState<string | undefined>(undefined);
useEffect(() => {
if (!imageUrl) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- random fill is client-only; first paint stays on the fallback class
setColor(randomPreviewSurfaceColor());
return;
}
let cancelled = false;
void dominantColorFromImageUrl(imageUrl).then((next) => {
if (cancelled) return;
setColor(next ?? randomPreviewSurfaceColor());
});
return () => {
cancelled = true;
};
}, [imageUrl]);
return color;
}
@@ -26,6 +26,7 @@ import {
} from "../../components/FinalReviewChipEditModal";
import { FinalReviewCommunityContextEditModal } from "../../components/FinalReviewCommunityContextEditModal";
import { FinalReviewTitleEditModal } from "../../components/FinalReviewTitleEditModal";
import { usePreviewSurfaceColor } from "../../hooks/usePreviewSurfaceColor";
import { useCreateFlowNavigation } from "../../hooks/useCreateFlowNavigation";
import { createFlowStepForFacetGroup } from "../../utils/facetGroupToCreateFlowStep";
import {
@@ -234,6 +235,13 @@ export function FinalReviewScreen({
const descriptionEmptyHint =
variant === "editPublished" ? t("communityContextEditModal.emptyHint") : undefined;
const avatarUrl =
typeof state.communityAvatarUrl === "string" &&
state.communityAvatarUrl.trim().length > 0
? state.communityAvatarUrl.trim()
: null;
const surfaceColor = usePreviewSurfaceColor(avatarUrl);
return (
<CreateFlowLockupCardStepShell
lockupTitle={
@@ -271,8 +279,10 @@ export function FinalReviewScreen({
}
size={mdUp ? "L" : "M"}
expanded={true}
descriptionAtBottom
backgroundColor="bg-[var(--color-surface-invert-brand-teal)]"
logoUrl={getAssetPath(vectorMarkPath("mutual-aid"))}
surfaceColor={surfaceColor}
logoUrl={avatarUrl ?? getAssetPath(vectorMarkPath("mutual-aid"))}
logoAlt={ruleCardTitle}
categories={finalReviewCategories}
className={CREATE_FLOW_REVIEW_RULE_LAYOUT_CLASS}
@@ -49,6 +49,8 @@ const RuleContainer = memo<RuleProps>(
recommended = false,
templateGridFigmaShell = false,
fluidWidth = false,
descriptionAtBottom = false,
surfaceColor,
}) => {
const size = sizeProp ?? "L";
const t = useTranslation("ruleCard");
@@ -112,6 +114,8 @@ const RuleContainer = memo<RuleProps>(
recommended={recommended}
templateGridFigmaShell={templateGridFigmaShell}
fluidWidth={fluidWidth}
descriptionAtBottom={descriptionAtBottom}
surfaceColor={surfaceColor}
cardAriaLabel={cardAriaLabel}
recommendedLabel={recommendedLabel}
/>
+12
View File
@@ -84,6 +84,16 @@ export interface RuleProps {
templateGridFigmaShell?: boolean;
/** When true, expanded cards fill their container instead of a fixed Figma width. */
fluidWidth?: boolean;
/**
* Expanded cards: pin the description under the category rows (review preview).
* Default keeps the description between the header and the categories.
*/
descriptionAtBottom?: boolean;
/**
* Runtime CSS color for the card fill. When set, it overrides
* {@link backgroundColor}.
*/
surfaceColor?: string;
}
export interface RuleViewProps {
@@ -113,6 +123,8 @@ export interface RuleViewProps {
recommended?: boolean;
templateGridFigmaShell?: boolean;
fluidWidth?: boolean;
descriptionAtBottom?: boolean;
surfaceColor?: string;
/** Interactive card aria-label; supplied by the container from `ruleCard` messages. */
cardAriaLabel: string;
recommendedLabel: string;
+56 -38
View File
@@ -35,6 +35,8 @@ export function RuleView({
recommended = false,
templateGridFigmaShell = false,
fluidWidth = false,
descriptionAtBottom = false,
surfaceColor,
cardAriaLabel,
recommendedLabel,
}: RuleViewProps) {
@@ -267,8 +269,58 @@ export function RuleView({
}
const cardClassName = `${backgroundColor} ${cardPadding} ${cardGap} ${borderRadiusClass} shadow-[0px_0px_48px_0px_rgba(0,0,0,0.1)] ${interactiveCard ? "hover:shadow-[0px_0px_64px_0px_rgba(0,0,0,0.15)] transition-shadow duration-200" : ""} flex flex-col items-start justify-center relative ${cardWidth || "w-full"} ${href ? "no-underline text-inherit" : ""} ${className || ""}`;
const surfaceStyle = surfaceColor
? { backgroundColor: surfaceColor }
: undefined;
const isLinkCard = Boolean(href) && interactiveCard;
const hasCategories = Boolean(categories && categories.length > 0);
const expandedDescriptionFrameClass = [
"relative w-full shrink-0",
hasCategories
? descriptionAtBottom
? "border-t border-solid border-[var(--color-content-invert-primary)] pt-[16px]"
: "border-b border-solid border-[var(--color-content-invert-primary)] pb-[16px]"
: "",
isLarge || isMedium ? "px-0" : "px-[12px]",
]
.filter(Boolean)
.join(" ");
const showExpandedDescription = Boolean(
description ||
(onDescriptionClick && typeof descriptionEmptyHint === "string"),
);
const expandedDescription = showExpandedDescription ? (
<div className={expandedDescriptionFrameClass}>
{onDescriptionClick ? (
<InlineTextButton
type="button"
underline={false}
data-testid="rule-description-edit"
ariaLabel={descriptionEditAriaLabel}
className={`${descriptionClass} w-full min-w-0 cursor-pointer whitespace-pre-wrap text-left text-[var(--color-content-invert-primary)] hover:!opacity-100 ${
!description && descriptionEmptyHint ? "opacity-70" : ""
}`.trim()}
onClick={(e) => {
e.stopPropagation();
onDescriptionClick();
}}
>
{description ?? descriptionEmptyHint ?? ""}
</InlineTextButton>
) : (
description && (
<p
className={`${descriptionClass} cursor-inherit text-[var(--color-content-invert-primary)]`}
>
{description}
</p>
)
)}
</div>
) : null;
const cardBody = (
<>
{/* Figma: Header = `border-b` row, `gap-px`, icon `pl-1 pr-2 py-2` + `border-l` on title. */}
@@ -382,43 +434,7 @@ export function RuleView({
</div>
) : expanded ? (
<>
{(description ||
(onDescriptionClick &&
typeof descriptionEmptyHint === "string")) && (
<div
className={`relative w-full shrink-0 ${
categories && categories.length > 0
? "border-b border-solid border-[var(--color-content-invert-primary)] pb-[16px]"
: ""
} ${expanded && (isLarge || isMedium) ? "px-0" : "px-[12px]"}`}
>
{onDescriptionClick ? (
<InlineTextButton
type="button"
underline={false}
data-testid="rule-description-edit"
ariaLabel={descriptionEditAriaLabel}
className={`${descriptionClass} w-full min-w-0 cursor-pointer whitespace-pre-wrap text-left text-[var(--color-content-invert-primary)] hover:!opacity-100 ${
!description && descriptionEmptyHint ? "opacity-70" : ""
}`.trim()}
onClick={(e) => {
e.stopPropagation();
onDescriptionClick();
}}
>
{description ?? descriptionEmptyHint ?? ""}
</InlineTextButton>
) : (
description && (
<p
className={`${descriptionClass} cursor-inherit text-[var(--color-content-invert-primary)]`}
>
{description}
</p>
)
)}
</div>
)}
{descriptionAtBottom ? null : expandedDescription}
{/* Categories Section - Using MultiSelect */}
{categories && categories.length > 0 && (
<div
@@ -459,6 +475,7 @@ export function RuleView({
))}
</div>
)}
{descriptionAtBottom ? expandedDescription : null}
</>
) : (
/* Collapsed State: Description */
@@ -488,6 +505,7 @@ export function RuleView({
<NextLink
href={href}
className={cardClassName}
style={surfaceStyle}
aria-label={ariaLabel}
onClick={onClick}
>
@@ -499,6 +517,7 @@ export function RuleView({
return (
<div
className={cardClassName}
style={surfaceStyle}
tabIndex={interactiveCard ? 0 : undefined}
role={interactiveCard ? "button" : "article"}
aria-label={ariaLabel}
@@ -510,4 +529,3 @@ export function RuleView({
</div>
);
}
+181
View File
@@ -0,0 +1,181 @@
const QUANTIZE_SHIFT = 4;
/** Ignore only fully clear pixels so a translucent logo fill still counts. */
const MIN_ALPHA = 16;
/** Black card type needs a light fill. Dark logo colors are lifted to this. */
const CARD_FILL_LIGHTNESS = 0.82;
const CARD_FILL_MAX_SATURATION = 0.48;
function rgbToHsl(
r: number,
g: number,
b: number,
): { h: number; s: number; l: number } {
const rn = r / 255;
const gn = g / 255;
const bn = b / 255;
const max = Math.max(rn, gn, bn);
const min = Math.min(rn, gn, bn);
const l = (max + min) / 2;
if (max === min) return { h: 0, s: 0, l };
const d = max - min;
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
let h = 0;
if (max === rn) h = (gn - bn) / d + (gn < bn ? 6 : 0);
else if (max === gn) h = (bn - rn) / d + 2;
else h = (rn - gn) / d + 4;
return { h: h / 6, s, l };
}
function hueChannel(p: number, q: number, t: number): number {
let channel = t;
if (channel < 0) channel += 1;
if (channel > 1) channel -= 1;
if (channel < 1 / 6) return p + (q - p) * 6 * channel;
if (channel < 1 / 2) return q;
if (channel < 2 / 3) return p + (q - p) * (2 / 3 - channel) * 6;
return p;
}
function hslToRgb(h: number, s: number, l: number): [number, number, number] {
if (s === 0) {
const value = Math.round(l * 255);
return [value, value, value];
}
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
return [
Math.round(hueChannel(p, q, h + 1 / 3) * 255),
Math.round(hueChannel(p, q, h) * 255),
Math.round(hueChannel(p, q, h - 1 / 3) * 255),
];
}
/**
* Keep an already-light logo color. Lift a dark one to a pale tint of the
* same hue so the card’s black type stays readable.
*/
export function readableCardFill(r: number, g: number, b: number): string {
const { h, s, l } = rgbToHsl(r, g, b);
if (l >= 0.7) return `rgb(${r}, ${g}, ${b})`;
const [rr, gg, bb] = hslToRgb(
h,
Math.min(s, CARD_FILL_MAX_SATURATION),
CARD_FILL_LIGHTNESS,
);
return `rgb(${rr}, ${gg}, ${bb})`;
}
/**
* Most common opaque color in `data` (RGBA). Nearby shades share a bucket
* so a logo’s main fill wins over anti-aliased edges. Returns `null` when
* every pixel is transparent.
*/
export function mostCommonOpaqueColor(data: Uint8ClampedArray): string | null {
const buckets = new Map<
number,
{ count: number; r: number; g: number; b: number }
>();
for (let i = 0; i < data.length; i += 4) {
const alpha = data[i + 3] ?? 0;
if (alpha < MIN_ALPHA) continue;
const r = data[i] ?? 0;
const g = data[i + 1] ?? 0;
const b = data[i + 2] ?? 0;
const key =
((r >> QUANTIZE_SHIFT) << 8) |
((g >> QUANTIZE_SHIFT) << 4) |
(b >> QUANTIZE_SHIFT);
const bucket = buckets.get(key);
if (bucket) {
bucket.count += 1;
bucket.r += r;
bucket.g += g;
bucket.b += b;
} else {
buckets.set(key, { count: 1, r, g, b });
}
}
let best: { count: number; r: number; g: number; b: number } | null = null;
for (const bucket of buckets.values()) {
if (!best || bucket.count > best.count) best = bucket;
}
if (!best || best.count === 0) return null;
const r = Math.round(best.r / best.count);
const g = Math.round(best.g / best.count);
const b = Math.round(best.b / best.count);
return `rgb(${r}, ${g}, ${b})`;
}
/**
* Light random fill. Hue is free; lightness stays high so the card’s black
* type remains readable.
*/
export function randomPreviewSurfaceColor(
random: () => number = Math.random,
): string {
const hue = Math.floor(random() * 360);
const saturation = 50 + Math.floor(random() * 35);
const lightness = 76 + Math.floor(random() * 12);
return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
}
function imageNeedsCors(url: string): boolean {
if (
url.startsWith("/") ||
url.startsWith("blob:") ||
url.startsWith("data:")
) {
return false;
}
try {
return new URL(url, window.location.href).origin !== window.location.origin;
} catch {
return false;
}
}
function loadImage(url: string): Promise<HTMLImageElement | null> {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => resolve(null);
if (imageNeedsCors(url)) img.crossOrigin = "anonymous";
img.src = url;
});
}
/** Downscale `url` and return its most common opaque color, or `null`. */
export async function dominantColorFromImageUrl(
url: string,
): Promise<string | null> {
if (typeof window === "undefined") return null;
const img = await loadImage(url);
if (!img) return null;
const size = 32;
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
const context = canvas.getContext("2d", { willReadFrequently: true });
if (!context) return null;
context.drawImage(img, 0, 0, size, size);
try {
const raw = mostCommonOpaqueColor(
context.getImageData(0, 0, size, size).data,
);
if (!raw) return null;
const match = raw.match(/^rgb\((\d+), (\d+), (\d+)\)$/);
if (!match) return raw;
return readableCardFill(
Number(match[1]),
Number(match[2]),
Number(match[3]),
);
} catch {
return null;
}
}
+37
View File
@@ -122,6 +122,43 @@ describe("FinalReviewScreen", () => {
).toBeInTheDocument();
});
it("places the group description under the category rows", async () => {
render(
<FinalReviewWithFlowState
title="Oak Park Commons"
communityContext="Local mutual aid."
/>,
);
const description = await screen.findByText(/Local mutual aid\./i);
expect(
screen.getByText("Values").compareDocumentPosition(description) &
Node.DOCUMENT_POSITION_FOLLOWING,
).not.toBe(0);
expect(description.parentElement).toHaveClass("border-t");
});
it("uses a random light background when no logo is uploaded", async () => {
render(
<FinalReviewWithFlowState
title="Oak Park Commons"
communityContext="Local mutual aid."
/>,
);
await waitFor(() => {
const card = screen.getByRole("button", { name: /Oak Park Commons/i });
const raw = card.style.backgroundColor;
expect(raw).toMatch(/^rgb\(\d+, \d+, \d+\)$/);
const channels = raw
.split(/[^\d]+/)
.filter(Boolean)
.map(Number);
const lightness =
(Math.max(...channels) + Math.min(...channels)) / 2 / 255;
expect(lightness).toBeGreaterThanOrEqual(0.74);
expect(lightness).toBeLessThanOrEqual(0.9);
});
});
it("renders Rule as a button (card is interactive)", () => {
render(<FinalReviewScreen />);
const buttons = screen.getAllByRole("button");
@@ -0,0 +1,48 @@
import { useLayoutEffect } from "react";
import { describe, expect, it, vi } from "vitest";
import { waitFor } from "@testing-library/react";
import { renderWithProviders as render, screen } from "../utils/test-utils";
import "@testing-library/jest-dom/vitest";
import { FinalReviewScreen } from "../../app/(app)/create/screens/review/FinalReviewScreen";
import { useCreateFlow } from "../../app/(app)/create/context/CreateFlowContext";
import { dominantColorFromImageUrl } from "../../lib/create/previewSurfaceColor";
vi.mock("../../lib/create/previewSurfaceColor", async (importOriginal) => {
const actual =
await importOriginal<
typeof import("../../lib/create/previewSurfaceColor")
>();
return {
...actual,
dominantColorFromImageUrl: vi.fn(async () => "rgb(12, 80, 40)"),
};
});
function FinalReviewWithAvatar() {
const { replaceState } = useCreateFlow();
useLayoutEffect(() => {
replaceState({
title: "Oak Park Commons",
communityContext: "Local mutual aid.",
communityAvatarUrl: "/api/uploads/avatar-1",
});
}, [replaceState]);
return <FinalReviewScreen />;
}
describe("Final review preview surface", () => {
it("fills the card from the uploaded logo’s most common color", async () => {
render(<FinalReviewWithAvatar />);
await waitFor(() => {
const card = screen.getByRole("button", { name: /Oak Park Commons/i });
expect(card).toHaveStyle({ backgroundColor: "rgb(12, 80, 40)" });
});
expect(vi.mocked(dominantColorFromImageUrl)).toHaveBeenCalledWith(
"/api/uploads/avatar-1",
);
expect(
screen.getByRole("img", { name: "Oak Park Commons" }),
).toHaveAttribute("src", expect.stringContaining("avatar-1"));
});
});
+41 -3
View File
@@ -257,11 +257,49 @@ describe("Rule Component", () => {
],
},
];
render(
<Rule {...defaultProps} expanded={true} categories={categories} />,
);
render(<Rule {...defaultProps} expanded={true} categories={categories} />);
expect(screen.getByText("Values")).toBeInTheDocument();
const description = screen.getByText("This is a test rule description");
expect(
description.compareDocumentPosition(screen.getByText("Values")) &
Node.DOCUMENT_POSITION_FOLLOWING,
).not.toBe(0);
expect(description.parentElement).toHaveClass("border-b");
});
it("pins the description under categories when descriptionAtBottom is set", () => {
const categories = [
{
name: "Values",
chipOptions: [
{ id: "v1", label: "Consciousness", state: "unselected" },
],
},
];
render(
<Rule
{...defaultProps}
expanded={true}
descriptionAtBottom
categories={categories}
/>,
);
const description = screen.getByText("This is a test rule description");
expect(
screen.getByText("Values").compareDocumentPosition(description) &
Node.DOCUMENT_POSITION_FOLLOWING,
).not.toBe(0);
expect(description.parentElement).toHaveClass("border-t");
expect(description.parentElement).not.toHaveClass("border-b");
});
it("applies a runtime surface color over the background class", () => {
render(<Rule {...defaultProps} surfaceColor="rgb(12, 80, 40)" />);
expect(screen.getByRole("button")).toHaveStyle({
backgroundColor: "rgb(12, 80, 40)",
});
});
it("renders with logo URL", () => {
+77
View File
@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import {
mostCommonOpaqueColor,
randomPreviewSurfaceColor,
readableCardFill,
} from "../../lib/create/previewSurfaceColor";
function pixel(r: number, g: number, b: number, a = 255): number[] {
return [r, g, b, a];
}
describe("mostCommonOpaqueColor", () => {
it("returns the average of the largest quantized bucket", () => {
const data = new Uint8ClampedArray([
...pixel(200, 10, 10),
...pixel(201, 11, 9),
...pixel(202, 9, 11),
...pixel(10, 10, 220),
]);
expect(mostCommonOpaqueColor(data)).toBe("rgb(201, 10, 10)");
});
it("ignores transparent pixels", () => {
const data = new Uint8ClampedArray([
...pixel(255, 255, 255, 0),
...pixel(255, 255, 255, 0),
...pixel(20, 40, 60),
]);
expect(mostCommonOpaqueColor(data)).toBe("rgb(20, 40, 60)");
});
it("returns null when nothing is opaque", () => {
const data = new Uint8ClampedArray([...pixel(1, 2, 3, 0)]);
expect(mostCommonOpaqueColor(data)).toBeNull();
});
it("counts a translucent fill", () => {
const data = new Uint8ClampedArray([...pixel(255, 0, 0, 127)]);
expect(mostCommonOpaqueColor(data)).toBe("rgb(255, 0, 0)");
});
});
describe("readableCardFill", () => {
it("keeps a light logo color", () => {
expect(readableCardFill(201, 254, 249)).toBe("rgb(201, 254, 249)");
});
it("lifts a dark logo color into a pale tint of the same hue", () => {
const fill = readableCardFill(255, 0, 0);
const channels = fill
.split(/[^\d]+/)
.filter(Boolean)
.map(Number);
const lightness =
(Math.max(...channels) + Math.min(...channels)) / 2 / 255;
expect(lightness).toBeGreaterThanOrEqual(0.8);
expect(channels[0]).toBeGreaterThan(channels[2]);
});
});
describe("randomPreviewSurfaceColor", () => {
it("stays in a light range so black type can sit on it", () => {
let call = 0;
const sequence = [0, 0, 0];
expect(randomPreviewSurfaceColor(() => sequence[call++] ?? 0)).toBe(
"hsl(0, 50%, 76%)",
);
call = 0;
const high = [0.999, 0.999, 0.999];
expect(randomPreviewSurfaceColor(() => high[call++] ?? 0)).toBe(
"hsl(359, 84%, 87%)",
);
});
});