From 56adcd59fdfbdb0eb62d7f382dffb3c0cefbe0b4 Mon Sep 17 00:00:00 2001 From: adilallo <39313955+adilallo@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:46:45 -0600 Subject: [PATCH 1/4] Pin the review card description under the sections and tint the card from the uploaded logo. Co-authored-by: Cursor --- .../create/hooks/usePreviewSurfaceColor.ts | 41 ++++ .../screens/review/FinalReviewScreen.tsx | 12 +- app/components/cards/Rule/Rule.container.tsx | 4 + app/components/cards/Rule/Rule.types.ts | 12 ++ app/components/cards/Rule/Rule.view.tsx | 94 +++++---- lib/create/previewSurfaceColor.ts | 181 ++++++++++++++++++ tests/components/FinalReviewPage.test.tsx | 37 ++++ .../FinalReviewPreviewSurface.test.tsx | 48 +++++ tests/unit/Rule.test.jsx | 44 ++++- tests/unit/previewSurfaceColor.test.ts | 77 ++++++++ 10 files changed, 508 insertions(+), 42 deletions(-) create mode 100644 app/(app)/create/hooks/usePreviewSurfaceColor.ts create mode 100644 lib/create/previewSurfaceColor.ts create mode 100644 tests/components/FinalReviewPreviewSurface.test.tsx create mode 100644 tests/unit/previewSurfaceColor.test.ts diff --git a/app/(app)/create/hooks/usePreviewSurfaceColor.ts b/app/(app)/create/hooks/usePreviewSurfaceColor.ts new file mode 100644 index 0000000..37daecd --- /dev/null +++ b/app/(app)/create/hooks/usePreviewSurfaceColor.ts @@ -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(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; +} diff --git a/app/(app)/create/screens/review/FinalReviewScreen.tsx b/app/(app)/create/screens/review/FinalReviewScreen.tsx index 1cd10d2..d0f8535 100644 --- a/app/(app)/create/screens/review/FinalReviewScreen.tsx +++ b/app/(app)/create/screens/review/FinalReviewScreen.tsx @@ -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 ( ( recommended = false, templateGridFigmaShell = false, fluidWidth = false, + descriptionAtBottom = false, + surfaceColor, }) => { const size = sizeProp ?? "L"; const t = useTranslation("ruleCard"); @@ -112,6 +114,8 @@ const RuleContainer = memo( recommended={recommended} templateGridFigmaShell={templateGridFigmaShell} fluidWidth={fluidWidth} + descriptionAtBottom={descriptionAtBottom} + surfaceColor={surfaceColor} cardAriaLabel={cardAriaLabel} recommendedLabel={recommendedLabel} /> diff --git a/app/components/cards/Rule/Rule.types.ts b/app/components/cards/Rule/Rule.types.ts index 73684df..d4b8303 100644 --- a/app/components/cards/Rule/Rule.types.ts +++ b/app/components/cards/Rule/Rule.types.ts @@ -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; diff --git a/app/components/cards/Rule/Rule.view.tsx b/app/components/cards/Rule/Rule.view.tsx index 20cf1ab..f642033 100644 --- a/app/components/cards/Rule/Rule.view.tsx +++ b/app/components/cards/Rule/Rule.view.tsx @@ -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 ? ( +
+ {onDescriptionClick ? ( + { + e.stopPropagation(); + onDescriptionClick(); + }} + > + {description ?? descriptionEmptyHint ?? ""} + + ) : ( + description && ( +

+ {description} +

+ ) + )} +
+ ) : 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({ ) : expanded ? ( <> - {(description || - (onDescriptionClick && - typeof descriptionEmptyHint === "string")) && ( -
0 - ? "border-b border-solid border-[var(--color-content-invert-primary)] pb-[16px]" - : "" - } ${expanded && (isLarge || isMedium) ? "px-0" : "px-[12px]"}`} - > - {onDescriptionClick ? ( - { - e.stopPropagation(); - onDescriptionClick(); - }} - > - {description ?? descriptionEmptyHint ?? ""} - - ) : ( - description && ( -

- {description} -

- ) - )} -
- )} + {descriptionAtBottom ? null : expandedDescription} {/* Categories Section - Using MultiSelect */} {categories && categories.length > 0 && (
)} + {descriptionAtBottom ? expandedDescription : null} ) : ( /* Collapsed State: Description */ @@ -488,6 +505,7 @@ export function RuleView({ @@ -499,6 +517,7 @@ export function RuleView({ return (
); } - diff --git a/lib/create/previewSurfaceColor.ts b/lib/create/previewSurfaceColor.ts new file mode 100644 index 0000000..86e65fb --- /dev/null +++ b/lib/create/previewSurfaceColor.ts @@ -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 { + 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 { + 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; + } +} diff --git a/tests/components/FinalReviewPage.test.tsx b/tests/components/FinalReviewPage.test.tsx index d35d1da..f9ef64a 100644 --- a/tests/components/FinalReviewPage.test.tsx +++ b/tests/components/FinalReviewPage.test.tsx @@ -122,6 +122,43 @@ describe("FinalReviewScreen", () => { ).toBeInTheDocument(); }); + it("places the group description under the category rows", async () => { + render( + , + ); + 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( + , + ); + 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(); const buttons = screen.getAllByRole("button"); diff --git a/tests/components/FinalReviewPreviewSurface.test.tsx b/tests/components/FinalReviewPreviewSurface.test.tsx new file mode 100644 index 0000000..935580d --- /dev/null +++ b/tests/components/FinalReviewPreviewSurface.test.tsx @@ -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 ; +} + +describe("Final review preview surface", () => { + it("fills the card from the uploaded logo’s most common color", async () => { + render(); + + 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")); + }); +}); diff --git a/tests/unit/Rule.test.jsx b/tests/unit/Rule.test.jsx index a3b69b0..b3e3616 100644 --- a/tests/unit/Rule.test.jsx +++ b/tests/unit/Rule.test.jsx @@ -257,11 +257,49 @@ describe("Rule Component", () => { ], }, ]; - render( - , - ); + render(); 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( + , + ); + + 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(); + expect(screen.getByRole("button")).toHaveStyle({ + backgroundColor: "rgb(12, 80, 40)", + }); }); it("renders with logo URL", () => { diff --git a/tests/unit/previewSurfaceColor.test.ts b/tests/unit/previewSurfaceColor.test.ts new file mode 100644 index 0000000..989cc40 --- /dev/null +++ b/tests/unit/previewSurfaceColor.test.ts @@ -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%)", + ); + }); +}); 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 2/4] =?UTF-8?q?Keep=20the=20review=20card=E2=80=99s=20fall?= =?UTF-8?q?back=20color=20on=20the=20draft=20so=20a=20reload=20does=20not?= =?UTF-8?q?=20pick=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); + }); +}); From b12de611f78f2dfcc4eb27659cb6b5462e70884d Mon Sep 17 00:00:00 2001 From: adilallo <39313955+adilallo@users.noreply.github.com> Date: Tue, 29 Sep 2026 14:05:30 -0600 Subject: [PATCH 3/4] Read the review card fill from localStorage before picking a new one. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The review screen’s effect ran before draft hydration, so reload always generated a fresh color and overwrote the stored one. Co-authored-by: Cursor --- .../create/hooks/usePreviewSurfaceColor.ts | 35 ++++----- .../screens/review/FinalReviewScreen.tsx | 21 ++++-- .../utils/resolvePreviewSurfaceFallback.ts | 64 +++++++++++++++++ .../FinalReviewPreviewSurface.test.tsx | 35 ++++++++- .../resolvePreviewSurfaceFallback.test.ts | 71 +++++++++++++++++++ 5 files changed, 199 insertions(+), 27 deletions(-) create mode 100644 app/(app)/create/utils/resolvePreviewSurfaceFallback.ts create mode 100644 tests/unit/resolvePreviewSurfaceFallback.test.ts diff --git a/app/(app)/create/hooks/usePreviewSurfaceColor.ts b/app/(app)/create/hooks/usePreviewSurfaceColor.ts index 542d560..ef39c1c 100644 --- a/app/(app)/create/hooks/usePreviewSurfaceColor.ts +++ b/app/(app)/create/hooks/usePreviewSurfaceColor.ts @@ -1,23 +1,8 @@ "use client"; 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; -} +import { dominantColorFromImageUrl } from "../../../../lib/create/previewSurfaceColor"; +import { resolvePreviewSurfaceFallback } from "../utils/resolvePreviewSurfaceFallback"; /** * Fill for the review preview card. An uploaded logo contributes its most @@ -27,7 +12,7 @@ function storedOrRandom( * * @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. + * @param persistFallback Writes the fallback onto React state once. * @returns A CSS color, or `undefined` before the client picks one. * * @example @@ -42,15 +27,23 @@ export function usePreviewSurfaceColor( useEffect(() => { if (!imageUrl) { + const next = resolvePreviewSurfaceFallback(storedFallback); + persistFallback(next); // eslint-disable-next-line react-hooks/set-state-in-effect -- random fill is client-only; first paint stays on the fallback class - setColor(storedOrRandom(storedFallback, persistFallback)); + setColor(next); return; } let cancelled = false; - void dominantColorFromImageUrl(imageUrl).then((next) => { + void dominantColorFromImageUrl(imageUrl).then((extracted) => { if (cancelled) return; - setColor(next ?? storedOrRandom(storedFallback, persistFallback)); + if (extracted) { + setColor(extracted); + return; + } + const next = resolvePreviewSurfaceFallback(storedFallback); + persistFallback(next); + setColor(next); }); return () => { diff --git a/app/(app)/create/screens/review/FinalReviewScreen.tsx b/app/(app)/create/screens/review/FinalReviewScreen.tsx index 233305d..ae5c337 100644 --- a/app/(app)/create/screens/review/FinalReviewScreen.tsx +++ b/app/(app)/create/screens/review/FinalReviewScreen.tsx @@ -34,6 +34,10 @@ import { vectorMarkPath, } from "../../../../../lib/assetUtils"; import { isPreviewSurfaceColor } from "../../../../../lib/create/previewSurfaceColor"; +import { + persistDraftPreviewSurfaceColor, + readDraftPreviewSurfaceColor, +} from "../../utils/resolvePreviewSurfaceFallback"; const FACET_FALLBACK_ORDER: readonly TemplateFacetGroupKey[] = [ "coreValues", @@ -247,12 +251,19 @@ export function FinalReviewScreen({ : undefined; const persistFallback = useCallback( (color: string) => { - replaceState((prev) => - prev.previewSurfaceColor && - isPreviewSurfaceColor(prev.previewSurfaceColor) + replaceState((prev) => { + persistDraftPreviewSurfaceColor(color, prev); + const keep = + (prev.previewSurfaceColor && + isPreviewSurfaceColor(prev.previewSurfaceColor) + ? prev.previewSurfaceColor + : undefined) ?? + readDraftPreviewSurfaceColor() ?? + color; + return prev.previewSurfaceColor === keep ? prev - : { ...prev, previewSurfaceColor: color }, - ); + : { ...prev, previewSurfaceColor: keep }; + }); }, [replaceState], ); diff --git a/app/(app)/create/utils/resolvePreviewSurfaceFallback.ts b/app/(app)/create/utils/resolvePreviewSurfaceFallback.ts new file mode 100644 index 0000000..842eb2e --- /dev/null +++ b/app/(app)/create/utils/resolvePreviewSurfaceFallback.ts @@ -0,0 +1,64 @@ +import type { CreateFlowState } from "../types"; +import { + isPreviewSurfaceColor, + randomPreviewSurfaceColor, +} from "../../../../lib/create/previewSurfaceColor"; +import { + readAnonymousCreateFlowState, + writeAnonymousCreateFlowState, +} from "./anonymousDraftStorage"; + +function validPreviewSurfaceColor( + value: string | undefined, +): string | undefined { + return typeof value === "string" && isPreviewSurfaceColor(value) + ? value + : undefined; +} + +/** Existing draft fill from React state or localStorage, if it is a stored hsl. */ +export function readDraftPreviewSurfaceColor( + storedFallback?: string, +): string | undefined { + return ( + validPreviewSurfaceColor(storedFallback) ?? + validPreviewSurfaceColor( + readAnonymousCreateFlowState().previewSurfaceColor, + ) + ); +} + +/** + * Keep a fallback fill on the anonymous draft immediately. The provider’s + * first mirrored write is skipped, so waiting on React state loses the color + * across reload. `state` is merged so a write before hydrate does not drop + * other draft fields already in memory. + */ +export function persistDraftPreviewSurfaceColor( + color: string, + state: CreateFlowState = {}, +): void { + const next = validPreviewSurfaceColor(color); + if (!next) return; + const current = readAnonymousCreateFlowState(); + const stored = validPreviewSurfaceColor(current.previewSurfaceColor); + writeAnonymousCreateFlowState({ + ...current, + ...state, + previewSurfaceColor: stored ?? next, + }); +} + +/** + * Reuse the draft fill when one exists; otherwise pick one and write it now. + * Reads localStorage itself so a child effect can run before context hydrates. + */ +export function resolvePreviewSurfaceFallback( + storedFallback?: string, +): string { + const existing = readDraftPreviewSurfaceColor(storedFallback); + if (existing) return existing; + const color = randomPreviewSurfaceColor(); + persistDraftPreviewSurfaceColor(color); + return color; +} diff --git a/tests/components/FinalReviewPreviewSurface.test.tsx b/tests/components/FinalReviewPreviewSurface.test.tsx index 3aa2b75..28778b1 100644 --- a/tests/components/FinalReviewPreviewSurface.test.tsx +++ b/tests/components/FinalReviewPreviewSurface.test.tsx @@ -1,10 +1,11 @@ import { useEffect, useLayoutEffect } from "react"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, 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 { CREATE_FLOW_ANONYMOUS_KEY } from "../../app/(app)/create/utils/anonymousDraftStorage"; import { dominantColorFromImageUrl, randomPreviewSurfaceColor, @@ -60,6 +61,10 @@ function FinalReviewWithStateProbe({ } describe("Final review preview surface", () => { + beforeEach(() => { + window.localStorage.removeItem(CREATE_FLOW_ANONYMOUS_KEY); + vi.mocked(randomPreviewSurfaceColor).mockClear(); + }); it("fills the card from the uploaded logo’s most common color", async () => { render(); @@ -95,6 +100,34 @@ describe("Final review preview surface", () => { expect(vi.mocked(randomPreviewSurfaceColor)).toHaveBeenCalled(); }); + it("reuses a fallback saved only in localStorage across a fresh mount", async () => { + window.localStorage.setItem( + CREATE_FLOW_ANONYMOUS_KEY, + JSON.stringify({ + title: "Oak Park Commons", + communityContext: "Local mutual aid.", + previewSurfaceColor: "hsl(200, 60%, 80%)", + }), + ); + let latest: CreateFlowState = {}; + render( + { + latest = state; + }} + />, + ); + + await waitFor(() => { + expect(latest.previewSurfaceColor).toBe("hsl(200, 60%, 80%)"); + }); + expect(vi.mocked(randomPreviewSurfaceColor)).not.toHaveBeenCalled(); + }); + it("reuses the stored fallback instead of picking a new color", async () => { vi.mocked(randomPreviewSurfaceColor).mockClear(); render( diff --git a/tests/unit/resolvePreviewSurfaceFallback.test.ts b/tests/unit/resolvePreviewSurfaceFallback.test.ts new file mode 100644 index 0000000..74f7ccd --- /dev/null +++ b/tests/unit/resolvePreviewSurfaceFallback.test.ts @@ -0,0 +1,71 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { CREATE_FLOW_ANONYMOUS_KEY } from "../../app/(app)/create/utils/anonymousDraftStorage"; +import { + persistDraftPreviewSurfaceColor, + readDraftPreviewSurfaceColor, + resolvePreviewSurfaceFallback, +} from "../../app/(app)/create/utils/resolvePreviewSurfaceFallback"; +import { randomPreviewSurfaceColor } from "../../lib/create/previewSurfaceColor"; + +vi.mock("../../lib/create/previewSurfaceColor", async (importOriginal) => { + const actual = + await importOriginal< + typeof import("../../lib/create/previewSurfaceColor") + >(); + return { + ...actual, + randomPreviewSurfaceColor: vi.fn(() => "hsl(12, 50%, 80%)"), + }; +}); + +describe("resolvePreviewSurfaceFallback", () => { + beforeEach(() => { + window.localStorage.removeItem(CREATE_FLOW_ANONYMOUS_KEY); + vi.mocked(randomPreviewSurfaceColor).mockClear(); + }); + + it("reuses a valid fill already on React state", () => { + expect(resolvePreviewSurfaceFallback("hsl(200, 60%, 80%)")).toBe( + "hsl(200, 60%, 80%)", + ); + expect(randomPreviewSurfaceColor).not.toHaveBeenCalled(); + }); + + it("reuses a fill already on the anonymous draft", () => { + window.localStorage.setItem( + CREATE_FLOW_ANONYMOUS_KEY, + JSON.stringify({ + title: "Oak Park Commons", + previewSurfaceColor: "hsl(200, 60%, 80%)", + }), + ); + expect(resolvePreviewSurfaceFallback(undefined)).toBe( + "hsl(200, 60%, 80%)", + ); + expect(randomPreviewSurfaceColor).not.toHaveBeenCalled(); + }); + + it("picks once and writes it onto the anonymous draft", () => { + expect(resolvePreviewSurfaceFallback(undefined)).toBe( + "hsl(12, 50%, 80%)", + ); + expect(randomPreviewSurfaceColor).toHaveBeenCalledTimes(1); + expect(readDraftPreviewSurfaceColor()).toBe("hsl(12, 50%, 80%)"); + expect(resolvePreviewSurfaceFallback(undefined)).toBe( + "hsl(12, 50%, 80%)", + ); + expect(randomPreviewSurfaceColor).toHaveBeenCalledTimes(1); + }); + + it("does not replace a fill already stored on the draft", () => { + window.localStorage.setItem( + CREATE_FLOW_ANONYMOUS_KEY, + JSON.stringify({ + title: "Oak Park Commons", + previewSurfaceColor: "hsl(200, 60%, 80%)", + }), + ); + persistDraftPreviewSurfaceColor("hsl(12, 50%, 80%)"); + expect(readDraftPreviewSurfaceColor()).toBe("hsl(200, 60%, 80%)"); + }); +}); From 782b1e1d64a0a9b4d5afc6a519ac097d5d05b45d Mon Sep 17 00:00:00 2001 From: adilallo <39313955+adilallo@users.noreply.github.com> Date: Tue, 29 Sep 2026 14:48:19 -0600 Subject: [PATCH 4/4] Apply the saved review-card fill before paint so reload does not flash the teal placeholder. Co-authored-by: Cursor --- .../create/hooks/usePreviewSurfaceColor.ts | 34 ++++++++++++------- .../screens/review/FinalReviewScreen.tsx | 4 +-- 2 files changed, 23 insertions(+), 15 deletions(-) diff --git a/app/(app)/create/hooks/usePreviewSurfaceColor.ts b/app/(app)/create/hooks/usePreviewSurfaceColor.ts index ef39c1c..4d32ebc 100644 --- a/app/(app)/create/hooks/usePreviewSurfaceColor.ts +++ b/app/(app)/create/hooks/usePreviewSurfaceColor.ts @@ -1,19 +1,21 @@ "use client"; -import { useEffect, useState } from "react"; -import { dominantColorFromImageUrl } from "../../../../lib/create/previewSurfaceColor"; +import { useEffect, useLayoutEffect, useState } from "react"; +import { + dominantColorFromImageUrl, + isPreviewSurfaceColor, +} from "../../../../lib/create/previewSurfaceColor"; import { resolvePreviewSurfaceFallback } from "../utils/resolvePreviewSurfaceFallback"; /** * Fill for the review preview card. An uploaded logo contributes its most * 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. + * reload keeps the same fill. * * @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 React state once. - * @returns A CSS color, or `undefined` before the client picks one. + * @returns A CSS color, or `undefined` before the client resolves one. * * @example * const color = usePreviewSurfaceColor(avatarUrl, state.previewSurfaceColor, persist); @@ -23,16 +25,22 @@ export function usePreviewSurfaceColor( storedFallback: string | undefined, persistFallback: (_color: string) => void, ): string | undefined { - const [color, setColor] = useState(undefined); + const [color, setColor] = useState(() => + storedFallback && isPreviewSurfaceColor(storedFallback) + ? storedFallback + : undefined, + ); + + useLayoutEffect(() => { + if (imageUrl) return; + const next = resolvePreviewSurfaceFallback(storedFallback); + persistFallback(next); + // eslint-disable-next-line react-hooks/set-state-in-effect -- apply before paint so reload does not flash the teal token + setColor((prev) => (prev === next ? prev : next)); + }, [imageUrl, persistFallback, storedFallback]); useEffect(() => { - if (!imageUrl) { - const next = resolvePreviewSurfaceFallback(storedFallback); - persistFallback(next); - // eslint-disable-next-line react-hooks/set-state-in-effect -- random fill is client-only; first paint stays on the fallback class - setColor(next); - return; - } + if (!imageUrl) return; let cancelled = false; void dominantColorFromImageUrl(imageUrl).then((extracted) => { diff --git a/app/(app)/create/screens/review/FinalReviewScreen.tsx b/app/(app)/create/screens/review/FinalReviewScreen.tsx index ae5c337..cee9ab7 100644 --- a/app/(app)/create/screens/review/FinalReviewScreen.tsx +++ b/app/(app)/create/screens/review/FinalReviewScreen.tsx @@ -311,12 +311,12 @@ export function FinalReviewScreen({ size={mdUp ? "L" : "M"} expanded={true} descriptionAtBottom - backgroundColor="bg-[var(--color-surface-invert-brand-teal)]" + backgroundColor="bg-transparent" surfaceColor={surfaceColor} logoUrl={avatarUrl ?? getAssetPath(vectorMarkPath("mutual-aid"))} logoAlt={ruleCardTitle} categories={finalReviewCategories} - className={CREATE_FLOW_REVIEW_RULE_LAYOUT_CLASS} + className={`${CREATE_FLOW_REVIEW_RULE_LAYOUT_CLASS}${surfaceColor ? "" : " invisible"}`} onClick={() => {}} />