Compare commits

...
10 Commits
Author SHA1 Message Date
adilalloandCursor 77b0ec8ad8 Return focus to the control that opened a dialog instead of leaving it on the document body.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-17 17:17:09 -06:00
an.di 98945ceb7e Merge pull request 'Restore the community photo after reload and reject empty, oversized, SVG, and spoofed uploads.' (#80) from adilallo/fix/CR-195-photo-upload into main
Reviewed-on: #80
2026-09-10 21:57:52 +00:00
adilalloandCursor 234f3998ad Restore the community photo after reload and reject empty, oversized, SVG, and spoofed uploads.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 15:50:32 -06:00
an.di 6ccc1e8c8e Merge pull request 'Keep the template picker in the create flow, make catalog cards real links, and stop showing a fake community when review has no draft.' (#79) from adilallo/fix/CR-183-builder-routing into main
Reviewed-on: #79
2026-09-10 21:30:10 +00:00
adilalloandCursor 90db213413 Keep the template picker in the create flow, make catalog cards real links, and stop showing a fake community when review has no draft.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 15:22:46 -06:00
an.di 3f9a8395be Merge pull request 'Unify create-flow gutters, viewport height, and action-bar width so tablet and wide layouts stay aligned with step content.' (#78) from adilallo/fix/CR-182-builder-layout into main
Reviewed-on: #78
2026-09-10 20:03:44 +00:00
adilalloandCursor 0e272f0c1e Unify create-flow gutters, viewport height, and action-bar width so tablet and wide layouts stay aligned with step content.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 12:02:06 -06:00
an.di 4055278628 Merge pull request 'Give builder fields persistent labels, treat description as optional, and validate save-progress email with a real form.' (#77) from adilallo/fix/CR-185-builder-inputs into main
Reviewed-on: #77
2026-09-10 17:40:55 +00:00
adilalloandCursor 42d83c8b62 Give builder fields persistent labels, treat description as optional, and validate save-progress email with a real form.
Name stays required; description is a labelled optional textarea. Email uses native validity and form submit. Drop the dead workshop link and fix a few copy errors.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 11:37:41 -06:00
an.di 130663f930 Merge pull request 'Expose builder chip selection beyond color, raise unselected contrast, and make the five-value limit visible' (#76) from adilallo/fix/CR-186-builder-chips into main
Reviewed-on: #76
2026-09-10 17:15:35 +00:00
94 changed files with 2543 additions and 632 deletions
+63 -27
View File
@@ -27,12 +27,12 @@ import {
shouldOfferCreateFlowSaveAndExit, shouldOfferCreateFlowSaveAndExit,
isDirectTemplateReviewEntry, isDirectTemplateReviewEntry,
createFlowStepUsesSelectSplitScroll, createFlowStepUsesSelectSplitScroll,
TEMPLATES_FACET_RECOMMEND_QUERY, isCreateFlowTemplatesPickerPath,
TEMPLATES_FACET_RECOMMEND_VALUE,
TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY, TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY,
TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE, TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE,
} from "./utils/flowSteps"; } from "./utils/flowSteps";
import { import {
CREATE_FLOW_COMMUNITY_SAVE_FORM_ID,
CREATE_FLOW_SYNC_DRAFT_QUERY, CREATE_FLOW_SYNC_DRAFT_QUERY,
CREATE_FLOW_SYNC_DRAFT_VALUE, CREATE_FLOW_SYNC_DRAFT_VALUE,
CREATE_ROUTES, CREATE_ROUTES,
@@ -75,7 +75,13 @@ import {
CREATE_FLOW_FOOTER_BUTTON_CLASS, CREATE_FLOW_FOOTER_BUTTON_CLASS,
CREATE_FLOW_FOOTER_BUTTON_ON_DARK_CLASS, CREATE_FLOW_FOOTER_BUTTON_ON_DARK_CLASS,
} from "./utils/createFlowFooterClassNames"; } from "./utils/createFlowFooterClassNames";
import { CREATE_FLOW_MD_CENTERED_MAIN_CLASS } from "./components/createFlowLayoutTokens"; import {
CREATE_FLOW_MD_CENTERED_MAIN_CLASS,
CREATE_FLOW_PAGE_GUTTER_CLASS,
CREATE_FLOW_SCROLL_REGION_CLASS,
CREATE_FLOW_VIEWPORT_FRAME_CLASS,
getCreateFlowContentMaxClass,
} from "./components/createFlowLayoutTokens";
import { import {
CUSTOM_RULE_CONFIRM_FOOTER_STEP_BY_STEP, CUSTOM_RULE_CONFIRM_FOOTER_STEP_BY_STEP,
methodCardFacetSectionForConfirmStep, methodCardFacetSectionForConfirmStep,
@@ -302,6 +308,8 @@ function CreateFlowLayoutContent({
fromCreateWizard, fromCreateWizard,
markCreateFlowInteraction, markCreateFlowInteraction,
}); });
const isCreateTemplatesPickerRoute =
isCreateFlowTemplatesPickerPath(pathname);
const runAuthenticatedExit = useCreateFlowExit({ const runAuthenticatedExit = useCreateFlowExit({
state, state,
@@ -482,12 +490,28 @@ function CreateFlowLayoutContent({
return () => window.clearTimeout(timeoutId); return () => window.clearTimeout(timeoutId);
}, [currentStep, sessionResolved, sessionUser, openLogin]); }, [currentStep, sessionResolved, sessionUser, openLogin]);
const handleCommunitySaveMagicLinkSubmit = useCallback(async () => { const handleCommunitySaveMagicLinkSubmit = useCallback(async (
formEl?: HTMLFormElement | null,
) => {
setCommunitySaveMagicLinkError(null); setCommunitySaveMagicLinkError(null);
setCommunitySaveMagicLinkSuccess(false); setCommunitySaveMagicLinkSuccess(false);
const raw = state.communitySaveEmail; const namedEmail = formEl?.elements.namedItem("email");
const trimmed = typeof raw === "string" ? raw.trim().toLowerCase() : ""; const emailField =
if (!isValidCreateFlowSaveEmail(trimmed)) return; namedEmail instanceof HTMLInputElement
? namedEmail
: typeof document === "undefined"
? null
: document.querySelector<HTMLInputElement>(
`input[name="email"][form="${CREATE_FLOW_COMMUNITY_SAVE_FORM_ID}"]`,
);
if (emailField != null && !emailField.checkValidity()) {
emailField.reportValidity();
return;
}
const raw =
emailField != null ? emailField.value : state.communitySaveEmail;
if (typeof raw !== "string" || !isValidCreateFlowSaveEmail(raw)) return;
const trimmed = raw.trim().toLowerCase();
setCommunitySaveMagicLinkSubmitting(true); setCommunitySaveMagicLinkSubmitting(true);
try { try {
@@ -543,6 +567,8 @@ function CreateFlowLayoutContent({
? "items-stretch overflow-y-auto md:overflow-hidden" ? "items-stretch overflow-y-auto md:overflow-hidden"
: isSelectSplitScrollStep : isSelectSplitScrollStep
? "items-start justify-start overflow-y-auto max-lg:overflow-y-auto lg:min-h-0 lg:items-stretch lg:overflow-hidden" ? "items-start justify-start overflow-y-auto max-lg:overflow-y-auto lg:min-h-0 lg:items-stretch lg:overflow-hidden"
: isCreateTemplatesPickerRoute
? "items-start justify-start overflow-y-auto"
: isTemplateReviewRoute || isFinalReviewLike || isCardLayoutStep : isTemplateReviewRoute || isFinalReviewLike || isCardLayoutStep
? CREATE_FLOW_MD_CENTERED_MAIN_CLASS ? CREATE_FLOW_MD_CENTERED_MAIN_CLASS
: "items-start justify-center overflow-y-auto md:items-center"; : "items-start justify-center overflow-y-auto md:items-center";
@@ -556,10 +582,9 @@ function CreateFlowLayoutContent({
? "max-md:flex-col max-md:items-stretch" ? "max-md:flex-col max-md:items-stretch"
: "max-md:flex-col max-md:items-center"; : "max-md:flex-col max-md:items-center";
const mainResponsiveLayout = `${mainMaxMdCross} ${mainMaxMdJustify} md:flex-row md:justify-center`; const mainResponsiveLayout = `${mainMaxMdCross} ${mainMaxMdJustify} md:flex-row md:justify-center`;
const saveDraftOnExit = shouldOfferCreateFlowSaveAndExit( const saveDraftOnExit =
currentStep, shouldOfferCreateFlowSaveAndExit(currentStep, sessionUser) ||
sessionUser, isCreateTemplatesPickerRoute;
);
const proportionBarProgress = getProportionBarProgressForCreateFlowStep( const proportionBarProgress = getProportionBarProgressForCreateFlowStep(
currentStep, currentStep,
@@ -651,10 +676,10 @@ function CreateFlowLayoutContent({
].filter((b): b is NonNullable<typeof b> => b !== null); ].filter((b): b is NonNullable<typeof b> => b !== null);
return ( return (
<div className="relative flex h-screen min-h-0 flex-col overflow-hidden bg-black"> <div className={`${CREATE_FLOW_VIEWPORT_FRAME_CLASS} bg-black`}>
{topBanners.length > 0 ? ( {topBanners.length > 0 ? (
<div <div
className="pointer-events-none fixed left-0 right-0 top-0 z-[200] flex flex-col gap-2 px-[var(--spacing-measures-spacing-500,20px)] pt-[var(--spacing-measures-spacing-300,12px)] md:px-[var(--measures-spacing-1800,64px)]" className={`pointer-events-none fixed left-0 right-0 top-0 z-[200] flex flex-col gap-2 ${CREATE_FLOW_PAGE_GUTTER_CLASS} pt-[var(--spacing-measures-spacing-300,12px)]`}
aria-live="polite" aria-live="polite"
> >
{topBanners.map((b) => ( {topBanners.map((b) => (
@@ -770,16 +795,32 @@ function CreateFlowLayoutContent({
isCompletedStep ? "!bg-[var(--color-teal-teal50,#c9fef9)]" : "" isCompletedStep ? "!bg-[var(--color-teal-teal50,#c9fef9)]" : ""
}`.trim()} }`.trim()}
/> />
{currentStep === "community-save" ? (
<form
id={CREATE_FLOW_COMMUNITY_SAVE_FORM_ID}
className="hidden"
onSubmit={(e) => {
e.preventDefault();
void handleCommunitySaveMagicLinkSubmit(e.currentTarget);
}}
/>
) : null}
<main <main
className={`flex min-h-0 min-w-0 flex-1 w-full max-w-full overflow-x-hidden ${mainContentClass} ${mainResponsiveLayout}`} className={`flex min-h-0 min-w-0 flex-1 w-full max-w-full overflow-x-hidden ${CREATE_FLOW_SCROLL_REGION_CLASS} ${mainContentClass} ${mainResponsiveLayout}`}
> >
{children} {children}
</main> </main>
{!isCompletedStep && ( {!isCompletedStep && (
<CreateFlowFooter <CreateFlowFooter
className="shrink-0" className="shrink-0"
contentMaxClass={getCreateFlowContentMaxClass({
step: currentStep,
isTemplateReview: isTemplateReviewRoute,
isTemplatesPicker: isCreateTemplatesPickerRoute,
})}
progressBar={ progressBar={
!isTemplateReviewRoute && !isTemplateReviewRoute &&
!isCreateTemplatesPickerRoute &&
!isFinalReviewLike && !isFinalReviewLike &&
reviewReturnTarget !== "edit-rule" reviewReturnTarget !== "edit-rule"
} }
@@ -841,6 +882,8 @@ function CreateFlowLayoutContent({
{footer.saveLater} {footer.saveLater}
</Button> </Button>
<Button <Button
type="submit"
form={CREATE_FLOW_COMMUNITY_SAVE_FORM_ID}
buttonType="filled" buttonType="filled"
palette="default" palette="default"
size="xsmall" size="xsmall"
@@ -851,9 +894,6 @@ function CreateFlowLayoutContent({
!isValidCreateFlowSaveEmail(state.communitySaveEmail) !isValidCreateFlowSaveEmail(state.communitySaveEmail)
} }
className={CREATE_FLOW_FOOTER_BUTTON_CLASS} className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
onClick={() => {
void handleCommunitySaveMagicLinkSubmit();
}}
> >
{communitySaveMagicLinkSubmitting {communitySaveMagicLinkSubmitting
? footer.submitEmailSending ? footer.submitEmailSending
@@ -884,15 +924,7 @@ function CreateFlowLayoutContent({
disabled={isPublishing} disabled={isPublishing}
className={CREATE_FLOW_FOOTER_BUTTON_CLASS} className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
onClick={() => { onClick={() => {
// `fromFlow=1` tells `/templates` to skip the fresh-slate router.push(CREATE_ROUTES.templatesPicker);
// draft clear it normally runs on template click, so the
// user's in-progress Create Community stage survives this
// detour. Direct entries to `/templates` (no marker) and
// home "Popular templates" clicks always start fresh by
// wiping anonymous draft storage at click time.
router.push(
`/templates?${TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY}=${TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE}&${TEMPLATES_FACET_RECOMMEND_QUERY}=${TEMPLATES_FACET_RECOMMEND_VALUE}`,
);
}} }}
> >
{footer.createFromTemplate} {footer.createFromTemplate}
@@ -977,7 +1009,11 @@ function CreateFlowLayoutContent({
) : null ) : null
} }
onBackClick={ onBackClick={
isTemplateReviewRoute isCreateTemplatesPickerRoute
? () => {
router.push(CREATE_ROUTES.review);
}
: isTemplateReviewRoute
? () => ? () =>
router.push( router.push(
templateReviewFooterBackToCreateReview templateReviewFooterBackToCreateReview
+6 -1
View File
@@ -11,9 +11,11 @@ import {
import { useCreateFlow } from "./context/CreateFlowContext"; import { useCreateFlow } from "./context/CreateFlowContext";
import { fetchDraftFromServer } from "../../../lib/create/api"; import { fetchDraftFromServer } from "../../../lib/create/api";
import messages from "../../../messages/en/index"; import messages from "../../../messages/en/index";
import { CREATE_FLOW_PAGE_GUTTER_CLASS } from "./components/createFlowLayoutTokens";
import Alert from "../../components/modals/Alert"; import Alert from "../../components/modals/Alert";
import { import {
isValidStep, isValidStep,
isCreateFlowTemplatesPickerPath,
parseCreateFlowScreenFromPathname, parseCreateFlowScreenFromPathname,
} from "./utils/flowSteps"; } from "./utils/flowSteps";
import { hasFreshEntryPending } from "./utils/prepareFreshCreateFlowEntry"; import { hasFreshEntryPending } from "./utils/prepareFreshCreateFlowEntry";
@@ -110,6 +112,9 @@ export function SignedInDraftHydration({
if (pathname?.includes("/create/review-template/")) { if (pathname?.includes("/create/review-template/")) {
return; return;
} }
if (isCreateFlowTemplatesPickerPath(pathname)) {
return;
}
if (touchedRef.current) { if (touchedRef.current) {
finishedUserIdRef.current = userId; finishedUserIdRef.current = userId;
return; return;
@@ -161,7 +166,7 @@ export function SignedInDraftHydration({
if (!loadingHydration) return null; if (!loadingHydration) return null;
return ( return (
<div className="pointer-events-none fixed left-0 right-0 top-14 z-[170] flex justify-center px-[var(--spacing-measures-spacing-500,20px)] pt-2 md:top-16 md:px-[var(--measures-spacing-1800,64px)]"> <div className={`pointer-events-none fixed left-0 right-0 top-14 z-[170] flex justify-center ${CREATE_FLOW_PAGE_GUTTER_CLASS} pt-2 md:top-16`}>
<div className="pointer-events-auto w-full max-w-[960px]"> <div className="pointer-events-auto w-full max-w-[960px]">
<Alert <Alert
type="banner" type="banner"
@@ -0,0 +1,56 @@
"use client";
import type { ReactNode } from "react";
import { CreateFlowHeaderLockup } from "./CreateFlowHeaderLockup";
import { CreateFlowStepShell } from "./CreateFlowStepShell";
import {
CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS,
CREATE_FLOW_MD_CENTERED_SHELL_CLASS,
CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS,
CREATE_FLOW_WIDE_MAX_CLASS,
} from "./createFlowLayoutTokens";
type CreateFlowCardStackStepShellProps = {
lockupTitle: string;
lockupDescription?: ReactNode;
/** When true, the stack region exposes `aria-busy` while recommendations load. */
stackBusy?: boolean;
children: ReactNode;
};
/**
* Centered lockup + card-stack layout shared by communication, membership, and
* conflict-management steps (Figma compact card stack).
*/
export function CreateFlowCardStackStepShell({
lockupTitle,
lockupDescription,
stackBusy = false,
children,
}: CreateFlowCardStackStepShellProps) {
return (
<CreateFlowStepShell
variant="wideGridLoosePadding"
contentTopBelowMd="space-800"
className={CREATE_FLOW_MD_CENTERED_SHELL_CLASS}
>
<div
className={`flex w-full min-w-0 flex-col items-center gap-6 ${CREATE_FLOW_WIDE_MAX_CLASS}`}
>
<div className={CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}>
<CreateFlowHeaderLockup
title={lockupTitle}
description={lockupDescription}
justification="center"
/>
</div>
<div
className={CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS}
aria-busy={stackBusy}
>
{children}
</div>
</div>
</CreateFlowStepShell>
);
}
@@ -2,7 +2,10 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { useCreateFlow } from "../context/CreateFlowContext"; import { useCreateFlow } from "../context/CreateFlowContext";
import { uploadCreateFlowFile } from "../../../../lib/create/uploadToServer"; import {
CreateFlowUploadValidationError,
uploadCreateFlowFile,
} from "../../../../lib/create/uploadToServer";
import { import {
clearPendingCommunityAvatarFile, clearPendingCommunityAvatarFile,
readPendingCommunityAvatarFile, readPendingCommunityAvatarFile,
@@ -19,13 +22,17 @@ export function CreateFlowPendingAvatarFlush({
sessionUser: { id: string; email: string } | null | undefined; sessionUser: { id: string; email: string } | null | undefined;
sessionResolved: boolean; sessionResolved: boolean;
}) { }) {
const { updateState } = useCreateFlow(); const { state, updateState } = useCreateFlow();
/** One successful flush per signed-in user id (survives React StrictMode remounts). */ /** One successful flush per signed-in user id (survives React StrictMode remounts). */
const lastFlushedUserIdRef = useRef<string | null>(null); const lastFlushedUserIdRef = useRef<string | null>(null);
const hasServerAvatar =
typeof state.communityAvatarUrl === "string" &&
state.communityAvatarUrl.trim().length > 0;
useEffect(() => { useEffect(() => {
if (!sessionResolved || !sessionUser) return; if (!sessionResolved || !sessionUser) return;
if (lastFlushedUserIdRef.current === sessionUser.id) return; if (lastFlushedUserIdRef.current === sessionUser.id) return;
if (hasServerAvatar) return;
let cancelled = false; let cancelled = false;
void (async () => { void (async () => {
@@ -37,15 +44,18 @@ export function CreateFlowPendingAvatarFlush({
await clearPendingCommunityAvatarFile(); await clearPendingCommunityAvatarFile();
updateState({ communityAvatarUrl: url }); updateState({ communityAvatarUrl: url });
lastFlushedUserIdRef.current = sessionUser.id; lastFlushedUserIdRef.current = sessionUser.id;
} catch { } catch (err) {
// Leave pending blob in place so the user can retry after fixing auth / UPLOAD_ROOT. if (err instanceof CreateFlowUploadValidationError) {
await clearPendingCommunityAvatarFile();
}
// Leave a transient (auth / UPLOAD_ROOT) failure in place to retry.
} }
})(); })();
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [sessionResolved, sessionUser, updateState]); }, [hasServerAvatar, sessionResolved, sessionUser, updateState]);
return null; return null;
} }
@@ -1,6 +1,7 @@
"use client"; "use client";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { CREATE_FLOW_PAGE_GUTTER_CLASS } from "./createFlowLayoutTokens";
export type CreateFlowStepShellVariant = export type CreateFlowStepShellVariant =
| "centeredNarrow" | "centeredNarrow"
@@ -13,15 +14,12 @@ export type CreateFlowStepShellVariant =
export type CreateFlowContentTopBelowMd = "none" | "space-1400" | "space-800"; export type CreateFlowContentTopBelowMd = "none" | "space-1400" | "space-800";
const outerByVariant: Record<CreateFlowStepShellVariant, string> = { const outerByVariant: Record<CreateFlowStepShellVariant, string> = {
centeredNarrow: centeredNarrow: `flex w-full min-w-0 flex-col items-center ${CREATE_FLOW_PAGE_GUTTER_CLASS}`,
"flex w-full min-w-0 flex-col items-center px-5 md:px-16", centeredNarrowBottomPad: `flex w-full min-w-0 flex-col items-center ${CREATE_FLOW_PAGE_GUTTER_CLASS} pb-28 md:pb-32`,
centeredNarrowBottomPad: /** Wide two-column steps; inner content supplies the 1328px cap. */
"flex w-full min-w-0 flex-col items-center px-5 pb-28 md:px-[var(--measures-spacing-1800,64px)] md:pb-32", wideGrid: `flex w-full min-w-0 shrink-0 flex-col items-center ${CREATE_FLOW_PAGE_GUTTER_CLASS}`,
/** Wide two-column steps; 1328px = two 640px columns + 48px gutter. */ /** Create Community review + card grid; inner content supplies the 1440px cap. */
wideGrid: "w-full min-w-0 max-w-[1328px] shrink-0 px-5 md:px-12", wideGridLoosePadding: `flex w-full min-w-0 shrink-0 flex-col items-center ${CREATE_FLOW_PAGE_GUTTER_CLASS}`,
/** Create Community review + card grid (Figma Flow — Review `19706:12135`): max width 1440. */
wideGridLoosePadding:
"w-full min-w-0 max-w-[1440px] shrink-0 px-5 md:px-16",
bare: "w-full min-w-0", bare: "w-full min-w-0",
}; };
@@ -41,7 +39,8 @@ interface CreateFlowStepShellProps {
/** /**
* Shared horizontal padding and width constraints for create-flow step pages. * Shared horizontal padding and width constraints for create-flow step pages.
* Horizontal padding uses Tailwind `md:` so it tracks `--breakpoint-md` (640px in `app/tailwind.css`). * Gutters come from {@link CREATE_FLOW_PAGE_GUTTER_CLASS} (`md` / `lg` track
* `--breakpoint-md` and `--breakpoint-lg` in `app/tailwind.css`).
*/ */
export function CreateFlowStepShell({ export function CreateFlowStepShell({
children, children,
@@ -5,7 +5,11 @@ import {
CreateFlowStepShell, CreateFlowStepShell,
type CreateFlowContentTopBelowMd, type CreateFlowContentTopBelowMd,
} from "./CreateFlowStepShell"; } from "./CreateFlowStepShell";
import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "./createFlowLayoutTokens"; import {
CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS,
CREATE_FLOW_SCROLL_REGION_CLASS,
CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS,
} from "./createFlowLayoutTokens";
export type CreateFlowSelectShellLgVerticalAlign = "center" | "start"; export type CreateFlowSelectShellLgVerticalAlign = "center" | "start";
@@ -26,10 +30,10 @@ interface CreateFlowTwoColumnSelectShellProps {
} }
/** /**
* Two-column layout for create-flow select steps (community size/structure, core values) and * Question-and-options layout for create-flow select steps (community size/structure,
* {@link DecisionApproachesScreen} (decision approaches). Below `lg` (1024px), one column + main scrolls. * core values, stakeholders) and {@link DecisionApproachesScreen}.
* At `lg+`, mirrors {@link CompletedScreen}: static header column + scrollable controls column * Below `lg` (1024px), one column uses available width and main scrolls.
* (`min-h-0` + `overflow-y-auto` height chain; see completed page right rail). * At `lg+`, static header column + scrollable options column (`min-h-0` + overflow).
*/ */
export function CreateFlowTwoColumnSelectShell({ export function CreateFlowTwoColumnSelectShell({
header, header,
@@ -56,24 +60,24 @@ export function CreateFlowTwoColumnSelectShell({
> >
<div <div
className={ className={
"flex w-full min-w-0 flex-col items-start gap-[var(--measures-spacing-400,16px)] md:max-w-[640px] " + "flex w-full min-w-0 flex-col items-start gap-[var(--measures-spacing-400,16px)] " +
"max-lg:flex-none lg:max-h-full lg:max-w-[1328px] lg:min-h-0 lg:flex-1 lg:flex-row lg:flex-nowrap " + "max-lg:flex-none lg:max-h-full lg:min-h-0 lg:flex-1 lg:flex-row lg:flex-nowrap " +
`${rowLgCrossAlignClass} lg:justify-center lg:gap-[var(--measures-spacing-1200,48px)] lg:overflow-hidden` `${rowLgCrossAlignClass} lg:justify-center lg:gap-[var(--measures-spacing-1200,48px)] lg:overflow-hidden ${CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS}`
} }
> >
<div <div
className={ className={
`flex w-full min-w-0 shrink-0 flex-col items-start gap-[var(--measures-spacing-200,8px)] ` + `flex w-full min-w-0 shrink-0 flex-col items-start gap-[var(--measures-spacing-200,8px)] ` +
`lg:flex-1 ${leftLgMainJustifyClass} lg:py-[12px] lg:max-w-[640px] ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}` `lg:flex-1 ${leftLgMainJustifyClass} lg:py-[12px] ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`
} }
> >
{header} {header}
</div> </div>
<div <div
className={ className={
`scrollbar-hide relative flex w-full min-w-0 flex-col items-start gap-[var(--measures-spacing-800,32px)] ` + `relative flex w-full min-w-0 flex-col items-start gap-[var(--measures-spacing-800,32px)] ` +
`overflow-x-hidden lg:min-h-0 lg:flex-1 lg:overflow-y-auto lg:pb-[var(--measures-spacing-300,12px)] ` + `overflow-x-hidden lg:min-h-0 lg:flex-1 lg:overflow-y-auto lg:pb-[var(--measures-spacing-300,12px)] ` +
CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS `${CREATE_FLOW_SCROLL_REGION_CLASS} ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`
} }
> >
{children} {children}
@@ -3,7 +3,10 @@
import { memo, useCallback, useRef, useState } from "react"; import { memo, useCallback, useRef, useState } from "react";
import { useTranslation } from "../../../../contexts/MessagesContext"; import { useTranslation } from "../../../../contexts/MessagesContext";
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks"; import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer"; import {
createFlowUploadFailureMessageKey,
uploadCreateFlowFile,
} from "../../../../../lib/create/uploadToServer";
import { CustomMethodCardUploadBlockRowView } from "./CustomMethodCardUploadBlockRow.view"; import { CustomMethodCardUploadBlockRowView } from "./CustomMethodCardUploadBlockRow.view";
import type { CustomMethodCardUploadBlockRowProps } from "./CustomMethodCardFieldBlocksSummary.types"; import type { CustomMethodCardUploadBlockRowProps } from "./CustomMethodCardFieldBlocksSummary.types";
@@ -68,8 +71,8 @@ function CustomMethodCardUploadBlockRowContainerComponent({
: b, : b,
), ),
); );
} catch { } catch (err) {
setErrorMessage(tUpload("errors.generic")); setErrorMessage(tUpload(createFlowUploadFailureMessageKey(err)));
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -4,6 +4,7 @@ import { memo } from "react";
import Upload from "../../../../components/controls/Upload"; import Upload from "../../../../components/controls/Upload";
import InputLabel from "../../../../components/type/InputLabel"; import InputLabel from "../../../../components/type/InputLabel";
import { ASSETS, getAssetPath } from "../../../../../lib/assetUtils"; import { ASSETS, getAssetPath } from "../../../../../lib/assetUtils";
import { CUSTOM_ATTACHMENT_ACCEPT } from "../../../../../lib/create/createFlowUploadValidation";
import type { CustomMethodCardUploadBlockRowViewProps } from "./CustomMethodCardFieldBlocksSummary.types"; import type { CustomMethodCardUploadBlockRowViewProps } from "./CustomMethodCardFieldBlocksSummary.types";
function CustomMethodCardUploadBlockRowViewComponent({ function CustomMethodCardUploadBlockRowViewComponent({
@@ -43,7 +44,7 @@ function CustomMethodCardUploadBlockRowViewComponent({
type="file" type="file"
className="sr-only" className="sr-only"
tabIndex={-1} tabIndex={-1}
accept="image/jpeg,image/png,image/webp,image/gif,application/pdf" accept={CUSTOM_ATTACHMENT_ACCEPT}
aria-label={uploadFileInputAriaLabel} aria-label={uploadFileInputAriaLabel}
onChange={onFileInputChange} onChange={onFileInputChange}
/> />
@@ -8,6 +8,7 @@ import {
import { useAsyncConfirm } from "../../../../hooks/useAsyncConfirm"; import { useAsyncConfirm } from "../../../../hooks/useAsyncConfirm";
import { useBeforeUnloadGuard } from "../../../../hooks/useBeforeUnloadGuard"; import { useBeforeUnloadGuard } from "../../../../hooks/useBeforeUnloadGuard";
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks"; import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
import { createFlowUploadFailureMessageKey } from "../../../../../lib/create/uploadToServer";
import { import {
CUSTOM_METHOD_CARD_WIZARD_MAX_DESCRIPTION_CHARS, CUSTOM_METHOD_CARD_WIZARD_MAX_DESCRIPTION_CHARS,
CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS, CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS,
@@ -462,8 +463,9 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
try { try {
const { url } = await onPersistCustomUploadFile(file); const { url } = await onPersistCustomUploadFile(file);
setUploadAssetUrl(url); setUploadAssetUrl(url);
} catch { } catch (err) {
setUploadFieldError(tUpload("errors.generic")); setUploadFileName(undefined);
setUploadFieldError(tUpload(createFlowUploadFailureMessageKey(err)));
} finally { } finally {
setUploadFieldBusy(false); setUploadFieldBusy(false);
} }
@@ -80,6 +80,8 @@ function CustomMethodCardWizardViewComponent({
<TextArea <TextArea
appearance="default" appearance="default"
formHeader={false} formHeader={false}
showHelpIcon={false}
label={copy.step2.title}
placeholder={copy.step2.fieldPlaceholder} placeholder={copy.step2.fieldPlaceholder}
value={policyDescription} value={policyDescription}
maxLength={maxDescriptionChars} maxLength={maxDescriptionChars}
@@ -1,6 +1,6 @@
"use client"; "use client";
import { memo } from "react"; import { memo, useId } from "react";
import { ASSETS, getAssetPath } from "../../../../../lib/assetUtils"; import { ASSETS, getAssetPath } from "../../../../../lib/assetUtils";
import InputWithCounter from "../../../../components/controls/InputWithCounter"; import InputWithCounter from "../../../../components/controls/InputWithCounter";
import TextArea from "../../../../components/controls/TextArea"; import TextArea from "../../../../components/controls/TextArea";
@@ -10,6 +10,7 @@ import IncrementerBlock from "../../../../components/controls/IncrementerBlock";
import InputLabel from "../../../../components/type/InputLabel"; import InputLabel from "../../../../components/type/InputLabel";
import ApplicableScopeField from "../ApplicableScopeField"; import ApplicableScopeField from "../ApplicableScopeField";
import { CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS } from "../../../../../lib/create/customMethodCardWizardConstants"; import { CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS } from "../../../../../lib/create/customMethodCardWizardConstants";
import { CUSTOM_ATTACHMENT_ACCEPT } from "../../../../../lib/create/createFlowUploadValidation";
import type { CustomMethodCardWizardFieldBodiesViewProps } from "./CustomMethodCardWizard.types"; import type { CustomMethodCardWizardFieldBodiesViewProps } from "./CustomMethodCardWizard.types";
const TEXT_PLACEHOLDER_MAX = 8000; const TEXT_PLACEHOLDER_MAX = 8000;
@@ -39,6 +40,7 @@ function CustomMethodCardWizardFieldBodiesViewComponent({
onProportionBlockTitleChange, onProportionBlockTitleChange,
onProportionDefaultChange, onProportionDefaultChange,
}: CustomMethodCardWizardFieldBodiesViewProps) { }: CustomMethodCardWizardFieldBodiesViewProps) {
const placeholderFieldId = useId();
const uploadPreviewTrimmed = uploadAssetPreviewUrl?.trim() ?? ""; const uploadPreviewTrimmed = uploadAssetPreviewUrl?.trim() ?? "";
const hasUploadPreview = uploadPreviewTrimmed.length > 0; const hasUploadPreview = uploadPreviewTrimmed.length > 0;
@@ -53,10 +55,14 @@ function CustomMethodCardWizardFieldBodiesViewComponent({
maxLength={CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS} maxLength={CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS}
/> />
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label className="text-[14px] leading-[20px] font-medium text-[var(--color-content-default-primary)]"> <label
htmlFor={placeholderFieldId}
className="text-[14px] leading-[20px] font-medium text-[var(--color-content-default-primary)]"
>
{copy.text.placeholderLabel} {copy.text.placeholderLabel}
</label> </label>
<TextArea <TextArea
id={placeholderFieldId}
formHeader={false} formHeader={false}
appearance="embedded" appearance="embedded"
value={textPlaceholderBody} value={textPlaceholderBody}
@@ -84,6 +90,7 @@ function CustomMethodCardWizardFieldBodiesViewComponent({
/> />
<TextInput <TextInput
formHeader={false} formHeader={false}
label={copy.badges.blockTitleLabel}
placeholder={copy.badges.blockTitlePlaceholder} placeholder={copy.badges.blockTitlePlaceholder}
value={badgeBlockTitle} value={badgeBlockTitle}
onChange={(e) => onBadgeBlockTitleChange(e.target.value)} onChange={(e) => onBadgeBlockTitleChange(e.target.value)}
@@ -113,6 +120,7 @@ function CustomMethodCardWizardFieldBodiesViewComponent({
type="file" type="file"
className="sr-only" className="sr-only"
tabIndex={-1} tabIndex={-1}
accept={CUSTOM_ATTACHMENT_ACCEPT}
aria-label={copy.upload.uploadFileInputAriaLabel} aria-label={copy.upload.uploadFileInputAriaLabel}
onChange={onFileChosen} onChange={onFileChosen}
/> />
@@ -8,7 +8,7 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useBeforeUnloadGuard } from "../../../hooks/useBeforeUnloadGuard"; import { useBeforeUnloadGuard } from "../../../hooks/useBeforeUnloadGuard";
import Create from "../../../components/modals/Create"; import Create from "../../../components/modals/Create";
import TextInput from "../../../components/controls/TextInput"; import TextArea from "../../../components/controls/TextArea";
import ContentLockup from "../../../components/type/ContentLockup"; import ContentLockup from "../../../components/type/ContentLockup";
import { useTranslation } from "../../../contexts/MessagesContext"; import { useTranslation } from "../../../contexts/MessagesContext";
@@ -95,18 +95,20 @@ export function FinalReviewCommunityContextEditModal({
ariaLabel={tModal("title")} ariaLabel={tModal("title")}
> >
<div className="pb-2"> <div className="pb-2">
<TextInput <TextArea
className="!transition-none" className="!transition-none"
type="text" label={tModal("title")}
placeholder={tField("placeholder")} placeholder={tField("placeholder")}
value={draft} value={draft}
onChange={(e) => { onChange={(e) => {
setDraft(e.target.value); setDraft(e.target.value);
}} }}
inputSize="medium" size="medium"
formHeader={false} formHeader={false}
showHelpIcon={false}
textHint={characterHint} textHint={characterHint}
maxLength={COMMUNITY_CONTEXT_FIELD_MAX_LENGTH} maxLength={COMMUNITY_CONTEXT_FIELD_MAX_LENGTH}
rows={4}
/> />
</div> </div>
</Create> </Create>
@@ -93,6 +93,7 @@ export function FinalReviewTitleEditModal({
<TextInput <TextInput
className="!transition-none" className="!transition-none"
type="text" type="text"
label={tModal("title")}
placeholder={tField("placeholder")} placeholder={tField("placeholder")}
value={draft} value={draft}
onChange={(e) => { onChange={(e) => {
@@ -100,8 +101,10 @@ export function FinalReviewTitleEditModal({
}} }}
inputSize="medium" inputSize="medium"
formHeader={false} formHeader={false}
showHelpIcon={false}
textHint={characterHint} textHint={characterHint}
maxLength={COMMUNITY_TITLE_FIELD_MAX_LENGTH} maxLength={COMMUNITY_TITLE_FIELD_MAX_LENGTH}
required
/> />
</div> </div>
</Create> </Create>
@@ -1,13 +1,47 @@
/** Single column/section: full width under `md`, max 640px from `--breakpoint-md` up. */ import type { CreateFlowStep } from "../types";
import { CREATE_FLOW_SCREEN_REGISTRY } from "../utils/createFlowScreenRegistry";
/**
* Shared page gutters for create-flow chrome and step shells.
* Six viewport bands collapse to three values: 20px below `md`, 48px from `md`,
* 64px from `lg` (320639 / 6401023 / 1024+).
*/
export const CREATE_FLOW_PAGE_GUTTER_CLASS = "px-5 md:px-12 lg:px-16";
/**
* Wizard viewport frame. `dvh` tracks mobile browser chrome so the action bar
* stays on-screen; `max-h-dvh` keeps the flex column from overflowing it.
*/
export const CREATE_FLOW_VIEWPORT_FRAME_CLASS =
"relative flex h-dvh max-h-dvh min-h-0 flex-col overflow-hidden";
/** Design-system scrollbar for create-flow scroll regions (not `scrollbar-hide`). */
export const CREATE_FLOW_SCROLL_REGION_CLASS = "scrollbar-design";
/**
* Fade above the fixed action bar so content that continues below the fold is
* visible as a continuation cue (Figma Scrim / footer overlay).
*/
export const CREATE_FLOW_FOOTER_SCRIM_CLASS =
"pointer-events-none absolute inset-x-0 top-0 z-[1] h-8 -translate-y-full bg-gradient-to-t from-[var(--color-surface-default-primary,#000)] to-transparent";
/**
* Single column: full width below `lg` so tablet widths use available space;
* 640px from `--breakpoint-lg` up (two-column layouts start at `lg`).
*/
export const CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS = export const CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS =
"w-full min-w-0 md:max-w-[640px]"; "w-full min-w-0 lg:max-w-[640px]";
/** Grid cell: same cap as column max, centered when the track is wider than 640px. */ /** Grid cell: same cap as column max, centered when the track is wider than 640px. */
export const CREATE_FLOW_MD_UP_GRID_CELL_CLASS = export const CREATE_FLOW_MD_UP_GRID_CELL_CLASS =
"w-full min-w-0 md:mx-auto md:max-w-[640px]"; "w-full min-w-0 lg:mx-auto lg:max-w-[640px]";
/** Two 640px columns + `--measures-spacing-1200` (48px) gutter. */ /** Two 640px columns + `--measures-spacing-1200` (48px) gutter. */
export const CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS = "md:max-w-[1328px]"; export const CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS =
"w-full min-w-0 lg:max-w-[1328px]";
/** Review / card-stack frames (Figma Flow — Review max 1440). */
export const CREATE_FLOW_WIDE_MAX_CLASS = "w-full min-w-0 lg:max-w-[1440px]";
/** /**
* Lockup+card and card-stack `<main>`: keep `items-start` so a tall card can * Lockup+card and card-stack `<main>`: keep `items-start` so a tall card can
@@ -29,3 +63,31 @@ export const CREATE_FLOW_MD_CENTERED_SHELL_CLASS = "md:my-auto md:pt-0";
*/ */
export const CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS = export const CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS =
"w-full min-w-0 md:max-w-[min(100%,860px)]"; "w-full min-w-0 md:max-w-[min(100%,860px)]";
/**
* Inner max-width for step content and the action bar, so the primary button
* lines up with the nearest content column above 1440.
*/
export function getCreateFlowContentMaxClass(options: {
step: CreateFlowStep | null | undefined;
isTemplateReview?: boolean;
isTemplatesPicker?: boolean;
}): string {
if (options.isTemplateReview || options.isTemplatesPicker) {
return CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS;
}
if (!options.step) {
return CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS;
}
switch (CREATE_FLOW_SCREEN_REGISTRY[options.step].layoutKind) {
case "select":
case "right-rail":
case "completed":
case "review":
return CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS;
case "card":
return CREATE_FLOW_WIDE_MAX_CLASS;
default:
return CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS;
}
}
@@ -43,7 +43,8 @@ const blurActiveElement = (): void => {
* *
* Template review footer Back uses {@link buildTemplateReviewHref}s * Template review footer Back uses {@link buildTemplateReviewHref}s
* `?fromFlow=1` marker (and persisted `templateReviewEntryFromCreateFlow`) so * `?fromFlow=1` marker (and persisted `templateReviewEntryFromCreateFlow`) so
* users who came from `/create/review` return there instead of `/`. * users who came from `/create/review` via `/create/templates` return there
* instead of `/`.
*/ */
export function useCreateFlowNavigation( export function useCreateFlowNavigation(
options?: CreateFlowNavigationOptions, options?: CreateFlowNavigationOptions,
+1 -1
View File
@@ -6,7 +6,7 @@
export default function CreateFlowRouteLoading() { export default function CreateFlowRouteLoading() {
return ( return (
<div <div
className="flex h-screen min-h-0 flex-col overflow-hidden bg-[var(--color-surface-default-primary)]" className="flex h-dvh max-h-dvh min-h-0 flex-col overflow-hidden bg-[var(--color-surface-default-primary)]"
aria-busy="true" aria-busy="true"
aria-live="polite" aria-live="polite"
> >
@@ -6,7 +6,7 @@
* *
* Lives under `screens/card/` (not `select/`): Figma **card stack** layout is a distinct shell from * Lives under `screens/card/` (not `select/`): Figma **card stack** layout is a distinct shell from
* two-column chip **select** frames. Future card-stack steps get their own `*Screen.tsx` here and * two-column chip **select** frames. Future card-stack steps get their own `*Screen.tsx` here and
* reuse `CardStack` / `CreateFlowStepShell` as needed. * reuse `CardStack` / `CreateFlowCardStackStepShell` as needed.
* *
* Card click opens the Figma create modal (node `20246-15829`) with three * Card click opens the Figma create modal (node `20246-15829`) with three
* editable sections rendered by {@link CommunicationMethodEditFields}. The * editable sections rendered by {@link CommunicationMethodEditFields}. The
@@ -22,16 +22,10 @@ import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import { useBeforeUnloadGuard } from "../../../../hooks/useBeforeUnloadGuard"; import { useBeforeUnloadGuard } from "../../../../hooks/useBeforeUnloadGuard";
import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm"; import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm";
import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering"; import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup"; import { CreateFlowCardStackStepShell } from "../../components/CreateFlowCardStackStepShell";
import CardStack from "../../../../components/cards/CardStack"; import CardStack from "../../../../components/cards/CardStack";
import Create from "../../../../components/modals/Create"; import Create from "../../../../components/modals/Create";
import InlineTextButton from "../../../../components/buttons/InlineTextButton"; import InlineTextButton from "../../../../components/buttons/InlineTextButton";
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
import {
CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS,
CREATE_FLOW_MD_CENTERED_SHELL_CLASS,
CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS,
} from "../../components/createFlowLayoutTokens";
import { CommunicationMethodEditFields } from "../../components/methodEditFields"; import { CommunicationMethodEditFields } from "../../components/methodEditFields";
import CustomMethodCardWizard from "../../components/CustomMethodCardWizard"; import CustomMethodCardWizard from "../../components/CustomMethodCardWizard";
import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer"; import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer";
@@ -700,22 +694,10 @@ export function CommunicationMethodsScreen() {
return ( return (
<> <>
<CreateFlowStepShell <CreateFlowCardStackStepShell
variant="wideGridLoosePadding" lockupTitle={title}
contentTopBelowMd="space-800" lockupDescription={description}
className={CREATE_FLOW_MD_CENTERED_SHELL_CLASS} stackBusy={!recommendationsReady}
>
<div className="flex w-full min-w-0 flex-col items-center gap-6">
<div className={CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}>
<CreateFlowHeaderLockup
title={title}
description={description}
justification="center"
/>
</div>
<div
className={CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS}
aria-busy={!recommendationsReady}
> >
{recommendationsReady && ( {recommendationsReady && (
<CardStack <CardStack
@@ -735,9 +717,7 @@ export function CommunicationMethodsScreen() {
headerLockupSize={mdUp ? "L" : "M"} headerLockupSize={mdUp ? "L" : "M"}
/> />
)} )}
</div> </CreateFlowCardStackStepShell>
</div>
<Create <Create
isOpen={createModalOpen} isOpen={createModalOpen}
onClose={handleCreateModalClose} onClose={handleCreateModalClose}
@@ -780,7 +760,6 @@ export function CommunicationMethodsScreen() {
) )
) : null} ) : null}
</Create> </Create>
</CreateFlowStepShell>
<CustomMethodCardWizard <CustomMethodCardWizard
isOpen={addCustomWizardOpen} isOpen={addCustomWizardOpen}
onClose={handleCloseAddWizard} onClose={handleCloseAddWizard}
@@ -19,16 +19,10 @@ import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import { useBeforeUnloadGuard } from "../../../../hooks/useBeforeUnloadGuard"; import { useBeforeUnloadGuard } from "../../../../hooks/useBeforeUnloadGuard";
import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm"; import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm";
import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering"; import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup"; import { CreateFlowCardStackStepShell } from "../../components/CreateFlowCardStackStepShell";
import CardStack from "../../../../components/cards/CardStack"; import CardStack from "../../../../components/cards/CardStack";
import Create from "../../../../components/modals/Create"; import Create from "../../../../components/modals/Create";
import InlineTextButton from "../../../../components/buttons/InlineTextButton"; import InlineTextButton from "../../../../components/buttons/InlineTextButton";
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
import {
CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS,
CREATE_FLOW_MD_CENTERED_SHELL_CLASS,
CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS,
} from "../../components/createFlowLayoutTokens";
import { ConflictManagementEditFields } from "../../components/methodEditFields"; import { ConflictManagementEditFields } from "../../components/methodEditFields";
import CustomMethodCardWizard from "../../components/CustomMethodCardWizard"; import CustomMethodCardWizard from "../../components/CustomMethodCardWizard";
import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer"; import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer";
@@ -701,22 +695,10 @@ export function ConflictManagementScreen() {
return ( return (
<> <>
<CreateFlowStepShell <CreateFlowCardStackStepShell
variant="wideGridLoosePadding" lockupTitle={title}
contentTopBelowMd="space-800" lockupDescription={description}
className={CREATE_FLOW_MD_CENTERED_SHELL_CLASS} stackBusy={!recommendationsReady}
>
<div className="flex w-full min-w-0 flex-col items-center gap-6">
<div className={CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}>
<CreateFlowHeaderLockup
title={title}
description={description}
justification="center"
/>
</div>
<div
className={CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS}
aria-busy={!recommendationsReady}
> >
{recommendationsReady && ( {recommendationsReady && (
<CardStack <CardStack
@@ -736,9 +718,7 @@ export function ConflictManagementScreen() {
headerLockupSize={mdUp ? "L" : "M"} headerLockupSize={mdUp ? "L" : "M"}
/> />
)} )}
</div> </CreateFlowCardStackStepShell>
</div>
<Create <Create
isOpen={createModalOpen} isOpen={createModalOpen}
onClose={handleCreateModalClose} onClose={handleCreateModalClose}
@@ -781,7 +761,6 @@ export function ConflictManagementScreen() {
) )
) : null} ) : null}
</Create> </Create>
</CreateFlowStepShell>
<CustomMethodCardWizard <CustomMethodCardWizard
isOpen={addCustomWizardOpen} isOpen={addCustomWizardOpen}
onClose={handleCloseAddWizard} onClose={handleCloseAddWizard}
@@ -20,16 +20,10 @@ import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import { useBeforeUnloadGuard } from "../../../../hooks/useBeforeUnloadGuard"; import { useBeforeUnloadGuard } from "../../../../hooks/useBeforeUnloadGuard";
import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm"; import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm";
import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering"; import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup"; import { CreateFlowCardStackStepShell } from "../../components/CreateFlowCardStackStepShell";
import CardStack from "../../../../components/cards/CardStack"; import CardStack from "../../../../components/cards/CardStack";
import Create from "../../../../components/modals/Create"; import Create from "../../../../components/modals/Create";
import InlineTextButton from "../../../../components/buttons/InlineTextButton"; import InlineTextButton from "../../../../components/buttons/InlineTextButton";
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
import {
CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS,
CREATE_FLOW_MD_CENTERED_SHELL_CLASS,
CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS,
} from "../../components/createFlowLayoutTokens";
import { MembershipMethodEditFields } from "../../components/methodEditFields"; import { MembershipMethodEditFields } from "../../components/methodEditFields";
import CustomMethodCardWizard from "../../components/CustomMethodCardWizard"; import CustomMethodCardWizard from "../../components/CustomMethodCardWizard";
import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer"; import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer";
@@ -694,22 +688,10 @@ export function MembershipMethodsScreen() {
return ( return (
<> <>
<CreateFlowStepShell <CreateFlowCardStackStepShell
variant="wideGridLoosePadding" lockupTitle={title}
contentTopBelowMd="space-800" lockupDescription={description}
className={CREATE_FLOW_MD_CENTERED_SHELL_CLASS} stackBusy={!recommendationsReady}
>
<div className="flex w-full min-w-0 flex-col items-center gap-6">
<div className={CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}>
<CreateFlowHeaderLockup
title={title}
description={description}
justification="center"
/>
</div>
<div
className={CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS}
aria-busy={!recommendationsReady}
> >
{recommendationsReady && ( {recommendationsReady && (
<CardStack <CardStack
@@ -729,9 +711,7 @@ export function MembershipMethodsScreen() {
headerLockupSize={mdUp ? "L" : "M"} headerLockupSize={mdUp ? "L" : "M"}
/> />
)} )}
</div> </CreateFlowCardStackStepShell>
</div>
<Create <Create
isOpen={createModalOpen} isOpen={createModalOpen}
onClose={handleCreateModalClose} onClose={handleCreateModalClose}
@@ -774,7 +754,6 @@ export function MembershipMethodsScreen() {
) )
) : null} ) : null}
</Create> </Create>
</CreateFlowStepShell>
<CustomMethodCardWizard <CustomMethodCardWizard
isOpen={addCustomWizardOpen} isOpen={addCustomWizardOpen}
onClose={handleCloseAddWizard} onClose={handleCloseAddWizard}
@@ -16,6 +16,8 @@ import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup"; import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import { import {
CREATE_FLOW_MD_UP_GRID_CELL_CLASS, CREATE_FLOW_MD_UP_GRID_CELL_CLASS,
CREATE_FLOW_PAGE_GUTTER_CLASS,
CREATE_FLOW_SCROLL_REGION_CLASS,
CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS, CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS,
} from "../../components/createFlowLayoutTokens"; } from "../../components/createFlowLayoutTokens";
import { import {
@@ -163,7 +165,7 @@ export function CompletedScreen() {
<> <>
<div className="flex min-h-0 w-full flex-1 flex-col overflow-hidden bg-[var(--color-teal-teal50,#c9fef9)] md:h-full"> <div className="flex min-h-0 w-full flex-1 flex-col overflow-hidden bg-[var(--color-teal-teal50,#c9fef9)] md:h-full">
<div <div
className={`mx-auto grid min-h-0 w-full grid-cols-1 gap-4 px-5 max-md:max-w-[639px] max-md:overflow-y-auto max-md:overscroll-y-contain max-md:pt-[var(--space-800)] max-md:pb-8 md:h-full md:grid-cols-2 md:grid-rows-1 md:items-stretch md:justify-items-center md:gap-[var(--measures-spacing-1200,48px)] md:overflow-hidden md:px-12 md:py-0 ${CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS}`} className={`mx-auto grid min-h-0 w-full grid-cols-1 gap-4 ${CREATE_FLOW_PAGE_GUTTER_CLASS} max-md:overflow-y-auto max-md:overscroll-y-contain max-md:pt-[var(--space-800)] max-md:pb-8 md:h-full md:grid-cols-2 md:grid-rows-1 md:items-stretch md:justify-items-center md:gap-[var(--measures-spacing-1200,48px)] md:overflow-hidden md:py-0 ${CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS}`}
> >
<div <div
className={`flex flex-col justify-start max-md:min-h-min max-md:overflow-visible min-h-0 overflow-hidden md:justify-center md:pb-8 ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`} className={`flex flex-col justify-start max-md:min-h-min max-md:overflow-visible min-h-0 overflow-hidden md:justify-center md:pb-8 ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`}
@@ -177,7 +179,7 @@ export function CompletedScreen() {
/> />
</div> </div>
<div <div
className={`scrollbar-hide relative flex min-h-0 flex-col self-stretch overflow-x-hidden md:max-h-full md:overflow-y-auto ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`} className={`relative flex min-h-0 flex-col self-stretch overflow-x-hidden md:max-h-full md:overflow-y-auto ${CREATE_FLOW_SCROLL_REGION_CLASS} ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`}
> >
<div <div
className="pointer-events-none sticky top-0 z-10 hidden h-5 shrink-0 bg-gradient-to-b from-[var(--color-teal-teal50,#c9fef9)]/55 from-0% via-[var(--color-teal-teal50,#c9fef9)]/20 via-50% to-transparent md:block" className="pointer-events-none sticky top-0 z-10 hidden h-5 shrink-0 bg-gradient-to-b from-[var(--color-teal-teal50,#c9fef9)]/55 from-0% via-[var(--color-teal-teal50,#c9fef9)]/20 via-50% to-transparent md:block"
@@ -115,6 +115,7 @@ export function renderCreateFlowScreen(screenId: CreateFlowStep): ReactNode {
messageNamespace="create.community.communityName" messageNamespace="create.community.communityName"
stateField="title" stateField="title"
maxLength={48} maxLength={48}
required
/> />
); );
case "community-structure": case "community-structure":
@@ -126,6 +127,7 @@ export function renderCreateFlowScreen(screenId: CreateFlowStep): ReactNode {
stateField="communityContext" stateField="communityContext"
maxLength={200} maxLength={200}
mainAlign="center" mainAlign="center"
multiline
/> />
); );
case "community-size": case "community-size":
@@ -1,6 +1,5 @@
"use client"; "use client";
import type { ReactNode } from "react";
import NumberedList from "../../../../components/type/NumberedList"; import NumberedList from "../../../../components/type/NumberedList";
import { useMessages } from "../../../../contexts/MessagesContext"; import { useMessages } from "../../../../contexts/MessagesContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp"; import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
@@ -31,22 +30,6 @@ export function InformationalScreen() {
}, },
]; ];
const description: ReactNode = (
<>
{copy.descriptionLead}{" "}
<a
href="#"
className="font-normal text-[var(--color-content-default-tertiary,#b4b4b4)] underline decoration-solid underline-offset-[3px] cursor-pointer"
onClick={(e) => {
e.preventDefault();
}}
>
{copy.workshopLabel}
</a>{" "}
{copy.descriptionTrail}
</>
);
return ( return (
<CreateFlowStepShell <CreateFlowStepShell
variant="centeredNarrow" variant="centeredNarrow"
@@ -57,7 +40,7 @@ export function InformationalScreen() {
> >
<CreateFlowHeaderLockup <CreateFlowHeaderLockup
title={copy.title} title={copy.title}
description={description} description={copy.description}
justification="left" justification="left"
/> />
<NumberedList items={items} size={mdUp ? "M" : "S"} /> <NumberedList items={items} size={mdUp ? "M" : "S"} />
@@ -1,8 +1,9 @@
"use client"; "use client";
import { useEffect, useRef } from "react"; import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Rule from "../../../../components/cards/Rule"; import Rule from "../../../../components/cards/Rule";
import Button from "../../../../components/buttons/Button";
import { useTranslation } from "../../../../contexts/MessagesContext"; import { useTranslation } from "../../../../contexts/MessagesContext";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup"; import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import { useCreateFlow } from "../../context/CreateFlowContext"; import { useCreateFlow } from "../../context/CreateFlowContext";
@@ -17,7 +18,7 @@ import {
vectorMarkPath, vectorMarkPath,
} from "../../../../../lib/assetUtils"; } from "../../../../../lib/assetUtils";
import { methodSectionsPinsForHydratedSelections } from "../../../../../lib/create/publishedDocumentToCreateFlowState"; import { methodSectionsPinsForHydratedSelections } from "../../../../../lib/create/publishedDocumentToCreateFlowState";
import { createFlowStepPath } from "../../utils/createFlowPaths"; import { CREATE_ROUTES, createFlowStepPath } from "../../utils/createFlowPaths";
/** Create Community review — Figma `19706:12135` (`/create/review`; two columns from `lg:`; column caps in `createFlowLayoutTokens`). */ /** Create Community review — Figma `19706:12135` (`/create/review`; two columns from `lg:`; column caps in `createFlowLayoutTokens`). */
export function CommunityReviewScreen() { export function CommunityReviewScreen() {
@@ -25,6 +26,15 @@ export function CommunityReviewScreen() {
const lgUp = useCreateFlowLgUp(); const lgUp = useCreateFlowLgUp();
const t = useTranslation("create.community.review"); const t = useTranslation("create.community.review");
const { state, updateState } = useCreateFlow(); const { state, updateState } = useCreateFlow();
/**
* Server layout has an empty context; the client layout may already hold a
* named draft. Defer empty-vs-congrats until after mount so the first paint
* matches SSR (`null`) instead of swapping HeaderLockup titles.
*/
const [reviewReady, setReviewReady] = useState(false);
useEffect(() => {
setReviewReady(true);
}, []);
/** /**
* If the user picked **Customize** from a template before finishing community * If the user picked **Customize** from a template before finishing community
@@ -70,11 +80,10 @@ export function CommunityReviewScreen() {
const cardTitle = const cardTitle =
typeof state.title === "string" && state.title.trim().length > 0 typeof state.title === "string" && state.title.trim().length > 0
? state.title.trim() ? state.title.trim()
: t("ruleCard.title"); : "";
/** /**
* No placeholder fallback: if the user skipped `community-context`, leave * No placeholder fallback: if the user skipped `community-context`, leave
* the card description off rather than render the old "Mutual Aid Monday * the card description off rather than render sample copy as real user data.
* is a grassroots community…" sample, which read as real user copy.
*/ */
const cardDescription = const cardDescription =
typeof state.communityContext === "string" && typeof state.communityContext === "string" &&
@@ -88,6 +97,36 @@ export function CommunityReviewScreen() {
? state.communityAvatarUrl.trim() ? state.communityAvatarUrl.trim()
: null; : null;
if (state.pendingTemplateAction || !reviewReady) {
return null;
}
if (!cardTitle) {
return (
<CreateFlowStepShell
variant="centeredNarrow"
contentTopBelowMd="space-1400"
>
<div
className={`flex flex-col items-start gap-6 ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`}
>
<CreateFlowHeaderLockup
title={t("empty.title")}
description={t("empty.description")}
/>
<Button
buttonType="filled"
palette="default"
size="xsmall"
href={CREATE_ROUTES.createFirstStep}
>
{t("empty.startLabel")}
</Button>
</div>
</CreateFlowStepShell>
);
}
return ( return (
<CreateFlowStepShell <CreateFlowStepShell
variant="wideGridLoosePadding" variant="wideGridLoosePadding"
@@ -9,8 +9,7 @@ import { useTranslation } from "../../../../contexts/MessagesContext";
import { MAX_STAKEHOLDER_EMAILS } from "../../../../../lib/create/stakeholderLimits"; import { MAX_STAKEHOLDER_EMAILS } from "../../../../../lib/create/stakeholderLimits";
import { useCreateFlow } from "../../context/CreateFlowContext"; import { useCreateFlow } from "../../context/CreateFlowContext";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup"; import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell"; import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowLayoutTokens";
import { import {
CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY, CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY,
CREATE_FLOW_MANAGE_STAKEHOLDERS_VALUE, CREATE_FLOW_MANAGE_STAKEHOLDERS_VALUE,
@@ -131,42 +130,31 @@ export function ConfirmStakeholdersScreen() {
if (managePublishedMode) { if (managePublishedMode) {
return ( return (
<CreateFlowStepShell <CreateFlowTwoColumnSelectShell
variant="centeredNarrowBottomPad" header={
contentTopBelowMd="space-1400"
>
<div
className={`flex flex-col items-start gap-[var(--measures-spacing-300,12px)] ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`}
>
<div className="flex w-full flex-col gap-[var(--measures-spacing-200,8px)] py-[12px]">
<CreateFlowHeaderLockup <CreateFlowHeaderLockup
title={t("managePublished.lockupTitle")} title={t("managePublished.lockupTitle")}
description={t("managePublished.lockupDescription")} description={t("managePublished.lockupDescription")}
justification="left" justification="left"
/> />
</div> }
>
<PublishedStakeholdersManagePanel ruleId={editingPublishedRuleId} /> <PublishedStakeholdersManagePanel ruleId={editingPublishedRuleId} />
</div> </CreateFlowTwoColumnSelectShell>
</CreateFlowStepShell>
); );
} }
return ( return (
<> <>
<CreateFlowStepShell <CreateFlowTwoColumnSelectShell
variant="centeredNarrowBottomPad" header={
contentTopBelowMd="space-1400"
>
<div
className={`flex flex-col items-start gap-[var(--measures-spacing-300,12px)] ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`}
>
<div className="flex w-full flex-col gap-[var(--measures-spacing-200,8px)] py-[12px]">
<CreateFlowHeaderLockup <CreateFlowHeaderLockup
title={t("title")} title={t("title")}
description={t("description")} description={t("description")}
justification="left" justification="left"
/> />
</div> }
>
{chipError ? ( {chipError ? (
<p <p
className="text-small-paragraph text-[var(--color-border-default-utility-negative)]" className="text-small-paragraph text-[var(--color-border-default-utility-negative)]"
@@ -187,8 +175,7 @@ export function ConfirmStakeholdersScreen() {
addButton addButton
addButtonText={t("addStakeholder")} addButtonText={t("addStakeholder")}
/> />
</div> </CreateFlowTwoColumnSelectShell>
</CreateFlowStepShell>
{!toastDismissed && ( {!toastDismissed && (
<div <div
@@ -2,6 +2,7 @@
import { useState, useEffect, type HTMLInputTypeAttribute } from "react"; import { useState, useEffect, type HTMLInputTypeAttribute } from "react";
import TextInput from "../../../../components/controls/TextInput"; import TextInput from "../../../../components/controls/TextInput";
import TextArea from "../../../../components/controls/TextArea";
import type { HeaderLockupJustificationValue } from "../../../../components/type/HeaderLockup/HeaderLockup.types"; import type { HeaderLockupJustificationValue } from "../../../../components/type/HeaderLockup/HeaderLockup.types";
import { useTranslation } from "../../../../contexts/MessagesContext"; import { useTranslation } from "../../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext"; import { useCreateFlow } from "../../context/CreateFlowContext";
@@ -12,6 +13,7 @@ import {
type CreateFlowContentTopBelowMd, type CreateFlowContentTopBelowMd,
} from "../../components/CreateFlowStepShell"; } from "../../components/CreateFlowStepShell";
import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowLayoutTokens"; import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowLayoutTokens";
import { CREATE_FLOW_COMMUNITY_SAVE_FORM_ID } from "../../utils/createFlowPaths";
import type { CreateFlowTextStateField } from "../../types"; import type { CreateFlowTextStateField } from "../../types";
type Props = { type Props = {
@@ -21,6 +23,10 @@ type Props = {
/** Figma Flow — Text (`20094:41243`): main column `items-center` + horizontal padding token. */ /** Figma Flow — Text (`20094:41243`): main column `items-center` + horizontal padding token. */
mainAlign?: "start" | "center"; mainAlign?: "start" | "center";
inputType?: HTMLInputTypeAttribute; inputType?: HTMLInputTypeAttribute;
/** Multi-line control (community description). */
multiline?: boolean;
/** Native `required` (community name and save-progress email). */
required?: boolean;
showCharacterCount?: boolean; showCharacterCount?: boolean;
headerJustification?: HeaderLockupJustificationValue; headerJustification?: HeaderLockupJustificationValue;
/** Top spacing under top chrome (`CreateFlowStepShell` / `CreateFlowContentTopBelowMd`). */ /** Top spacing under top chrome (`CreateFlowStepShell` / `CreateFlowContentTopBelowMd`). */
@@ -28,7 +34,7 @@ type Props = {
}; };
/** /**
* Shared narrow-column + TextInput pattern for Create Community text frames. * Shared narrow-column + labelled field pattern for Create Community text frames.
*/ */
export function CreateFlowTextFieldScreen({ export function CreateFlowTextFieldScreen({
messageNamespace, messageNamespace,
@@ -36,6 +42,8 @@ export function CreateFlowTextFieldScreen({
maxLength, maxLength,
mainAlign = "start", mainAlign = "start",
inputType = "text", inputType = "text",
multiline = false,
required = false,
showCharacterCount = true, showCharacterCount = true,
headerJustification = "left", headerJustification = "left",
contentTopBelowMd = "space-1400", contentTopBelowMd = "space-1400",
@@ -69,6 +77,15 @@ export function CreateFlowTextFieldScreen({
const mainItems = const mainItems =
mainAlign === "center" ? "items-center" : "items-start"; mainAlign === "center" ? "items-center" : "items-start";
const isEmail = inputType === "email";
const persistValue = (next: string) => {
setValue(next);
markCreateFlowInteraction();
updateState({ [stateField]: next } as Record<string, string>);
};
const fieldLabel = t("inputLabel");
return ( return (
<CreateFlowStepShell <CreateFlowStepShell
variant="centeredNarrow" variant="centeredNarrow"
@@ -85,22 +102,60 @@ export function CreateFlowTextFieldScreen({
/> />
</div> </div>
<div className="w-full"> <div className="w-full">
{multiline ? (
<TextArea
className="!transition-none"
label={fieldLabel}
placeholder={t("placeholder")}
value={value}
onChange={(e) => persistValue(e.target.value)}
onBlur={() => {
const trimmed = value.trim();
if (trimmed !== value) persistValue(trimmed);
}}
size={mdUp ? "medium" : "small"}
formHeader={false}
showHelpIcon={false}
textHint={hint}
maxLength={maxLength}
required={required}
rows={4}
/>
) : (
<TextInput <TextInput
className="!transition-none" className="!transition-none"
type={inputType} type={inputType}
name={isEmail ? "email" : undefined}
label={fieldLabel}
placeholder={t("placeholder")} placeholder={t("placeholder")}
value={value} value={value}
onChange={(e) => { onChange={(e) => persistValue(e.target.value)}
const v = e.target.value; onBlur={() => {
setValue(v); const trimmed = isEmail
markCreateFlowInteraction(); ? value.trim().toLowerCase()
updateState({ [stateField]: v } as Record<string, string>); : value.trim();
if (trimmed !== value) persistValue(trimmed);
}} }}
onKeyDown={
isEmail
? (e) => {
if (e.key !== "Enter") return;
e.preventDefault();
e.currentTarget.form?.requestSubmit();
}
: undefined
}
inputSize={mdUp ? "medium" : "small"} inputSize={mdUp ? "medium" : "small"}
formHeader={false} formHeader={false}
showHelpIcon={false}
textHint={hint} textHint={hint}
maxLength={maxLength} maxLength={maxLength}
required={required || isEmail}
autoComplete={isEmail ? "email" : undefined}
inputMode={isEmail ? "email" : undefined}
form={isEmail ? CREATE_FLOW_COMMUNITY_SAVE_FORM_ID : undefined}
/> />
)}
</div> </div>
</div> </div>
</CreateFlowStepShell> </CreateFlowStepShell>
@@ -16,14 +16,24 @@ import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowL
import { fetchAuthSession } from "../../../../../lib/create/api"; import { fetchAuthSession } from "../../../../../lib/create/api";
import { ASSETS, getAssetPath } from "../../../../../lib/assetUtils"; import { ASSETS, getAssetPath } from "../../../../../lib/assetUtils";
import { import {
UploadToServerError, createFlowUploadFailureMessageKey,
uploadCreateFlowFile, uploadCreateFlowFile,
} from "../../../../../lib/create/uploadToServer"; } from "../../../../../lib/create/uploadToServer";
import {
COMMUNITY_AVATAR_ACCEPT,
messageKeyForCreateFlowUploadReason,
validateCreateFlowUploadFile,
} from "../../../../../lib/create/createFlowUploadValidation";
import { import {
clearPendingCommunityAvatarFile, clearPendingCommunityAvatarFile,
readPendingCommunityAvatarFile,
storePendingCommunityAvatarFile, storePendingCommunityAvatarFile,
} from "../../../../../lib/create/pendingCommunityAvatarUpload"; } from "../../../../../lib/create/pendingCommunityAvatarUpload";
function hasCommunityAvatarUrl(url: string | undefined): boolean {
return typeof url === "string" && url.trim().length > 0;
}
/** Create Community — Figma Flow — Upload `20094:41524`. */ /** Create Community — Figma Flow — Upload `20094:41524`. */
export function CommunityUploadScreen() { export function CommunityUploadScreen() {
const m = useMessages(); const m = useMessages();
@@ -54,17 +64,57 @@ export function CommunityUploadScreen() {
[localPreviewUrl], [localPreviewUrl],
); );
const serverAvatarUrl = hasCommunityAvatarUrl(state.communityAvatarUrl)
? state.communityAvatarUrl!.trim()
: null;
const serverAvatarUrlRef = useRef(serverAvatarUrl);
serverAvatarUrlRef.current = serverAvatarUrl;
useEffect(() => {
if (serverAvatarUrl) {
setLocalPreviewUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return null;
});
}
}, [serverAvatarUrl]);
useEffect(() => {
let cancelled = false;
void (async () => {
try {
const file = await readPendingCommunityAvatarFile();
if (cancelled || !file) return;
const validated = await validateCreateFlowUploadFile(
file,
"communityAvatar",
);
if (validated.ok === false) {
await clearPendingCommunityAvatarFile();
return;
}
if (cancelled) return;
if (serverAvatarUrlRef.current) return;
const objectUrl = URL.createObjectURL(file);
if (cancelled) {
URL.revokeObjectURL(objectUrl);
return;
}
setLocalPreviewUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return objectUrl;
});
} catch {
// Missing IndexedDB / quota: leave the picker empty.
}
})();
return () => {
cancelled = true;
};
}, []);
const resolveUploadError = useCallback( const resolveUploadError = useCallback(
(err: unknown) => { (err: unknown) => tUpload(createFlowUploadFailureMessageKey(err)),
if (err instanceof UploadToServerError) {
if (err.status === 413) return tUpload("errors.tooLarge");
if (err.status === 401) return tUpload("errors.unauthorized");
if (err.code === "server_misconfigured") {
return tUpload("errors.misconfigured");
}
}
return tUpload("errors.generic");
},
[tUpload], [tUpload],
); );
@@ -94,6 +144,16 @@ export function CommunityUploadScreen() {
} }
if (signedIn === false) { if (signedIn === false) {
const validated = await validateCreateFlowUploadFile(
file,
"communityAvatar",
);
if (validated.ok === false) {
setErrorMessage(
tUpload(messageKeyForCreateFlowUploadReason(validated.reason)),
);
return;
}
try { try {
await storePendingCommunityAvatarFile(file); await storePendingCommunityAvatarFile(file);
setLocalPreviewUrl((prev) => { setLocalPreviewUrl((prev) => {
@@ -121,24 +181,16 @@ export function CommunityUploadScreen() {
if (prev) URL.revokeObjectURL(prev); if (prev) URL.revokeObjectURL(prev);
return null; return null;
}); });
if ( if (hasCommunityAvatarUrl(state.communityAvatarUrl)) {
typeof state.communityAvatarUrl === "string" &&
state.communityAvatarUrl.trim().length > 0
) {
updateState({ communityAvatarUrl: undefined }); updateState({ communityAvatarUrl: undefined });
} }
// Clear any anonymous staged blob so the post-sign-in flush won't resurrect it.
void clearPendingCommunityAvatarFile(); void clearPendingCommunityAvatarFile();
if (fileInputRef.current) { if (fileInputRef.current) {
fileInputRef.current.value = ""; fileInputRef.current.value = "";
} }
}, [markCreateFlowInteraction, state.communityAvatarUrl, updateState]); }, [markCreateFlowInteraction, state.communityAvatarUrl, updateState]);
const displaySrc = const displaySrc = serverAvatarUrl ?? localPreviewUrl;
typeof state.communityAvatarUrl === "string" &&
state.communityAvatarUrl.trim().length > 0
? state.communityAvatarUrl.trim()
: localPreviewUrl;
const hasPreview = typeof displaySrc === "string" && displaySrc.length > 0; const hasPreview = typeof displaySrc === "string" && displaySrc.length > 0;
return ( return (
@@ -161,7 +213,7 @@ export function CommunityUploadScreen() {
type="file" type="file"
className="sr-only" className="sr-only"
tabIndex={-1} tabIndex={-1}
accept="image/jpeg,image/png,image/webp,image/gif" accept={COMMUNITY_AVATAR_ACCEPT}
aria-label={u.hintText} aria-label={u.hintText}
onChange={handleFileChange} onChange={handleFileChange}
/> />
@@ -0,0 +1,45 @@
"use client";
import { GovernanceTemplateGrid } from "../../../components/sections/GovernanceTemplateGrid";
import type { TemplateGridCardEntry } from "../../../../lib/templates/templateGridPresentation";
import { useTranslation } from "../../../contexts/MessagesContext";
import { useTemplatesFacetGridEntries } from "../../../(marketing)/templates/useTemplatesFacetGridEntries";
import { CreateFlowHeaderLockup } from "../components/CreateFlowHeaderLockup";
import { CreateFlowStepShell } from "../components/CreateFlowStepShell";
import { CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS } from "../components/createFlowLayoutTokens";
import { buildTemplateReviewHref } from "../utils/flowSteps";
export function CreateFlowTemplatesPageClient({
initialGridEntries,
}: {
initialGridEntries: TemplateGridCardEntry[];
}) {
const t = useTranslation("pages.templates");
const entries = useTemplatesFacetGridEntries({
initialGridEntries,
enableFacetRecommendations: true,
});
return (
<CreateFlowStepShell
variant="wideGridLoosePadding"
contentTopBelowMd="space-1400"
>
<div
className={`mx-auto flex w-full min-w-0 flex-col gap-6 pb-8 ${CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS}`}
>
<CreateFlowHeaderLockup
title={t("title")}
description={t("subtitle")}
justification="left"
/>
<GovernanceTemplateGrid
entries={entries}
hrefForTemplate={(slug) =>
buildTemplateReviewHref(slug, { fromCreateWizard: true })
}
/>
</div>
</CreateFlowStepShell>
);
}
+18
View File
@@ -0,0 +1,18 @@
import type { Metadata } from "next";
import messages from "../../../../messages/en/index";
import { listRuleTemplatesFromDb } from "../../../../lib/server/ruleTemplates";
import { routeMetadata } from "../../../../lib/siteMetadata";
import { gridEntriesForFullCatalogWithFallback } from "../../../../lib/templates/templateGridPresentation";
import { CREATE_ROUTES } from "../utils/createFlowPaths";
import { CreateFlowTemplatesPageClient } from "./CreateFlowTemplatesPageClient";
export const metadata: Metadata = routeMetadata(CREATE_ROUTES.templatesPicker, {
title: messages.metadata.templates.title,
});
/** In-flow template catalog after review “Create from template”. */
export default async function CreateFlowTemplatesPage() {
const rows = await listRuleTemplatesFromDb();
const initialGridEntries = gridEntriesForFullCatalogWithFallback(rows);
return <CreateFlowTemplatesPageClient initialGridEntries={initialGridEntries} />;
}
+1 -1
View File
@@ -215,7 +215,7 @@ export interface CreateFlowState {
templateReviewBackSlug?: string; templateReviewBackSlug?: string;
/** /**
* True when the user opened `/create/review-template/{slug}` from the create * True when the user opened `/create/review-template/{slug}` from the create
* wizard (`/templates?fromFlow=1` after `/create/review`). Persisted so Back * wizard (`/create/templates` after `/create/review`). Persisted so Back
* from template review targets `/create/review` and so returning from * from template review targets `/create/review` and so returning from
* `confirm-stakeholders` can re-apply `?fromFlow=1` on the template URL. * `confirm-stakeholders` can re-apply `?fromFlow=1` on the template URL.
*/ */
@@ -10,12 +10,18 @@ import {
FIRST_STEP, FIRST_STEP,
} from "./flowSteps"; } from "./flowSteps";
/** Associates the save-progress email field with the layout footer submit. */
export const CREATE_FLOW_COMMUNITY_SAVE_FORM_ID =
"create-flow-community-save";
export const CREATE_ROUTES = { export const CREATE_ROUTES = {
root: "/", root: "/",
createRoot: "/create", createRoot: "/create",
/** Direct path to the first wizard step so client navigations skip the redirect hop. */ /** Direct path to the first wizard step so client navigations skip the redirect hop. */
createFirstStep: `/create/${FIRST_STEP}`, createFirstStep: `/create/${FIRST_STEP}`,
review: "/create/review", review: "/create/review",
/** In-flow template catalog (wizard chrome). Marketing catalog remains `/templates`. */
templatesPicker: "/create/templates",
finalReview: "/create/final-review", finalReview: "/create/final-review",
completed: "/create/completed", completed: "/create/completed",
editRule: "/create/edit-rule", editRule: "/create/edit-rule",
+20 -3
View File
@@ -195,13 +195,14 @@ export function shouldOfferCreateFlowSaveAndExit(
/** /**
* Steps where below `lg` the main column scrolls with split layout * Steps where below `lg` the main column scrolls with split layout
* (`CreateFlowLayoutClient` — Linear CR-92 §4). * (`CreateFlowLayoutClient`).
*/ */
export const CREATE_FLOW_SELECT_SPLIT_SCROLL_STEPS: readonly CreateFlowStep[] = [ export const CREATE_FLOW_SELECT_SPLIT_SCROLL_STEPS: readonly CreateFlowStep[] = [
"community-size", "community-size",
"community-structure", "community-structure",
"core-values", "core-values",
"decision-approaches", "decision-approaches",
"confirm-stakeholders",
] as const; ] as const;
export function createFlowStepUsesSelectSplitScroll( export function createFlowStepUsesSelectSplitScroll(
@@ -234,18 +235,34 @@ export function parseCreateFlowScreenFromPathname(
): CreateFlowStep | null { ): CreateFlowStep | null {
if (!pathname || pathname.length === 0) return null; if (!pathname || pathname.length === 0) return null;
if (pathname.includes("/create/review-template/")) return null; if (pathname.includes("/create/review-template/")) return null;
if (isCreateFlowTemplatesPickerPath(pathname)) return null;
const parts = pathname.split("/").filter(Boolean); const parts = pathname.split("/").filter(Boolean);
const createIdx = parts.indexOf("create"); const createIdx = parts.indexOf("create");
if (createIdx === -1 || createIdx >= parts.length - 1) return null; if (createIdx === -1 || createIdx >= parts.length - 1) return null;
const segment = parts[createIdx + 1]; const segment = parts[createIdx + 1];
if (segment === "review-template") return null; if (segment === "review-template" || segment === "templates") return null;
return isValidStep(segment) ? segment : null; return isValidStep(segment) ? segment : null;
} }
/** Same query as `/templates?fromFlow=1` — template was picked after `/create/review`. */ /**
* `/create/templates` — in-flow catalog after review “Create from template”.
* Not a wizard step; layout keeps Back / Save & Exit and does not use
* marketing `/templates` chrome.
*/
export function isCreateFlowTemplatesPickerPath(
pathname: string | null | undefined,
): boolean {
if (!pathname) return false;
const parts = pathname.split("/").filter(Boolean);
const createIdx = parts.indexOf("create");
if (createIdx === -1 || createIdx !== parts.length - 2) return false;
return parts[createIdx + 1] === "templates";
}
/** Same query as `/create/review-template/…?fromFlow=1` — template was picked in-flow. */
export const TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY = "fromFlow" as const; export const TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY = "fromFlow" as const;
export const TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE = "1" as const; export const TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE = "1" as const;
@@ -1,7 +1,7 @@
"use client"; "use client";
import { Suspense } from "react"; import { Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import HeaderLockup from "../../components/type/HeaderLockup"; import HeaderLockup from "../../components/type/HeaderLockup";
import { GovernanceTemplateGrid } from "../../components/sections/GovernanceTemplateGrid"; import { GovernanceTemplateGrid } from "../../components/sections/GovernanceTemplateGrid";
import type { TemplateGridCardEntry } from "../../../lib/templates/templateGridPresentation"; import type { TemplateGridCardEntry } from "../../../lib/templates/templateGridPresentation";
@@ -68,8 +68,8 @@ export default function TemplatesPageClient({
/** /**
* - `fromFlow=1` — skip `prepareFreshCreateFlowEntry` on template click * - `fromFlow=1` — skip `prepareFreshCreateFlowEntry` on template click
* (draft preserved). Used by review “Create from template” and profile. * (draft preserved). Used by profile “Create from template”.
* - `recommendTemplates=1` (with review only) — rank templates + “RECOMMENDED” * - `recommendTemplates=1` — rank templates + “RECOMMENDED”
* from `GET /api/templates?facet.*` using the persisted community draft. * from `GET /api/templates?facet.*` using the persisted community draft.
*/ */
function TemplatesGridWithSearchParams({ function TemplatesGridWithSearchParams({
@@ -96,19 +96,18 @@ function TemplatesGrid({
entries: TemplateGridCardEntry[]; entries: TemplateGridCardEntry[];
fromFlow: boolean; fromFlow: boolean;
}) { }) {
const router = useRouter();
return ( return (
<GovernanceTemplateGrid <GovernanceTemplateGrid
entries={entries} entries={entries}
onTemplateClick={(slug) => { hrefForTemplate={(slug) =>
buildTemplateReviewHref(slug, { fromCreateWizard: fromFlow })
}
onTemplateClick={() => {
if (!fromFlow) { if (!fromFlow) {
// Marketing /templates has no session in hand; DELETE is // Marketing /templates has no session in hand; DELETE is
// best-effort and the sentinel blocks stale-draft hydration. // best-effort and the sentinel blocks stale-draft hydration.
prepareFreshCreateFlowEntrySync({ signedIn: true }); prepareFreshCreateFlowEntrySync({ signedIn: true });
} }
router.push(
buildTemplateReviewHref(slug, { fromCreateWizard: fromFlow }),
);
}} }}
/> />
); );
@@ -18,9 +18,10 @@ type UseTemplatesFacetGridEntriesArgs = {
}; };
/** /**
* When `enableFacetRecommendations` (review → “Create from template” only), * When `enableFacetRecommendations` (in-flow `/create/templates`, or marketing
* re-fetch ranked templates from `GET /api/templates?facet.*` using the * `/templates?recommendTemplates=1`), re-fetch ranked templates from
* persisted create-flow draft. Otherwise returns `initialGridEntries` from SSR. * `GET /api/templates?facet.*` using the persisted create-flow draft.
* Otherwise returns `initialGridEntries` from SSR.
*/ */
export function useTemplatesFacetGridEntries({ export function useTemplatesFacetGridEntries({
initialGridEntries, initialGridEntries,
+52 -7
View File
@@ -14,13 +14,40 @@ import { saveCreateFlowUpload } from "../../../lib/server/uploads/saveCreateFlow
import { getUploadRootFromEnv } from "../../../lib/server/uploads/uploadRoot"; import { getUploadRootFromEnv } from "../../../lib/server/uploads/uploadRoot";
import { import {
CREATE_FLOW_UPLOAD_MAX_BYTES, CREATE_FLOW_UPLOAD_MAX_BYTES,
maxBytesForPurpose,
type CreateFlowUploadPurpose, type CreateFlowUploadPurpose,
} from "../../../lib/server/uploads/uploadConstants"; } from "../../../lib/server/uploads/uploadConstants";
import type { CreateFlowUploadValidationReason } from "../../../lib/create/createFlowUploadValidation";
function asUploadedBlob(value: FormDataEntryValue | null): Blob | null {
if (typeof value !== "object" || value === null) return null;
const candidate = value as Partial<Blob>;
if (typeof candidate.arrayBuffer !== "function") return null;
if (typeof candidate.size !== "number") return null;
return value as Blob;
}
function isPurpose(x: string): x is CreateFlowUploadPurpose { function isPurpose(x: string): x is CreateFlowUploadPurpose {
return x === "communityAvatar" || x === "customMethodAttachment"; return x === "communityAvatar" || x === "customMethodAttachment";
} }
function messageForValidationReason(
reason: CreateFlowUploadValidationReason,
): string {
switch (reason) {
case "empty":
return "File is empty.";
case "tooLarge":
return "File exceeds the maximum allowed size for this upload purpose.";
case "svg":
return "SVG uploads are not allowed.";
case "undecodable":
return "File could not be decoded as a valid image.";
case "invalidType":
return "File type is not allowed for this upload purpose.";
}
}
export const POST = apiRoute("uploads.post", async (request: NextRequest) => { export const POST = apiRoute("uploads.post", async (request: NextRequest) => {
if (!isDatabaseConfigured()) { if (!isDatabaseConfigured()) {
return dbUnavailable(); return dbUnavailable();
@@ -54,7 +81,7 @@ export const POST = apiRoute("uploads.post", async (request: NextRequest) => {
} }
const purposeRaw = formData.get("purpose"); const purposeRaw = formData.get("purpose");
const file = formData.get("file"); const file = asUploadedBlob(formData.get("file"));
if (typeof purposeRaw !== "string" || !isPurpose(purposeRaw)) { if (typeof purposeRaw !== "string" || !isPurpose(purposeRaw)) {
return errorJson( return errorJson(
@@ -64,7 +91,7 @@ export const POST = apiRoute("uploads.post", async (request: NextRequest) => {
); );
} }
if (!(file instanceof File)) { if (!file) {
return errorJson( return errorJson(
"validation_error", "validation_error",
"Missing `file` field (multipart file).", "Missing `file` field (multipart file).",
@@ -72,21 +99,29 @@ export const POST = apiRoute("uploads.post", async (request: NextRequest) => {
); );
} }
if (file.size > CREATE_FLOW_UPLOAD_MAX_BYTES) { if (file.size === 0) {
return errorJson("validation_error", messageForValidationReason("empty"), 400, {
details: { reason: "empty" },
});
}
if (
file.size > CREATE_FLOW_UPLOAD_MAX_BYTES ||
file.size > maxBytesForPurpose(purposeRaw)
) {
return errorJson( return errorJson(
"payload_too_large", "payload_too_large",
`File exceeds maximum allowed size (${CREATE_FLOW_UPLOAD_MAX_BYTES} bytes).`, messageForValidationReason("tooLarge"),
413, 413,
{ details: { reason: "tooLarge" } },
); );
} }
const buf = Buffer.from(await file.arrayBuffer()); const buf = Buffer.from(await file.arrayBuffer());
const mimeType = file.type || "application/octet-stream";
const saved = await saveCreateFlowUpload({ const saved = await saveCreateFlowUpload({
purpose: purposeRaw, purpose: purposeRaw,
buffer: buf, buffer: buf,
mimeType,
}); });
if ("error" in saved) { if ("error" in saved) {
@@ -95,10 +130,20 @@ export const POST = apiRoute("uploads.post", async (request: NextRequest) => {
"File uploads are not configured (UPLOAD_ROOT is unset).", "File uploads are not configured (UPLOAD_ROOT is unset).",
); );
} }
const reason = saved.reason ?? "invalidType";
if (reason === "tooLarge") {
return errorJson(
"payload_too_large",
messageForValidationReason("tooLarge"),
413,
{ details: { reason } },
);
}
return errorJson( return errorJson(
"validation_error", "validation_error",
"File type or size is not allowed for this upload purpose.", messageForValidationReason(reason),
400, 400,
{ details: { reason } },
); );
} }
+3 -1
View File
@@ -35,6 +35,7 @@ const RuleContainer = memo<RuleProps>(
backgroundColor = "bg-[var(--color-community-teal-100)]", backgroundColor = "bg-[var(--color-community-teal-100)]",
className = "", className = "",
onClick, onClick,
href,
expanded = false, expanded = false,
size: sizeProp, size: sizeProp,
categories, categories,
@@ -95,8 +96,9 @@ const RuleContainer = memo<RuleProps>(
icon={icon} icon={icon}
backgroundColor={backgroundColor} backgroundColor={backgroundColor}
className={className} className={className}
href={hasBottomLinks ? undefined : href}
onClick={hasBottomLinks ? undefined : handleClick} onClick={hasBottomLinks ? undefined : handleClick}
onKeyDown={hasBottomLinks ? undefined : handleKeyDown} onKeyDown={hasBottomLinks || href ? undefined : handleKeyDown}
expanded={expanded} expanded={expanded}
size={size} size={size}
categories={categories} categories={categories}
+6
View File
@@ -50,6 +50,11 @@ export interface RuleProps {
backgroundColor?: string; backgroundColor?: string;
className?: string; className?: string;
onClick?: () => void; onClick?: () => void;
/**
* When set, the card is a real link (focusable, Enter, open in a new tab)
* instead of a click-only `role="button"` surface.
*/
href?: string;
expanded?: boolean; expanded?: boolean;
size?: RuleSizeValue; size?: RuleSizeValue;
categories?: Category[]; categories?: Category[];
@@ -93,6 +98,7 @@ export interface RuleViewProps {
backgroundColor: string; backgroundColor: string;
className: string; className: string;
onClick?: () => void; onClick?: () => void;
href?: string;
onKeyDown?: (_event: React.KeyboardEvent<HTMLDivElement>) => void; onKeyDown?: (_event: React.KeyboardEvent<HTMLDivElement>) => void;
expanded: boolean; expanded: boolean;
size: RuleSizeValue; size: RuleSizeValue;
+34 -10
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import Image from "next/image"; import Image from "next/image";
import NextLink from "next/link";
import MultiSelect from "../../controls/MultiSelect"; import MultiSelect from "../../controls/MultiSelect";
import InlineTextButton from "../../buttons/InlineTextButton"; import InlineTextButton from "../../buttons/InlineTextButton";
import NavigationLink from "../../navigation/Link"; import NavigationLink from "../../navigation/Link";
@@ -19,6 +20,7 @@ export function RuleView({
backgroundColor, backgroundColor,
className, className,
onClick, onClick,
href,
onKeyDown, onKeyDown,
expanded, expanded,
size, size,
@@ -264,16 +266,11 @@ export function RuleView({
); );
} }
return ( 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 || ""}`;
<div const isLinkCard = Boolean(href) && interactiveCard;
className={`${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"} ${className || ""}`}
tabIndex={interactiveCard ? 0 : undefined} const cardBody = (
role={interactiveCard ? "button" : "article"} <>
aria-label={ariaLabel}
aria-expanded={interactiveCard ? expanded : undefined}
onClick={interactiveCard ? onClick : undefined}
onKeyDown={interactiveCard ? onKeyDown : undefined}
>
{/* 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. */}
<div <div
className=" className="
@@ -483,6 +480,33 @@ export function RuleView({
</div> </div>
) )
)} )}
</>
);
if (isLinkCard && href) {
return (
<NextLink
href={href}
className={cardClassName}
aria-label={ariaLabel}
onClick={onClick}
>
{cardBody}
</NextLink>
);
}
return (
<div
className={cardClassName}
tabIndex={interactiveCard ? 0 : undefined}
role={interactiveCard ? "button" : "article"}
aria-label={ariaLabel}
aria-expanded={interactiveCard ? expanded : undefined}
onClick={interactiveCard ? onClick : undefined}
onKeyDown={interactiveCard ? onKeyDown : undefined}
>
{cardBody}
</div> </div>
); );
} }
@@ -1,3 +1,6 @@
"use client";
import { useId } from "react";
import type { InputWithCounterProps } from "./InputWithCounter.types"; import type { InputWithCounterProps } from "./InputWithCounter.types";
export function InputWithCounterView({ export function InputWithCounterView({
@@ -10,12 +13,16 @@ export function InputWithCounterView({
className = "", className = "",
inputClassName = "", inputClassName = "",
}: InputWithCounterProps) { }: InputWithCounterProps) {
const inputId = useId();
return ( return (
<div className={`space-y-[var(--spacing-scale-008)] ${className}`}> <div className={`space-y-[var(--spacing-scale-008)] ${className}`}>
{/* Label with help icon */} {/* Label with help icon */}
{label && ( {label && (
<div className="flex items-center gap-[var(--spacing-scale-002)]"> <div className="flex items-center gap-[var(--spacing-scale-002)]">
<label className="text-[14px] leading-[20px] font-medium text-[var(--color-content-default-primary)]"> <label
htmlFor={inputId}
className="text-[14px] leading-[20px] font-medium text-[var(--color-content-default-primary)]"
>
{label} {label}
</label> </label>
{showHelpIcon && ( {showHelpIcon && (
@@ -52,6 +59,7 @@ export function InputWithCounterView({
{/* Input field */} {/* Input field */}
<div className="relative"> <div className="relative">
<input <input
id={inputId}
type="text" type="text"
placeholder={placeholder} placeholder={placeholder}
value={value} value={value}
@@ -36,9 +36,8 @@ export const TextAreaView = forwardRef<HTMLTextAreaElement, TextAreaViewProps>(
}, },
ref, ref,
) => { ) => {
return ( const labelEl = label ? (
<div className={containerClasses}> formHeader ? (
{formHeader && label && (
<div className="flex flex-wrap gap-[var(--measures-spacing-200,4px_8px)] items-baseline pr-[var(--measures-spacing-100,4px)] relative shrink-0 w-full"> <div className="flex flex-wrap gap-[var(--measures-spacing-200,4px_8px)] items-baseline pr-[var(--measures-spacing-100,4px)] relative shrink-0 w-full">
<div className="flex gap-[var(--measures-spacing-050,2px)] items-center relative shrink-0"> <div className="flex gap-[var(--measures-spacing-050,2px)] items-center relative shrink-0">
<label <label
@@ -60,7 +59,16 @@ export const TextAreaView = forwardRef<HTMLTextAreaElement, TextAreaViewProps>(
)} )}
</div> </div>
</div> </div>
)} ) : (
<label id={labelId} htmlFor={textareaId} className="sr-only">
{label}
</label>
)
) : null;
return (
<div className={containerClasses}>
{labelEl}
<div className={disabled ? "opacity-40" : ""}> <div className={disabled ? "opacity-40" : ""}>
<textarea <textarea
ref={ref} ref={ref}
@@ -31,6 +31,11 @@ const TextInputContainer = forwardRef<HTMLInputElement, TextInputProps>(
textHint = false, textHint = false,
formHeader = true, formHeader = true,
maxLength, maxLength,
autoComplete,
inputMode,
required = false,
form,
onKeyDown,
...props ...props
}, },
ref, ref,
@@ -250,6 +255,11 @@ const TextInputContainer = forwardRef<HTMLInputElement, TextInputProps>(
maxLength={maxLength} maxLength={maxLength}
helpIconAlt={t("helpIconAlt")} helpIconAlt={t("helpIconAlt")}
hintDefault={t("hintDefault")} hintDefault={t("hintDefault")}
autoComplete={autoComplete}
inputMode={inputMode}
required={required}
form={form}
onKeyDown={onKeyDown}
{...props} {...props}
/> />
); );
@@ -67,4 +67,9 @@ export interface TextInputViewProps {
maxLength?: number; maxLength?: number;
helpIconAlt: string; helpIconAlt: string;
hintDefault: string; hintDefault: string;
autoComplete?: string;
inputMode?: React.HTMLAttributes<HTMLInputElement>["inputMode"];
required?: boolean;
form?: string;
onKeyDown?: React.KeyboardEventHandler<HTMLInputElement>;
} }
@@ -13,7 +13,7 @@ export const TextInputView = forwardRef<HTMLInputElement, TextInputViewProps>(
name, name,
type, type,
disabled, disabled,
error: _error, error = false,
className: _className, className: _className,
containerClasses, containerClasses,
labelClasses, labelClasses,
@@ -31,12 +31,18 @@ export const TextInputView = forwardRef<HTMLInputElement, TextInputViewProps>(
maxLength, maxLength,
helpIconAlt, helpIconAlt,
hintDefault, hintDefault,
autoComplete,
inputMode,
required = false,
form,
onKeyDown,
state: _state,
isFilled: _isFilled,
}, },
ref, ref,
) => { ) => {
return ( const labelEl = label ? (
<div className={containerClasses}> formHeader ? (
{formHeader && label && (
<div className="flex flex-wrap gap-[var(--measures-spacing-200,4px_8px)] items-baseline pr-[var(--measures-spacing-100,4px)] relative shrink-0 w-full"> <div className="flex flex-wrap gap-[var(--measures-spacing-200,4px_8px)] items-baseline pr-[var(--measures-spacing-100,4px)] relative shrink-0 w-full">
<div className="flex gap-[var(--measures-spacing-050,2px)] items-center relative shrink-0"> <div className="flex gap-[var(--measures-spacing-050,2px)] items-center relative shrink-0">
<label <label
@@ -58,7 +64,16 @@ export const TextInputView = forwardRef<HTMLInputElement, TextInputViewProps>(
)} )}
</div> </div>
</div> </div>
)} ) : (
<label id={labelId} htmlFor={inputId} className="sr-only">
{label}
</label>
)
) : null;
return (
<div className={containerClasses}>
{labelEl}
<div className={inputWrapperClasses}> <div className={inputWrapperClasses}>
<div className={disabled ? "opacity-40" : ""}> <div className={disabled ? "opacity-40" : ""}>
<input <input
@@ -71,9 +86,16 @@ export const TextInputView = forwardRef<HTMLInputElement, TextInputViewProps>(
onChange={handleChange} onChange={handleChange}
onFocus={handleFocus} onFocus={handleFocus}
onBlur={handleBlur} onBlur={handleBlur}
onKeyDown={onKeyDown}
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
disabled={disabled} disabled={disabled}
maxLength={maxLength} maxLength={maxLength}
autoComplete={autoComplete}
inputMode={inputMode}
required={required}
form={form}
aria-invalid={error || undefined}
aria-required={required || undefined}
className={inputClasses} className={inputClasses}
style={{ borderRadius }} style={{ borderRadius }}
/> />
@@ -1,10 +1,93 @@
"use client"; "use client";
import type { RefObject } from "react"; import type { RefObject } from "react";
import { useEffect, useRef } from "react"; import { useEffect, useLayoutEffect, useRef } from "react";
const FOCUSABLE_SELECTOR =
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
let lastInteractedFocusable: HTMLElement | null = null;
let interactionTrackingBound = false;
function closestFocusable(target: EventTarget | null): HTMLElement | null {
if (!(target instanceof Element)) return null;
const match = target.closest(FOCUSABLE_SELECTOR);
return match instanceof HTMLElement ? match : null;
}
/** Menu items unmount on select; restore to the control that opened the menu. */
function menuTriggerFor(item: HTMLElement): HTMLElement | null {
if (item.getAttribute("role") !== "menuitem") return null;
const menu = item.closest("[role='menu']");
const menuId = menu?.getAttribute("id");
if (menuId) {
const trigger = document.querySelector(
`[aria-controls="${CSS.escape(menuId)}"]`,
);
if (trigger instanceof HTMLElement) return trigger;
}
const expanded = document.querySelector(
'[aria-haspopup="menu"][aria-expanded="true"]',
);
return expanded instanceof HTMLElement ? expanded : null;
}
function stableFocusableFrom(target: EventTarget | null): HTMLElement | null {
const focusable = closestFocusable(target);
if (!focusable) return null;
return menuTriggerFor(focusable) ?? focusable;
}
function isRestorable(
node: HTMLElement | null,
dialog: HTMLElement | null,
): node is HTMLElement {
if (!node?.isConnected) return false;
if (node === document.body || node === document.documentElement) return false;
if (dialog?.contains(node)) return false;
return true;
}
function retainLastInteracted(): void {
if (interactionTrackingBound || typeof document === "undefined") return;
interactionTrackingBound = true;
const save = (event: Event) => {
const el = stableFocusableFrom(event.target);
if (!el) return;
lastInteractedFocusable = el;
};
document.addEventListener("pointerdown", save, true);
document.addEventListener("focusin", save);
}
retainLastInteracted();
function snapshotTrigger(dialog: HTMLElement | null): HTMLElement | null {
if (lastInteractedFocusable && !lastInteractedFocusable.isConnected) {
lastInteractedFocusable = null;
}
const active =
document.activeElement instanceof HTMLElement
? document.activeElement
: null;
const fromActive = isRestorable(active, dialog)
? (menuTriggerFor(active) ?? active)
: null;
if (fromActive && isRestorable(fromActive, dialog)) return fromActive;
if (isRestorable(lastInteractedFocusable, dialog)) {
return lastInteractedFocusable;
}
return null;
}
function restoreFocus(node: HTMLElement | null): void {
if (!node?.isConnected) return;
node.focus();
}
/** /**
* Escape-to-close, body scroll lock, focus move-in and tab trap for Create-shell modals. * Escape-to-close, body scroll lock, focus move-in, tab trap, and restore
* focus to the control that opened a Create-shell modal.
*/ */
export function useCreateModalA11y( export function useCreateModalA11y(
isOpen: boolean, isOpen: boolean,
@@ -28,17 +111,17 @@ export function useCreateModalA11y(
}; };
}, [isOpen, onClose]); }, [isOpen, onClose]);
useEffect(() => { useLayoutEffect(() => {
if (!isOpen) return; if (!isOpen) return;
previousActiveElementRef.current = document.activeElement as HTMLElement; previousActiveElementRef.current = snapshotTrigger(dialogRef.current);
document.body.style.overflow = "hidden"; document.body.style.overflow = "hidden";
if (dialogRef.current) { if (dialogRef.current) {
const focusableElements = dialogRef.current.querySelectorAll( const focusableElements = dialogRef.current.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', FOCUSABLE_SELECTOR,
); );
const firstElement = focusableElements[0] as HTMLElement; const firstElement = focusableElements[0] as HTMLElement | undefined;
if (firstElement) { if (firstElement) {
firstElement.focus(); firstElement.focus();
} else { } else {
@@ -51,24 +134,22 @@ export function useCreateModalA11y(
if (e.key !== "Tab" || !dialogRef.current) return; if (e.key !== "Tab" || !dialogRef.current) return;
const focusableElements = dialogRef.current.querySelectorAll( const focusableElements = dialogRef.current.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', FOCUSABLE_SELECTOR,
); );
const firstElement = focusableElements[0] as HTMLElement; const firstElement = focusableElements[0] as HTMLElement | undefined;
const lastElement = focusableElements[ const lastElement = focusableElements[
focusableElements.length - 1 focusableElements.length - 1
] as HTMLElement; ] as HTMLElement | undefined;
if (e.shiftKey) { if (e.shiftKey) {
if (document.activeElement === firstElement) { if (document.activeElement === firstElement) {
e.preventDefault(); e.preventDefault();
lastElement?.focus(); lastElement?.focus();
} }
} else { } else if (document.activeElement === lastElement) {
if (document.activeElement === lastElement) {
e.preventDefault(); e.preventDefault();
firstElement?.focus(); firstElement?.focus();
} }
}
}; };
document.addEventListener("keydown", handleTab); document.addEventListener("keydown", handleTab);
@@ -76,7 +157,7 @@ export function useCreateModalA11y(
return () => { return () => {
document.body.style.overflow = ""; document.body.style.overflow = "";
document.removeEventListener("keydown", handleTab); document.removeEventListener("keydown", handleTab);
previousActiveElementRef.current?.focus(); restoreFocus(previousActiveElementRef.current);
}; };
}, [dialogRef, isOpen]); }, [dialogRef, isOpen]);
} }
@@ -17,6 +17,7 @@ const CreateFlowFooterContainer = memo<CreateFlowFooterProps>(
proportionBarVariant, proportionBarVariant,
onBackClick, onBackClick,
className = "", className = "",
contentMaxClass,
footerAriaLabel, footerAriaLabel,
}) => { }) => {
const t = useTranslation("controlsChrome"); const t = useTranslation("controlsChrome");
@@ -29,6 +30,7 @@ const CreateFlowFooterContainer = memo<CreateFlowFooterProps>(
proportionBarVariant={proportionBarVariant} proportionBarVariant={proportionBarVariant}
onBackClick={onBackClick} onBackClick={onBackClick}
className={className} className={className}
contentMaxClass={contentMaxClass}
footerAriaLabel={footerAriaLabel ?? t("createFlowFooterAriaLabel")} footerAriaLabel={footerAriaLabel ?? t("createFlowFooterAriaLabel")}
/> />
); );
@@ -36,6 +36,11 @@ export interface CreateFlowFooterProps {
* Additional CSS classes * Additional CSS classes
*/ */
className?: string; className?: string;
/**
* Inner max-width for the progress bar and actions, matching the step
* content column so the primary button does not drift at wide viewports.
*/
contentMaxClass?: string;
/** /**
* Accessible name for the footer landmark. * Accessible name for the footer landmark.
*/ */
@@ -1,6 +1,11 @@
import ProportionBar from "../../progress/ProportionBar"; import ProportionBar from "../../progress/ProportionBar";
import Button from "../../buttons/Button"; import Button from "../../buttons/Button";
import type { CreateFlowFooterProps } from "./CreateFlowFooter.types"; import type { CreateFlowFooterProps } from "./CreateFlowFooter.types";
import {
CREATE_FLOW_FOOTER_SCRIM_CLASS,
CREATE_FLOW_PAGE_GUTTER_CLASS,
CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS,
} from "../../../(app)/create/components/createFlowLayoutTokens";
export function CreateFlowFooterView({ export function CreateFlowFooterView({
secondButton, secondButton,
@@ -9,18 +14,21 @@ export function CreateFlowFooterView({
proportionBarVariant: proportionBarVariantProp, proportionBarVariant: proportionBarVariantProp,
onBackClick, onBackClick,
className = "", className = "",
contentMaxClass = CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS,
footerAriaLabel, footerAriaLabel,
}: CreateFlowFooterProps) { }: CreateFlowFooterProps) {
const proportionBarVariant = proportionBarVariantProp ?? "default"; const proportionBarVariant = proportionBarVariantProp ?? "default";
return ( return (
<footer <footer
className={`bg-black w-full ${className}`} className={`relative bg-black w-full pb-[env(safe-area-inset-bottom,0px)] ${className}`}
role="contentinfo" role="contentinfo"
aria-label={footerAriaLabel} aria-label={footerAriaLabel}
> >
{/* Progress Bar - Top */} <div className={CREATE_FLOW_FOOTER_SCRIM_CLASS} aria-hidden />
<div className={CREATE_FLOW_PAGE_GUTTER_CLASS}>
<div className={`mx-auto w-full ${contentMaxClass}`}>
{progressBar && ( {progressBar && (
<div className="px-[var(--spacing-measures-spacing-500,20px)] md:px-[var(--spacing-measures-spacing-1200,48px)] pt-[var(--spacing-measures-spacing-300,12px)]"> <div className="pt-[var(--spacing-measures-spacing-300,12px)]">
<ProportionBar <ProportionBar
progress={proportionBarProgress} progress={proportionBarProgress}
variant={proportionBarVariant} variant={proportionBarVariant}
@@ -28,9 +36,7 @@ export function CreateFlowFooterView({
</div> </div>
)} )}
{/* Buttons Container */} <div className="flex items-center justify-between py-[var(--spacing-measures-spacing-300,12px)] gap-[var(--spacing-measures-spacing-300,12px)]">
<div className="flex items-center justify-between mx-auto max-w-[639px] md:max-w-[1920px] px-[var(--spacing-measures-spacing-500,20px)] md:px-[var(--spacing-measures-spacing-1200,48px)] py-[var(--spacing-measures-spacing-300,12px)] gap-[var(--spacing-measures-spacing-300,12px)]">
{/* Back Button - Left */}
<Button <Button
buttonType="ghost" buttonType="ghost"
palette="default" palette="default"
@@ -42,9 +48,10 @@ export function CreateFlowFooterView({
Back Back
</Button> </Button>
{/* Second Button - Right */}
{secondButton && <div className="flex-shrink-0">{secondButton}</div>} {secondButton && <div className="flex-shrink-0">{secondButton}</div>}
</div> </div>
</div>
</div>
</footer> </footer>
); );
} }
@@ -5,6 +5,7 @@ import Button from "../../buttons/Button";
import ListItem from "../../layout/ListItem"; import ListItem from "../../layout/ListItem";
import Popover from "../../modals/Popover"; import Popover from "../../modals/Popover";
import type { CreateFlowTopNavViewProps } from "./CreateFlowTopNav.types"; import type { CreateFlowTopNavViewProps } from "./CreateFlowTopNav.types";
import { CREATE_FLOW_PAGE_GUTTER_CLASS } from "../../../(app)/create/components/createFlowLayoutTokens";
const outlineButtonClass = const outlineButtonClass =
"md:!text-x-small-label !text-xx-small-label !px-[var(--spacing-scale-006,6px)] md:!px-[var(--spacing-scale-008,8px)] !py-[6px] md:!py-[8px] !border md:!border-[1.5px]"; "md:!text-x-small-label !text-xx-small-label !px-[var(--spacing-scale-006,6px)] md:!px-[var(--spacing-scale-008,8px)] !py-[6px] md:!py-[8px] !border md:!border-[1.5px]";
@@ -229,7 +230,7 @@ export function CreateFlowTopNavView({
aria-label={bannerAriaLabel} aria-label={bannerAriaLabel}
> >
<nav <nav
className="flex items-center justify-between mx-auto max-w-[639px] md:max-w-[1920px] px-[var(--spacing-measures-spacing-500,20px)] md:px-[48px] py-[var(--spacing-measures-spacing-300,12px)] md:py-[var(--spacing-measures-spacing-016,16px)]" className={`flex items-center justify-between mx-auto w-full max-w-[1920px] ${CREATE_FLOW_PAGE_GUTTER_CLASS} py-[var(--spacing-measures-spacing-300,12px)] md:py-[var(--spacing-measures-spacing-016,16px)]`}
role="navigation" role="navigation"
aria-label={navAriaLabel} aria-label={navAriaLabel}
> >
@@ -9,7 +9,13 @@ import type { GovernanceTemplateCatalogEntry } from "../../../../lib/templates/g
export interface GovernanceTemplateGridProps { export interface GovernanceTemplateGridProps {
entries: GovernanceTemplateCatalogEntry[]; entries: GovernanceTemplateCatalogEntry[];
onTemplateClick: (_slug: string) => void; /**
* Real navigation target per card. Cards render as anchors so they are
* keyboard-focusable and work in a new tab.
*/
hrefForTemplate: (_slug: string) => string;
/** Optional side effects on activate (analytics, draft reset). */
onTemplateClick?: (_slug: string) => void;
/** /**
* When true, use project **`md`** (640px) for a 2-column grid (e.g. `/use-cases`). * When true, use project **`md`** (640px) for a 2-column grid (e.g. `/use-cases`).
* Default keeps the template shell break at **768px**. * Default keeps the template shell break at **768px**.
@@ -19,6 +25,7 @@ export interface GovernanceTemplateGridProps {
export function GovernanceTemplateGrid({ export function GovernanceTemplateGrid({
entries, entries,
hrefForTemplate,
onTemplateClick, onTemplateClick,
twoColumnsFromMd = false, twoColumnsFromMd = false,
}: GovernanceTemplateGridProps) { }: GovernanceTemplateGridProps) {
@@ -105,9 +112,14 @@ export function GovernanceTemplateGrid({
/> />
} }
backgroundColor={card.backgroundColor} backgroundColor={card.backgroundColor}
onClick={() => { href={hrefForTemplate(card.slug)}
onClick={
onTemplateClick
? () => {
onTemplateClick(card.slug); onTemplateClick(card.slug);
}} }
: undefined
}
/> />
))} ))}
</div> </div>
@@ -5,10 +5,10 @@
*/ */
import { memo, useEffect, useState } from "react"; import { memo, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslation } from "../../../contexts/MessagesContext"; import { useTranslation } from "../../../contexts/MessagesContext";
import { logger } from "../../../../lib/logger"; import { logger } from "../../../../lib/logger";
import { prepareFreshCreateFlowEntrySync } from "../../../(app)/create/utils/prepareFreshCreateFlowEntry"; import { prepareFreshCreateFlowEntrySync } from "../../../(app)/create/utils/prepareFreshCreateFlowEntry";
import { buildTemplateReviewHref } from "../../../(app)/create/utils/flowSteps";
import { import {
fetchTemplates, fetchTemplates,
isTemplatesFetchAborted, isTemplatesFetchAborted,
@@ -34,7 +34,6 @@ declare global {
const RuleStackContainer = memo<RuleStackProps>( const RuleStackContainer = memo<RuleStackProps>(
({ className = "", initialGridEntries, translationNamespace, twoColumnsFromMd }) => { ({ className = "", initialGridEntries, translationNamespace, twoColumnsFromMd }) => {
const router = useRouter();
const namespace = translationNamespace ?? "pages.home.ruleStack"; const namespace = translationNamespace ?? "pages.home.ruleStack";
const t = useTranslation(namespace); const t = useTranslation(namespace);
const [gridEntries, setGridEntries] = useState<TemplateGridCardEntry[] | null>( const [gridEntries, setGridEntries] = useState<TemplateGridCardEntry[] | null>(
@@ -101,12 +100,12 @@ const RuleStackContainer = memo<RuleStackProps>(
// `signedIn: true` because this surface has no session in hand; DELETE is // `signedIn: true` because this surface has no session in hand; DELETE is
// best-effort and the sentinel blocks stale-draft hydration. // best-effort and the sentinel blocks stale-draft hydration.
prepareFreshCreateFlowEntrySync({ signedIn: true }); prepareFreshCreateFlowEntrySync({ signedIn: true });
router.push(`/create/review-template/${encodeURIComponent(slug)}`);
}; };
return ( return (
<RuleStackView <RuleStackView
className={className} className={className}
hrefForTemplate={(slug) => buildTemplateReviewHref(slug)}
onTemplateClick={handleTemplateClick} onTemplateClick={handleTemplateClick}
gridEntries={gridEntries} gridEntries={gridEntries}
sectionTitle={t("title")} sectionTitle={t("title")}
@@ -21,6 +21,7 @@ export interface RuleStackProps {
export interface RuleStackViewProps { export interface RuleStackViewProps {
className: string; className: string;
onTemplateClick: (_slug: string) => void; onTemplateClick: (_slug: string) => void;
hrefForTemplate: (_slug: string) => string;
/** `null` while loading curated templates from the API. */ /** `null` while loading curated templates from the API. */
gridEntries: TemplateGridCardEntry[] | null; gridEntries: TemplateGridCardEntry[] | null;
sectionTitle: string; sectionTitle: string;
@@ -10,6 +10,7 @@ import type { RuleStackViewProps } from "./RuleStack.types";
export function RuleStackView({ export function RuleStackView({
className, className,
onTemplateClick, onTemplateClick,
hrefForTemplate,
gridEntries, gridEntries,
sectionTitle, sectionTitle,
sectionSubtitle, sectionSubtitle,
@@ -46,6 +47,7 @@ export function RuleStackView({
) : ( ) : (
<GovernanceTemplateGrid <GovernanceTemplateGrid
entries={gridEntries} entries={gridEntries}
hrefForTemplate={hrefForTemplate}
onTemplateClick={onTemplateClick} onTemplateClick={onTemplateClick}
twoColumnsFromMd={twoColumnsFromMd} twoColumnsFromMd={twoColumnsFromMd}
/> />
+4 -3
View File
@@ -76,6 +76,7 @@ Call sites for **`prepareFreshCreateFlowEntry`**: [`Top.container.tsx`](../app/c
| Path | Purpose | | Path | Purpose |
| --- | --- | | --- | --- |
| `/create/review-template/[slug]` | Template preview in the create shell; uses the same layout/footer chrome as other create pages but **is not** part of `FLOW_STEP_ORDER` **or** the three Figma stages above. | | `/create/review-template/[slug]` | Template preview in the create shell; uses the same layout/footer chrome as other create pages but **is not** part of `FLOW_STEP_ORDER` **or** the three Figma stages above. |
| `/create/templates` | In-flow template catalog after review **Create from template**. Same create chrome (Back, Save & Exit); cards link to `/create/review-template/[slug]?fromFlow=1`. Marketing `/templates` stays the public catalog. |
From that page, **Customize** pre-fills the custom-rule selections on the current `CreateFlowState` (via [`buildTemplateCustomizePrefill`](../lib/create/applyTemplatePrefill.ts)) and routes to **`/create/core-values`** when the community name (`state.title`) is already set, otherwise to **`/create/informational`**. Name-only is the gate because other community-stage fields (e.g. `communityStructureChipSnapshots`) are sticky once the user lands on those screens; a non-empty title is also the minimum bar [`buildPublishPayload`](../lib/create/buildPublishPayload.ts) enforces, so the two checks stay aligned. No query-param plumbing: state persists via the usual anonymous/server-draft mirrors. From that page, **Customize** pre-fills the custom-rule selections on the current `CreateFlowState` (via [`buildTemplateCustomizePrefill`](../lib/create/applyTemplatePrefill.ts)) and routes to **`/create/core-values`** when the community name (`state.title`) is already set, otherwise to **`/create/informational`**. Name-only is the gate because other community-stage fields (e.g. `communityStructureChipSnapshots`) are sticky once the user lands on those screens; a non-empty title is also the minimum bar [`buildPublishPayload`](../lib/create/buildPublishPayload.ts) enforces, so the two checks stay aligned. No query-param plumbing: state persists via the usual anonymous/server-draft mirrors.
@@ -87,7 +88,7 @@ From that page, **Customize** pre-fills the custom-rule selections on the curren
- **Customize** routes to `/create/informational` and walks the full Create Community questionnaire. Once the user reaches `/create/review`, [`CommunityReviewScreen`](../app/(app)/create/screens/review/CommunityReviewScreen.tsx) reads the action on mount, clears it via `updateState`, and `router.replace`s to `/create/core-values`. `replace` (not `push`) keeps `community-save` as the Back-button target from that destination. - **Customize** routes to `/create/informational` and walks the full Create Community questionnaire. Once the user reaches `/create/review`, [`CommunityReviewScreen`](../app/(app)/create/screens/review/CommunityReviewScreen.tsx) reads the action on mount, clears it via `updateState`, and `router.replace`s to `/create/core-values`. `replace` (not `push`) keeps `community-save` as the Back-button target from that destination.
- **Use without changes (direct from a template)** — home Popular templates, `/templates` without `fromFlow`, or a pasted review-template URL. Always routes to `/create/community-name` and walks **name → description → optional photo**, then `/create/confirm-stakeholders`. A leftover draft title is **not** “community already done”; those identity fields are cleared so cache cannot skip or pre-fill the path. Intro, structure, size, save, community review, and custom-rule authoring are skipped. Back from name returns to template review. - **Use without changes (direct from a template)** — home Popular templates, `/templates` without `fromFlow`, or a pasted review-template URL. Always routes to `/create/community-name` and walks **name → description → optional photo**, then `/create/confirm-stakeholders`. A leftover draft title is **not** “community already done”; those identity fields are cleared so cache cannot skip or pre-fill the path. Intro, structure, size, save, community review, and custom-rule authoring are skipped. Back from name returns to template review.
- **Use without changes (in-flow)** — `/create/review``/templates?fromFlow=1` → template. Community identity is already collected, so this action seeds the template and routes to `/create/confirm-stakeholders`. Back returns to template review. - **Use without changes (in-flow)** — `/create/review``/create/templates` → template. Community identity is already collected, so this action seeds the template and routes to `/create/confirm-stakeholders`. Back returns to template review.
The action for Customize is cleared on the first fire so later direct visits to `/create/review` render normally. The review screen is not part of the Use-without-changes identity path. If they land on `/create/review` with a leftover Use-without-changes pin, the screen `replace`s to `community-name`. The action for Customize is cleared on the first fire so later direct visits to `/create/review` render normally. The review screen is not part of the Use-without-changes identity path. If they land on `/create/review` with a leftover Use-without-changes pin, the screen `replace`s to `community-name`.
@@ -97,9 +98,9 @@ The action for Customize is cleared on the first fire so later direct visits to
| --- | --- | --- | | --- | --- | --- |
| Home marketing "Popular templates" ([`RuleStack.container.tsx`](../app/components/sections/RuleStack/RuleStack.container.tsx)) | always `await prepareFreshCreateFlowEntry()` then navigate | `/create/review-template/[slug]` | | Home marketing "Popular templates" ([`RuleStack.container.tsx`](../app/components/sections/RuleStack/RuleStack.container.tsx)) | always `await prepareFreshCreateFlowEntry()` then navigate | `/create/review-template/[slug]` |
| `/templates` index ([`TemplatesPageClient.tsx`](../app/(marketing)/templates/TemplatesPageClient.tsx)) visited directly / via pasted URL | `fromFlow` absent → `await prepareFreshCreateFlowEntry()` then navigate | `/create/review-template/[slug]` | | `/templates` index ([`TemplatesPageClient.tsx`](../app/(marketing)/templates/TemplatesPageClient.tsx)) visited directly / via pasted URL | `fromFlow` absent → `await prepareFreshCreateFlowEntry()` then navigate | `/create/review-template/[slug]` |
| In-flow: `/create/review` footer "Create from template" → `/templates?fromFlow=1` → template click | `fromFlow=1` no fresh-entry prep | `/create/review-template/[slug]?fromFlow=1` | | In-flow: `/create/review` footer "Create from template" → `/create/templates` → template click | no fresh-entry prep; cards are `?fromFlow=1` links | `/create/review-template/[slug]?fromFlow=1` |
Only one `?fromFlow=1` marker exists on `/templates`. Template clicks forward it onto `/create/review-template/[slug]?fromFlow=1` so **Use without changes** can tell in-flow (skip identity) from a direct catalog start (collect name / description / photo). **Customize** still branches on `state.title` (full questionnaire vs `core-values`). Only one `?fromFlow=1` marker exists on template-review URLs. In-flow catalog clicks on `/create/templates` always include it so **Use without changes** can tell in-flow (skip identity) from a direct catalog start (collect name / description / photo). Profile still uses marketing `/templates?fromFlow=1`. **Customize** still branches on `state.title` (full questionnaire vs `core-values`).
**Resume from profile** remains explicit-only: **Continue** clears local mirrors then opens `/create/{step}` so [`SignedInDraftHydration`](../app/(app)/create/SignedInDraftHydration.tsx) can load `/api/drafts/me` when the client buffer is empty. There is no automatic “pick template from marketing → silently merge server draft” path. **Resume from profile** remains explicit-only: **Continue** clears local mirrors then opens `/create/{step}` so [`SignedInDraftHydration`](../app/(app)/create/SignedInDraftHydration.tsx) can load `/api/drafts/me` when the client buffer is empty. There is no automatic “pick template from marketing → silently merge server draft” path.
+402
View File
@@ -0,0 +1,402 @@
import type { CreateFlowUploadPurpose } from "./createFlowUploadPurpose";
/** Community avatar cap (bytes). */
const COMMUNITY_AVATAR_MAX_BYTES = 5 * 1024 * 1024;
/** Custom-method attachment cap (bytes). */
const CUSTOM_ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024;
export const COMMUNITY_AVATAR_ACCEPT =
"image/jpeg,image/png,image/webp,image/gif";
export const CUSTOM_ATTACHMENT_ACCEPT = `${COMMUNITY_AVATAR_ACCEPT},application/pdf`;
export type CreateFlowUploadValidationReason =
| "empty"
| "tooLarge"
| "svg"
| "invalidType"
| "undecodable";
type SniffedUploadKind =
| "jpeg"
| "png"
| "gif"
| "webp"
| "pdf"
| "svg"
| "empty"
| "unknown";
type CreateFlowUploadRasterKind = "jpeg" | "png" | "gif" | "webp";
type CreateFlowUploadValidationOk = {
ok: true;
kind: CreateFlowUploadRasterKind | "pdf";
mimeType: string;
};
type CreateFlowUploadValidationFail = {
ok: false;
reason: CreateFlowUploadValidationReason;
};
export type CreateFlowUploadValidationResult =
| CreateFlowUploadValidationOk
| CreateFlowUploadValidationFail;
export class CreateFlowUploadValidationError extends Error {
readonly reason: CreateFlowUploadValidationReason;
constructor(reason: CreateFlowUploadValidationReason) {
super(reason);
this.name = "CreateFlowUploadValidationError";
this.reason = reason;
}
}
const SVG_MIME = new Set(["image/svg+xml", "image/svg"]);
export function maxBytesForPurpose(purpose: CreateFlowUploadPurpose): number {
return purpose === "communityAvatar"
? COMMUNITY_AVATAR_MAX_BYTES
: CUSTOM_ATTACHMENT_MAX_BYTES;
}
function mimeTypeForSniffedKind(
kind: CreateFlowUploadRasterKind | "pdf",
): string {
switch (kind) {
case "jpeg":
return "image/jpeg";
case "png":
return "image/png";
case "gif":
return "image/gif";
case "webp":
return "image/webp";
case "pdf":
return "application/pdf";
}
}
export function messageKeyForCreateFlowUploadReason(
reason: CreateFlowUploadValidationReason,
): `errors.${CreateFlowUploadValidationReason}` {
return `errors.${reason}`;
}
function fileLooksLikeSvg(file: File): boolean {
const declared = file.type.toLowerCase().split(";")[0]?.trim() ?? "";
if (SVG_MIME.has(declared)) return true;
return /\.svgz?$/i.test(file.name);
}
function readU16LE(bytes: Uint8Array, offset: number): number {
return bytes[offset]! | (bytes[offset + 1]! << 8);
}
function readU16BE(bytes: Uint8Array, offset: number): number {
return (bytes[offset]! << 8) | bytes[offset + 1]!;
}
function readU24LE(bytes: Uint8Array, offset: number): number {
return (
bytes[offset]! | (bytes[offset + 1]! << 8) | (bytes[offset + 2]! << 16)
);
}
function readU32BE(bytes: Uint8Array, offset: number): number {
return (
((bytes[offset]! << 24) |
(bytes[offset + 1]! << 16) |
(bytes[offset + 2]! << 8) |
bytes[offset + 3]!) >>>
0
);
}
function startsWith(bytes: Uint8Array, offset: number, ascii: string): boolean {
if (offset + ascii.length > bytes.length) return false;
for (let i = 0; i < ascii.length; i++) {
if (bytes[offset + i] !== ascii.charCodeAt(i)) return false;
}
return true;
}
function looksLikeSvgBytes(bytes: Uint8Array): boolean {
let i = 0;
if (
bytes.length >= 3 &&
bytes[0] === 0xef &&
bytes[1] === 0xbb &&
bytes[2] === 0xbf
) {
i = 3;
}
while (
i < bytes.length &&
(bytes[i] === 0x20 ||
bytes[i] === 0x09 ||
bytes[i] === 0x0d ||
bytes[i] === 0x0a)
) {
i += 1;
}
const head = new TextDecoder("utf-8", { fatal: false })
.decode(bytes.subarray(i, Math.min(i + 512, bytes.length)))
.toLowerCase();
return head.includes("<svg") || head.includes("<!doctype svg");
}
/**
* True-type sniff from magic bytes. Raster/PDF signatures win over a later
* `<svg` substring so binary comments cannot be classified as SVG.
*/
function sniffCreateFlowUploadBytes(
bytes: Uint8Array,
): SniffedUploadKind {
if (bytes.length === 0) return "empty";
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
return "jpeg";
}
if (
bytes.length >= 8 &&
bytes[0] === 0x89 &&
bytes[1] === 0x50 &&
bytes[2] === 0x4e &&
bytes[3] === 0x47 &&
bytes[4] === 0x0d &&
bytes[5] === 0x0a &&
bytes[6] === 0x1a &&
bytes[7] === 0x0a
) {
return "png";
}
if (startsWith(bytes, 0, "GIF87a") || startsWith(bytes, 0, "GIF89a")) {
return "gif";
}
if (
bytes.length >= 12 &&
startsWith(bytes, 0, "RIFF") &&
startsWith(bytes, 8, "WEBP")
) {
return "webp";
}
if (bytes.length >= 5 && startsWith(bytes, 0, "%PDF-")) {
return "pdf";
}
if (looksLikeSvgBytes(bytes)) return "svg";
return "unknown";
}
function pngDimensions(
bytes: Uint8Array,
): { width: number; height: number } | null {
if (bytes.length < 24 || !startsWith(bytes, 12, "IHDR")) return null;
const width = readU32BE(bytes, 16);
const height = readU32BE(bytes, 20);
if (width < 1 || height < 1) return null;
return { width, height };
}
function gifDimensions(
bytes: Uint8Array,
): { width: number; height: number } | null {
if (bytes.length < 10) return null;
const width = readU16LE(bytes, 6);
const height = readU16LE(bytes, 8);
if (width < 1 || height < 1) return null;
return { width, height };
}
function jpegDimensions(
bytes: Uint8Array,
): { width: number; height: number } | null {
if (bytes.length < 4) return null;
let offset = 2;
while (offset + 8 < bytes.length) {
if (bytes[offset] !== 0xff) {
offset += 1;
continue;
}
const marker = bytes[offset + 1]!;
if (marker === 0xff) {
offset += 1;
continue;
}
if (marker === 0xd8 || marker === 0xd9 || (marker >= 0xd0 && marker <= 0xd7)) {
offset += 2;
continue;
}
if (offset + 3 >= bytes.length) return null;
const size = readU16BE(bytes, offset + 2);
if (size < 2) return null;
const isSof =
(marker >= 0xc0 && marker <= 0xc3) ||
(marker >= 0xc5 && marker <= 0xc7) ||
(marker >= 0xc9 && marker <= 0xcb) ||
(marker >= 0xcd && marker <= 0xcf);
if (isSof) {
if (offset + 8 >= bytes.length) return null;
const height = readU16BE(bytes, offset + 5);
const width = readU16BE(bytes, offset + 7);
if (width < 1 || height < 1) return null;
return { width, height };
}
offset += 2 + size;
}
return null;
}
function webpDimensions(
bytes: Uint8Array,
): { width: number; height: number } | null {
if (bytes.length < 20) return null;
if (startsWith(bytes, 12, "VP8X")) {
if (bytes.length < 30) return null;
const width = readU24LE(bytes, 24) + 1;
const height = readU24LE(bytes, 27) + 1;
if (width < 1 || height < 1) return null;
return { width, height };
}
if (startsWith(bytes, 12, "VP8L")) {
if (bytes.length < 25 || bytes[20] !== 0x2f) return null;
const bits =
bytes[21]! |
(bytes[22]! << 8) |
(bytes[23]! << 16) |
(bytes[24]! << 24);
const width = (bits & 0x3fff) + 1;
const height = ((bits >> 14) & 0x3fff) + 1;
if (width < 1 || height < 1) return null;
return { width, height };
}
if (startsWith(bytes, 12, "VP8 ")) {
if (bytes.length < 30) return null;
if (bytes[23] !== 0x9d || bytes[24] !== 0x01 || bytes[25] !== 0x2a) {
return null;
}
const width = readU16LE(bytes, 26) & 0x3fff;
const height = readU16LE(bytes, 28) & 0x3fff;
if (width < 1 || height < 1) return null;
return { width, height };
}
return null;
}
function rasterDimensions(
kind: CreateFlowUploadRasterKind,
bytes: Uint8Array,
): { width: number; height: number } | null {
switch (kind) {
case "png":
return pngDimensions(bytes);
case "gif":
return gifDimensions(bytes);
case "jpeg":
return jpegDimensions(bytes);
case "webp":
return webpDimensions(bytes);
}
}
function purposeAllowsKind(
purpose: CreateFlowUploadPurpose,
kind: CreateFlowUploadRasterKind | "pdf",
): boolean {
if (kind === "pdf") return purpose === "customMethodAttachment";
return true;
}
/**
* Size, true-type, SVG, and header-decode checks shared by the browser and
* `POST /api/uploads`. Call {@link validateCreateFlowUploadFile} on the client
* so oversized files are rejected before they are read into memory.
*/
export function validateCreateFlowUploadBytes(
purpose: CreateFlowUploadPurpose,
bytes: Uint8Array,
): CreateFlowUploadValidationResult {
if (bytes.length === 0) return { ok: false, reason: "empty" };
if (bytes.length > maxBytesForPurpose(purpose)) {
return { ok: false, reason: "tooLarge" };
}
const sniffed = sniffCreateFlowUploadBytes(bytes);
if (sniffed === "empty") return { ok: false, reason: "empty" };
if (sniffed === "svg") return { ok: false, reason: "svg" };
if (sniffed === "unknown") return { ok: false, reason: "invalidType" };
if (!purposeAllowsKind(purpose, sniffed)) {
return { ok: false, reason: "invalidType" };
}
if (sniffed === "pdf") {
return { ok: true, kind: "pdf", mimeType: mimeTypeForSniffedKind("pdf") };
}
if (rasterDimensions(sniffed, bytes) == null) {
return { ok: false, reason: "undecodable" };
}
return {
ok: true,
kind: sniffed,
mimeType: mimeTypeForSniffedKind(sniffed),
};
}
async function readBlobBytes(blob: Blob): Promise<Uint8Array> {
if (typeof blob.arrayBuffer === "function") {
return new Uint8Array(await blob.arrayBuffer());
}
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
resolve(new Uint8Array(reader.result as ArrayBuffer));
};
reader.onerror = () => {
reject(reader.error ?? new Error("FileReader failed"));
};
reader.readAsArrayBuffer(blob);
});
}
async function decodeRasterInBrowser(
bytes: Uint8Array,
mimeType: string,
): Promise<boolean> {
if (typeof createImageBitmap !== "function") return true;
try {
const copy = new Uint8Array(bytes);
const bitmap = await createImageBitmap(
new Blob([copy], { type: mimeType }),
);
const ok = bitmap.width > 0 && bitmap.height > 0;
bitmap.close();
return ok;
} catch {
return false;
}
}
/**
* Client-side validation: size and SVG name/type before reading bytes, then
* the same sniff/decode path as the server, plus `createImageBitmap` when
* available.
*/
export async function validateCreateFlowUploadFile(
file: File,
purpose: CreateFlowUploadPurpose,
): Promise<CreateFlowUploadValidationResult> {
if (file.size === 0) return { ok: false, reason: "empty" };
if (file.size > maxBytesForPurpose(purpose)) {
return { ok: false, reason: "tooLarge" };
}
if (fileLooksLikeSvg(file)) return { ok: false, reason: "svg" };
const bytes = await readBlobBytes(file);
const result = validateCreateFlowUploadBytes(purpose, bytes);
if (result.ok === false || result.kind === "pdf") return result;
const decoded = await decodeRasterInBrowser(bytes, result.mimeType);
if (!decoded) return { ok: false, reason: "undecodable" };
return result;
}
+25 -4
View File
@@ -1,10 +1,31 @@
/** RFC 5321 max mailbox length (no angle brackets). */ /** RFC 5321 max mailbox length (no angle brackets). */
export const EMAIL_MAX_LEN = 254; export const EMAIL_MAX_LEN = 254;
/** Pragmatic check for the create-flow “save progress” email field (draft + footer enablement). */ /**
* Fallback when `HTMLInputElement.checkValidity()` is unavailable (SSR).
* Matches the HTML living standards type=email pattern closely enough
* for the save-progress field; the live input is the source of truth in
* the browser.
*/
const EMAIL_FALLBACK_PATTERN =
/^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
/**
* Whether a create-flow save-progress email would pass a required
* `type="email"` inputs `checkValidity()`. Does not trim: native
* validity rejects leading or trailing spaces.
*/
export function isValidCreateFlowSaveEmail(value: unknown): boolean { export function isValidCreateFlowSaveEmail(value: unknown): boolean {
if (typeof value !== "string") return false; if (typeof value !== "string") return false;
const t = value.trim(); if (value.length === 0 || value.length > EMAIL_MAX_LEN) return false;
if (t.length === 0 || t.length > EMAIL_MAX_LEN) return false; // Native type=email rejects whitespace; jsdoms checkValidity does not.
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(t); if (/\s/.test(value)) return false;
if (typeof document === "undefined") {
return EMAIL_FALLBACK_PATTERN.test(value);
}
const input = document.createElement("input");
input.type = "email";
input.required = true;
input.value = value;
return input.checkValidity();
} }
+15 -2
View File
@@ -22,7 +22,20 @@ function openDb(): Promise<IDBDatabase> {
}); });
} }
function coerceToFile(value: unknown): File | null {
if (value instanceof File) return value;
if (typeof Blob !== "undefined" && value instanceof Blob) {
return new File([value], "community-avatar", {
type: value.type || "application/octet-stream",
});
}
return null;
}
export async function storePendingCommunityAvatarFile(file: File): Promise<void> { export async function storePendingCommunityAvatarFile(file: File): Promise<void> {
if (typeof indexedDB === "undefined") {
throw new Error("indexedDB is not available");
}
const db = await openDb(); const db = await openDb();
try { try {
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
@@ -38,6 +51,7 @@ export async function storePendingCommunityAvatarFile(file: File): Promise<void>
/** Read staged file without removing it (caller clears after successful upload). */ /** Read staged file without removing it (caller clears after successful upload). */
export async function readPendingCommunityAvatarFile(): Promise<File | null> { export async function readPendingCommunityAvatarFile(): Promise<File | null> {
if (typeof indexedDB === "undefined") return null;
const db = await openDb(); const db = await openDb();
try { try {
return await new Promise<File | null>((resolve, reject) => { return await new Promise<File | null>((resolve, reject) => {
@@ -45,8 +59,7 @@ export async function readPendingCommunityAvatarFile(): Promise<File | null> {
tx.onerror = () => reject(tx.error ?? new Error("indexedDB read failed")); tx.onerror = () => reject(tx.error ?? new Error("indexedDB read failed"));
const getReq = tx.objectStore(STORE).get(KEY); const getReq = tx.objectStore(STORE).get(KEY);
getReq.onsuccess = () => { getReq.onsuccess = () => {
const v = getReq.result; resolve(coerceToFile(getReq.result));
resolve(v instanceof File ? v : null);
}; };
getReq.onerror = () => reject(getReq.error); getReq.onerror = () => reject(getReq.error);
}); });
+62 -4
View File
@@ -1,4 +1,10 @@
import type { CreateFlowUploadPurpose } from "./createFlowUploadPurpose"; import type { CreateFlowUploadPurpose } from "./createFlowUploadPurpose";
import {
CreateFlowUploadValidationError,
messageKeyForCreateFlowUploadReason,
validateCreateFlowUploadFile,
type CreateFlowUploadValidationReason,
} from "./createFlowUploadValidation";
export type UploadToServerResult = { export type UploadToServerResult = {
url: string; url: string;
@@ -7,6 +13,20 @@ export type UploadToServerResult = {
byteLength: number; byteLength: number;
}; };
const VALIDATION_REASONS = new Set<CreateFlowUploadValidationReason>([
"empty",
"tooLarge",
"svg",
"invalidType",
"undecodable",
]);
function reasonFromUnknown(value: unknown): CreateFlowUploadValidationReason | null {
return typeof value === "string" && VALIDATION_REASONS.has(value as CreateFlowUploadValidationReason)
? (value as CreateFlowUploadValidationReason)
: null;
}
/** /**
* Authenticated multipart upload to `POST /api/uploads`. * Authenticated multipart upload to `POST /api/uploads`.
* Caller must have a session cookie (same-origin fetch). * Caller must have a session cookie (same-origin fetch).
@@ -15,6 +35,11 @@ export async function uploadCreateFlowFile(
file: File, file: File,
purpose: CreateFlowUploadPurpose, purpose: CreateFlowUploadPurpose,
): Promise<UploadToServerResult> { ): Promise<UploadToServerResult> {
const validated = await validateCreateFlowUploadFile(file, purpose);
if (validated.ok === false) {
throw new CreateFlowUploadValidationError(validated.reason);
}
const formData = new FormData(); const formData = new FormData();
formData.append("purpose", purpose); formData.append("purpose", purpose);
formData.append("file", file); formData.append("file", file);
@@ -36,14 +61,24 @@ export async function uploadCreateFlowFile(
if (body && typeof body === "object" && "error" in body) { if (body && typeof body === "object" && "error" in body) {
const e = (body as { const e = (body as {
error?: { message?: string; code?: string }; error?: { message?: string; code?: string };
details?: { reason?: unknown };
}).error; }).error;
if (!e) return { message: null as string | null, code: null as string | null }; const details = (body as { details?: { reason?: unknown } }).details;
const reason = reasonFromUnknown(details?.reason);
if (!e) {
return {
message: null as string | null,
code: null as string | null,
reason,
};
}
return { return {
message: typeof e.message === "string" ? e.message : null, message: typeof e.message === "string" ? e.message : null,
code: typeof e.code === "string" ? e.code : null, code: typeof e.code === "string" ? e.code : null,
reason,
}; };
} }
return { message: null, code: null }; return { message: null, code: null, reason: null };
})(); })();
if (!res.ok) { if (!res.ok) {
@@ -54,7 +89,7 @@ export async function uploadCreateFlowFile(
? "UNAUTHORIZED" ? "UNAUTHORIZED"
: "UPLOAD_FAILED"; : "UPLOAD_FAILED";
const code = errParts.code ?? errParts.message ?? fallback; const code = errParts.code ?? errParts.message ?? fallback;
throw new UploadToServerError(res.status, code); throw new UploadToServerError(res.status, code, errParts.reason);
} }
const data = body as { const data = body as {
@@ -83,11 +118,34 @@ export async function uploadCreateFlowFile(
export class UploadToServerError extends Error { export class UploadToServerError extends Error {
readonly status: number; readonly status: number;
readonly code: string; readonly code: string;
readonly reason: CreateFlowUploadValidationReason | null;
constructor(status: number, code: string) { constructor(
status: number,
code: string,
reason: CreateFlowUploadValidationReason | null = null,
) {
super(code); super(code);
this.name = "UploadToServerError"; this.name = "UploadToServerError";
this.status = status; this.status = status;
this.code = code; this.code = code;
this.reason = reason;
} }
} }
export function createFlowUploadFailureMessageKey(err: unknown): string {
if (err instanceof CreateFlowUploadValidationError) {
return messageKeyForCreateFlowUploadReason(err.reason);
}
if (err instanceof UploadToServerError) {
if (err.reason) return messageKeyForCreateFlowUploadReason(err.reason);
if (err.status === 413) return "errors.tooLarge";
if (err.status === 401) return "errors.unauthorized";
if (err.code === "server_misconfigured") {
return "errors.misconfigured";
}
}
return "errors.generic";
}
export { CreateFlowUploadValidationError };
+18 -16
View File
@@ -2,12 +2,12 @@ import { writeFile } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import type { CreateFlowUploadPurpose } from "./uploadConstants"; import type { CreateFlowUploadPurpose } from "./uploadConstants";
import { import { extensionForMime } from "./uploadConstants";
extensionForMime,
isAllowedMime,
maxBytesForPurpose,
} from "./uploadConstants";
import { ensureUploadRootExists, getUploadRootFromEnv } from "./uploadRoot"; import { ensureUploadRootExists, getUploadRootFromEnv } from "./uploadRoot";
import {
validateCreateFlowUploadBytes,
type CreateFlowUploadValidationReason,
} from "../../create/createFlowUploadValidation";
export type SaveCreateFlowUploadResult = { export type SaveCreateFlowUploadResult = {
/** Filename stem (UUID) without extension — used in GET URL. */ /** Filename stem (UUID) without extension — used in GET URL. */
@@ -18,30 +18,32 @@ export type SaveCreateFlowUploadResult = {
byteLength: number; byteLength: number;
}; };
export type SaveCreateFlowUploadFailure = {
error: "misconfigured" | "validation";
reason?: CreateFlowUploadValidationReason;
};
/** /**
* Writes bytes under `UPLOAD_ROOT/{id}{ext}` and returns a stable app URL path. * Writes bytes under `UPLOAD_ROOT/{id}{ext}` and returns a stable app URL path.
* Trusts sniffed bytes, not the client-declared MIME type.
*/ */
export async function saveCreateFlowUpload(params: { export async function saveCreateFlowUpload(params: {
purpose: CreateFlowUploadPurpose; purpose: CreateFlowUploadPurpose;
buffer: Buffer; buffer: Buffer;
/** Declared MIME from the client `File.type` (validated server-side). */ }): Promise<SaveCreateFlowUploadResult | SaveCreateFlowUploadFailure> {
mimeType: string;
}): Promise<SaveCreateFlowUploadResult | { error: "misconfigured" | "validation" }> {
const root = getUploadRootFromEnv(); const root = getUploadRootFromEnv();
if (!root) { if (!root) {
return { error: "misconfigured" }; return { error: "misconfigured" };
} }
const { purpose, buffer, mimeType } = params; const { purpose, buffer } = params;
if (buffer.length > maxBytesForPurpose(purpose)) { const validated = validateCreateFlowUploadBytes(purpose, buffer);
return { error: "validation" }; if (validated.ok === false) {
} return { error: "validation", reason: validated.reason };
if (!isAllowedMime(purpose, mimeType)) {
return { error: "validation" };
} }
const id = randomUUID(); const id = randomUUID();
const ext = extensionForMime(mimeType); const ext = extensionForMime(validated.mimeType);
const fileName = `${id}${ext}`; const fileName = `${id}${ext}`;
const absolutePath = path.join(root, fileName); const absolutePath = path.join(root, fileName);
@@ -51,7 +53,7 @@ export async function saveCreateFlowUpload(params: {
return { return {
id, id,
urlPath: `/api/uploads/${id}`, urlPath: `/api/uploads/${id}`,
mimeType: mimeType.toLowerCase().split(";")[0]?.trim() ?? "application/octet-stream", mimeType: validated.mimeType,
byteLength: buffer.length, byteLength: buffer.length,
}; };
} }
+2 -31
View File
@@ -1,39 +1,10 @@
import type { CreateFlowUploadPurpose } from "../../create/createFlowUploadPurpose"; export type { CreateFlowUploadPurpose } from "../../create/createFlowUploadPurpose";
export type { CreateFlowUploadPurpose };
export { CREATE_FLOW_UPLOAD_PURPOSES } from "../../create/createFlowUploadPurpose"; export { CREATE_FLOW_UPLOAD_PURPOSES } from "../../create/createFlowUploadPurpose";
export { maxBytesForPurpose } from "../../create/createFlowUploadValidation";
/** Max body size for multipart upload (bytes). */ /** Max body size for multipart upload (bytes). */
export const CREATE_FLOW_UPLOAD_MAX_BYTES = 12 * 1024 * 1024; export const CREATE_FLOW_UPLOAD_MAX_BYTES = 12 * 1024 * 1024;
const COMMUNITY_MAX = 5 * 1024 * 1024;
const CUSTOM_MAX = 10 * 1024 * 1024;
const IMAGE_MIMES = new Set([
"image/jpeg",
"image/png",
"image/webp",
"image/gif",
]);
const CUSTOM_EXTRA_MIMES = new Set(["application/pdf"]);
export function maxBytesForPurpose(purpose: CreateFlowUploadPurpose): number {
return purpose === "communityAvatar" ? COMMUNITY_MAX : CUSTOM_MAX;
}
export function isAllowedMime(
purpose: CreateFlowUploadPurpose,
mime: string,
): boolean {
const m = mime.toLowerCase().split(";")[0]?.trim() ?? "";
if (IMAGE_MIMES.has(m)) return true;
if (purpose === "customMethodAttachment" && CUSTOM_EXTRA_MIMES.has(m)) {
return true;
}
return false;
}
/** Extension including dot, from normalized mime (lowercase). */ /** Extension including dot, from normalized mime (lowercase). */
export function extensionForMime(mime: string): string { export function extensionForMime(mime: string): string {
const m = mime.toLowerCase().split(";")[0]?.trim() ?? ""; const m = mime.toLowerCase().split(";")[0]?.trim() ?? "";
@@ -1,6 +1,7 @@
{ {
"title": "Why does your community exist?", "title": "Why does your community exist?",
"description": "Edit or change the description to match how youd like the organization to be described to other users. Try and describe your mission, goals, and scope.", "description": "Optional. Write a short paragraph about your mission, goals, and scope. You can skip this and add it later.",
"inputLabel": "Community description",
"placeholder": "Describe your community", "placeholder": "Describe your community",
"characterCountTemplate": "{current}/{max}" "characterCountTemplate": "{current}/{max}"
} }
@@ -1,6 +1,7 @@
{ {
"title": "What is your community called?", "title": "What is your community called?",
"description": "This will be the name of your community", "description": "This will be the name of your community.",
"inputLabel": "Community name",
"placeholder": "Enter community name", "placeholder": "Enter community name",
"characterCountTemplate": "{current}/{max}" "characterCountTemplate": "{current}/{max}"
} }
@@ -1,6 +1,7 @@
{ {
"title": "Save your progress", "title": "Save your progress",
"description": "We need your email to save your CommunityRule progress\nand make it accessible to you later.", "description": "We need your email to save your CommunityRule progress\nand make it accessible to you later.",
"inputLabel": "Email address",
"placeholder": "email@domain.com", "placeholder": "email@domain.com",
"characterCountTemplate": "{current}/{max}", "characterCountTemplate": "{current}/{max}",
"magicLinkSuccessTitle": "Check your email to log in", "magicLinkSuccessTitle": "Check your email to log in",
@@ -1,7 +1,7 @@
{ {
"header": { "header": {
"title": "What kind of community would you like to improve?", "title": "What kind of community would you like to improve?",
"description": "Choose tags the describe your community. You can also combine or add new values to the list." "description": "Choose tags that describe your community. You can also combine or add new values to the list."
}, },
"organizationMultiSelect": { "organizationMultiSelect": {
"label": "Organization Type", "label": "Organization Type",
@@ -1,6 +1,6 @@
{ {
"title": "Add a photo to identify your group", "title": "Add a photo to identify your group",
"description": "This photo be used as a profile picture for your group and will be editable later. If possible, try to use a simple logo or graphic.", "description": "This photo will be used as a profile picture for your group and will be editable later. If possible, try to use a simple logo or graphic.",
"hintText": "Add image from your device", "hintText": "Add image from your device",
"signInToUploadNote": "Your photo will upload after you sign in (use Save progress from the next step, or Log in from the header).", "signInToUploadNote": "Your photo will upload after you sign in (use Save progress from the next step, or Log in from the header).",
"uploadingLabel": "Uploading your photo…", "uploadingLabel": "Uploading your photo…",
@@ -1,8 +1,6 @@
{ {
"title": "How CommunityRule helps groups like yours", "title": "How CommunityRule helps groups like yours",
"descriptionLead": "This flow will give you recommendations to improve your community and help you put together a proposal for your group to consider. Alternatively, there is a", "description": "This flow will give you recommendations to improve your community and help you put together a proposal for your group to consider.",
"workshopLabel": "workshop",
"descriptionTrail": "that your group can use to go through the process it together.",
"steps": { "steps": {
"0": { "0": {
"title": "Tell us about your organization", "title": "Tell us about your organization",
+4 -2
View File
@@ -3,7 +3,9 @@
"title": "Your community is added - congrats!", "title": "Your community is added - congrats!",
"description": "In the next section, we'll go through membership, decision-making, conflict resolution, and community values and create a custom operating manual for your organization based on the specifics you just shared." "description": "In the next section, we'll go through membership, decision-making, conflict resolution, and community values and create a custom operating manual for your organization based on the specifics you just shared."
}, },
"ruleCard": { "empty": {
"title": "Mutual Aid Mondays" "title": "No community in progress yet",
"description": "This step is for a community you have already named. Start from the beginning to add one.",
"startLabel": "Start creating"
} }
} }
@@ -11,7 +11,7 @@
}, },
"2": { "2": {
"title": "How do you want to describe your new policy?", "title": "How do you want to describe your new policy?",
"description": "This description will show up with the title to offer additional context about what the policy is how it should be applied.", "description": "This description will show up with the title to offer additional context about what the policy is and how it should be applied.",
"fieldPlaceholder": "Policy description" "fieldPlaceholder": "Policy description"
}, },
"3": { "3": {
+6 -2
View File
@@ -1,9 +1,13 @@
{ {
"errors": { "errors": {
"generic": "Something went wrong while uploading. Try again.", "generic": "Something went wrong while uploading. Try again.",
"tooLarge": "That file is too large. Try a smaller image or PDF.", "tooLarge": "That file is too large. Community photos can be up to 5 MB; attachments up to 10 MB.",
"unauthorized": "Sign in to upload files. Use Save progress if you started without an account.", "unauthorized": "Sign in to upload files. Use Save progress if you started without an account.",
"misconfigured": "Uploads are not available on this server yet." "misconfigured": "Uploads are not available on this server yet.",
"empty": "That file is empty. Choose a file that has content.",
"svg": "SVG files aren't supported. Export a JPEG, PNG, WebP, or GIF instead.",
"invalidType": "That file isn't a supported image (or PDF for attachments). Renaming the extension isn't enough.",
"undecodable": "We couldn't read that image. It may be damaged — try another file."
}, },
"uploading": "Uploading…" "uploading": "Uploading…"
} }
+23
View File
@@ -11,5 +11,28 @@ export const Default = {
messageNamespace: "create.community.communityName", messageNamespace: "create.community.communityName",
stateField: "title", stateField: "title",
maxLength: 48, maxLength: 48,
required: true,
},
};
export const CommunityDescription = {
args: {
messageNamespace: "create.community.communityContext",
stateField: "communityContext",
maxLength: 200,
mainAlign: "center",
multiline: true,
},
};
export const CommunitySave = {
args: {
messageNamespace: "create.community.communitySave",
stateField: "communitySaveEmail",
maxLength: 254,
mainAlign: "center",
inputType: "email",
showCharacterCount: false,
headerJustification: "center",
}, },
}; };
@@ -12,6 +12,10 @@ export default {
control: false, control: false,
description: "Catalog entries to render as a 2-column grid of Rules", description: "Catalog entries to render as a 2-column grid of Rules",
}, },
hrefForTemplate: {
control: false,
description: "Builds the review URL for each template card (real <a> href)",
},
onTemplateClick: { action: "template-clicked" }, onTemplateClick: { action: "template-clicked" },
}, },
}; };
@@ -19,11 +23,13 @@ export default {
export const Default = { export const Default = {
args: { args: {
entries: GOVERNANCE_TEMPLATE_CATALOG.slice(0, 4), entries: GOVERNANCE_TEMPLATE_CATALOG.slice(0, 4),
hrefForTemplate: (slug) => `/create/review-template/${slug}`,
}, },
}; };
export const SingleEntry = { export const SingleEntry = {
args: { args: {
entries: GOVERNANCE_TEMPLATE_CATALOG.slice(0, 1), entries: GOVERNANCE_TEMPLATE_CATALOG.slice(0, 1),
hrefForTemplate: (slug) => `/create/review-template/${slug}`,
}, },
}; };
@@ -11,6 +11,9 @@ describe("CommunityStructureSelectScreen", () => {
expect(screen.getByText("Organization Type")).toBeInTheDocument(); expect(screen.getByText("Organization Type")).toBeInTheDocument();
expect(screen.getByText("Scale")).toBeInTheDocument(); expect(screen.getByText("Scale")).toBeInTheDocument();
expect(screen.getByText("Maturity")).toBeInTheDocument(); expect(screen.getByText("Maturity")).toBeInTheDocument();
expect(
screen.getByText(/Choose tags that describe your community/i),
).toBeInTheDocument();
expect(screen.queryByAltText("Help")).not.toBeInTheDocument(); expect(screen.queryByAltText("Help")).not.toBeInTheDocument();
}); });
}); });
+30 -1
View File
@@ -1,6 +1,7 @@
import React from "react"; import React, { useState } from "react";
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import { screen, fireEvent, waitFor } from "@testing-library/react"; import { screen, fireEvent, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
import { renderWithProviders } from "../utils/test-utils"; import { renderWithProviders } from "../utils/test-utils";
import Create from "../../app/components/modals/Create"; import Create from "../../app/components/modals/Create";
@@ -8,6 +9,23 @@ import TextInput from "../../app/components/controls/TextInput";
type CreateProps = React.ComponentProps<typeof Create>; type CreateProps = React.ComponentProps<typeof Create>;
function CreateOpenHarness() {
const [open, setOpen] = useState(false);
return (
<>
<button type="button" onClick={() => setOpen(true)}>
Open create dialog
</button>
<Create
isOpen={open}
onClose={() => setOpen(false)}
title="Test Create Dialog"
description="Test description"
/>
</>
);
}
describe("Create", () => { describe("Create", () => {
const defaultProps: CreateProps = { const defaultProps: CreateProps = {
isOpen: true, isOpen: true,
@@ -233,6 +251,17 @@ describe("Create", () => {
expect(document.body.style.overflow).toBe(""); expect(document.body.style.overflow).toBe("");
}); });
it("restores focus to the control that opened it", async () => {
const user = userEvent.setup();
renderWithProviders(<CreateOpenHarness />);
const trigger = screen.getByRole("button", { name: "Open create dialog" });
await user.click(trigger);
expect(screen.getByRole("dialog")).toBeInTheDocument();
await user.keyboard("{Escape}");
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
expect(trigger).toHaveFocus();
});
it("traps focus within create dialog", async () => { it("traps focus within create dialog", async () => {
renderWithProviders( renderWithProviders(
<Create {...defaultProps}> <Create {...defaultProps}>
@@ -86,4 +86,17 @@ describe("CreateFlowFooter (behavioral tests)", () => {
expect(buttons).toHaveLength(1); expect(buttons).toHaveLength(1);
expect(buttons[0]).toHaveTextContent("Back"); expect(buttons[0]).toHaveTextContent("Back");
}); });
it("constrains the action row to the step content max-width", () => {
const { container } = render(
<CreateFlowFooter contentMaxClass="w-full min-w-0 lg:max-w-[640px]" />,
);
const footer = screen.getByRole("contentinfo", {
name: "Create Flow Footer",
});
expect(footer.className).toContain("pb-[env(safe-area-inset-bottom,0px)]");
expect(
container.querySelector('[class*="lg:max-w-[640px]"]'),
).not.toBeNull();
});
}); });
+110 -1
View File
@@ -1,12 +1,84 @@
import React from "react"; import React, { useState } from "react";
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import { screen, fireEvent } from "@testing-library/react"; import { screen, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
import { renderWithProviders } from "../utils/test-utils"; import { renderWithProviders } from "../utils/test-utils";
import Dialog from "../../app/components/modals/Dialog"; import Dialog from "../../app/components/modals/Dialog";
type Props = React.ComponentProps<typeof Dialog>; type Props = React.ComponentProps<typeof Dialog>;
const dialogCopy = {
title: "Confirm action",
description: "This cannot be undone.",
};
function DialogOpenHarness() {
const [open, setOpen] = useState(false);
return (
<>
<button type="button" onClick={() => setOpen(true)}>
Open dialog
</button>
<Dialog
isOpen={open}
onClose={() => setOpen(false)}
title={dialogCopy.title}
description={dialogCopy.description}
footer={
<button type="button" onClick={() => setOpen(false)}>
Cancel
</button>
}
/>
</>
);
}
function DialogFromMenuHarness() {
const [menuOpen, setMenuOpen] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false);
const menuId = "dialog-focus-restore-menu";
return (
<>
<button
type="button"
aria-haspopup="menu"
aria-expanded={menuOpen}
aria-controls={menuId}
onClick={() => setMenuOpen((open) => !open)}
>
More options
</button>
{menuOpen ? (
<div role="menu" id={menuId}>
<button
type="button"
role="menuitem"
onClick={() => {
setDialogOpen(true);
setMenuOpen(false);
}}
>
Remove
</button>
</div>
) : null}
<Dialog
isOpen={dialogOpen}
onClose={() => setDialogOpen(false)}
title={dialogCopy.title}
description={dialogCopy.description}
footer={
<button type="button" onClick={() => setDialogOpen(false)}>
Cancel
</button>
}
/>
</>
);
}
describe("Dialog", () => { describe("Dialog", () => {
const defaultProps: Props = { const defaultProps: Props = {
isOpen: true, isOpen: true,
@@ -57,4 +129,41 @@ describe("Dialog", () => {
renderWithProviders(<Dialog {...defaultProps} />); renderWithProviders(<Dialog {...defaultProps} />);
expect(document.body.style.overflow).toBe("hidden"); expect(document.body.style.overflow).toBe("hidden");
}); });
it("restores focus to the control that opened it", async () => {
const user = userEvent.setup();
renderWithProviders(<DialogOpenHarness />);
const trigger = screen.getByRole("button", { name: "Open dialog" });
await user.click(trigger);
expect(screen.getByRole("dialog")).toBeInTheDocument();
await user.keyboard("{Escape}");
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
expect(trigger).toHaveFocus();
});
it("restores focus to the opener when the opening click did not focus it", () => {
renderWithProviders(<DialogOpenHarness />);
const trigger = screen.getByRole("button", { name: "Open dialog" });
fireEvent.pointerDown(trigger);
fireEvent.click(trigger);
expect(screen.getByRole("dialog")).toBeInTheDocument();
fireEvent.keyDown(document, { key: "Escape" });
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
expect(trigger).toHaveFocus();
});
it("restores focus to the menu trigger when a menu item opened the dialog", async () => {
const user = userEvent.setup();
renderWithProviders(<DialogFromMenuHarness />);
const menuTrigger = screen.getByRole("button", { name: "More options" });
await user.click(menuTrigger);
await user.click(screen.getByRole("menuitem", { name: "Remove" }));
expect(screen.getByRole("dialog")).toBeInTheDocument();
expect(
screen.queryByRole("menuitem", { name: "Remove" }),
).not.toBeInTheDocument();
await user.keyboard("{Escape}");
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
expect(menuTrigger).toHaveFocus();
});
}); });
+6 -6
View File
@@ -1044,8 +1044,9 @@ describe("FinalReviewScreen — edit published title and description", () => {
fireEvent.click(await screen.findByTestId("rule-title-edit")); fireEvent.click(await screen.findByTestId("rule-title-edit"));
const dialog = await screen.findByRole("dialog"); const dialog = await screen.findByRole("dialog");
expect(within(dialog).getByText(/Community name/i)).toBeInTheDocument(); const input = within(dialog).getByRole("textbox", {
const input = within(dialog).getByRole("textbox"); name: /Community name/i,
});
fireEvent.change(input, { target: { value: "Renamed Commons" } }); fireEvent.change(input, { target: { value: "Renamed Commons" } });
fireEvent.click(within(dialog).getByRole("button", { name: "Save" })); fireEvent.click(within(dialog).getByRole("button", { name: "Save" }));
@@ -1076,10 +1077,9 @@ describe("FinalReviewScreen — edit published title and description", () => {
fireEvent.click(await screen.findByTestId("rule-description-edit")); fireEvent.click(await screen.findByTestId("rule-description-edit"));
const dialog = await screen.findByRole("dialog"); const dialog = await screen.findByRole("dialog");
expect( const input = within(dialog).getByRole("textbox", {
within(dialog).getByText(/Community description/i), name: /Community description/i,
).toBeInTheDocument(); });
const input = within(dialog).getByRole("textbox");
fireEvent.change(input, { target: { value: "Updated copy" } }); fireEvent.change(input, { target: { value: "Updated copy" } });
fireEvent.click(within(dialog).getByRole("button", { name: "Save" })); fireEvent.click(within(dialog).getByRole("button", { name: "Save" }));
@@ -1,10 +1,13 @@
import { describe, vi } from "vitest"; import { describe, expect, it } from "vitest";
import { screen } from "@testing-library/react";
import { import {
componentTestSuite, componentTestSuite,
type ComponentTestSuiteConfig, type ComponentTestSuiteConfig,
} from "../utils/componentTestSuite"; } from "../utils/componentTestSuite";
import { renderWithProviders as render } from "../utils/test-utils";
import { GovernanceTemplateGrid } from "../../app/components/sections/GovernanceTemplateGrid"; import { GovernanceTemplateGrid } from "../../app/components/sections/GovernanceTemplateGrid";
import { GOVERNANCE_TEMPLATE_CATALOG } from "../../lib/templates/governanceTemplateCatalog"; import { GOVERNANCE_TEMPLATE_CATALOG } from "../../lib/templates/governanceTemplateCatalog";
import "@testing-library/jest-dom/vitest";
type Props = React.ComponentProps<typeof GovernanceTemplateGrid>; type Props = React.ComponentProps<typeof GovernanceTemplateGrid>;
@@ -13,10 +16,10 @@ const config: ComponentTestSuiteConfig<Props> = {
name: "GovernanceTemplateGrid", name: "GovernanceTemplateGrid",
props: { props: {
entries: GOVERNANCE_TEMPLATE_CATALOG.slice(0, 2), entries: GOVERNANCE_TEMPLATE_CATALOG.slice(0, 2),
onTemplateClick: vi.fn(), hrefForTemplate: (slug: string) => `/create/review-template/${slug}`,
} as Props, } as Props,
requiredProps: ["entries", "onTemplateClick"], requiredProps: ["entries", "hrefForTemplate"],
primaryRole: "button", primaryRole: "link",
testCases: { testCases: {
renders: true, renders: true,
accessibility: true, accessibility: true,
@@ -25,4 +28,21 @@ const config: ComponentTestSuiteConfig<Props> = {
describe("GovernanceTemplateGrid", () => { describe("GovernanceTemplateGrid", () => {
componentTestSuite<Props>(config); componentTestSuite<Props>(config);
it("renders each catalog card as a link to template review", () => {
const entries = GOVERNANCE_TEMPLATE_CATALOG.slice(0, 2);
render(
<GovernanceTemplateGrid
entries={entries}
hrefForTemplate={(slug) => `/create/review-template/${slug}`}
/>,
);
for (const entry of entries) {
expect(
screen.getByRole("link", { name: new RegExp(entry.title, "i") }),
).toHaveAttribute("href", `/create/review-template/${entry.slug}`);
}
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
}); });
+2 -4
View File
@@ -22,11 +22,9 @@ describe("InformationalScreen", () => {
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
it("renders workshop as a link (URL TBD) with underline per Figma", () => { it("does not offer a workshop link until a destination exists", () => {
render(<InformationalScreen />); render(<InformationalScreen />);
const workshop = screen.getByRole("link", { name: "workshop" }); expect(screen.queryByRole("link", { name: "workshop" })).not.toBeInTheDocument();
expect(workshop).toHaveAttribute("href", "#");
expect(workshop.className).toMatch(/underline/);
}); });
it("renders first numbered list item title", () => { it("renders first numbered list item title", () => {
+21 -1
View File
@@ -1,4 +1,9 @@
import { describe, vi } from "vitest"; import { describe, it, expect, vi } from "vitest";
import {
renderWithProviders as render,
screen,
} from "../utils/test-utils";
import "@testing-library/jest-dom/vitest";
import { import {
componentTestSuite, componentTestSuite,
type ComponentTestSuiteConfig, type ComponentTestSuiteConfig,
@@ -28,4 +33,19 @@ const config: ComponentTestSuiteConfig<Props> = {
describe("InputWithCounter", () => { describe("InputWithCounter", () => {
componentTestSuite<Props>(config); componentTestSuite<Props>(config);
it("associates the visible label with the input", () => {
render(
<InputWithCounter
label="Community name"
placeholder="Enter a name"
value=""
onChange={vi.fn()}
maxLength={50}
/>,
);
expect(
screen.getByRole("textbox", { name: "Community name" }),
).toBeInTheDocument();
});
}); });
+40 -12
View File
@@ -10,6 +10,18 @@ import { CommunityReviewScreen } from "../../app/(app)/create/screens/review/Com
import { useCreateFlow } from "../../app/(app)/create/context/CreateFlowContext"; import { useCreateFlow } from "../../app/(app)/create/context/CreateFlowContext";
import { testRouter } from "../mocks/navigation"; import { testRouter } from "../mocks/navigation";
function ReviewWithTitle({ title }: { title: string }) {
const { state, updateState } = useCreateFlow();
const seededRef = React.useRef(false);
useEffect(() => {
if (seededRef.current) return;
seededRef.current = true;
updateState({ title });
}, [title, updateState]);
if (state.title !== title) return null;
return <CommunityReviewScreen />;
}
describe("CommunityReviewScreen", () => { describe("CommunityReviewScreen", () => {
beforeEach(() => { beforeEach(() => {
testRouter.replace.mockReset(); testRouter.replace.mockReset();
@@ -21,8 +33,24 @@ describe("CommunityReviewScreen", () => {
expect(screen.getByRole("heading", { level: 1 })).toBeInTheDocument(); expect(screen.getByRole("heading", { level: 1 })).toBeInTheDocument();
}); });
it("renders HeaderLockup with expected title", () => { it("shows an empty state instead of a baked-in community name", () => {
render(<CommunityReviewScreen />); render(<CommunityReviewScreen />);
expect(
screen.getByRole("heading", { name: "No community in progress yet" }),
).toBeInTheDocument();
expect(screen.queryByText("Mutual Aid Mondays")).not.toBeInTheDocument();
expect(
screen.queryByRole("heading", {
name: "Your community is added - congrats!",
}),
).not.toBeInTheDocument();
expect(
screen.getByRole("link", { name: "Start creating" }),
).toHaveAttribute("href", "/create/informational");
});
it("renders HeaderLockup with expected title when a community name exists", () => {
render(<ReviewWithTitle title="Garden Club" />);
expect( expect(
screen.getByRole("heading", { screen.getByRole("heading", {
name: "Your community is added - congrats!", name: "Your community is added - congrats!",
@@ -30,8 +58,8 @@ describe("CommunityReviewScreen", () => {
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
it("renders HeaderLockup with expected description", () => { it("renders HeaderLockup with expected description when a community name exists", () => {
render(<CommunityReviewScreen />); render(<ReviewWithTitle title="Garden Club" />);
expect( expect(
screen.getByText( screen.getByText(
/In the next section, we'll go through membership, decision-making, conflict resolution, and community values and create a custom operating manual for your organization based on the specifics you just shared./i, /In the next section, we'll go through membership, decision-making, conflict resolution, and community values and create a custom operating manual for your organization based on the specifics you just shared./i,
@@ -39,13 +67,13 @@ describe("CommunityReviewScreen", () => {
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
it("renders Rule with title fallback when no community name is set", () => { it("renders Rule with the community name from state", () => {
render(<CommunityReviewScreen />); render(<ReviewWithTitle title="Garden Club" />);
expect(screen.getByText("Mutual Aid Mondays")).toBeInTheDocument(); expect(screen.getByText("Garden Club")).toBeInTheDocument();
}); });
it("omits the Rule description when the user has not entered community context", () => { it("omits the Rule description when the user has not entered community context", () => {
render(<CommunityReviewScreen />); render(<ReviewWithTitle title="Garden Club" />);
expect( expect(
screen.queryByText( screen.queryByText(
/Mutual Aid Monday is a grassroots community in Denver/i, /Mutual Aid Monday is a grassroots community in Denver/i,
@@ -53,13 +81,13 @@ describe("CommunityReviewScreen", () => {
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
}); });
it("renders Rule as a button (card is interactive)", () => { it("renders Rule as a button when a community name exists", () => {
render(<CommunityReviewScreen />); render(<ReviewWithTitle title="Garden Club" />);
const buttons = screen.getAllByRole("button"); const buttons = screen.getAllByRole("button");
expect(buttons.length).toBeGreaterThanOrEqual(1); expect(buttons.length).toBeGreaterThanOrEqual(1);
expect( expect(buttons.some((el) => el.textContent?.includes("Garden Club"))).toBe(
buttons.some((el) => el.textContent?.includes("Mutual Aid Mondays")), true,
).toBe(true); );
}); });
}); });
+10
View File
@@ -43,6 +43,16 @@ describe("TextArea appearance", () => {
expect(textarea).toHaveClass("border-0"); expect(textarea).toHaveClass("border-0");
}); });
it("keeps a programmatic label when the visible header is off", () => {
const { container } = renderWithProviders(
<TextArea label="Community description" formHeader={false} value="" />,
);
expect(
screen.getByRole("textbox", { name: "Community description" }),
).toBeInTheDocument();
expect(container.querySelector("label")).toHaveClass("sr-only");
});
it("uses tertiary text in the embedded default state and primary on focus", () => { it("uses tertiary text in the embedded default state and primary on focus", () => {
renderWithProviders( renderWithProviders(
<TextArea label="Notes" value="Some text" appearance="embedded" />, <TextArea label="Notes" value="Some text" appearance="embedded" />,
+26
View File
@@ -55,4 +55,30 @@ describe("TextInput (size tests)", () => {
const input = container.querySelector("input"); const input = container.querySelector("input");
expect(input).toHaveAttribute("maxLength", "200"); expect(input).toHaveAttribute("maxLength", "200");
}); });
it("keeps a programmatic label when the visible header is off", () => {
const { getByRole, container } = render(
<TextInput label="Community name" formHeader={false} />,
);
expect(getByRole("textbox", { name: "Community name" })).toBeInTheDocument();
expect(container.querySelector("label")).toHaveClass("sr-only");
});
it("forwards autocomplete, inputMode, required, and form", () => {
const { container } = render(
<TextInput
label="Email address"
type="email"
autoComplete="email"
inputMode="email"
required
form="create-flow-community-save"
/>,
);
const input = container.querySelector("input");
expect(input).toHaveAttribute("autocomplete", "email");
expect(input).toHaveAttribute("inputmode", "email");
expect(input).toBeRequired();
expect(input).toHaveAttribute("form", "create-flow-community-save");
});
}); });
+51 -5
View File
@@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest";
import { renderWithProviders as render, screen } from "../utils/test-utils"; import { renderWithProviders as render, screen } from "../utils/test-utils";
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
import { CreateFlowTextFieldScreen } from "../../app/(app)/create/screens/text/CreateFlowTextFieldScreen"; import { CreateFlowTextFieldScreen } from "../../app/(app)/create/screens/text/CreateFlowTextFieldScreen";
import { CREATE_FLOW_COMMUNITY_SAVE_FORM_ID } from "../../app/(app)/create/utils/createFlowPaths";
describe("CreateFlowTextFieldScreen (community name)", () => { describe("CreateFlowTextFieldScreen (community name)", () => {
it("renders main heading", () => { it("renders main heading", () => {
@@ -10,6 +11,7 @@ describe("CreateFlowTextFieldScreen (community name)", () => {
messageNamespace="create.community.communityName" messageNamespace="create.community.communityName"
stateField="title" stateField="title"
maxLength={48} maxLength={48}
required
/>, />,
); );
expect( expect(
@@ -19,19 +21,63 @@ describe("CreateFlowTextFieldScreen (community name)", () => {
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
it("renders description and text field", () => { it("renders description and a labelled text field", () => {
render( render(
<CreateFlowTextFieldScreen <CreateFlowTextFieldScreen
messageNamespace="create.community.communityName" messageNamespace="create.community.communityName"
stateField="title" stateField="title"
maxLength={48} maxLength={48}
required
/>, />,
); );
expect( expect(
screen.getByText("This will be the name of your community"), screen.getByText("This will be the name of your community."),
).toBeInTheDocument();
expect(
screen.getByPlaceholderText("Enter community name"),
).toBeInTheDocument(); ).toBeInTheDocument();
const input = screen.getByRole("textbox", { name: "Community name" });
expect(input).toBeInTheDocument();
expect(input).toHaveAttribute("placeholder", "Enter community name");
expect(input).toBeRequired();
});
});
describe("CreateFlowTextFieldScreen (community description)", () => {
it("uses a textarea and treats the field as optional", () => {
render(
<CreateFlowTextFieldScreen
messageNamespace="create.community.communityContext"
stateField="communityContext"
maxLength={200}
multiline
/>,
);
expect(
screen.getByText(/Optional\. Write a short paragraph/i),
).toBeInTheDocument();
const field = screen.getByRole("textbox", {
name: "Community description",
});
expect(field.tagName).toBe("TEXTAREA");
expect(field).not.toBeRequired();
});
});
describe("CreateFlowTextFieldScreen (save progress email)", () => {
it("uses a labelled email field with native email attributes", () => {
render(
<CreateFlowTextFieldScreen
messageNamespace="create.community.communitySave"
stateField="communitySaveEmail"
maxLength={254}
inputType="email"
showCharacterCount={false}
/>,
);
const input = screen.getByRole("textbox", { name: "Email address" });
expect(input).toHaveAttribute("type", "email");
expect(input).toHaveAttribute("autocomplete", "email");
expect(input).toHaveAttribute("inputmode", "email");
expect(input).toHaveAttribute("name", "email");
expect(input).toHaveAttribute("form", CREATE_FLOW_COMMUNITY_SAVE_FORM_ID);
expect(input).toBeRequired();
}); });
}); });
+137 -3
View File
@@ -1,9 +1,71 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { renderWithProviders as render, screen } from "../utils/test-utils"; import {
renderWithProviders as render,
screen,
fireEvent,
cleanup,
} from "../utils/test-utils";
import userEvent from "@testing-library/user-event";
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
import { CommunityUploadScreen } from "../../app/(app)/create/screens/upload/CommunityUploadScreen"; import { CommunityUploadScreen } from "../../app/(app)/create/screens/upload/CommunityUploadScreen";
import {
clearPendingCommunityAvatarFile,
readPendingCommunityAvatarFile,
storePendingCommunityAvatarFile,
} from "../../lib/create/pendingCommunityAvatarUpload";
import { fetchAuthSession } from "../../lib/create/api";
import messages from "../../messages/en/index";
vi.mock("../../lib/create/pendingCommunityAvatarUpload", () => ({
readPendingCommunityAvatarFile: vi.fn().mockResolvedValue(null),
storePendingCommunityAvatarFile: vi.fn().mockResolvedValue(undefined),
clearPendingCommunityAvatarFile: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("../../lib/create/api", () => ({
fetchAuthSession: vi.fn().mockResolvedValue({ user: null }),
}));
const pngBytes = Uint8Array.from(
Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
"base64",
),
);
const copy = messages.create.community.communityUpload;
const uploadErrors = messages.create.upload.errors;
function fileInput(): HTMLInputElement {
const input = document.querySelector('input[type="file"]');
expect(input).toBeInstanceOf(HTMLInputElement);
return input as HTMLInputElement;
}
describe("CommunityUploadScreen", () => { describe("CommunityUploadScreen", () => {
beforeEach(() => {
vi.mocked(readPendingCommunityAvatarFile).mockResolvedValue(null);
vi.mocked(storePendingCommunityAvatarFile).mockResolvedValue(undefined);
vi.mocked(clearPendingCommunityAvatarFile).mockResolvedValue(undefined);
vi.mocked(fetchAuthSession).mockResolvedValue({ user: null });
Object.defineProperty(URL, "createObjectURL", {
value: vi.fn(() => "blob:pending-avatar"),
writable: true,
configurable: true,
});
Object.defineProperty(URL, "revokeObjectURL", {
value: vi.fn(),
writable: true,
configurable: true,
});
});
afterEach(() => {
cleanup();
Reflect.deleteProperty(URL, "createObjectURL");
Reflect.deleteProperty(URL, "revokeObjectURL");
});
it("renders HeaderLockup", () => { it("renders HeaderLockup", () => {
render(<CommunityUploadScreen />); render(<CommunityUploadScreen />);
expect( expect(
@@ -18,8 +80,80 @@ describe("CommunityUploadScreen", () => {
expect(screen.getByRole("button", { name: "Upload" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Upload" })).toBeInTheDocument();
expect( expect(
screen.getByText( screen.getByText(
/This photo be used as a profile picture for your group/i, /This photo will be used as a profile picture for your group/i,
), ),
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
it("restores a pending IndexedDB photo and lets the user remove it", async () => {
const user = userEvent.setup();
const pending = new File([pngBytes], "avatar.png", { type: "image/png" });
vi.mocked(readPendingCommunityAvatarFile).mockResolvedValue(pending);
render(<CommunityUploadScreen />);
const preview = await screen.findByRole("img", { name: copy.previewAlt });
expect(preview).toHaveAttribute("src", "blob:pending-avatar");
const remove = screen.getByRole("button", {
name: copy.clearPendingUploadAriaLabel,
});
await user.click(remove);
expect(clearPendingCommunityAvatarFile).toHaveBeenCalled();
expect(screen.getByRole("button", { name: "Upload" })).toBeInTheDocument();
expect(
screen.queryByRole("img", { name: copy.previewAlt }),
).not.toBeInTheDocument();
});
it("rejects an empty file with a clear error and does not stage it", async () => {
render(<CommunityUploadScreen />);
await screen.findByText(copy.signInToUploadNote);
fireEvent.change(fileInput(), {
target: { files: [new File([], "empty.png", { type: "image/png" })] },
});
expect(await screen.findByRole("alert")).toHaveTextContent(
uploadErrors.empty,
);
expect(storePendingCommunityAvatarFile).not.toHaveBeenCalled();
});
it("rejects a text file renamed as PNG", async () => {
render(<CommunityUploadScreen />);
await screen.findByText(copy.signInToUploadNote);
fireEvent.change(fileInput(), {
target: {
files: [new File(["hello"], "photo.png", { type: "image/png" })],
},
});
expect(await screen.findByRole("alert")).toHaveTextContent(
uploadErrors.invalidType,
);
expect(storePendingCommunityAvatarFile).not.toHaveBeenCalled();
});
it("rejects SVG uploads", async () => {
render(<CommunityUploadScreen />);
await screen.findByText(copy.signInToUploadNote);
fireEvent.change(fileInput(), {
target: {
files: [
new File(
['<svg xmlns="http://www.w3.org/2000/svg"></svg>'],
"photo.png",
{ type: "image/png" },
),
],
},
});
expect(await screen.findByRole("alert")).toHaveTextContent(uploadErrors.svg);
expect(storePendingCommunityAvatarFile).not.toHaveBeenCalled();
});
}); });
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, test } from "vitest";
import {
renderWithProviders as render,
screen,
} from "../utils/test-utils";
import { CreateFlowTemplatesPageClient } from "../../app/(app)/create/templates/CreateFlowTemplatesPageClient";
import { GOVERNANCE_TEMPLATE_CATALOG } from "../../lib/templates/governanceTemplateCatalog";
import "@testing-library/jest-dom/vitest";
describe("Create flow templates picker", () => {
test("renders catalog cards as in-flow template review links", () => {
render(
<CreateFlowTemplatesPageClient
initialGridEntries={GOVERNANCE_TEMPLATE_CATALOG}
/>,
);
expect(
screen.getByRole("heading", { name: "Templates", level: 1 }),
).toBeInTheDocument();
expect(
screen.getByRole("link", { name: /Consensus/i }),
).toHaveAttribute(
"href",
"/create/review-template/consensus?fromFlow=1",
);
expect(
screen.getByRole("link", { name: /Solidarity Network/i }),
).toHaveAttribute(
"href",
"/create/review-template/solidarity-network?fromFlow=1",
);
});
});
+14 -30
View File
@@ -54,30 +54,18 @@ describe("Templates page (/templates)", () => {
} }
}); });
test("each template card navigates to review flow for its slug", async () => { test("each template card is a link to review flow for its slug", () => {
const user = userEvent.setup();
render( render(
<TemplatesPageClient initialGridEntries={GOVERNANCE_TEMPLATE_CATALOG} />, <TemplatesPageClient initialGridEntries={GOVERNANCE_TEMPLATE_CATALOG} />,
); );
await user.click( for (const entry of GOVERNANCE_TEMPLATE_CATALOG) {
screen.getByRole("button", { name: /Consensus/i }), expect(
); screen.getByRole("link", {
await waitFor(() => { name: `Learn more about ${entry.title} governance pattern`,
expect(testRouter.push).toHaveBeenCalledWith( }),
"/create/review-template/consensus", ).toHaveAttribute("href", `/create/review-template/${entry.slug}`);
); }
});
testRouter.push.mockClear();
await user.click(
screen.getByRole("button", { name: /Solidarity Network/i }),
);
await waitFor(() => {
expect(testRouter.push).toHaveBeenCalledWith(
"/create/review-template/solidarity-network",
);
});
}); });
test("direct entry (no ?fromFlow=1): wipes anonymous draft before navigating", async () => { test("direct entry (no ?fromFlow=1): wipes anonymous draft before navigating", async () => {
@@ -88,7 +76,7 @@ describe("Templates page (/templates)", () => {
); );
await user.click( await user.click(
screen.getByRole("button", { name: /Consensus/i }), screen.getByRole("link", { name: /Consensus/i }),
); );
await waitFor(() => { await waitFor(() => {
@@ -96,9 +84,6 @@ describe("Templates page (/templates)", () => {
expect( expect(
window.localStorage.getItem(CORE_VALUE_DETAILS_STORAGE_KEY), window.localStorage.getItem(CORE_VALUE_DETAILS_STORAGE_KEY),
).toBeNull(); ).toBeNull();
expect(testRouter.push).toHaveBeenCalledWith(
"/create/review-template/consensus",
);
}); });
}); });
@@ -113,7 +98,7 @@ describe("Templates page (/templates)", () => {
); );
await user.click( await user.click(
screen.getByRole("button", { name: /Consensus/i }), screen.getByRole("link", { name: /Consensus/i }),
); );
expect(window.localStorage.getItem(CREATE_FLOW_ANONYMOUS_KEY)).toBe( expect(window.localStorage.getItem(CREATE_FLOW_ANONYMOUS_KEY)).toBe(
@@ -124,12 +109,11 @@ describe("Templates page (/templates)", () => {
).toBe( ).toBe(
JSON.stringify({ "1": { meaning: "stale", signals: "stale" } }), JSON.stringify({ "1": { meaning: "stale", signals: "stale" } }),
); );
// In-flow picks also pass `?fromFlow=1` on the template review URL so expect(
// footer Back on `/create/review-template/…` returns to `/create/review`. screen.getByRole("link", { name: /Consensus/i }),
await waitFor(() => { ).toHaveAttribute(
expect(testRouter.push).toHaveBeenCalledWith( "href",
"/create/review-template/consensus?fromFlow=1", "/create/review-template/consensus?fromFlow=1",
); );
}); });
});
}); });
+18
View File
@@ -76,6 +76,24 @@ describe("Rule Component", () => {
expect(handleClick).toHaveBeenCalledTimes(2); expect(handleClick).toHaveBeenCalledTimes(2);
}); });
it("renders as a link when href is set", () => {
const handleClick = vi.fn();
render(
<Rule
{...defaultProps}
href="/create/review-template/consensus"
onClick={handleClick}
/>,
);
const card = screen.getByRole("link", {
name: "Learn more about Test Rule governance pattern",
});
expect(card).toHaveAttribute("href", "/create/review-template/consensus");
fireEvent.click(card, { preventDefault() {} });
expect(handleClick).toHaveBeenCalledTimes(1);
});
it("applies hover effects correctly", () => { it("applies hover effects correctly", () => {
render(<Rule {...defaultProps} />); render(<Rule {...defaultProps} />);
+11 -8
View File
@@ -226,13 +226,14 @@ describe("RuleStack Component", () => {
render(<RuleStack />); render(<RuleStack />);
await waitForRuleStackCards(); await waitForRuleStackCards();
const consensusCard = screen.getByText("Consensus").closest("div"); const consensusCard = screen.getByRole("link", { name: /Consensus/i });
expect(consensusCard).toHaveAttribute(
"href",
"/create/review-template/consensus",
);
await user.click(consensusCard); await user.click(consensusCard);
expect(debugSpy).toHaveBeenCalledWith("consensus template clicked"); expect(debugSpy).toHaveBeenCalledWith("consensus template clicked");
expect(testRouter.push).toHaveBeenCalledWith(
"/create/review-template/consensus",
);
debugSpy.mockRestore(); debugSpy.mockRestore();
}); });
@@ -251,7 +252,7 @@ describe("RuleStack Component", () => {
render(<RuleStack />); render(<RuleStack />);
await waitForRuleStackCards(); await waitForRuleStackCards();
const consensusCard = screen.getByText("Consensus").closest("div"); const consensusCard = screen.getByRole("link", { name: /Consensus/i });
await user.click(consensusCard); await user.click(consensusCard);
expect(window.localStorage.getItem(CREATE_FLOW_ANONYMOUS_KEY)).toBeNull(); expect(window.localStorage.getItem(CREATE_FLOW_ANONYMOUS_KEY)).toBeNull();
@@ -319,8 +320,10 @@ describe("RuleStack Component", () => {
render(<RuleStack />); render(<RuleStack />);
await waitForRuleStackCards(); await waitForRuleStackCards();
const buttons = document.querySelectorAll('[role="button"]'); const cards = screen.getAllByRole("link").filter((el) =>
const templateSurfaces = [...buttons].filter((el) => el.getAttribute("href")?.includes("/create/review-template/"),
);
const templateSurfaces = [...cards].filter((el) =>
el.className.includes("--color-surface-invert"), el.className.includes("--color-surface-invert"),
); );
expect(templateSurfaces.length).toBe(homeFeatured.length); expect(templateSurfaces.length).toBe(homeFeatured.length);
@@ -375,7 +378,7 @@ describe("RuleStack Component", () => {
render(<RuleStack />); render(<RuleStack />);
await waitForRuleStackCards(); await waitForRuleStackCards();
const doOcracyCard = screen.getByText("Do-ocracy").closest("div"); const doOcracyCard = screen.getByRole("link", { name: /Do-ocracy/i });
await user.click(doOcracyCard); await user.click(doOcracyCard);
expect(gtagSpy).toHaveBeenCalledWith("event", "template_click", { expect(gtagSpy).toHaveBeenCalledWith("event", "template_click", {
+46 -3
View File
@@ -3,19 +3,35 @@ import {
CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS, CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS,
CREATE_FLOW_MD_UP_GRID_CELL_CLASS, CREATE_FLOW_MD_UP_GRID_CELL_CLASS,
CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS, CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS,
CREATE_FLOW_WIDE_MAX_CLASS,
CREATE_FLOW_MD_CENTERED_MAIN_CLASS, CREATE_FLOW_MD_CENTERED_MAIN_CLASS,
CREATE_FLOW_MD_CENTERED_SHELL_CLASS, CREATE_FLOW_MD_CENTERED_SHELL_CLASS,
CREATE_FLOW_PAGE_GUTTER_CLASS,
CREATE_FLOW_VIEWPORT_FRAME_CLASS,
getCreateFlowContentMaxClass,
} from "../../app/(app)/create/components/createFlowLayoutTokens"; } from "../../app/(app)/create/components/createFlowLayoutTokens";
describe("createFlowLayoutTokens", () => { describe("createFlowLayoutTokens", () => {
it("exports create-flow column and two-column max class strings", () => { it("exports create-flow column and two-column max class strings", () => {
expect(CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS).toBe( expect(CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS).toBe(
"w-full min-w-0 md:max-w-[640px]", "w-full min-w-0 lg:max-w-[640px]",
); );
expect(CREATE_FLOW_MD_UP_GRID_CELL_CLASS).toBe( expect(CREATE_FLOW_MD_UP_GRID_CELL_CLASS).toBe(
"w-full min-w-0 md:mx-auto md:max-w-[640px]", "w-full min-w-0 lg:mx-auto lg:max-w-[640px]",
); );
expect(CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS).toBe("md:max-w-[1328px]"); expect(CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS).toBe(
"w-full min-w-0 lg:max-w-[1328px]",
);
expect(CREATE_FLOW_WIDE_MAX_CLASS).toBe("w-full min-w-0 lg:max-w-[1440px]");
});
it("uses one gutter scale across the six viewport bands", () => {
expect(CREATE_FLOW_PAGE_GUTTER_CLASS).toBe("px-5 md:px-12 lg:px-16");
});
it("sizes the wizard frame with dynamic viewport height", () => {
expect(CREATE_FLOW_VIEWPORT_FRAME_CLASS).toContain("h-dvh");
expect(CREATE_FLOW_VIEWPORT_FRAME_CLASS).toContain("max-h-dvh");
}); });
it("centers lockup+card and card-stack steps in the navfooter band from md", () => { it("centers lockup+card and card-stack steps in the navfooter band from md", () => {
@@ -24,4 +40,31 @@ describe("createFlowLayoutTokens", () => {
); );
expect(CREATE_FLOW_MD_CENTERED_SHELL_CLASS).toBe("md:my-auto md:pt-0"); expect(CREATE_FLOW_MD_CENTERED_SHELL_CLASS).toBe("md:my-auto md:pt-0");
}); });
it("aligns the action bar max-width with the step content column", () => {
expect(
getCreateFlowContentMaxClass({ step: "community-name" }),
).toBe(CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS);
expect(
getCreateFlowContentMaxClass({ step: "community-structure" }),
).toBe(CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS);
expect(
getCreateFlowContentMaxClass({ step: "communication-methods" }),
).toBe(CREATE_FLOW_WIDE_MAX_CLASS);
expect(
getCreateFlowContentMaxClass({ step: "final-review" }),
).toBe(CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS);
expect(
getCreateFlowContentMaxClass({
step: "informational",
isTemplateReview: true,
}),
).toBe(CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS);
expect(
getCreateFlowContentMaxClass({
step: null,
isTemplatesPicker: true,
}),
).toBe(CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS);
});
}); });
+8
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
CREATE_FLOW_COMMUNITY_SAVE_FORM_ID,
CREATE_FLOW_SYNC_DRAFT_QUERY, CREATE_FLOW_SYNC_DRAFT_QUERY,
CREATE_FLOW_SYNC_DRAFT_VALUE, CREATE_FLOW_SYNC_DRAFT_VALUE,
CREATE_ROUTES, CREATE_ROUTES,
@@ -40,6 +41,13 @@ describe("createFlowPaths (CR-92 §2)", () => {
it("CREATE_ROUTES constants", () => { it("CREATE_ROUTES constants", () => {
expect(CREATE_ROUTES.review).toBe("/create/review"); expect(CREATE_ROUTES.review).toBe("/create/review");
expect(CREATE_ROUTES.templatesPicker).toBe("/create/templates");
expect(CREATE_ROUTES.completed).toBe("/create/completed"); expect(CREATE_ROUTES.completed).toBe("/create/completed");
}); });
it("exposes a stable form id for the save-progress email step", () => {
expect(CREATE_FLOW_COMMUNITY_SAVE_FORM_ID).toBe(
"create-flow-community-save",
);
});
}); });
@@ -1,7 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
extensionForMime, extensionForMime,
isAllowedMime,
isValidUploadFileId, isValidUploadFileId,
maxBytesForPurpose, maxBytesForPurpose,
} from "../../lib/server/uploads/uploadConstants"; } from "../../lib/server/uploads/uploadConstants";
@@ -12,18 +11,6 @@ describe("createFlow upload constants", () => {
expect(maxBytesForPurpose("customMethodAttachment")).toBe(10 * 1024 * 1024); expect(maxBytesForPurpose("customMethodAttachment")).toBe(10 * 1024 * 1024);
}); });
it("isAllowedMime allows images for both purposes", () => {
expect(isAllowedMime("communityAvatar", "image/png")).toBe(true);
expect(isAllowedMime("customMethodAttachment", "image/jpeg")).toBe(true);
});
it("isAllowedMime allows pdf only for customMethodAttachment", () => {
expect(isAllowedMime("communityAvatar", "application/pdf")).toBe(false);
expect(isAllowedMime("customMethodAttachment", "application/pdf")).toBe(
true,
);
});
it("extensionForMime maps common types", () => { it("extensionForMime maps common types", () => {
expect(extensionForMime("image/png")).toBe(".png"); expect(extensionForMime("image/png")).toBe(".png");
expect(extensionForMime("image/jpeg")).toBe(".jpg"); expect(extensionForMime("image/jpeg")).toBe(".jpg");
@@ -0,0 +1,164 @@
import { describe, expect, it, vi } from "vitest";
import {
CreateFlowUploadValidationError,
maxBytesForPurpose,
validateCreateFlowUploadBytes,
validateCreateFlowUploadFile,
} from "../../lib/create/createFlowUploadValidation";
import { uploadCreateFlowFile } from "../../lib/create/uploadToServer";
/** 1×1 PNG (valid IHDR). */
const PNG_1X1 = Uint8Array.from(
Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
"base64",
),
);
/** SOF0 1×1 JPEG (header-decodable; not necessarily a complete scan). */
const JPEG_1X1_SOF = Uint8Array.from([
0xff, 0xd8, 0xff, 0xc0, 0x00, 0x0b, 0x08, 0x00, 0x01, 0x00, 0x01, 0x01, 0x01,
0x11, 0x00, 0xff, 0xd9,
]);
const GIF_1X1 = Uint8Array.from([
0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00,
0x3b,
]);
/** VP8X 1×1 WebP. */
const WEBP_1X1 = Uint8Array.from([
0x52, 0x49, 0x46, 0x46, 0x16, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50, 0x56,
0x50, 0x38, 0x58, 0x0a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00,
]);
const PDF_STUB = new TextEncoder().encode("%PDF-1.4\n%%EOF\n");
const SVG_MARKUP = new TextEncoder().encode(
'<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"></svg>',
);
const XML_SVG = new TextEncoder().encode(
'<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg"/>',
);
describe("validateCreateFlowUploadBytes", () => {
it("accepts a real PNG as a community avatar", () => {
const result = validateCreateFlowUploadBytes("communityAvatar", PNG_1X1);
expect(result).toEqual({
ok: true,
kind: "png",
mimeType: "image/png",
});
});
it("accepts JPEG, GIF, and WebP headers", () => {
expect(validateCreateFlowUploadBytes("communityAvatar", JPEG_1X1_SOF).ok).toBe(
true,
);
expect(validateCreateFlowUploadBytes("communityAvatar", GIF_1X1).ok).toBe(
true,
);
expect(validateCreateFlowUploadBytes("communityAvatar", WEBP_1X1).ok).toBe(
true,
);
});
it("rejects an empty buffer", () => {
expect(validateCreateFlowUploadBytes("communityAvatar", new Uint8Array())).toEqual(
{ ok: false, reason: "empty" },
);
});
it("rejects a file larger than the purpose cap", () => {
const oversized = new Uint8Array(maxBytesForPurpose("communityAvatar") + 1);
oversized.set(PNG_1X1.subarray(0, 8), 0);
expect(validateCreateFlowUploadBytes("communityAvatar", oversized)).toEqual({
ok: false,
reason: "tooLarge",
});
expect(maxBytesForPurpose("customMethodAttachment")).toBeGreaterThan(
maxBytesForPurpose("communityAvatar"),
);
});
it("rejects text spoofed as PNG", () => {
expect(
validateCreateFlowUploadBytes(
"communityAvatar",
new TextEncoder().encode("hello world"),
),
).toEqual({ ok: false, reason: "invalidType" });
});
it("rejects SVG markup, including xml-prefixed SVG", () => {
expect(validateCreateFlowUploadBytes("communityAvatar", SVG_MARKUP)).toEqual(
{ ok: false, reason: "svg" },
);
expect(validateCreateFlowUploadBytes("customMethodAttachment", XML_SVG)).toEqual(
{ ok: false, reason: "svg" },
);
});
it("rejects a PNG signature that cannot be decoded", () => {
const truncated = PNG_1X1.subarray(0, 8);
expect(validateCreateFlowUploadBytes("communityAvatar", truncated)).toEqual({
ok: false,
reason: "undecodable",
});
});
it("rejects PDF for community avatars and allows it for attachments", () => {
expect(validateCreateFlowUploadBytes("communityAvatar", PDF_STUB)).toEqual({
ok: false,
reason: "invalidType",
});
expect(validateCreateFlowUploadBytes("customMethodAttachment", PDF_STUB)).toEqual(
{
ok: true,
kind: "pdf",
mimeType: "application/pdf",
},
);
});
});
describe("validateCreateFlowUploadFile", () => {
it("rejects empty files before reading bytes", async () => {
const file = new File([], "empty.png", { type: "image/png" });
await expect(
validateCreateFlowUploadFile(file, "communityAvatar"),
).resolves.toEqual({ ok: false, reason: "empty" });
});
it("rejects SVG by name even when the MIME is spoofed", async () => {
const file = new File([PNG_1X1], "logo.svg", { type: "image/png" });
await expect(
validateCreateFlowUploadFile(file, "communityAvatar"),
).resolves.toEqual({ ok: false, reason: "svg" });
});
it("rejects a 17MB file by size without treating it as a type error", async () => {
const file = new File([new Uint8Array([0x89, 0x50])], "huge.png", {
type: "image/png",
});
Object.defineProperty(file, "size", { value: 17 * 1024 * 1024 });
await expect(
validateCreateFlowUploadFile(file, "communityAvatar"),
).resolves.toEqual({ ok: false, reason: "tooLarge" });
});
});
describe("uploadCreateFlowFile", () => {
it("does not POST when client validation fails", async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const file = new File(["not an image"], "photo.png", { type: "image/png" });
await expect(
uploadCreateFlowFile(file, "communityAvatar"),
).rejects.toBeInstanceOf(CreateFlowUploadValidationError);
expect(fetchMock).not.toHaveBeenCalled();
vi.unstubAllGlobals();
});
});
+29 -7
View File
@@ -7,13 +7,12 @@ import {
isValidStep, isValidStep,
getStepIndex, getStepIndex,
parseReviewReturnSearchParam, parseReviewReturnSearchParam,
parseCreateFlowScreenFromPathname,
resolveCreateFlowBackTarget, resolveCreateFlowBackTarget,
shouldOfferCreateFlowSaveAndExit, shouldOfferCreateFlowSaveAndExit,
isDirectTemplateReviewEntry, isDirectTemplateReviewEntry,
TEMPLATES_FACET_RECOMMEND_QUERY, isCreateFlowTemplatesPickerPath,
TEMPLATES_FACET_RECOMMEND_VALUE, createFlowStepUsesSelectSplitScroll,
TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY,
TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE,
} from "../../app/(app)/create/utils/flowSteps"; } from "../../app/(app)/create/utils/flowSteps";
describe("flowSteps", () => { describe("flowSteps", () => {
@@ -157,10 +156,23 @@ describe("flowSteps", () => {
); );
}); });
it("review Create from template uses fromFlow and recommendTemplates together", () => { it("isCreateFlowTemplatesPickerPath is true only for /create/templates", () => {
expect(isCreateFlowTemplatesPickerPath("/create/templates")).toBe(true);
expect(isCreateFlowTemplatesPickerPath("/create/templates/")).toBe(true);
expect(isCreateFlowTemplatesPickerPath("/create/review-template/consensus")).toBe(
false,
);
expect(isCreateFlowTemplatesPickerPath("/templates")).toBe(false);
expect(isCreateFlowTemplatesPickerPath("/create/review")).toBe(false);
expect(isCreateFlowTemplatesPickerPath(null)).toBe(false);
});
it("parseCreateFlowScreenFromPathname ignores template auxiliary routes", () => {
expect(parseCreateFlowScreenFromPathname("/create/review")).toBe("review");
expect(parseCreateFlowScreenFromPathname("/create/templates")).toBeNull();
expect( expect(
`/templates?${TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY}=${TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE}&${TEMPLATES_FACET_RECOMMEND_QUERY}=${TEMPLATES_FACET_RECOMMEND_VALUE}`, parseCreateFlowScreenFromPathname("/create/review-template/consensus"),
).toBe("/templates?fromFlow=1&recommendTemplates=1"); ).toBeNull();
}); });
it("parseReviewReturnSearchParam accepts only final-review and edit-rule", () => { it("parseReviewReturnSearchParam accepts only final-review and edit-rule", () => {
@@ -221,4 +233,14 @@ describe("flowSteps", () => {
).toBe(false); ).toBe(false);
expect(isDirectTemplateReviewEntry(null)).toBe(false); expect(isDirectTemplateReviewEntry(null)).toBe(false);
}); });
it("uses split-column scroll for select, right-rail, and stakeholders", () => {
expect(createFlowStepUsesSelectSplitScroll("community-structure")).toBe(
true,
);
expect(createFlowStepUsesSelectSplitScroll("confirm-stakeholders")).toBe(
true,
);
expect(createFlowStepUsesSelectSplitScroll("community-name")).toBe(false);
});
}); });
@@ -0,0 +1,21 @@
import { describe, it, expect } from "vitest";
import { isValidCreateFlowSaveEmail } from "../../lib/create/isValidCreateFlowSaveEmail";
describe("isValidCreateFlowSaveEmail", () => {
it("rejects empty, whitespace, and non-string values", () => {
expect(isValidCreateFlowSaveEmail("")).toBe(false);
expect(isValidCreateFlowSaveEmail(" ")).toBe(false);
expect(isValidCreateFlowSaveEmail(null)).toBe(false);
expect(isValidCreateFlowSaveEmail(undefined)).toBe(false);
});
it("rejects values the native email control would reject", () => {
expect(isValidCreateFlowSaveEmail("not-an-email")).toBe(false);
expect(isValidCreateFlowSaveEmail("user@example.com ")).toBe(false);
expect(isValidCreateFlowSaveEmail(" user@example.com")).toBe(false);
});
it("accepts a required type=email value", () => {
expect(isValidCreateFlowSaveEmail("user@example.com")).toBe(true);
});
});
+57
View File
@@ -0,0 +1,57 @@
import { mkdtemp, readdir } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const getUploadRootFromEnvMock = vi.fn();
let uploadRoot: string | null = null;
vi.mock("../../lib/server/uploads/uploadRoot", async () => {
const actual = await vi.importActual<
typeof import("../../lib/server/uploads/uploadRoot")
>("../../lib/server/uploads/uploadRoot");
return {
...actual,
getUploadRootFromEnv: () => getUploadRootFromEnvMock(),
};
});
import { saveCreateFlowUpload } from "../../lib/server/uploads/saveCreateFlowUpload";
const PNG_1X1 = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
"base64",
);
describe("saveCreateFlowUpload", () => {
beforeEach(async () => {
uploadRoot = await mkdtemp(path.join(tmpdir(), "cr-upload-save-"));
getUploadRootFromEnvMock.mockReset();
getUploadRootFromEnvMock.mockImplementation(() => uploadRoot);
});
afterEach(() => {
uploadRoot = null;
});
it("writes a sniffed PNG even when the client declared the wrong MIME", async () => {
const saved = await saveCreateFlowUpload({
purpose: "communityAvatar",
buffer: PNG_1X1,
});
expect("error" in saved).toBe(false);
if ("error" in saved) return;
expect(saved.mimeType).toBe("image/png");
const files = await readdir(uploadRoot!);
expect(files).toEqual([`${saved.id}.png`]);
});
it("rejects spoofed bytes without writing", async () => {
const saved = await saveCreateFlowUpload({
purpose: "communityAvatar",
buffer: Buffer.from("not an image"),
});
expect(saved).toEqual({ error: "validation", reason: "invalidType" });
expect(await readdir(uploadRoot!)).toEqual([]);
});
});
+56 -3
View File
@@ -27,6 +27,7 @@ function multipartRequest(opts: {
purpose?: string; purpose?: string;
fileName?: string; fileName?: string;
fileContent?: string; fileContent?: string;
contentType?: string;
}): NextRequest { }): NextRequest {
const boundary = "----VitestBoundary"; const boundary = "----VitestBoundary";
const parts: string[] = []; const parts: string[] = [];
@@ -36,8 +37,9 @@ function multipartRequest(opts: {
); );
} }
if (opts.fileName && opts.fileContent !== undefined) { if (opts.fileName && opts.fileContent !== undefined) {
const contentType = opts.contentType ?? "image/png";
parts.push( parts.push(
`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${opts.fileName}"\r\nContent-Type: image/png\r\n\r\n${opts.fileContent}\r\n`, `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${opts.fileName}"\r\nContent-Type: ${contentType}\r\n\r\n${opts.fileContent}\r\n`,
); );
} }
parts.push(`--${boundary}--\r\n`); parts.push(`--${boundary}--\r\n`);
@@ -56,6 +58,7 @@ beforeEach(() => {
getUploadRootFromEnvMock.mockReset(); getUploadRootFromEnvMock.mockReset();
isDatabaseConfiguredMock.mockReturnValue(true); isDatabaseConfiguredMock.mockReturnValue(true);
getUploadRootFromEnvMock.mockReturnValue("/tmp/uploads"); getUploadRootFromEnvMock.mockReturnValue("/tmp/uploads");
getSessionUserMock.mockResolvedValue({ id: "u1", email: "a@b.c" });
}); });
describe("POST /api/uploads", () => { describe("POST /api/uploads", () => {
@@ -78,7 +81,6 @@ describe("POST /api/uploads", () => {
}); });
it("returns 500 when UPLOAD_ROOT is unset", async () => { it("returns 500 when UPLOAD_ROOT is unset", async () => {
getSessionUserMock.mockResolvedValueOnce({ id: "u1", email: "a@b.c" });
getUploadRootFromEnvMock.mockReturnValueOnce(null); getUploadRootFromEnvMock.mockReturnValueOnce(null);
const res = await POST( const res = await POST(
new NextRequest("https://x.test/api/uploads", { method: "POST" }), new NextRequest("https://x.test/api/uploads", { method: "POST" }),
@@ -90,7 +92,6 @@ describe("POST /api/uploads", () => {
}); });
it("returns 400 when purpose is missing", async () => { it("returns 400 when purpose is missing", async () => {
getSessionUserMock.mockResolvedValueOnce({ id: "u1", email: "a@b.c" });
const res = await POST( const res = await POST(
multipartRequest({ fileName: "avatar.png", fileContent: "x" }), multipartRequest({ fileName: "avatar.png", fileContent: "x" }),
undefined, undefined,
@@ -99,4 +100,56 @@ describe("POST /api/uploads", () => {
const body = (await res.json()) as { error: { code: string } }; const body = (await res.json()) as { error: { code: string } };
expect(body.error.code).toBe("validation_error"); expect(body.error.code).toBe("validation_error");
}); });
it("returns 400 for an empty file", async () => {
const res = await POST(
multipartRequest({
purpose: "communityAvatar",
fileName: "empty.png",
fileContent: "",
}),
undefined,
);
expect(res.status).toBe(400);
const body = (await res.json()) as {
error: { code: string };
details?: { reason?: string };
};
expect(body.error.code).toBe("validation_error");
expect(body.details?.reason).toBe("empty");
});
it("returns 400 for SVG even when named .png", async () => {
const res = await POST(
multipartRequest({
purpose: "communityAvatar",
fileName: "photo.png",
fileContent: '<svg xmlns="http://www.w3.org/2000/svg"></svg>',
contentType: "image/png",
}),
undefined,
);
expect(res.status).toBe(400);
const body = (await res.json()) as {
details?: { reason?: string };
};
expect(body.details?.reason).toBe("svg");
});
it("returns 400 for a text file spoofed as PNG", async () => {
const res = await POST(
multipartRequest({
purpose: "communityAvatar",
fileName: "photo.png",
fileContent: "not an image",
contentType: "image/png",
}),
undefined,
);
expect(res.status).toBe(400);
const body = (await res.json()) as {
details?: { reason?: string };
};
expect(body.details?.reason).toBe("invalidType");
});
}); });