diff --git a/app/(app)/create/hooks/usePreviewSurfaceColor.ts b/app/(app)/create/hooks/usePreviewSurfaceColor.ts new file mode 100644 index 0000000..4d32ebc --- /dev/null +++ b/app/(app)/create/hooks/usePreviewSurfaceColor.ts @@ -0,0 +1,63 @@ +"use client"; + +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. + * + * @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 resolves 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(() => + 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) return; + + let cancelled = false; + void dominantColorFromImageUrl(imageUrl).then((extracted) => { + if (cancelled) return; + if (extracted) { + setColor(extracted); + return; + } + const next = resolvePreviewSurfaceFallback(storedFallback); + persistFallback(next); + setColor(next); + }); + + return () => { + cancelled = true; + }; + }, [imageUrl, persistFallback, storedFallback]); + + return color; +} diff --git a/app/(app)/create/screens/review/FinalReviewScreen.tsx b/app/(app)/create/screens/review/FinalReviewScreen.tsx index 1cd10d2..cee9ab7 100644 --- a/app/(app)/create/screens/review/FinalReviewScreen.tsx +++ b/app/(app)/create/screens/review/FinalReviewScreen.tsx @@ -26,12 +26,18 @@ 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 { getAssetPath, vectorMarkPath, } from "../../../../../lib/assetUtils"; +import { isPreviewSurfaceColor } from "../../../../../lib/create/previewSurfaceColor"; +import { + persistDraftPreviewSurfaceColor, + readDraftPreviewSurfaceColor, +} from "../../utils/resolvePreviewSurfaceFallback"; const FACET_FALLBACK_ORDER: readonly TemplateFacetGroupKey[] = [ "coreValues", @@ -234,6 +240,39 @@ 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 storedFallback = + typeof state.previewSurfaceColor === "string" + ? state.previewSurfaceColor + : undefined; + const persistFallback = useCallback( + (color: string) => { + replaceState((prev) => { + persistDraftPreviewSurfaceColor(color, prev); + const keep = + (prev.previewSurfaceColor && + isPreviewSurfaceColor(prev.previewSurfaceColor) + ? prev.previewSurfaceColor + : undefined) ?? + readDraftPreviewSurfaceColor() ?? + color; + return prev.previewSurfaceColor === keep + ? prev + : { ...prev, previewSurfaceColor: keep }; + }); + }, + [replaceState], + ); + const surfaceColor = usePreviewSurfaceColor( + avatarUrl, + storedFallback, + persistFallback, + ); + 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..467b77d --- /dev/null +++ b/lib/create/previewSurfaceColor.ts @@ -0,0 +1,189 @@ +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}%)`; +} + +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 { + 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/lib/server/validation/createFlowSchemas.ts b/lib/server/validation/createFlowSchemas.ts index 0284b8e..2f3f60c 100644 --- a/lib/server/validation/createFlowSchemas.ts +++ b/lib/server/validation/createFlowSchemas.ts @@ -106,6 +106,11 @@ export const createFlowStateSchema = z communityContext: z.string().max(200).optional(), communitySaveEmail: z.string().max(320).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(), selectedOrganizationTypeIds: z.array(z.string()).optional(), selectedScaleIds: z.array(z.string()).optional(), 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..28778b1 --- /dev/null +++ b/tests/components/FinalReviewPreviewSurface.test.tsx @@ -0,0 +1,152 @@ +import { useEffect, useLayoutEffect } from "react"; +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, +} from "../../lib/create/previewSurfaceColor"; +import type { CreateFlowState } from "../../app/(app)/create/types"; + +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)"), + randomPreviewSurfaceColor: vi.fn(() => "hsl(12, 50%, 80%)"), + }; +}); + +function FinalReviewWithAvatar() { + const { replaceState } = useCreateFlow(); + useLayoutEffect(() => { + replaceState({ + title: "Oak Park Commons", + communityContext: "Local mutual aid.", + communityAvatarUrl: "/api/uploads/avatar-1", + }); + }, [replaceState]); + 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", () => { + 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(); + + 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")); + }); + + 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 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( + {}} + />, + ); + + 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/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/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 new file mode 100644 index 0000000..5e5bcd2 --- /dev/null +++ b/tests/unit/previewSurfaceColor.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; +import { + isPreviewSurfaceColor, + 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%)", + ); + }); +}); + +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); + }); +}); 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%)"); + }); +});