Merge pull request 'Put the group description under the review-card sections and tint the card from the logo' (#83) from adilallo/fix/CR-218-review-preview-card into main
Reviewed-on: #83
This commit was merged in pull request #83.
This commit is contained in:
commit
9c5cbf3bcd
15 files changed
+847
-44
No files matched your search
@@ -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<string | undefined>(() =>
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -26,12 +26,18 @@ import {
|
|||||||
} from "../../components/FinalReviewChipEditModal";
|
} from "../../components/FinalReviewChipEditModal";
|
||||||
import { FinalReviewCommunityContextEditModal } from "../../components/FinalReviewCommunityContextEditModal";
|
import { FinalReviewCommunityContextEditModal } from "../../components/FinalReviewCommunityContextEditModal";
|
||||||
import { FinalReviewTitleEditModal } from "../../components/FinalReviewTitleEditModal";
|
import { FinalReviewTitleEditModal } from "../../components/FinalReviewTitleEditModal";
|
||||||
|
import { usePreviewSurfaceColor } from "../../hooks/usePreviewSurfaceColor";
|
||||||
import { useCreateFlowNavigation } from "../../hooks/useCreateFlowNavigation";
|
import { useCreateFlowNavigation } from "../../hooks/useCreateFlowNavigation";
|
||||||
import { createFlowStepForFacetGroup } from "../../utils/facetGroupToCreateFlowStep";
|
import { createFlowStepForFacetGroup } from "../../utils/facetGroupToCreateFlowStep";
|
||||||
import {
|
import {
|
||||||
getAssetPath,
|
getAssetPath,
|
||||||
vectorMarkPath,
|
vectorMarkPath,
|
||||||
} from "../../../../../lib/assetUtils";
|
} from "../../../../../lib/assetUtils";
|
||||||
|
import { isPreviewSurfaceColor } from "../../../../../lib/create/previewSurfaceColor";
|
||||||
|
import {
|
||||||
|
persistDraftPreviewSurfaceColor,
|
||||||
|
readDraftPreviewSurfaceColor,
|
||||||
|
} from "../../utils/resolvePreviewSurfaceFallback";
|
||||||
|
|
||||||
const FACET_FALLBACK_ORDER: readonly TemplateFacetGroupKey[] = [
|
const FACET_FALLBACK_ORDER: readonly TemplateFacetGroupKey[] = [
|
||||||
"coreValues",
|
"coreValues",
|
||||||
@@ -234,6 +240,39 @@ export function FinalReviewScreen({
|
|||||||
const descriptionEmptyHint =
|
const descriptionEmptyHint =
|
||||||
variant === "editPublished" ? t("communityContextEditModal.emptyHint") : undefined;
|
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 (
|
return (
|
||||||
<CreateFlowLockupCardStepShell
|
<CreateFlowLockupCardStepShell
|
||||||
lockupTitle={
|
lockupTitle={
|
||||||
@@ -271,11 +310,13 @@ export function FinalReviewScreen({
|
|||||||
}
|
}
|
||||||
size={mdUp ? "L" : "M"}
|
size={mdUp ? "L" : "M"}
|
||||||
expanded={true}
|
expanded={true}
|
||||||
backgroundColor="bg-[var(--color-surface-invert-brand-teal)]"
|
descriptionAtBottom
|
||||||
logoUrl={getAssetPath(vectorMarkPath("mutual-aid"))}
|
backgroundColor="bg-transparent"
|
||||||
|
surfaceColor={surfaceColor}
|
||||||
|
logoUrl={avatarUrl ?? getAssetPath(vectorMarkPath("mutual-aid"))}
|
||||||
logoAlt={ruleCardTitle}
|
logoAlt={ruleCardTitle}
|
||||||
categories={finalReviewCategories}
|
categories={finalReviewCategories}
|
||||||
className={CREATE_FLOW_REVIEW_RULE_LAYOUT_CLASS}
|
className={`${CREATE_FLOW_REVIEW_RULE_LAYOUT_CLASS}${surfaceColor ? "" : " invisible"}`}
|
||||||
onClick={() => {}}
|
onClick={() => {}}
|
||||||
/>
|
/>
|
||||||
<FinalReviewChipEditModal
|
<FinalReviewChipEditModal
|
||||||
|
|||||||
@@ -118,6 +118,11 @@ export interface CreateFlowState {
|
|||||||
* Set after successful `POST /api/uploads` with purpose `communityAvatar`.
|
* Set after successful `POST /api/uploads` with purpose `communityAvatar`.
|
||||||
*/
|
*/
|
||||||
communityAvatarUrl?: string;
|
communityAvatarUrl?: string;
|
||||||
|
/**
|
||||||
|
* Light `hsl(...)` fill for the review card when no logo is uploaded.
|
||||||
|
* Picked once and kept on the draft so reload does not change it.
|
||||||
|
*/
|
||||||
|
previewSurfaceColor?: string;
|
||||||
/** Selected chip ids from `community-size` (MultiSelect). */
|
/** Selected chip ids from `community-size` (MultiSelect). */
|
||||||
selectedCommunitySizeIds?: string[];
|
selectedCommunitySizeIds?: string[];
|
||||||
/** Selected chip ids from `community-structure` (organization types). */
|
/** Selected chip ids from `community-structure` (organization types). */
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -49,6 +49,8 @@ const RuleContainer = memo<RuleProps>(
|
|||||||
recommended = false,
|
recommended = false,
|
||||||
templateGridFigmaShell = false,
|
templateGridFigmaShell = false,
|
||||||
fluidWidth = false,
|
fluidWidth = false,
|
||||||
|
descriptionAtBottom = false,
|
||||||
|
surfaceColor,
|
||||||
}) => {
|
}) => {
|
||||||
const size = sizeProp ?? "L";
|
const size = sizeProp ?? "L";
|
||||||
const t = useTranslation("ruleCard");
|
const t = useTranslation("ruleCard");
|
||||||
@@ -112,6 +114,8 @@ const RuleContainer = memo<RuleProps>(
|
|||||||
recommended={recommended}
|
recommended={recommended}
|
||||||
templateGridFigmaShell={templateGridFigmaShell}
|
templateGridFigmaShell={templateGridFigmaShell}
|
||||||
fluidWidth={fluidWidth}
|
fluidWidth={fluidWidth}
|
||||||
|
descriptionAtBottom={descriptionAtBottom}
|
||||||
|
surfaceColor={surfaceColor}
|
||||||
cardAriaLabel={cardAriaLabel}
|
cardAriaLabel={cardAriaLabel}
|
||||||
recommendedLabel={recommendedLabel}
|
recommendedLabel={recommendedLabel}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -84,6 +84,16 @@ export interface RuleProps {
|
|||||||
templateGridFigmaShell?: boolean;
|
templateGridFigmaShell?: boolean;
|
||||||
/** When true, expanded cards fill their container instead of a fixed Figma width. */
|
/** When true, expanded cards fill their container instead of a fixed Figma width. */
|
||||||
fluidWidth?: boolean;
|
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 {
|
export interface RuleViewProps {
|
||||||
@@ -113,6 +123,8 @@ export interface RuleViewProps {
|
|||||||
recommended?: boolean;
|
recommended?: boolean;
|
||||||
templateGridFigmaShell?: boolean;
|
templateGridFigmaShell?: boolean;
|
||||||
fluidWidth?: boolean;
|
fluidWidth?: boolean;
|
||||||
|
descriptionAtBottom?: boolean;
|
||||||
|
surfaceColor?: string;
|
||||||
/** Interactive card aria-label; supplied by the container from `ruleCard` messages. */
|
/** Interactive card aria-label; supplied by the container from `ruleCard` messages. */
|
||||||
cardAriaLabel: string;
|
cardAriaLabel: string;
|
||||||
recommendedLabel: string;
|
recommendedLabel: string;
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ export function RuleView({
|
|||||||
recommended = false,
|
recommended = false,
|
||||||
templateGridFigmaShell = false,
|
templateGridFigmaShell = false,
|
||||||
fluidWidth = false,
|
fluidWidth = false,
|
||||||
|
descriptionAtBottom = false,
|
||||||
|
surfaceColor,
|
||||||
cardAriaLabel,
|
cardAriaLabel,
|
||||||
recommendedLabel,
|
recommendedLabel,
|
||||||
}: RuleViewProps) {
|
}: 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 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 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 ? (
|
||||||
|
<div className={expandedDescriptionFrameClass}>
|
||||||
|
{onDescriptionClick ? (
|
||||||
|
<InlineTextButton
|
||||||
|
type="button"
|
||||||
|
underline={false}
|
||||||
|
data-testid="rule-description-edit"
|
||||||
|
ariaLabel={descriptionEditAriaLabel}
|
||||||
|
className={`${descriptionClass} w-full min-w-0 cursor-pointer whitespace-pre-wrap text-left text-[var(--color-content-invert-primary)] hover:!opacity-100 ${
|
||||||
|
!description && descriptionEmptyHint ? "opacity-70" : ""
|
||||||
|
}`.trim()}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDescriptionClick();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{description ?? descriptionEmptyHint ?? ""}
|
||||||
|
</InlineTextButton>
|
||||||
|
) : (
|
||||||
|
description && (
|
||||||
|
<p
|
||||||
|
className={`${descriptionClass} cursor-inherit text-[var(--color-content-invert-primary)]`}
|
||||||
|
>
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
|
||||||
const cardBody = (
|
const cardBody = (
|
||||||
<>
|
<>
|
||||||
{/* Figma: Header = `border-b` row, `gap-px`, icon `pl-1 pr-2 py-2` + `border-l` on title. */}
|
{/* 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({
|
|||||||
</div>
|
</div>
|
||||||
) : expanded ? (
|
) : expanded ? (
|
||||||
<>
|
<>
|
||||||
{(description ||
|
{descriptionAtBottom ? null : expandedDescription}
|
||||||
(onDescriptionClick &&
|
|
||||||
typeof descriptionEmptyHint === "string")) && (
|
|
||||||
<div
|
|
||||||
className={`relative w-full shrink-0 ${
|
|
||||||
categories && categories.length > 0
|
|
||||||
? "border-b border-solid border-[var(--color-content-invert-primary)] pb-[16px]"
|
|
||||||
: ""
|
|
||||||
} ${expanded && (isLarge || isMedium) ? "px-0" : "px-[12px]"}`}
|
|
||||||
>
|
|
||||||
{onDescriptionClick ? (
|
|
||||||
<InlineTextButton
|
|
||||||
type="button"
|
|
||||||
underline={false}
|
|
||||||
data-testid="rule-description-edit"
|
|
||||||
ariaLabel={descriptionEditAriaLabel}
|
|
||||||
className={`${descriptionClass} w-full min-w-0 cursor-pointer whitespace-pre-wrap text-left text-[var(--color-content-invert-primary)] hover:!opacity-100 ${
|
|
||||||
!description && descriptionEmptyHint ? "opacity-70" : ""
|
|
||||||
}`.trim()}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onDescriptionClick();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{description ?? descriptionEmptyHint ?? ""}
|
|
||||||
</InlineTextButton>
|
|
||||||
) : (
|
|
||||||
description && (
|
|
||||||
<p
|
|
||||||
className={`${descriptionClass} cursor-inherit text-[var(--color-content-invert-primary)]`}
|
|
||||||
>
|
|
||||||
{description}
|
|
||||||
</p>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{/* Categories Section - Using MultiSelect */}
|
{/* Categories Section - Using MultiSelect */}
|
||||||
{categories && categories.length > 0 && (
|
{categories && categories.length > 0 && (
|
||||||
<div
|
<div
|
||||||
@@ -459,6 +475,7 @@ export function RuleView({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{descriptionAtBottom ? expandedDescription : null}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
/* Collapsed State: Description */
|
/* Collapsed State: Description */
|
||||||
@@ -488,6 +505,7 @@ export function RuleView({
|
|||||||
<NextLink
|
<NextLink
|
||||||
href={href}
|
href={href}
|
||||||
className={cardClassName}
|
className={cardClassName}
|
||||||
|
style={surfaceStyle}
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
@@ -499,6 +517,7 @@ export function RuleView({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cardClassName}
|
className={cardClassName}
|
||||||
|
style={surfaceStyle}
|
||||||
tabIndex={interactiveCard ? 0 : undefined}
|
tabIndex={interactiveCard ? 0 : undefined}
|
||||||
role={interactiveCard ? "button" : "article"}
|
role={interactiveCard ? "button" : "article"}
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
@@ -510,4 +529,3 @@ export function RuleView({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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<HTMLImageElement | null> {
|
||||||
|
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<string | null> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -106,6 +106,11 @@ export const createFlowStateSchema = z
|
|||||||
communityContext: z.string().max(200).optional(),
|
communityContext: z.string().max(200).optional(),
|
||||||
communitySaveEmail: z.string().max(320).optional(),
|
communitySaveEmail: z.string().max(320).optional(),
|
||||||
communityAvatarUrl: z.string().max(512).optional(),
|
communityAvatarUrl: z.string().max(512).optional(),
|
||||||
|
previewSurfaceColor: z
|
||||||
|
.string()
|
||||||
|
.max(32)
|
||||||
|
.regex(/^hsl\(\d{1,3}, \d{1,3}%, \d{1,3}%\)$/)
|
||||||
|
.optional(),
|
||||||
selectedCommunitySizeIds: z.array(z.string()).optional(),
|
selectedCommunitySizeIds: z.array(z.string()).optional(),
|
||||||
selectedOrganizationTypeIds: z.array(z.string()).optional(),
|
selectedOrganizationTypeIds: z.array(z.string()).optional(),
|
||||||
selectedScaleIds: z.array(z.string()).optional(),
|
selectedScaleIds: z.array(z.string()).optional(),
|
||||||
|
|||||||
@@ -122,6 +122,43 @@ describe("FinalReviewScreen", () => {
|
|||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("places the group description under the category rows", async () => {
|
||||||
|
render(
|
||||||
|
<FinalReviewWithFlowState
|
||||||
|
title="Oak Park Commons"
|
||||||
|
communityContext="Local mutual aid."
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<FinalReviewWithFlowState
|
||||||
|
title="Oak Park Commons"
|
||||||
|
communityContext="Local mutual aid."
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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)", () => {
|
it("renders Rule as a button (card is interactive)", () => {
|
||||||
render(<FinalReviewScreen />);
|
render(<FinalReviewScreen />);
|
||||||
const buttons = screen.getAllByRole("button");
|
const buttons = screen.getAllByRole("button");
|
||||||
|
|||||||
@@ -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 <FinalReviewScreen />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FinalReviewWithStateProbe({
|
||||||
|
initial,
|
||||||
|
onState,
|
||||||
|
}: {
|
||||||
|
initial: CreateFlowState;
|
||||||
|
onState: (_state: CreateFlowState) => void;
|
||||||
|
}) {
|
||||||
|
const { state, replaceState } = useCreateFlow();
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
replaceState(initial);
|
||||||
|
}, [replaceState, initial]);
|
||||||
|
useEffect(() => {
|
||||||
|
onState(state);
|
||||||
|
}, [onState, state]);
|
||||||
|
if (state.title !== initial.title) return null;
|
||||||
|
if (
|
||||||
|
initial.previewSurfaceColor &&
|
||||||
|
state.previewSurfaceColor !== initial.previewSurfaceColor
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return <FinalReviewScreen />;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Final review preview surface", () => {
|
||||||
|
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(<FinalReviewWithAvatar />);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<FinalReviewWithStateProbe
|
||||||
|
initial={{
|
||||||
|
title: "Oak Park Commons",
|
||||||
|
communityContext: "Local mutual aid.",
|
||||||
|
}}
|
||||||
|
onState={(state) => {
|
||||||
|
latest = state;
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(latest.previewSurfaceColor).toBe("hsl(12, 50%, 80%)");
|
||||||
|
});
|
||||||
|
expect(vi.mocked(randomPreviewSurfaceColor)).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reuses 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(
|
||||||
|
<FinalReviewWithStateProbe
|
||||||
|
initial={{
|
||||||
|
title: "Oak Park Commons",
|
||||||
|
communityContext: "Local mutual aid.",
|
||||||
|
}}
|
||||||
|
onState={(state) => {
|
||||||
|
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(
|
||||||
|
<FinalReviewWithStateProbe
|
||||||
|
initial={{
|
||||||
|
title: "Oak Park Commons",
|
||||||
|
communityContext: "Local mutual aid.",
|
||||||
|
previewSurfaceColor: "hsl(200, 60%, 80%)",
|
||||||
|
}}
|
||||||
|
onState={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const card = screen.getByRole("button", { name: /Oak Park Commons/i });
|
||||||
|
expect(card.style.backgroundColor).not.toBe("");
|
||||||
|
});
|
||||||
|
expect(vi.mocked(randomPreviewSurfaceColor)).not.toHaveBeenCalled();
|
||||||
|
const card = screen.getByRole("button", { name: /Oak Park Commons/i });
|
||||||
|
expect(card.style.backgroundColor).not.toBe("rgb(12, 80, 40)");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -257,11 +257,49 @@ describe("Rule Component", () => {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
render(
|
render(<Rule {...defaultProps} expanded={true} categories={categories} />);
|
||||||
<Rule {...defaultProps} expanded={true} categories={categories} />,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText("Values")).toBeInTheDocument();
|
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(
|
||||||
|
<Rule
|
||||||
|
{...defaultProps}
|
||||||
|
expanded={true}
|
||||||
|
descriptionAtBottom
|
||||||
|
categories={categories}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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(<Rule {...defaultProps} surfaceColor="rgb(12, 80, 40)" />);
|
||||||
|
expect(screen.getByRole("button")).toHaveStyle({
|
||||||
|
backgroundColor: "rgb(12, 80, 40)",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders with logo URL", () => {
|
it("renders with logo URL", () => {
|
||||||
|
|||||||
@@ -148,6 +148,20 @@ describe("createFlowStateSchema", () => {
|
|||||||
expect(r.success).toBe(true);
|
expect(r.success).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("accepts previewSurfaceColor hsl fills", () => {
|
||||||
|
const r = createFlowStateSchema.safeParse({
|
||||||
|
previewSurfaceColor: "hsl(12, 50%, 80%)",
|
||||||
|
});
|
||||||
|
expect(r.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects previewSurfaceColor that is not an hsl fill", () => {
|
||||||
|
const r = createFlowStateSchema.safeParse({
|
||||||
|
previewSurfaceColor: "red",
|
||||||
|
});
|
||||||
|
expect(r.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("accepts templateReviewBackSlug", () => {
|
it("accepts templateReviewBackSlug", () => {
|
||||||
const r = createFlowStateSchema.safeParse({
|
const r = createFlowStateSchema.safeParse({
|
||||||
templateReviewBackSlug: "mutual-aid-mondays",
|
templateReviewBackSlug: "mutual-aid-mondays",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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%)");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user