From 6d536880fe02b96192136fa8851a5526449aab3c Mon Sep 17 00:00:00 2001 From: adilallo <39313955+adilallo@users.noreply.github.com> Date: Tue, 29 Sep 2026 14:01:38 -0600 Subject: [PATCH] =?UTF-8?q?Keep=20the=20review=20card=E2=80=99s=20fallback?= =?UTF-8?q?=20color=20on=20the=20draft=20so=20a=20reload=20does=20not=20pi?= =?UTF-8?q?ck=20a=20new=20one.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../create/hooks/usePreviewSurfaceColor.ts | 31 ++++++-- .../screens/review/FinalReviewScreen.tsx | 22 +++++- app/(app)/create/types.ts | 5 ++ lib/create/previewSurfaceColor.ts | 8 ++ lib/server/validation/createFlowSchemas.ts | 5 ++ .../FinalReviewPreviewSurface.test.tsx | 75 ++++++++++++++++++- tests/unit/createFlowValidation.test.ts | 14 ++++ tests/unit/previewSurfaceColor.test.ts | 13 ++++ 8 files changed, 165 insertions(+), 8 deletions(-) diff --git a/app/(app)/create/hooks/usePreviewSurfaceColor.ts b/app/(app)/create/hooks/usePreviewSurfaceColor.ts index 37daecd..542d560 100644 --- a/app/(app)/create/hooks/usePreviewSurfaceColor.ts +++ b/app/(app)/create/hooks/usePreviewSurfaceColor.ts @@ -3,39 +3,60 @@ import { useEffect, useState } from "react"; import { dominantColorFromImageUrl, + isPreviewSurfaceColor, randomPreviewSurfaceColor, } 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 - * common color; no image uses a random light color. Unset until mount so the - * server and client share the fallback class. + * common color; no image uses a random light color stored on the draft so + * 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 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. + * + * @example + * const color = usePreviewSurfaceColor(avatarUrl, state.previewSurfaceColor, persist); */ export function usePreviewSurfaceColor( imageUrl: string | null, + storedFallback: string | undefined, + persistFallback: (_color: string) => void, ): string | undefined { const [color, setColor] = useState(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()); + setColor(storedOrRandom(storedFallback, persistFallback)); return; } let cancelled = false; void dominantColorFromImageUrl(imageUrl).then((next) => { if (cancelled) return; - setColor(next ?? randomPreviewSurfaceColor()); + setColor(next ?? storedOrRandom(storedFallback, persistFallback)); }); return () => { cancelled = true; }; - }, [imageUrl]); + }, [imageUrl, persistFallback, storedFallback]); return color; } diff --git a/app/(app)/create/screens/review/FinalReviewScreen.tsx b/app/(app)/create/screens/review/FinalReviewScreen.tsx index d0f8535..233305d 100644 --- a/app/(app)/create/screens/review/FinalReviewScreen.tsx +++ b/app/(app)/create/screens/review/FinalReviewScreen.tsx @@ -33,6 +33,7 @@ import { getAssetPath, vectorMarkPath, } from "../../../../../lib/assetUtils"; +import { isPreviewSurfaceColor } from "../../../../../lib/create/previewSurfaceColor"; const FACET_FALLBACK_ORDER: readonly TemplateFacetGroupKey[] = [ "coreValues", @@ -240,7 +241,26 @@ export function FinalReviewScreen({ state.communityAvatarUrl.trim().length > 0 ? state.communityAvatarUrl.trim() : 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 ( { const actual = @@ -15,6 +19,7 @@ vi.mock("../../lib/create/previewSurfaceColor", async (importOriginal) => { return { ...actual, dominantColorFromImageUrl: vi.fn(async () => "rgb(12, 80, 40)"), + randomPreviewSurfaceColor: vi.fn(() => "hsl(12, 50%, 80%)"), }; }); @@ -30,6 +35,30 @@ function FinalReviewWithAvatar() { return ; } +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 ; +} + describe("Final review preview surface", () => { it("fills the card from the uploaded logo’s most common color", async () => { render(); @@ -45,4 +74,46 @@ describe("Final review preview surface", () => { screen.getByRole("img", { name: "Oak Park Commons" }), ).toHaveAttribute("src", expect.stringContaining("avatar-1")); }); + + it("writes a fallback fill onto the draft when no logo is uploaded", async () => { + let latest: CreateFlowState = {}; + render( + { + 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( + {}} + />, + ); + + 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)"); + }); }); diff --git a/tests/unit/createFlowValidation.test.ts b/tests/unit/createFlowValidation.test.ts index c3af55e..b946e0b 100644 --- a/tests/unit/createFlowValidation.test.ts +++ b/tests/unit/createFlowValidation.test.ts @@ -148,6 +148,20 @@ describe("createFlowStateSchema", () => { 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", () => { const r = createFlowStateSchema.safeParse({ templateReviewBackSlug: "mutual-aid-mondays", diff --git a/tests/unit/previewSurfaceColor.test.ts b/tests/unit/previewSurfaceColor.test.ts index 989cc40..5e5bcd2 100644 --- a/tests/unit/previewSurfaceColor.test.ts +++ b/tests/unit/previewSurfaceColor.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + isPreviewSurfaceColor, mostCommonOpaqueColor, randomPreviewSurfaceColor, 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); + }); +});