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] 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%)", + ); + }); +});