Keep the review card’s fallback color on the draft so a reload does not pick a new one.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
adilalloandCursor committed 2026-09-29 14:01:38 -06:00
1 parent 56adcd59fd
commit 6d536880fe
8 files changed
+165 -8

No files matched your search

@@ -3,39 +3,60 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { import {
dominantColorFromImageUrl, dominantColorFromImageUrl,
isPreviewSurfaceColor,
randomPreviewSurfaceColor, randomPreviewSurfaceColor,
} from "../../../../lib/create/previewSurfaceColor"; } from "../../../../lib/create/previewSurfaceColor";
function storedOrRandom(
storedFallback: string | undefined,
persistFallback: (_color: string) => void,
): string {
if (storedFallback && isPreviewSurfaceColor(storedFallback)) {
return storedFallback;
}
const next = randomPreviewSurfaceColor();
persistFallback(next);
return next;
}
/** /**
* Fill for the review preview card. An uploaded logo contributes its most * 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 * common color; no image uses a random light color stored on the draft so
* server and client share the fallback class. * reload keeps the same fill. 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. * @param imageUrl Uploaded logo URL, or `null` when the user skipped the photo.
* @param storedFallback Draft `previewSurfaceColor`, if already picked.
* @param persistFallback Writes the fallback onto the draft once.
* @returns A CSS color, or `undefined` before the client picks one. * @returns A CSS color, or `undefined` before the client picks one.
*
* @example
* const color = usePreviewSurfaceColor(avatarUrl, state.previewSurfaceColor, persist);
*/ */
export function usePreviewSurfaceColor( export function usePreviewSurfaceColor(
imageUrl: string | null, imageUrl: string | null,
storedFallback: string | undefined,
persistFallback: (_color: string) => void,
): string | undefined { ): string | undefined {
const [color, setColor] = useState<string | undefined>(undefined); const [color, setColor] = useState<string | undefined>(undefined);
useEffect(() => { useEffect(() => {
if (!imageUrl) { if (!imageUrl) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- random fill is client-only; first paint stays on the fallback class // eslint-disable-next-line react-hooks/set-state-in-effect -- random fill is client-only; first paint stays on the fallback class
setColor(randomPreviewSurfaceColor()); setColor(storedOrRandom(storedFallback, persistFallback));
return; return;
} }
let cancelled = false; let cancelled = false;
void dominantColorFromImageUrl(imageUrl).then((next) => { void dominantColorFromImageUrl(imageUrl).then((next) => {
if (cancelled) return; if (cancelled) return;
setColor(next ?? randomPreviewSurfaceColor()); setColor(next ?? storedOrRandom(storedFallback, persistFallback));
}); });
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [imageUrl]); }, [imageUrl, persistFallback, storedFallback]);
return color; return color;
} }
@@ -33,6 +33,7 @@ import {
getAssetPath, getAssetPath,
vectorMarkPath, vectorMarkPath,
} from "../../../../../lib/assetUtils"; } from "../../../../../lib/assetUtils";
import { isPreviewSurfaceColor } from "../../../../../lib/create/previewSurfaceColor";
const FACET_FALLBACK_ORDER: readonly TemplateFacetGroupKey[] = [ const FACET_FALLBACK_ORDER: readonly TemplateFacetGroupKey[] = [
"coreValues", "coreValues",
@@ -240,7 +241,26 @@ export function FinalReviewScreen({
state.communityAvatarUrl.trim().length > 0 state.communityAvatarUrl.trim().length > 0
? state.communityAvatarUrl.trim() ? state.communityAvatarUrl.trim()
: null; : null;
const surfaceColor = usePreviewSurfaceColor(avatarUrl); const storedFallback =
typeof state.previewSurfaceColor === "string"
? state.previewSurfaceColor
: undefined;
const persistFallback = useCallback(
(color: string) => {
replaceState((prev) =>
prev.previewSurfaceColor &&
isPreviewSurfaceColor(prev.previewSurfaceColor)
? prev
: { ...prev, previewSurfaceColor: color },
);
},
[replaceState],
);
const surfaceColor = usePreviewSurfaceColor(
avatarUrl,
storedFallback,
persistFallback,
);
return ( return (
<CreateFlowLockupCardStepShell <CreateFlowLockupCardStepShell
+5
View File
@@ -118,6 +118,11 @@ export interface CreateFlowState {
* Set after successful `POST /api/uploads` with purpose `communityAvatar`. * Set after successful `POST /api/uploads` with purpose `communityAvatar`.
*/ */
communityAvatarUrl?: string; communityAvatarUrl?: string;
/**
* Light `hsl(...)` fill for the review card when no logo is uploaded.
* Picked once and kept on the draft so reload does not change it.
*/
previewSurfaceColor?: string;
/** Selected chip ids from `community-size` (MultiSelect). */ /** Selected chip ids from `community-size` (MultiSelect). */
selectedCommunitySizeIds?: string[]; selectedCommunitySizeIds?: string[];
/** Selected chip ids from `community-structure` (organization types). */ /** Selected chip ids from `community-structure` (organization types). */
+8
View File
@@ -122,6 +122,14 @@ export function randomPreviewSurfaceColor(
return `hsl(${hue}, ${saturation}%, ${lightness}%)`; return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
} }
const STORED_PREVIEW_SURFACE_COLOR =
/^hsl\(\d{1,3}, \d{1,3}%, \d{1,3}%\)$/;
/** True when `value` is a draft-stored review-card fallback fill. */
export function isPreviewSurfaceColor(value: string): boolean {
return STORED_PREVIEW_SURFACE_COLOR.test(value);
}
function imageNeedsCors(url: string): boolean { function imageNeedsCors(url: string): boolean {
if ( if (
url.startsWith("/") || url.startsWith("/") ||
@@ -106,6 +106,11 @@ export const createFlowStateSchema = z
communityContext: z.string().max(200).optional(), communityContext: z.string().max(200).optional(),
communitySaveEmail: z.string().max(320).optional(), communitySaveEmail: z.string().max(320).optional(),
communityAvatarUrl: z.string().max(512).optional(), communityAvatarUrl: z.string().max(512).optional(),
previewSurfaceColor: z
.string()
.max(32)
.regex(/^hsl\(\d{1,3}, \d{1,3}%, \d{1,3}%\)$/)
.optional(),
selectedCommunitySizeIds: z.array(z.string()).optional(), selectedCommunitySizeIds: z.array(z.string()).optional(),
selectedOrganizationTypeIds: z.array(z.string()).optional(), selectedOrganizationTypeIds: z.array(z.string()).optional(),
selectedScaleIds: z.array(z.string()).optional(), selectedScaleIds: z.array(z.string()).optional(),
@@ -1,11 +1,15 @@
import { useLayoutEffect } from "react"; import { useEffect, useLayoutEffect } from "react";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { waitFor } from "@testing-library/react"; import { waitFor } from "@testing-library/react";
import { renderWithProviders as render, screen } from "../utils/test-utils"; import { renderWithProviders as render, screen } from "../utils/test-utils";
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
import { FinalReviewScreen } from "../../app/(app)/create/screens/review/FinalReviewScreen"; import { FinalReviewScreen } from "../../app/(app)/create/screens/review/FinalReviewScreen";
import { useCreateFlow } from "../../app/(app)/create/context/CreateFlowContext"; import { useCreateFlow } from "../../app/(app)/create/context/CreateFlowContext";
import { dominantColorFromImageUrl } from "../../lib/create/previewSurfaceColor"; import {
dominantColorFromImageUrl,
randomPreviewSurfaceColor,
} from "../../lib/create/previewSurfaceColor";
import type { CreateFlowState } from "../../app/(app)/create/types";
vi.mock("../../lib/create/previewSurfaceColor", async (importOriginal) => { vi.mock("../../lib/create/previewSurfaceColor", async (importOriginal) => {
const actual = const actual =
@@ -15,6 +19,7 @@ vi.mock("../../lib/create/previewSurfaceColor", async (importOriginal) => {
return { return {
...actual, ...actual,
dominantColorFromImageUrl: vi.fn(async () => "rgb(12, 80, 40)"), dominantColorFromImageUrl: vi.fn(async () => "rgb(12, 80, 40)"),
randomPreviewSurfaceColor: vi.fn(() => "hsl(12, 50%, 80%)"),
}; };
}); });
@@ -30,6 +35,30 @@ function FinalReviewWithAvatar() {
return <FinalReviewScreen />; return <FinalReviewScreen />;
} }
function FinalReviewWithStateProbe({
initial,
onState,
}: {
initial: CreateFlowState;
onState: (_state: CreateFlowState) => void;
}) {
const { state, replaceState } = useCreateFlow();
useLayoutEffect(() => {
replaceState(initial);
}, [replaceState, initial]);
useEffect(() => {
onState(state);
}, [onState, state]);
if (state.title !== initial.title) return null;
if (
initial.previewSurfaceColor &&
state.previewSurfaceColor !== initial.previewSurfaceColor
) {
return null;
}
return <FinalReviewScreen />;
}
describe("Final review preview surface", () => { describe("Final review preview surface", () => {
it("fills the card from the uploaded logo’s most common color", async () => { it("fills the card from the uploaded logo’s most common color", async () => {
render(<FinalReviewWithAvatar />); render(<FinalReviewWithAvatar />);
@@ -45,4 +74,46 @@ describe("Final review preview surface", () => {
screen.getByRole("img", { name: "Oak Park Commons" }), screen.getByRole("img", { name: "Oak Park Commons" }),
).toHaveAttribute("src", expect.stringContaining("avatar-1")); ).toHaveAttribute("src", expect.stringContaining("avatar-1"));
}); });
it("writes a fallback fill onto the draft when no logo is uploaded", async () => {
let latest: CreateFlowState = {};
render(
<FinalReviewWithStateProbe
initial={{
title: "Oak Park Commons",
communityContext: "Local mutual aid.",
}}
onState={(state) => {
latest = state;
}}
/>,
);
await waitFor(() => {
expect(latest.previewSurfaceColor).toBe("hsl(12, 50%, 80%)");
});
expect(vi.mocked(randomPreviewSurfaceColor)).toHaveBeenCalled();
});
it("reuses the stored fallback instead of picking a new color", async () => {
vi.mocked(randomPreviewSurfaceColor).mockClear();
render(
<FinalReviewWithStateProbe
initial={{
title: "Oak Park Commons",
communityContext: "Local mutual aid.",
previewSurfaceColor: "hsl(200, 60%, 80%)",
}}
onState={() => {}}
/>,
);
await waitFor(() => {
const card = screen.getByRole("button", { name: /Oak Park Commons/i });
expect(card.style.backgroundColor).not.toBe("");
});
expect(vi.mocked(randomPreviewSurfaceColor)).not.toHaveBeenCalled();
const card = screen.getByRole("button", { name: /Oak Park Commons/i });
expect(card.style.backgroundColor).not.toBe("rgb(12, 80, 40)");
});
}); });
+14
View File
@@ -148,6 +148,20 @@ describe("createFlowStateSchema", () => {
expect(r.success).toBe(true); expect(r.success).toBe(true);
}); });
it("accepts previewSurfaceColor hsl fills", () => {
const r = createFlowStateSchema.safeParse({
previewSurfaceColor: "hsl(12, 50%, 80%)",
});
expect(r.success).toBe(true);
});
it("rejects previewSurfaceColor that is not an hsl fill", () => {
const r = createFlowStateSchema.safeParse({
previewSurfaceColor: "red",
});
expect(r.success).toBe(false);
});
it("accepts templateReviewBackSlug", () => { it("accepts templateReviewBackSlug", () => {
const r = createFlowStateSchema.safeParse({ const r = createFlowStateSchema.safeParse({
templateReviewBackSlug: "mutual-aid-mondays", templateReviewBackSlug: "mutual-aid-mondays",
+13
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
isPreviewSurfaceColor,
mostCommonOpaqueColor, mostCommonOpaqueColor,
randomPreviewSurfaceColor, randomPreviewSurfaceColor,
readableCardFill, readableCardFill,
@@ -75,3 +76,15 @@ describe("randomPreviewSurfaceColor", () => {
); );
}); });
}); });
describe("isPreviewSurfaceColor", () => {
it("accepts the hsl strings this module writes", () => {
expect(isPreviewSurfaceColor("hsl(12, 50%, 80%)")).toBe(true);
expect(isPreviewSurfaceColor("hsl(359, 84%, 87%)")).toBe(true);
});
it("rejects other CSS colors", () => {
expect(isPreviewSurfaceColor("rgb(12, 80, 40)")).toBe(false);
expect(isPreviewSurfaceColor("red")).toBe(false);
});
});