Refine use cases rule examples

This commit is contained in:
adilallo
2026-05-19 22:16:08 -06:00
parent 7c46cbd87b
commit 2f2b5d0dc2
65 changed files with 3129 additions and 252 deletions
+1
View File
@@ -51,6 +51,7 @@ npm-cache/
/build /build
# misc # misc
/tmp/
*.pem *.pem
# IDE and editor files # IDE and editor files
@@ -0,0 +1,109 @@
"use client";
/**
* Edit published rule: community name with the same 48-char limit as
* {@link CreateFlowTextFieldScreen} `community-name` step.
*/
import { useEffect, useMemo, useRef, useState } from "react";
import Create from "../../../components/modals/Create";
import TextInput from "../../../components/controls/TextInput";
import ContentLockup from "../../../components/type/ContentLockup";
import { useTranslation } from "../../../contexts/MessagesContext";
/** Matches `community-name` step (`CreateFlowTextFieldScreen` `maxLength={48}`). */
export const COMMUNITY_TITLE_FIELD_MAX_LENGTH = 48;
export interface FinalReviewTitleEditModalProps {
isOpen: boolean;
onClose: () => void;
initialValue: string;
onSave: (_value: string) => void;
}
export function FinalReviewTitleEditModal({
isOpen,
onClose,
initialValue,
onSave,
}: FinalReviewTitleEditModalProps) {
const tModal = useTranslation(
"create.reviewAndComplete.finalReview.titleEditModal",
);
const tField = useTranslation("create.community.communityName");
const tSave = useTranslation(
"create.reviewAndComplete.finalReview.chipEditModal",
);
const [draft, setDraft] = useState("");
const initialRef = useRef("");
const seededOpenRef = useRef(false);
useEffect(() => {
if (!isOpen) {
seededOpenRef.current = false;
return;
}
if (seededOpenRef.current) return;
seededOpenRef.current = true;
const seed = initialValue;
setDraft(seed);
initialRef.current = seed;
}, [isOpen, initialValue]);
const isDirty = useMemo(() => draft !== initialRef.current, [draft]);
const trimmedDraft = draft.trim();
const canSave = isDirty && trimmedDraft.length > 0;
const characterHint = tField("characterCountTemplate")
.replace("{current}", String(draft.length))
.replace("{max}", String(COMMUNITY_TITLE_FIELD_MAX_LENGTH));
const handleSave = () => {
if (!canSave) return;
const capped = trimmedDraft.slice(0, COMMUNITY_TITLE_FIELD_MAX_LENGTH);
onSave(capped);
onClose();
};
return (
<Create
isOpen={isOpen}
onClose={onClose}
backdropVariant="blurredYellow"
headerContent={
<div className="bg-[var(--color-surface-default-primary)] px-[24px] py-[12px] shrink-0">
<ContentLockup
title={tModal("title")}
description={tModal("description")}
variant="modal"
alignment="left"
/>
</div>
}
showBackButton={false}
showNextButton
nextButtonText={tSave("saveButton")}
nextButtonDisabled={!canSave}
onNext={handleSave}
ariaLabel={tModal("title")}
>
<div className="pb-2">
<TextInput
className="!transition-none"
type="text"
placeholder={tField("placeholder")}
value={draft}
onChange={(e) => {
setDraft(e.target.value);
}}
inputSize="medium"
formHeader={false}
textHint={characterHint}
maxLength={COMMUNITY_TITLE_FIELD_MAX_LENGTH}
/>
</div>
</Create>
);
}
@@ -0,0 +1,20 @@
"use client";
import { useEffect, useState } from "react";
import { useMediaQuery } from "../../../hooks/useMediaQuery";
/** `--breakpoint-sm2` (440px); pairs with Tailwind `sm2:` on create-flow chrome. */
const CREATE_FLOW_MIN_WIDTH_SM2 = "(min-width: 440px)";
/** True at viewport ≥440px. */
export function useCreateFlowSm2Up(): boolean {
const [isMounted, setIsMounted] = useState(false);
const isSm2OrLarger = useMediaQuery(CREATE_FLOW_MIN_WIDTH_SM2);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- defer until mount for SSR/first-paint alignment
setIsMounted(true);
}, []);
return !isMounted || isSm2OrLarger;
}
@@ -163,10 +163,10 @@ 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:pt-[var(--space-800)] max-md:pb-8 md:h-full md:grid-cols-2 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 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}`}
> >
<div <div
className={`flex flex-col justify-start 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}`}
> >
<CreateFlowHeaderLockup <CreateFlowHeaderLockup
title={headerTitle} title={headerTitle}
@@ -177,7 +177,7 @@ export function CompletedScreen() {
/> />
</div> </div>
<div <div
className={`scrollbar-hide relative flex min-h-0 flex-col overflow-x-hidden md:overflow-y-auto ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`} 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}`}
> >
<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"
@@ -25,6 +25,7 @@ import {
type FinalReviewChipEditTarget, type FinalReviewChipEditTarget,
} from "../../components/FinalReviewChipEditModal"; } from "../../components/FinalReviewChipEditModal";
import { FinalReviewCommunityContextEditModal } from "../../components/FinalReviewCommunityContextEditModal"; import { FinalReviewCommunityContextEditModal } from "../../components/FinalReviewCommunityContextEditModal";
import { FinalReviewTitleEditModal } from "../../components/FinalReviewTitleEditModal";
import { useCreateFlowNavigation } from "../../hooks/useCreateFlowNavigation"; import { useCreateFlowNavigation } from "../../hooks/useCreateFlowNavigation";
import { createFlowStepForFacetGroup } from "../../utils/facetGroupToCreateFlowStep"; import { createFlowStepForFacetGroup } from "../../utils/facetGroupToCreateFlowStep";
import { import {
@@ -114,6 +115,7 @@ export function FinalReviewScreen({
useState<TemplateChipDetail | null>(null); useState<TemplateChipDetail | null>(null);
const [communityContextModalOpen, setCommunityContextModalOpen] = const [communityContextModalOpen, setCommunityContextModalOpen] =
useState(false); useState(false);
const [titleModalOpen, setTitleModalOpen] = useState(false);
const handleSave = useCallback( const handleSave = useCallback(
(patch: FinalReviewChipEditPatch) => { (patch: FinalReviewChipEditPatch) => {
@@ -225,6 +227,9 @@ export function FinalReviewScreen({
const rawCommunityContextForModal = const rawCommunityContextForModal =
typeof state.communityContext === "string" ? state.communityContext : ""; typeof state.communityContext === "string" ? state.communityContext : "";
const rawTitleForModal =
typeof state.title === "string" ? state.title : "";
const descriptionEmptyHint = const descriptionEmptyHint =
variant === "editPublished" ? t("communityContextEditModal.emptyHint") : undefined; variant === "editPublished" ? t("communityContextEditModal.emptyHint") : undefined;
@@ -242,6 +247,16 @@ export function FinalReviewScreen({
<Rule <Rule
title={ruleCardTitle} title={ruleCardTitle}
description={ruleCardDescription} description={ruleCardDescription}
onTitleClick={
variant === "editPublished"
? () => setTitleModalOpen(true)
: undefined
}
titleEditAriaLabel={
variant === "editPublished"
? t("titleEditModal.ariaEditTitle")
: undefined
}
onDescriptionClick={ onDescriptionClick={
variant === "editPublished" variant === "editPublished"
? () => setCommunityContextModalOpen(true) ? () => setCommunityContextModalOpen(true)
@@ -278,15 +293,26 @@ export function FinalReviewScreen({
detail={activeReadOnlyDetail} detail={activeReadOnlyDetail}
/> />
{variant === "editPublished" ? ( {variant === "editPublished" ? (
<FinalReviewCommunityContextEditModal <>
isOpen={communityContextModalOpen} <FinalReviewTitleEditModal
onClose={() => setCommunityContextModalOpen(false)} isOpen={titleModalOpen}
initialValue={rawCommunityContextForModal} onClose={() => setTitleModalOpen(false)}
onSave={(value) => { initialValue={rawTitleForModal}
markCreateFlowInteraction(); onSave={(value) => {
updateState({ communityContext: value, summary: value }); markCreateFlowInteraction();
}} updateState({ title: value });
/> }}
/>
<FinalReviewCommunityContextEditModal
isOpen={communityContextModalOpen}
onClose={() => setCommunityContextModalOpen(false)}
initialValue={rawCommunityContextForModal}
onSave={(value) => {
markCreateFlowInteraction();
updateState({ communityContext: value, summary: value });
}}
/>
</>
) : null} ) : null}
</CreateFlowLockupCardStepShell> </CreateFlowLockupCardStepShell>
); );
@@ -125,6 +125,7 @@ export default async function UseCaseDetailPage({ params }: PageProps) {
description: ruleCard.description, description: ruleCard.description,
backgroundColor: ruleCard.backgroundColor, backgroundColor: ruleCard.backgroundColor,
iconPath: ruleCard.iconPath, iconPath: ruleCard.iconPath,
href: `/use-cases/${slug}/rule`,
}} }}
/> />
<article <article
+14
View File
@@ -0,0 +1,14 @@
import type { ReactNode } from "react";
/** Full-viewport case-study surfaces (completed rule demos) — no marketing footer. */
export default function MarketingCaseStudyLayout({
children,
}: {
children: ReactNode;
}) {
return (
<main className="flex h-dvh min-h-0 flex-col overflow-hidden">
{children}
</main>
);
}
@@ -0,0 +1,136 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import CommunityRule from "../../../../../components/type/CommunityRule";
import type { CommunityRuleSection } from "../../../../../components/type/CommunityRule/CommunityRule.types";
import CreateFlowTopNav from "../../../../../components/navigation/CreateFlowTopNav";
import Share from "../../../../../components/modals/Share";
import Alert from "../../../../../components/modals/Alert";
import { CreateFlowHeaderLockup } from "../../../../../(app)/create/components/CreateFlowHeaderLockup";
import {
CREATE_FLOW_MD_UP_GRID_CELL_CLASS,
CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS,
} from "../../../../../(app)/create/components/createFlowLayoutTokens";
import { useCreateFlowMdUp } from "../../../../../(app)/create/hooks/useCreateFlowMdUp";
import { useTranslation } from "../../../../../contexts/MessagesContext";
import type { UseCaseDetailSlug } from "../../../../../../lib/useCaseSyntheticPost";
import type { UseCaseCompletedRuleFixture } from "../../../../../../lib/useCaseCompletedRule";
import {
useUseCaseCompletedRuleActions,
type UseCaseCompletedRuleActionBanner,
} from "./useUseCaseCompletedRuleActions";
export type UseCaseCompletedRuleViewProps = {
slug: UseCaseDetailSlug;
fixture: UseCaseCompletedRuleFixture;
sections: CommunityRuleSection[];
};
/** Figma: Completed CR — use case demos (21995:39476, 21995:40092, 22015:42413). */
export function UseCaseCompletedRuleView({
slug,
fixture,
sections,
}: UseCaseCompletedRuleViewProps) {
const router = useRouter();
const mdUp = useCreateFlowMdUp();
const tTopNav = useTranslation("pages.useCasesCompletedRule.topNav");
const [shareModalOpen, setShareModalOpen] = useState(false);
const [actionBanner, setActionBanner] =
useState<UseCaseCompletedRuleActionBanner | null>(null);
const { copyPageLink, mailtoPageLink, handleDuplicate } =
useUseCaseCompletedRuleActions({
slug,
fixture,
setActionBanner,
});
const pageBg = fixture.pageBackground;
return (
<>
{/*
Mobile: grid scrolls (title sticky at top of scrollport).
Desktop: viewport-tall columns; rule scrolls in the right column only.
*/}
<div
className="flex min-h-0 w-full flex-1 flex-col overflow-hidden md:h-full"
style={{ background: pageBg }}
>
{actionBanner ? (
<div className="pointer-events-none fixed inset-x-0 top-0 z-20 flex justify-center px-5 pt-3">
<div className="pointer-events-auto w-full max-w-[639px]">
<Alert
type="banner"
status={actionBanner.status}
title={actionBanner.title}
description={actionBanner.description}
hasLeadingIcon
hasBodyText={Boolean(actionBanner.description)}
onClose={() => setActionBanner(null)}
className="w-full"
/>
</div>
</div>
) : null}
<Share
isOpen={shareModalOpen}
onClose={() => setShareModalOpen(false)}
onCopyLink={() => void copyPageLink()}
onEmailShare={mailtoPageLink}
onSignalShare={() => void copyPageLink()}
onSlackShare={() => void copyPageLink()}
onDiscordShare={() => void copyPageLink()}
/>
<CreateFlowTopNav
hasShare
hasDuplicate
duplicateLabel={tTopNav("duplicate")}
duplicateAriaLabel={tTopNav("duplicateAriaLabel")}
exitLabel={tTopNav("return")}
buttonPalette="inverse"
className="shrink-0 !bg-transparent"
onShare={() => setShareModalOpen(true)}
onDuplicate={() => void handleDuplicate()}
onExit={() => router.push(`/use-cases/${slug}`)}
/>
<div
className={`mx-auto grid w-full min-h-0 flex-1 grid-cols-1 gap-4 px-5 max-md:max-w-[639px] max-md:gap-6 max-md:overflow-y-auto max-md:overscroll-y-contain max-md:pt-[var(--space-800)] max-md:pb-8 md:h-full md:flex-1 md:grid-cols-2 md:grid-rows-1 md:items-start 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}`}
>
<div
className={`relative z-[1] flex flex-col justify-start max-md:sticky max-md:top-0 max-md:z-10 max-md:shrink-0 max-md:pb-4 md:sticky md:top-0 md:z-[1] md:flex md:h-[calc(100dvh-4rem)] md:max-h-[calc(100dvh-4rem)] md:flex-col md:justify-center md:self-start md:overflow-hidden md:pb-8 ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`}
style={{ background: pageBg }}
>
<CreateFlowHeaderLockup
title={fixture.title}
description={fixture.summary}
justification="left"
palette="inverse"
/>
</div>
<div
className={`scrollbar-hide relative z-0 flex min-h-min flex-col overflow-x-hidden max-md:shrink-0 md:h-[calc(100dvh-4rem)] md:max-h-[calc(100dvh-4rem)] md:min-h-0 md:self-start md:overflow-y-auto ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`}
>
<div
className="pointer-events-none sticky top-0 z-10 hidden h-5 shrink-0 md:block"
style={{
backgroundImage: `linear-gradient(to bottom, color-mix(in srgb, ${pageBg} 55%, transparent), color-mix(in srgb, ${pageBg} 20%, transparent) 50%, transparent)`,
}}
aria-hidden
/>
<div className="w-full min-w-0 py-0 md:pb-8">
<CommunityRule
sections={sections}
useCardStyle={!mdUp}
cardAccentColor={pageBg}
className={mdUp ? "min-w-0" : "w-full min-w-0 p-4"}
/>
</div>
</div>
</div>
</div>
</>
);
}
@@ -0,0 +1,122 @@
"use client";
import { useCallback, useState } from "react";
import { usePathname, useRouter } from "next/navigation";
import { useAuthModal } from "../../../../../contexts/AuthModalContext";
import { useTranslation } from "../../../../../contexts/MessagesContext";
import {
duplicateUseCaseTemplate,
fetchAuthSession,
} from "../../../../../../lib/create/api";
import type { UseCaseDetailSlug } from "../../../../../../lib/useCaseSyntheticPost";
import type { UseCaseCompletedRuleFixture } from "../../../../../../lib/useCaseCompletedRule";
export type UseCaseCompletedRuleActionBanner = {
key: string;
status: "positive" | "danger";
title: string;
description?: string;
};
export function useUseCaseCompletedRuleActions({
slug,
fixture,
setActionBanner,
}: {
slug: UseCaseDetailSlug;
fixture: UseCaseCompletedRuleFixture;
setActionBanner: (_: UseCaseCompletedRuleActionBanner | null) => void;
}) {
const router = useRouter();
const pathname = usePathname();
const { openLogin } = useAuthModal();
const t = useTranslation("pages.useCasesCompletedRule.topNav");
const [duplicateBusy, setDuplicateBusy] = useState(false);
const copyPageLink = useCallback(async () => {
if (typeof window === "undefined") return;
try {
await navigator.clipboard.writeText(window.location.href);
setActionBanner({
key: "shareCopied",
status: "positive",
title: t("shareLinkCopiedTitle"),
description: t("shareLinkCopiedDescription"),
});
} catch {
setActionBanner({
key: "shareCopyFailed",
status: "danger",
title: t("shareCopyFailedTitle"),
description: t("shareCopyFailedDescription"),
});
}
}, [setActionBanner, t]);
const mailtoPageLink = useCallback(() => {
if (typeof window === "undefined") return;
const url = window.location.href;
const subject = encodeURIComponent(fixture.title);
const body = encodeURIComponent(`${fixture.summary}\n\n${url}`);
window.location.href = `mailto:?subject=${subject}&body=${body}`;
}, [fixture.summary, fixture.title]);
const handleDuplicate = useCallback(async () => {
if (duplicateBusy) return;
setActionBanner(null);
const { user } = await fetchAuthSession();
if (!user) {
openLogin({
nextPath:
pathname && pathname.length > 0
? pathname
: `/use-cases/${slug}/rule`,
backdropVariant: "blurredYellow",
});
return;
}
setDuplicateBusy(true);
const res = await duplicateUseCaseTemplate(slug);
setDuplicateBusy(false);
if (res.ok === false) {
if (res.status === 401) {
openLogin({
nextPath:
pathname && pathname.length > 0
? pathname
: `/use-cases/${slug}/rule`,
backdropVariant: "blurredYellow",
});
return;
}
setActionBanner({
key: "duplicateFailed",
status: "danger",
title: t("duplicateFailedTitle"),
description:
res.status === 404 ? t("duplicateNotFoundDescription") : res.error,
});
return;
}
router.push("/profile");
}, [
duplicateBusy,
openLogin,
pathname,
router,
setActionBanner,
slug,
t,
]);
return {
copyPageLink,
mailtoPageLink,
handleDuplicate,
};
}
@@ -0,0 +1,66 @@
/**
* Figma: Completed CR — use case community rule demos
* (21995:39476, 21995:40092, 22015:42413)
*/
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import messages from "../../../../../messages/en/index";
import { resolveUseCaseCompletedRule } from "../../../../../lib/useCaseCompletedRule";
import {
USE_CASE_DETAIL_SLUGS,
useCaseContentKeyForSlug,
} from "../../../../../lib/useCaseSyntheticPost";
import { UseCaseCompletedRuleView } from "./_components/UseCaseCompletedRule.view";
type PageProps = {
params: Promise<{ slug: string }>;
};
export function generateStaticParams() {
return USE_CASE_DETAIL_SLUGS.map((slug) => ({ slug }));
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { slug } = await params;
const resolved = resolveUseCaseCompletedRule(
slug,
messages.pages.useCasesCompletedRules,
);
if (!resolved) {
return {};
}
const contentKey = useCaseContentKeyForSlug(resolved.slug);
const meta = messages.metadata.useCasesCompletedRule[contentKey];
return {
title: meta.title,
description: meta.description,
keywords: meta.keywords,
openGraph: {
title: meta.title,
description: meta.description,
type: "website",
siteName: "CommunityRule",
},
};
}
export default async function UseCaseCompletedRulePage({ params }: PageProps) {
const { slug } = await params;
const resolved = resolveUseCaseCompletedRule(
slug,
messages.pages.useCasesCompletedRules,
);
if (!resolved) {
notFound();
}
return (
<UseCaseCompletedRuleView
slug={resolved.slug}
fixture={resolved.fixture}
sections={resolved.sections}
/>
);
}
@@ -0,0 +1,67 @@
import type { Prisma } from "@prisma/client";
import { NextResponse } from "next/server";
import messages from "../../../../../messages/en/index";
import { prisma } from "../../../../../lib/server/db";
import { isDatabaseConfigured } from "../../../../../lib/server/env";
import {
dbUnavailable,
notFound,
unauthorized,
} from "../../../../../lib/server/responses";
import { getSessionUser } from "../../../../../lib/server/session";
import { apiRoute } from "../../../../../lib/server/apiRoute";
import { resolveUseCaseCompletedRule } from "../../../../../lib/useCaseCompletedRule";
import { isUseCaseDetailSlug } from "../../../../../lib/useCaseSyntheticPost";
import { normalizePublishedDocumentForEdit } from "../../../../../lib/create/normalizePublishedDocumentForEdit";
import { useCaseTemplateDuplicateTitle } from "../../../../../lib/useCaseTemplateDuplicate";
type RouteContext = { params: Promise<{ slug: string }> };
export const POST = apiRoute<RouteContext>(
"useCases.bySlug.duplicate",
async (_request, context) => {
if (!isDatabaseConfigured()) {
return dbUnavailable();
}
const user = await getSessionUser();
if (!user) {
return unauthorized();
}
const { slug } = await context.params;
if (!isUseCaseDetailSlug(slug)) {
return notFound();
}
const resolved = resolveUseCaseCompletedRule(
slug,
messages.pages.useCasesCompletedRules,
);
if (!resolved) {
return notFound();
}
const { fixture } = resolved;
const newRule = await prisma.publishedRule.create({
data: {
userId: user.id,
title: useCaseTemplateDuplicateTitle(fixture.title),
summary: fixture.summary,
document: normalizePublishedDocumentForEdit(
fixture.document,
) as Prisma.InputJsonValue,
},
});
return NextResponse.json({
rule: {
id: newRule.id,
title: newRule.title,
summary: newRule.summary,
createdAt: newRule.createdAt,
updatedAt: newRule.updatedAt,
},
});
},
);
@@ -10,11 +10,11 @@ const SURFACE_CLASS: Record<CaseStudyProps["surface"], string> = {
rose: "bg-[var(--color-surface-invert-brand-red)]", rose: "bg-[var(--color-surface-invert-brand-red)]",
}; };
/** Default art per tile: PNG composites (FNB/BCSM) or vector Mutual Aid logo. */ /** Default art per tile: Figma-exported SVG composites (305×305 incl. rounded bg). */
const SURFACE_ART: Record<CaseStudyProps["surface"], string> = { const SURFACE_ART: Record<CaseStudyProps["surface"], string> = {
lavender: "/assets/case-study/case-study-mutual-aid.svg", lavender: "/assets/case-study/case-study-mutual-aid.svg",
neutral: "/assets/use-cases/case-study-food-not-bombs.png", neutral: "/assets/case-study/case-study-food-not-bombs.svg",
rose: "/assets/use-cases/case-study-boulder-county-street-medics.png", rose: "/assets/case-study/case-study-boulder-county-street-medics.svg",
}; };
/** Figma: ~23px corner (“Card / CaseStudy” shells). */ /** Figma: ~23px corner (“Card / CaseStudy” shells). */
@@ -39,12 +39,8 @@ function CaseStudyView({
alt={imageAlt} alt={imageAlt}
width={305} width={305}
height={305} height={305}
unoptimized={ unoptimized
SURFACE_ART[surface].endsWith(".svg") ? true : undefined className="pointer-events-none size-full select-none object-contain object-center"
}
className={`pointer-events-none select-none ${
surface === "lavender" ? "object-contain object-center" : "object-cover"
}`}
draggable={false} draggable={false}
/> />
)} )}
@@ -28,6 +28,8 @@ const RuleContainer = memo<RuleProps>(
onDescriptionClick, onDescriptionClick,
descriptionEmptyHint, descriptionEmptyHint,
descriptionEditAriaLabel, descriptionEditAriaLabel,
onTitleClick,
titleEditAriaLabel,
icon, icon,
backgroundColor = "bg-[var(--color-community-teal-100)]", backgroundColor = "bg-[var(--color-community-teal-100)]",
className = "", className = "",
@@ -84,6 +86,8 @@ const RuleContainer = memo<RuleProps>(
onDescriptionClick={onDescriptionClick} onDescriptionClick={onDescriptionClick}
descriptionEmptyHint={descriptionEmptyHint} descriptionEmptyHint={descriptionEmptyHint}
descriptionEditAriaLabel={descriptionEditAriaLabel} descriptionEditAriaLabel={descriptionEditAriaLabel}
onTitleClick={onTitleClick}
titleEditAriaLabel={titleEditAriaLabel}
icon={icon} icon={icon}
backgroundColor={backgroundColor} backgroundColor={backgroundColor}
className={className} className={className}
+9
View File
@@ -39,6 +39,13 @@ export interface RuleProps {
descriptionEditAriaLabel?: string; descriptionEditAriaLabel?: string;
/** Shown when {@link onDescriptionClick} is set and `description` is empty. */ /** Shown when {@link onDescriptionClick} is set and `description` is empty. */
descriptionEmptyHint?: string; descriptionEmptyHint?: string;
/**
* When set, the title in the card header is clickable — caller handles modal /
* navigation (e.g. edit published rule).
*/
onTitleClick?: () => void;
/** When {@link onTitleClick} is set, forwarded to the controls `aria-label`. */
titleEditAriaLabel?: string;
icon?: React.ReactNode; icon?: React.ReactNode;
backgroundColor?: string; backgroundColor?: string;
className?: string; className?: string;
@@ -80,6 +87,8 @@ export interface RuleViewProps {
onDescriptionClick?: () => void; onDescriptionClick?: () => void;
descriptionEmptyHint?: string; descriptionEmptyHint?: string;
descriptionEditAriaLabel?: string; descriptionEditAriaLabel?: string;
onTitleClick?: () => void;
titleEditAriaLabel?: string;
icon?: React.ReactNode; icon?: React.ReactNode;
backgroundColor: string; backgroundColor: string;
className: string; className: string;
+23 -5
View File
@@ -14,6 +14,8 @@ export function RuleView({
onDescriptionClick, onDescriptionClick,
descriptionEmptyHint, descriptionEmptyHint,
descriptionEditAriaLabel, descriptionEditAriaLabel,
onTitleClick,
titleEditAriaLabel,
icon, icon,
backgroundColor, backgroundColor,
className, className,
@@ -307,11 +309,27 @@ export function RuleView({
{t("recommendedLabel")} {t("recommendedLabel")}
</Tag> </Tag>
) : null} ) : null}
<h3 {onTitleClick ? (
className={`${titleClass} cursor-inherit text-[var(--color-content-invert-primary)] overflow-hidden text-ellipsis w-full`} <InlineTextButton
> type="button"
{title} underline={false}
</h3> data-testid="rule-title-edit"
ariaLabel={titleEditAriaLabel}
className={`${titleClass} w-full min-w-0 cursor-pointer text-left text-[var(--color-content-invert-primary)] hover:!opacity-100 overflow-hidden text-ellipsis`}
onClick={(e) => {
e.stopPropagation();
onTitleClick();
}}
>
{title}
</InlineTextButton>
) : (
<h3
className={`${titleClass} cursor-inherit text-[var(--color-content-invert-primary)] overflow-hidden text-ellipsis w-full`}
>
{title}
</h3>
)}
</div> </div>
</div> </div>
)} )}
@@ -2,6 +2,7 @@
import { memo } from "react"; import { memo } from "react";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { isChromelessNavigationPath } from "../../../lib/navigationChromelessPath";
import TopWithPathname from "./Top/TopWithPathname"; import TopWithPathname from "./Top/TopWithPathname";
export type ConditionalNavigationClientProps = { export type ConditionalNavigationClientProps = {
@@ -15,10 +16,8 @@ export type ConditionalNavigationClientProps = {
const ConditionalNavigationClient = memo( const ConditionalNavigationClient = memo(
({ initialSignedIn }: ConditionalNavigationClientProps) => { ({ initialSignedIn }: ConditionalNavigationClientProps) => {
const pathname = usePathname(); const pathname = usePathname();
const isCreateFlow = pathname?.startsWith("/create");
const isLogin = pathname === "/login";
if (isCreateFlow || isLogin) { if (isChromelessNavigationPath(pathname)) {
return null; return null;
} }
@@ -16,17 +16,23 @@ const CreateFlowTopNavContainer = memo<CreateFlowTopNavProps>(
hasShare = false, hasShare = false,
hasExport = false, hasExport = false,
hasEdit = false, hasEdit = false,
hasDuplicate = false,
hasManageStakeholders = false, hasManageStakeholders = false,
saveDraftOnExit = false, saveDraftOnExit = false,
onShare, onShare,
onSelectExportFormat, onSelectExportFormat,
onEdit, onEdit,
onDuplicate,
onManageStakeholders, onManageStakeholders,
onExit, onExit,
exitLabel,
duplicateLabel,
duplicateAriaLabel,
buttonPalette, buttonPalette,
className = "", className = "",
}) => { }) => {
const router = useRouter(); const router = useRouter();
const t = useTranslation("create.topNav");
const tPopover = useTranslation("modals.popoverExport"); const tPopover = useTranslation("modals.popoverExport");
const handleExit = (options?: { saveDraft?: boolean }) => { const handleExit = (options?: { saveDraft?: boolean }) => {
@@ -43,19 +49,26 @@ const CreateFlowTopNavContainer = memo<CreateFlowTopNavProps>(
hasShare={hasShare} hasShare={hasShare}
hasExport={hasExport} hasExport={hasExport}
hasEdit={hasEdit} hasEdit={hasEdit}
hasDuplicate={hasDuplicate}
hasManageStakeholders={hasManageStakeholders} hasManageStakeholders={hasManageStakeholders}
saveDraftOnExit={saveDraftOnExit} saveDraftOnExit={saveDraftOnExit}
onShare={onShare} onShare={onShare}
onSelectExportFormat={onSelectExportFormat} onSelectExportFormat={onSelectExportFormat}
onEdit={onEdit} onEdit={onEdit}
onDuplicate={onDuplicate}
onManageStakeholders={onManageStakeholders} onManageStakeholders={onManageStakeholders}
onExit={handleExit} onExit={handleExit}
exitLabel={exitLabel}
duplicateLabel={duplicateLabel}
duplicateAriaLabel={duplicateAriaLabel}
buttonPalette={buttonPalette} buttonPalette={buttonPalette}
className={className} className={className}
exportPopoverMenuAriaLabel={tPopover("menuAriaLabel")} exportPopoverMenuAriaLabel={tPopover("menuAriaLabel")}
exportPopoverPdfLabel={tPopover("downloadPdf")} exportPopoverPdfLabel={tPopover("downloadPdf")}
exportPopoverCsvLabel={tPopover("downloadCsv")} exportPopoverCsvLabel={tPopover("downloadCsv")}
exportPopoverMarkdownLabel={tPopover("downloadMarkdown")} exportPopoverMarkdownLabel={tPopover("downloadMarkdown")}
moreOptionsAriaLabel={t("moreOptionsAriaLabel")}
actionsMenuAriaLabel={t("actionsMenuAriaLabel")}
/> />
); );
}, },
@@ -21,6 +21,11 @@ export interface CreateFlowTopNavProps {
* @default false * @default false
*/ */
hasEdit?: boolean; hasEdit?: boolean;
/**
* Whether to show Duplicate instead of Edit (marketing completed demos).
* @default false
*/
hasDuplicate?: boolean;
/** /**
* Whether to show **Manage Stakeholders** (published-rule invite management). * Whether to show **Manage Stakeholders** (published-rule invite management).
* Used on `/create/edit-rule` only. * Used on `/create/edit-rule` only.
@@ -45,6 +50,17 @@ export interface CreateFlowTopNavProps {
* Callback when Edit button is clicked * Callback when Edit button is clicked
*/ */
onEdit?: () => void; onEdit?: () => void;
/**
* Callback when Duplicate button is clicked
*/
onDuplicate?: () => void;
/**
* Override exit button label (e.g. "Return" on marketing demos).
*/
exitLabel?: string;
/** Label for Duplicate when {@link hasDuplicate} is true. */
duplicateLabel?: string;
duplicateAriaLabel?: string;
/** /**
* Callback when Manage Stakeholders is clicked * Callback when Manage Stakeholders is clicked
*/ */
@@ -71,4 +87,6 @@ export type CreateFlowTopNavViewProps = CreateFlowTopNavProps & {
exportPopoverPdfLabel: string; exportPopoverPdfLabel: string;
exportPopoverCsvLabel: string; exportPopoverCsvLabel: string;
exportPopoverMarkdownLabel: string; exportPopoverMarkdownLabel: string;
moreOptionsAriaLabel: string;
actionsMenuAriaLabel: string;
}; };
@@ -1,39 +1,178 @@
"use client"; "use client";
import { useEffect, useId, useRef, useState } from "react"; import { useEffect, useId, useMemo, useRef, useState } from "react";
import type { IconName } from "../../asset/icon";
import Logo from "../../asset/Logo"; import Logo from "../../asset/Logo";
import Button from "../../buttons/Button"; 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 { useCreateFlowSm2Up } from "../../../(app)/create/hooks/useCreateFlowSm2Up";
import { useTranslation } from "../../../contexts/MessagesContext"; import { useTranslation } from "../../../contexts/MessagesContext";
import type { CreateFlowTopNavViewProps } from "./CreateFlowTopNav.types"; import type { CreateFlowTopNavViewProps } from "./CreateFlowTopNav.types";
const outlineButtonClass =
"md:!text-[12px] md:!leading-[14px] !text-[10px] !leading-[12px] !px-[var(--spacing-scale-006,6px)] md:!px-[var(--spacing-scale-008,8px)] !py-[6px] md:!py-[8px] !border md:!border-[1.5px]";
const exitButtonFigmaClass = const exitButtonFigmaClass =
"!rounded-[var(--radius-measures-radius-full,9999px)] !border-[1.25px] !px-[var(--spacing-measures-spacing-250,10px)] !py-[var(--spacing-measures-spacing-200,8px)] md:!text-[12px] md:!leading-[14px]"; "!rounded-[var(--radius-measures-radius-full,9999px)] !border-[1.25px] !px-[var(--spacing-measures-spacing-250,10px)] !py-[var(--spacing-measures-spacing-200,8px)] md:!text-[12px] md:!leading-[14px]";
type ActionMenuItem = {
id: string;
label: string;
leadingIcon: IconName;
onClick: () => void;
};
function KebabIcon({ className = "" }: { className?: string }) {
return (
<svg
width="12"
height="12"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={`shrink-0 md:h-[14px] md:w-[14px] ${className}`}
aria-hidden="true"
>
<circle cx="4" cy="8" r="1.5" fill="currentColor" />
<circle cx="8" cy="8" r="1.5" fill="currentColor" />
<circle cx="12" cy="8" r="1.5" fill="currentColor" />
</svg>
);
}
export function CreateFlowTopNavView({ export function CreateFlowTopNavView({
hasShare = false, hasShare = false,
hasExport = false, hasExport = false,
hasEdit = false, hasEdit = false,
hasDuplicate = false,
hasManageStakeholders = false, hasManageStakeholders = false,
saveDraftOnExit = false, saveDraftOnExit = false,
onShare, onShare,
onSelectExportFormat, onSelectExportFormat,
onEdit, onEdit,
onDuplicate,
onManageStakeholders, onManageStakeholders,
onExit, onExit,
exitLabel,
duplicateLabel,
duplicateAriaLabel,
buttonPalette = "default", buttonPalette = "default",
className = "", className = "",
exportPopoverMenuAriaLabel, exportPopoverMenuAriaLabel,
exportPopoverPdfLabel, exportPopoverPdfLabel,
exportPopoverCsvLabel, exportPopoverCsvLabel,
exportPopoverMarkdownLabel, exportPopoverMarkdownLabel,
moreOptionsAriaLabel,
actionsMenuAriaLabel,
}: CreateFlowTopNavViewProps) { }: CreateFlowTopNavViewProps) {
const t = useTranslation("create.topNav"); const t = useTranslation("create.topNav");
const exitButtonText = saveDraftOnExit ? t("saveAndExit") : t("exit"); const sm2Up = useCreateFlowSm2Up();
const exitButtonText =
exitLabel ?? (saveDraftOnExit ? t("saveAndExit") : t("exit"));
const [exportMenuOpen, setExportMenuOpen] = useState(false); const [exportMenuOpen, setExportMenuOpen] = useState(false);
const [actionsMenuOpen, setActionsMenuOpen] = useState(false);
const exportWrapRef = useRef<HTMLDivElement>(null); const exportWrapRef = useRef<HTMLDivElement>(null);
const actionsWrapRef = useRef<HTMLDivElement>(null);
const exportMenuId = useId(); const exportMenuId = useId();
const actionsMenuId = useId();
const hasSecondaryActions =
hasShare ||
hasExport ||
hasEdit ||
hasDuplicate ||
hasManageStakeholders;
const useKebabMenu = hasSecondaryActions && !sm2Up;
const actionMenuItems = useMemo((): ActionMenuItem[] => {
const items: ActionMenuItem[] = [];
if (hasShare && onShare) {
items.push({
id: "share",
label: t("share"),
leadingIcon: "mail",
onClick: onShare,
});
}
if (hasExport && onSelectExportFormat) {
items.push(
{
id: "export-pdf",
label: exportPopoverPdfLabel,
leadingIcon: "picture_as_pdf",
onClick: () => onSelectExportFormat("pdf"),
},
{
id: "export-csv",
label: exportPopoverCsvLabel,
leadingIcon: "csv",
onClick: () => onSelectExportFormat("csv"),
},
{
id: "export-markdown",
label: exportPopoverMarkdownLabel,
leadingIcon: "markdown_copy",
onClick: () => onSelectExportFormat("markdown"),
},
);
}
if (hasDuplicate && onDuplicate) {
items.push({
id: "duplicate",
label: duplicateLabel ?? t("edit"),
leadingIcon: "content_copy",
onClick: onDuplicate,
});
} else if (hasEdit && onEdit) {
items.push({
id: "edit",
label: t("edit"),
leadingIcon: "edit",
onClick: onEdit,
});
}
if (hasManageStakeholders && onManageStakeholders) {
items.push({
id: "manage-stakeholders",
label: t("manageStakeholders"),
leadingIcon: "tags",
onClick: onManageStakeholders,
});
}
items.push({
id: "exit",
label: exitButtonText,
leadingIcon: "log_out",
onClick: () => void onExit?.({ saveDraft: saveDraftOnExit }),
});
return items;
}, [
duplicateLabel,
exitButtonText,
exportPopoverCsvLabel,
exportPopoverMarkdownLabel,
exportPopoverPdfLabel,
hasDuplicate,
hasEdit,
hasExport,
hasManageStakeholders,
hasShare,
onDuplicate,
onEdit,
onExit,
onManageStakeholders,
onSelectExportFormat,
onShare,
saveDraftOnExit,
t,
]);
useEffect(() => { useEffect(() => {
if (!exportMenuOpen) return; if (!exportMenuOpen) return;
@@ -49,6 +188,20 @@ export function CreateFlowTopNavView({
return () => document.removeEventListener("mousedown", onDoc); return () => document.removeEventListener("mousedown", onDoc);
}, [exportMenuOpen]); }, [exportMenuOpen]);
useEffect(() => {
if (!actionsMenuOpen) return;
const onDoc = (e: MouseEvent) => {
if (
actionsWrapRef.current &&
!actionsWrapRef.current.contains(e.target as Node)
) {
setActionsMenuOpen(false);
}
};
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, [actionsMenuOpen]);
useEffect(() => { useEffect(() => {
if (!exportMenuOpen) return; if (!exportMenuOpen) return;
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
@@ -58,6 +211,155 @@ export function CreateFlowTopNavView({
return () => window.removeEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey);
}, [exportMenuOpen]); }, [exportMenuOpen]);
useEffect(() => {
if (!actionsMenuOpen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setActionsMenuOpen(false);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [actionsMenuOpen]);
const inlineActions = (
<>
{hasShare && (
<Button
buttonType="outline"
palette={buttonPalette}
size="xsmall"
onClick={onShare}
ariaLabel={t("shareAriaLabel")}
className={outlineButtonClass}
>
{t("share")}
</Button>
)}
{hasExport && onSelectExportFormat ? (
<div className="relative" ref={exportWrapRef}>
<Button
buttonType="outline"
palette={buttonPalette}
size="xsmall"
type="button"
ariaLabel={t("exportAriaLabel")}
aria-haspopup="menu"
aria-expanded={exportMenuOpen}
aria-controls={exportMenuId}
onClick={() => setExportMenuOpen((o) => !o)}
className={`justify-center gap-[var(--spacing-scale-002,2px)] !pl-[var(--spacing-scale-012,12px)] !pr-[var(--spacing-scale-006,6px)] md:!pr-[var(--spacing-scale-006,6px)] ${outlineButtonClass}`}
>
<span>{t("export")}</span>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className="shrink-0 md:h-[14px] md:w-[14px]"
aria-hidden="true"
>
<path d="M19 9l-7 7-7-7" />
</svg>
</Button>
{exportMenuOpen ? (
<div className="absolute right-0 top-[calc(100%+var(--spacing-measures-spacing-200,8px))] z-[300]">
<Popover
id={exportMenuId}
menuAriaLabel={exportPopoverMenuAriaLabel}
>
<ListItem
showDivider
leadingIcon="picture_as_pdf"
label={exportPopoverPdfLabel}
onClick={() => {
onSelectExportFormat("pdf");
setExportMenuOpen(false);
}}
/>
<ListItem
showDivider
leadingIcon="csv"
label={exportPopoverCsvLabel}
onClick={() => {
onSelectExportFormat("csv");
setExportMenuOpen(false);
}}
/>
<ListItem
showDivider={false}
leadingIcon="markdown_copy"
label={exportPopoverMarkdownLabel}
onClick={() => {
onSelectExportFormat("markdown");
setExportMenuOpen(false);
}}
/>
</Popover>
</div>
) : null}
</div>
) : null}
{hasDuplicate && (
<Button
buttonType="outline"
palette={buttonPalette}
size="xsmall"
onClick={onDuplicate}
ariaLabel={
duplicateAriaLabel ?? duplicateLabel ?? t("editAriaLabel")
}
className={outlineButtonClass}
>
{duplicateLabel ?? t("edit")}
</Button>
)}
{hasEdit && !hasDuplicate && (
<Button
buttonType="outline"
palette={buttonPalette}
size="xsmall"
onClick={onEdit}
ariaLabel={t("editAriaLabel")}
className={outlineButtonClass}
>
{t("edit")}
</Button>
)}
{hasManageStakeholders && onManageStakeholders ? (
<Button
buttonType="outline"
palette={buttonPalette}
size="xsmall"
type="button"
onClick={onManageStakeholders}
ariaLabel={t("manageStakeholdersAriaLabel")}
className={outlineButtonClass}
>
{t("manageStakeholders")}
</Button>
) : null}
<Button
buttonType="outline"
palette={buttonPalette}
size="xsmall"
type="button"
onClick={() => void onExit?.({ saveDraft: saveDraftOnExit })}
ariaLabel={exitButtonText}
className={`shrink-0 ${exitButtonFigmaClass} !text-[10px] !leading-[12px] !py-[6px] md:!py-[8px]`}
>
{exitButtonText}
</Button>
</>
);
return ( return (
<header <header
className={`bg-black w-full ${className}`} className={`bg-black w-full ${className}`}
@@ -72,126 +374,56 @@ export function CreateFlowTopNavView({
<Logo size="createFlow" wordmark palette={buttonPalette} /> <Logo size="createFlow" wordmark palette={buttonPalette} />
<div className="flex flex-wrap items-center justify-end gap-[var(--spacing-scale-012,12px)]"> <div className="flex flex-wrap items-center justify-end gap-[var(--spacing-scale-012,12px)]">
{hasShare && ( {useKebabMenu ? (
<Button <div className="relative" ref={actionsWrapRef}>
buttonType="outline"
palette={buttonPalette}
size="xsmall"
onClick={onShare}
ariaLabel={t("shareAriaLabel")}
className="md:!text-[12px] md:!leading-[14px] !text-[10px] !leading-[12px] !px-[var(--spacing-scale-006,6px)] md:!px-[var(--spacing-scale-008,8px)] !py-[6px] md:!py-[8px] !border md:!border-[1.5px]"
>
{t("share")}
</Button>
)}
{hasExport && onSelectExportFormat ? (
<div className="relative" ref={exportWrapRef}>
<Button <Button
buttonType="outline" buttonType="outline"
palette={buttonPalette} palette={buttonPalette}
size="xsmall" size="xsmall"
type="button" type="button"
ariaLabel={t("exportAriaLabel")} ariaLabel={moreOptionsAriaLabel}
aria-haspopup="menu" aria-haspopup="menu"
aria-expanded={exportMenuOpen} aria-expanded={actionsMenuOpen}
aria-controls={exportMenuId} aria-controls={actionsMenuId}
onClick={() => setExportMenuOpen((o) => !o)} onClick={() => setActionsMenuOpen((open) => !open)}
className="justify-center gap-[var(--spacing-scale-002,2px)] !pl-[var(--spacing-scale-012,12px)] !pr-[var(--spacing-scale-006,6px)] md:!pr-[var(--spacing-scale-006,6px)] !text-[10px] md:!text-[12px] !leading-[12px] md:!leading-[14px] !py-[6px] md:!py-[8px] !border md:!border-[1.5px]" className={`!px-[var(--spacing-scale-010,10px)] ${outlineButtonClass}`}
> >
<span>{t("export")}</span> <KebabIcon />
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className="shrink-0 md:w-[14px] md:h-[14px]"
aria-hidden="true"
>
<path d="M19 9l-7 7-7-7" />
</svg>
</Button> </Button>
{exportMenuOpen ? ( {actionsMenuOpen ? (
<div className="absolute right-0 top-[calc(100%+var(--spacing-measures-spacing-200,8px))] z-[300]"> <div className="absolute right-0 top-[calc(100%+var(--spacing-measures-spacing-200,8px))] z-[300]">
<Popover <Popover id={actionsMenuId} menuAriaLabel={actionsMenuAriaLabel}>
id={exportMenuId} {actionMenuItems.map((item, index) => (
menuAriaLabel={exportPopoverMenuAriaLabel} <ListItem
> key={item.id}
<ListItem showDivider={index < actionMenuItems.length - 1}
showDivider leadingIcon={item.leadingIcon}
leadingIcon="picture_as_pdf" label={item.label}
label={exportPopoverPdfLabel} onClick={() => {
onClick={() => { item.onClick();
onSelectExportFormat("pdf"); setActionsMenuOpen(false);
setExportMenuOpen(false); }}
}} />
/> ))}
<ListItem
showDivider
leadingIcon="csv"
label={exportPopoverCsvLabel}
onClick={() => {
onSelectExportFormat("csv");
setExportMenuOpen(false);
}}
/>
<ListItem
showDivider={false}
leadingIcon="markdown_copy"
label={exportPopoverMarkdownLabel}
onClick={() => {
onSelectExportFormat("markdown");
setExportMenuOpen(false);
}}
/>
</Popover> </Popover>
</div> </div>
) : null} ) : null}
</div> </div>
) : null} ) : hasSecondaryActions ? (
inlineActions
{hasEdit && ( ) : (
<Button
buttonType="outline"
palette={buttonPalette}
size="xsmall"
onClick={onEdit}
ariaLabel={t("editAriaLabel")}
className="md:!text-[12px] md:!leading-[14px] !text-[10px] !leading-[12px] !px-[var(--spacing-scale-006,6px)] md:!px-[var(--spacing-scale-008,8px)] !py-[6px] md:!py-[8px] !border md:!border-[1.5px]"
>
{t("edit")}
</Button>
)}
{hasManageStakeholders && onManageStakeholders ? (
<Button <Button
buttonType="outline" buttonType="outline"
palette={buttonPalette} palette={buttonPalette}
size="xsmall" size="xsmall"
type="button" type="button"
onClick={onManageStakeholders} onClick={() => void onExit?.({ saveDraft: saveDraftOnExit })}
ariaLabel={t("manageStakeholdersAriaLabel")} ariaLabel={exitButtonText}
className="md:!text-[12px] md:!leading-[14px] !text-[10px] !leading-[12px] !px-[var(--spacing-scale-006,6px)] md:!px-[var(--spacing-scale-008,8px)] !py-[6px] md:!py-[8px] !border md:!border-[1.5px]" className={`shrink-0 ${exitButtonFigmaClass} !text-[10px] !leading-[12px] !py-[6px] md:!py-[8px]`}
> >
{t("manageStakeholders")} {exitButtonText}
</Button> </Button>
) : null} )}
<Button
buttonType="outline"
palette={buttonPalette}
size="xsmall"
type="button"
onClick={() => void onExit?.({ saveDraft: saveDraftOnExit })}
ariaLabel={exitButtonText}
className={`md:!text-[12px] md:!leading-[14px] !text-[10px] !leading-[12px] !py-[6px] md:!py-[8px] shrink-0 ${exitButtonFigmaClass}`}
>
{exitButtonText}
</Button>
</div> </div>
</nav> </nav>
</header> </header>
@@ -36,7 +36,7 @@ const VARIANT_STYLES: Record<
}, },
}; };
/** Figma **Section/AskOrganizer** [18116:15960](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=18116-15960&m=dev) (`lg` shell + type + button). Use-case detail: [22015:42624](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22015-42624&m=dev). */ /** Figma **Section/AskOrganizer** — baseline default [17487:12288](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=17487-12288&m=dev), inverse [19189:8140](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=19189-8140&m=dev); md+ [16306:14995](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=16306-14995&m=dev). Use-case detail instance: [22015:42624](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22015-42624&m=dev). */
const AskOrganizerContainer = memo<AskOrganizerProps>( const AskOrganizerContainer = memo<AskOrganizerProps>(
({ ({
title, title,
@@ -61,9 +61,9 @@ const AskOrganizerContainer = memo<AskOrganizerProps>(
const sectionPadding = const sectionPadding =
resolvedVariant === "compact" resolvedVariant === "compact"
? "py-[var(--spacing-scale-016)] px-[var(--spacing-scale-016)] md:py-[var(--spacing-scale-032)] md:px-[var(--spacing-scale-032)]" ? "py-[var(--spacing-scale-016)] px-[var(--spacing-scale-016)] md:py-[var(--spacing-scale-032)] md:px-[var(--spacing-scale-032)]"
: resolvedVariant === "use-case-detail" : resolvedVariant === "use-case-detail" || resolvedVariant === "inverse"
? "w-full py-[var(--spacing-scale-096)] px-[var(--spacing-scale-032)] md:px-[var(--spacing-scale-064)]" ? "w-full py-[var(--spacing-scale-032)] px-[var(--spacing-scale-032)] md:py-[var(--spacing-scale-096)] md:px-[var(--spacing-scale-064)]"
: "py-[var(--spacing-scale-096)] px-[var(--spacing-scale-032)] md:px-[var(--spacing-scale-064)]"; : "py-[var(--spacing-scale-040)] px-[var(--spacing-scale-032)] md:py-[var(--spacing-scale-096)] md:px-[var(--spacing-scale-064)]";
const contentGap = const contentGap =
resolvedVariant === "compact" resolvedVariant === "compact"
@@ -38,7 +38,7 @@ function AskOrganizerView({
data-figma-node={isUseCaseDetail ? "22015-42624" : "18116-15960"} data-figma-node={isUseCaseDetail ? "22015-42624" : "18116-15960"}
> >
<div <div
className={`mx-auto flex w-full min-w-[358px] max-w-[1280px] flex-col ${contentGap} ${isUseCaseDetail ? "items-center" : ""}`} className={`mx-auto flex w-full min-w-0 max-w-[1280px] flex-col md:min-w-[358px] ${contentGap} ${isUseCaseDetail ? "items-center" : ""}`}
> >
{/* Content Lockup */} {/* Content Lockup */}
<ContentLockup <ContentLockup
@@ -56,10 +56,10 @@ function AskOrganizerView({
> >
<Button <Button
{...(buttonHref ? { href: buttonHref } : {})} {...(buttonHref ? { href: buttonHref } : {})}
size="large" size="small"
buttonType="filled" buttonType="filled"
palette={buttonPalette} palette={buttonPalette}
className="!px-[var(--spacing-scale-016)] !py-[var(--spacing-scale-012)]" className="!px-[var(--spacing-scale-010)] md:!px-[var(--spacing-scale-016)] md:!py-[var(--spacing-scale-012)] md:!text-[16px] md:!leading-[20px]"
onClick={onContactClick} onClick={onContactClick}
ariaLabel={ariaLabel} ariaLabel={ariaLabel}
data-testid="ask-organizer-cta" data-testid="ask-organizer-cta"
@@ -9,6 +9,8 @@ export interface ContentBannerRulePreview {
description: string; description: string;
backgroundColor: string; backgroundColor: string;
iconPath: string; iconPath: string;
/** When set, the rule preview links to the completed community rule screen. */
href?: string;
} }
export interface ContentBannerProps { export interface ContentBannerProps {
@@ -1,7 +1,9 @@
"use client"; "use client";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link";
import { memo } from "react"; import { memo } from "react";
import { useTranslation } from "../../../contexts/MessagesContext";
import ContentContainer from "../../content/ContentContainer"; import ContentContainer from "../../content/ContentContainer";
import Rule from "../../cards/Rule"; import Rule from "../../cards/Rule";
import { import {
@@ -123,16 +125,23 @@ function ContentBannerArticleView({
function ContentBannerUseCaseView({ function ContentBannerUseCaseView({
post, post,
rulePreview, rulePreview,
}: Pick<ContentBannerViewProps, "post" | "rulePreview">) { contentTone = "inverse",
leadingImageSrc,
leadingImageAlt,
}: Pick<
ContentBannerViewProps,
| "post"
| "rulePreview"
| "contentTone"
| "leadingImageSrc"
| "leadingImageAlt"
>) {
const t = useTranslation("pages.useCasesCompletedRule");
if (!rulePreview) { if (!rulePreview) {
return null; return null;
} }
const { title, description, author, date } = post.frontmatter; const { title } = post.frontmatter;
const formattedDate = new Date(date).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
});
return ( return (
<section <section
@@ -141,52 +150,77 @@ function ContentBannerUseCaseView({
> >
<div <div
data-figma-node="22015:42621" data-figma-node="22015:42621"
className="mx-auto flex w-full max-w-[1024px] flex-col items-center gap-[var(--space-800)] px-[var(--space-1200)] py-[var(--space-1000)] md:flex-row md:items-center" className="mx-auto grid w-full max-w-[1024px] grid-cols-1 items-center gap-[var(--space-800)] px-[var(--space-1200)] py-[var(--space-1000)] lg:grid-cols-2 lg:items-center"
> >
<div <div
data-node-id="19189:9171" data-node-id="19189:9171"
className="flex w-full max-w-[365px] shrink-0 flex-col gap-[var(--spacing-scale-024)]" className="flex w-full min-w-0 shrink-0 flex-col lg:max-w-[365px]"
> >
<div className="flex w-full flex-col gap-[var(--measures-spacing-016)]"> <ContentContainer
<div className="flex w-full flex-col gap-[var(--measures-spacing-004)] text-[var(--color-content-inverse-brand-royal)]"> post={post}
<h1 className="w-full font-bricolage font-medium text-[32px] leading-[110%] sm:text-[40px] lg:text-[44px]"> size="responsive"
{title} tone={contentTone}
</h1> showLeadingImage={false}
{description ? ( leadingImageSrc={leadingImageSrc}
<p className="w-full font-inter font-normal text-[16px] leading-[130%] sm:text-[18px]"> leadingImageAlt={leadingImageAlt}
{description} />
</p>
) : null}
</div>
</div>
<div className="flex w-full items-end gap-[var(--measures-spacing-008)] font-inter text-[14px] leading-[20px] text-[var(--color-content-inverse-brand-royal)]">
<span>{author}</span>
<span>{formattedDate}</span>
</div>
</div> </div>
<div className="flex min-w-0 w-full flex-1"> <div className="flex min-w-0 w-full">
<Rule {rulePreview.href ? (
title={rulePreview.title} <Link
description={rulePreview.description} href={rulePreview.href}
expanded className="block w-full rounded-[24px] outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-default-brand-primary)] focus-visible:ring-offset-2"
fluidWidth aria-label={t("ruleCardLinkAriaLabel").replace(
size="L" "{title}",
templateGridFigmaShell rulePreview.title,
backgroundColor={rulePreview.backgroundColor} )}
className="pointer-events-none w-full select-none rounded-[24px]" >
icon={ <Rule
<Image title={rulePreview.title}
src={getAssetPath(rulePreview.iconPath)} description={rulePreview.description}
alt="" expanded
width={103} fluidWidth
height={103} size="L"
draggable={false} templateGridFigmaShell
unoptimized={rulePreview.iconPath.endsWith(".svg")} backgroundColor={rulePreview.backgroundColor}
className="aspect-square size-full max-h-[103px] max-w-[103px] object-contain mix-blend-luminosity" className="w-full cursor-pointer rounded-[24px] transition-opacity hover:opacity-95"
icon={
<Image
src={getAssetPath(rulePreview.iconPath)}
alt=""
width={103}
height={103}
draggable={false}
unoptimized={rulePreview.iconPath.endsWith(".svg")}
className="aspect-square size-full max-h-[103px] max-w-[103px] object-contain mix-blend-luminosity"
/>
}
/> />
} </Link>
/> ) : (
<Rule
title={rulePreview.title}
description={rulePreview.description}
expanded
fluidWidth
size="L"
templateGridFigmaShell
backgroundColor={rulePreview.backgroundColor}
className="pointer-events-none w-full select-none rounded-[24px]"
icon={
<Image
src={getAssetPath(rulePreview.iconPath)}
alt=""
width={103}
height={103}
draggable={false}
unoptimized={rulePreview.iconPath.endsWith(".svg")}
className="aspect-square size-full max-h-[103px] max-w-[103px] object-contain mix-blend-luminosity"
/>
}
/>
)}
</div> </div>
</div> </div>
</section> </section>
@@ -39,6 +39,7 @@ export function RuleStackView({
subtitle={t("subtitle")} subtitle={t("subtitle")}
variant="multi-line" variant="multi-line"
ruleStackDesktopTypeScale ruleStackDesktopTypeScale
twoColumnsFromMd={twoColumnsFromMd}
/> />
{gridEntries === null ? ( {gridEntries === null ? (
@@ -28,6 +28,11 @@ export interface CommunityRuleSection {
export interface CommunityRuleProps { export interface CommunityRuleProps {
sections: CommunityRuleSection[]; sections: CommunityRuleSection[];
className?: string; className?: string;
/** When true, wrap in white background with left teal bar (small breakpoint). */ /** When true, wrap in white background with left accent bar (small breakpoint). */
useCardStyle?: boolean; useCardStyle?: boolean;
/**
* Accent bar color when {@link useCardStyle} is true; should match the page
* surface behind the card (defaults to create-flow teal).
*/
cardAccentColor?: string;
} }
@@ -17,17 +17,22 @@ function CommunityRuleView({
sections, sections,
className = "", className = "",
useCardStyle = false, useCardStyle = false,
cardAccentColor,
}: CommunityRuleProps) { }: CommunityRuleProps) {
const accent = cardAccentColor ?? TEAL_BG;
const rootClass = useCardStyle const rootClass = useCardStyle
? `rounded-[12px] bg-white pl-3 border-l-4 ${className}` ? `rounded-[12px] bg-white pl-4 ${className}`
: className; : className;
const rootStyle = useCardStyle ? { borderLeftColor: TEAL_BG } : undefined; const rootStyle = useCardStyle
? {
gap: SECTION_GAP,
// Inset bar (not border) — Safari mishandles `border-left` + CSS variable accent colors.
boxShadow: `inset 4px 0 0 0 ${accent}`,
}
: { gap: SECTION_GAP };
return ( return (
<div <div className={`flex flex-col min-w-0 ${rootClass}`} style={rootStyle}>
className={`flex flex-col min-w-0 ${rootClass}`}
style={{ gap: SECTION_GAP, ...rootStyle }}
>
{sections.map((ruleSection, sectionIndex) => ( {sections.map((ruleSection, sectionIndex) => (
<Section <Section
key={sectionIndex} key={sectionIndex}
@@ -111,9 +111,9 @@ const ContentLockupContainer = memo<ContentLockupProps>(
titleGroup: "flex flex-col gap-[var(--spacing-scale-008)]", titleGroup: "flex flex-col gap-[var(--spacing-scale-008)]",
titleContainer: "flex gap-[var(--spacing-scale-008)] items-center", titleContainer: "flex gap-[var(--spacing-scale-008)] items-center",
title: title:
"font-bricolage-grotesque font-medium text-[36px] leading-[110%] tracking-[0] lg:text-[44px] lg:leading-[1.1] text-[var(--color-content-default-brand-primary)]", "font-bricolage-grotesque font-medium text-[32px] leading-[1.1] tracking-[0] md:text-[44px] md:leading-[1.1] text-[var(--color-content-default-brand-primary)]",
subtitle: subtitle:
"font-inter font-normal text-[18px] leading-[130%] tracking-[0] md:text-[24px] md:leading-[32px] text-[var(--color-content-default-primary)]", "font-inter font-normal text-[18px] leading-[1.3] tracking-[0] md:text-[24px] md:leading-[32px] text-[var(--color-content-default-primary)]",
shape: shape:
"w-[16px] h-[16px] md:w-[20px] md:h-[20px] lg:w-[24px] lg:h-[24px]", "w-[16px] h-[16px] md:w-[20px] md:h-[20px] lg:w-[24px] lg:h-[24px]",
}, },
@@ -123,9 +123,9 @@ const ContentLockupContainer = memo<ContentLockupProps>(
titleGroup: "flex flex-col gap-[var(--spacing-scale-008)] w-full", titleGroup: "flex flex-col gap-[var(--spacing-scale-008)] w-full",
titleContainer: "flex gap-[var(--spacing-scale-008)] items-center", titleContainer: "flex gap-[var(--spacing-scale-008)] items-center",
title: title:
"font-bricolage-grotesque font-medium text-[36px] leading-[110%] tracking-[0] lg:text-[44px] lg:leading-[1.1] text-[var(--color-content-inverse-primary)]", "font-bricolage-grotesque font-medium text-[32px] leading-[1.1] tracking-[0] md:text-[44px] md:leading-[1.1] text-[var(--color-content-inverse-primary)]",
subtitle: subtitle:
"font-inter font-normal text-[18px] leading-[130%] tracking-[0] md:text-[24px] md:leading-[32px] text-[var(--color-content-invert-secondary)]", "font-inter font-normal text-[18px] leading-[1.3] tracking-[0] md:text-[24px] md:leading-[32px] text-[var(--color-content-invert-secondary)]",
shape: shape:
"w-[16px] h-[16px] md:w-[20px] md:h-[20px] lg:w-[24px] lg:h-[24px]", "w-[16px] h-[16px] md:w-[20px] md:h-[20px] lg:w-[24px] lg:h-[24px]",
}, },
@@ -29,10 +29,10 @@ function HeaderLockupView({
}`} }`}
> >
{/* Title */} {/* Title */}
<div className="flex items-center relative shrink-0 w-full"> <div className="flex w-full shrink-0 items-center">
<h1 <h1
id={titleId} id={titleId}
className={`flex-[1_0_0] min-h-px min-w-px overflow-hidden relative ${titleColorClass} text-ellipsis whitespace-pre-wrap ${ className={`relative w-full min-w-0 ${titleColorClass} whitespace-pre-wrap ${
isLeft ? "text-left" : "text-center" isLeft ? "text-left" : "text-center"
} ${ } ${
isL isL
@@ -49,7 +49,7 @@ function HeaderLockupView({
!(typeof description === "string" && description.length === 0) && !(typeof description === "string" && description.length === 0) &&
(typeof description === "string" ? ( (typeof description === "string" ? (
<p <p
className={`font-inter font-normal max-w-[640px] overflow-hidden relative shrink-0 ${descriptionColorClass} text-ellipsis w-full whitespace-pre-wrap ${ className={`font-inter font-normal max-w-[640px] overflow-visible relative shrink-0 ${descriptionColorClass} w-full whitespace-pre-wrap ${
isLeft ? "" : "text-center" isLeft ? "" : "text-center"
} ${ } ${
isL ? "text-[18px] leading-[1.3]" : "text-[14px] leading-[20px]" isL ? "text-[18px] leading-[1.3]" : "text-[14px] leading-[20px]"
@@ -59,7 +59,7 @@ function HeaderLockupView({
</p> </p>
) : ( ) : (
<div <div
className={`font-inter font-normal max-w-[640px] overflow-hidden relative shrink-0 ${descriptionColorClass} text-ellipsis w-full whitespace-pre-wrap ${ className={`font-inter font-normal max-w-[640px] overflow-visible relative shrink-0 ${descriptionColorClass} w-full whitespace-pre-wrap ${
isLeft ? "" : "text-center" isLeft ? "" : "text-center"
} ${ } ${
isL ? "text-[18px] leading-[1.3]" : "text-[14px] leading-[20px]" isL ? "text-[18px] leading-[1.3]" : "text-[14px] leading-[20px]"
@@ -15,6 +15,11 @@ interface SectionHeaderProps {
* subtitle **18 / 1.3** at `lg`, **24/32** at `xl`, **left-aligned** in its column from `lg` (Figma **22085:860413**). * subtitle **18 / 1.3** at `lg`, **24/32** at `xl`, **left-aligned** in its column from `lg` (Figma **22085:860413**).
*/ */
ruleStackDesktopTypeScale?: boolean; ruleStackDesktopTypeScale?: boolean;
/**
* When true with `ruleStackDesktopTypeScale`, title and subtitle split into two columns from project **`md`** (640px),
* e.g. `/use-cases` Rule stack. Default keeps the split at **`lg`** (1024px).
*/
twoColumnsFromMd?: boolean;
} }
/** /**
@@ -29,12 +34,18 @@ const SectionHeader = memo<SectionHeaderProps>(
variant: variantProp = "default", variant: variantProp = "default",
stackedDesktopLines, stackedDesktopLines,
ruleStackDesktopTypeScale = false, ruleStackDesktopTypeScale = false,
twoColumnsFromMd = false,
}) => { }) => {
const variant = variantProp; const variant = variantProp;
const useStackedDesktop = const useStackedDesktop =
variant === "multi-line" && stackedDesktopLines != null; variant === "multi-line" && stackedDesktopLines != null;
const rowAlignClasses = const splitFromMd =
variant === "multi-line" twoColumnsFromMd &&
ruleStackDesktopTypeScale &&
variant === "multi-line";
const rowAlignClasses = splitFromMd
? "md:flex-row md:justify-between md:items-center xl:gap-[var(--spacing-scale-024)]"
: variant === "multi-line"
? "lg:flex-row lg:justify-between lg:items-center xl:gap-[var(--spacing-scale-024)]" ? "lg:flex-row lg:justify-between lg:items-center xl:gap-[var(--spacing-scale-024)]"
: "lg:flex-row lg:justify-between lg:items-start xl:gap-[var(--spacing-scale-024)]"; : "lg:flex-row lg:justify-between lg:items-start xl:gap-[var(--spacing-scale-024)]";
@@ -42,11 +53,13 @@ const SectionHeader = memo<SectionHeaderProps>(
<div <div
className={`flex flex-col gap-[var(--spacing-scale-004)] w-full ${rowAlignClasses}`} className={`flex flex-col gap-[var(--spacing-scale-004)] w-full ${rowAlignClasses}`}
> >
{/* Title — left column at lg+ */} {/* Title — left column at md+ (use cases) or lg+ */}
<div <div
className={ className={
variant === "multi-line" variant === "multi-line"
? "lg:w-[50%] lg:h-[var(--spacing-scale-120)] lg:flex lg:items-center xl:w-[50%] xl:h-[156px] xl:flex xl:items-center" ? splitFromMd
? "md:w-[50%] md:h-[var(--spacing-scale-120)] md:flex md:items-center xl:w-[50%] xl:h-[156px] xl:flex xl:items-center"
: "lg:w-[50%] lg:h-[var(--spacing-scale-120)] lg:flex lg:items-center xl:w-[50%] xl:h-[156px] xl:flex xl:items-center"
: "lg:w-[369px] lg:h-[var(--spacing-scale-120)] lg:flex lg:items-center xl:w-[452px] xl:h-[156px] xl:flex xl:items-center" : "lg:w-[369px] lg:h-[var(--spacing-scale-120)] lg:flex lg:items-center xl:w-[452px] xl:h-[156px] xl:flex xl:items-center"
} }
> >
@@ -54,30 +67,38 @@ const SectionHeader = memo<SectionHeaderProps>(
className={ className={
variant === "multi-line" variant === "multi-line"
? ruleStackDesktopTypeScale ? ruleStackDesktopTypeScale
? "font-bricolage-grotesque font-bold text-[28px] leading-[36px] md:text-[32px] md:leading-[40px] lg:w-full lg:max-w-none lg:text-left lg:text-[32px] lg:leading-[40px] xl:text-[40px] xl:leading-[52px] text-[var(--color-content-default-primary)]" ? splitFromMd
? "font-bricolage-grotesque font-bold text-[28px] leading-[36px] text-[var(--color-content-default-primary)] md:w-full md:max-w-none md:text-left md:text-[32px] md:leading-[40px] xl:text-[40px] xl:leading-[52px]"
: "font-bricolage-grotesque font-bold text-[28px] leading-[36px] md:text-[32px] md:leading-[40px] lg:w-full lg:max-w-none lg:text-left lg:text-[32px] lg:leading-[40px] xl:text-[40px] xl:leading-[52px] text-[var(--color-content-default-primary)]"
: "font-bricolage-grotesque font-bold text-[28px] leading-[36px] md:text-[32px] md:leading-[40px] lg:w-[410px] lg:text-left xl:text-[40px] xl:leading-[52px] text-[var(--color-content-default-primary)]" : "font-bricolage-grotesque font-bold text-[28px] leading-[36px] md:text-[32px] md:leading-[40px] lg:w-[410px] lg:text-left xl:text-[40px] xl:leading-[52px] text-[var(--color-content-default-primary)]"
: "font-bricolage-grotesque font-bold text-[28px] leading-[36px] sm:text-[32px] sm:leading-[40px] lg:text-[32px] lg:leading-[40px] lg:w-[369px] lg:pr-[var(--spacing-scale-096)] xl:text-[40px] xl:leading-[52px] xl:w-[452px] xl:pr-[var(--spacing-scale-096)] text-[var(--color-content-default-primary)]" : "font-bricolage-grotesque font-bold text-[28px] leading-[36px] sm:text-[32px] sm:leading-[40px] lg:text-[32px] lg:leading-[40px] lg:w-[369px] lg:pr-[var(--spacing-scale-096)] xl:text-[40px] xl:leading-[52px] xl:w-[452px] xl:pr-[var(--spacing-scale-096)] text-[var(--color-content-default-primary)]"
} }
> >
<span className="block lg:hidden">{title}</span> <span className={splitFromMd ? "block md:hidden" : "block lg:hidden"}>
{title}
</span>
{useStackedDesktop ? ( {useStackedDesktop ? (
<span className="hidden lg:block"> <span className={splitFromMd ? "hidden md:block" : "hidden lg:block"}>
<span className="block">{stackedDesktopLines[0]}</span> <span className="block">{stackedDesktopLines[0]}</span>
<span className="block">{stackedDesktopLines[1]}</span> <span className="block">{stackedDesktopLines[1]}</span>
<span className="block">{stackedDesktopLines[2]}</span> <span className="block">{stackedDesktopLines[2]}</span>
</span> </span>
) : ( ) : (
<span className="hidden lg:block">{titleLg || title}</span> <span className={splitFromMd ? "hidden md:block" : "hidden lg:block"}>
{titleLg || title}
</span>
)} )}
</h2> </h2>
</div> </div>
{/* Subtitle — right column at lg+ (Figma X Large / Large / stacked small) */} {/* Subtitle — right column at md+ (use cases) or lg+ */}
<div <div
className={ className={
variant === "multi-line" variant === "multi-line"
? ruleStackDesktopTypeScale ? ruleStackDesktopTypeScale
? "lg:w-[50%] lg:h-[var(--spacing-scale-120)] lg:flex lg:items-center lg:justify-start lg:ml-[var(--spacing-scale-016)] xl:ml-0 xl:w-[50%] xl:h-[156px] xl:flex xl:items-center xl:justify-start" ? splitFromMd
? "md:w-[50%] md:h-[var(--spacing-scale-120)] md:flex md:items-center md:justify-start md:ml-[var(--spacing-scale-016)] xl:ml-0 xl:w-[50%] xl:h-[156px] xl:flex xl:items-center xl:justify-start"
: "lg:w-[50%] lg:h-[var(--spacing-scale-120)] lg:flex lg:items-center lg:justify-start lg:ml-[var(--spacing-scale-016)] xl:ml-0 xl:w-[50%] xl:h-[156px] xl:flex xl:items-center xl:justify-start"
: "lg:w-[50%] lg:h-[var(--spacing-scale-120)] lg:flex lg:items-center lg:justify-end lg:ml-[var(--spacing-scale-016)] xl:ml-0 xl:w-[50%] xl:h-[156px] xl:flex xl:items-center xl:justify-end" : "lg:w-[50%] lg:h-[var(--spacing-scale-120)] lg:flex lg:items-center lg:justify-end lg:ml-[var(--spacing-scale-016)] xl:ml-0 xl:w-[50%] xl:h-[156px] xl:flex xl:items-center xl:justify-end"
: "lg:w-[928px] lg:h-[var(--spacing-scale-120)] lg:flex lg:items-center lg:justify-end xl:h-[156px] xl:flex xl:items-center xl:justify-end" : "lg:w-[928px] lg:h-[var(--spacing-scale-120)] lg:flex lg:items-center lg:justify-end xl:h-[156px] xl:flex xl:items-center xl:justify-end"
} }
@@ -86,7 +107,9 @@ const SectionHeader = memo<SectionHeaderProps>(
className={ className={
variant === "multi-line" variant === "multi-line"
? ruleStackDesktopTypeScale ? ruleStackDesktopTypeScale
? "font-inter font-normal text-[14px] leading-[20px] md:text-[18px] md:leading-[130%] lg:text-left lg:text-[18px] lg:leading-[130%] text-[var(--color-content-default-tertiary)] xl:text-[24px] xl:leading-[32px]" ? splitFromMd
? "font-inter font-normal text-[14px] leading-[20px] text-[var(--color-content-default-tertiary)] md:text-left md:text-[18px] md:leading-[130%] xl:text-[24px] xl:leading-[32px]"
: "font-inter font-normal text-[14px] leading-[20px] md:text-[18px] md:leading-[130%] lg:text-left lg:text-[18px] lg:leading-[130%] text-[var(--color-content-default-tertiary)] xl:text-[24px] xl:leading-[32px]"
: "font-inter font-normal text-[14px] leading-[20px] md:text-[18px] md:leading-[130%] xl:text-[24px] xl:leading-[32px] text-[var(--color-content-default-tertiary)] lg:text-right" : "font-inter font-normal text-[14px] leading-[20px] md:text-[18px] md:leading-[130%] xl:text-[24px] xl:leading-[32px] text-[var(--color-content-default-tertiary)] lg:text-right"
: "font-inter font-normal text-[18px] leading-[130%] sm:text-[18px] sm:leading-[32px] lg:text-[24px] lg:leading-[32px] xl:text-[32px] xl:leading-[40px] xl:text-right text-[#484848] sm:text-[var(--color-content-default-tertiary)] lg:text-[var(--color-content-default-tertiary)] xl:text-[var(--color-content-default-tertiary)] tracking-[0px]" : "font-inter font-normal text-[18px] leading-[130%] sm:text-[18px] sm:leading-[32px] lg:text-[24px] lg:leading-[32px] xl:text-[32px] xl:leading-[40px] xl:text-right text-[#484848] sm:text-[var(--color-content-default-tertiary)] lg:text-[var(--color-content-default-tertiary)] xl:text-[var(--color-content-default-tertiary)] tracking-[0px]"
} }
@@ -6,7 +6,7 @@ import type { TripleTextBlockProps } from "./TripleTextBlock.types";
/** /**
* Figma: "Type / TripleTextBlock" — use cases **`lg` 22037-26994**, **`xl` 22085-860414**; * Figma: "Type / TripleTextBlock" — use cases **`lg` 22037-26994**, **`xl` 22085-860414**;
* **`md` 22085-862437**; stacked 22137:890676; lg 22128:888715; xl 22135:889705 (default). * baseline **22112-871529**; **`md` 22085-862437**; stacked 22137:890676; lg 22128:888715; xl 22135:889705 (default).
*/ */
const TripleTextBlockContainer = memo<TripleTextBlockProps>((props) => { const TripleTextBlockContainer = memo<TripleTextBlockProps>((props) => {
const headingId = useId(); const headingId = useId();
@@ -14,19 +14,19 @@ function columnUsesLargeBreakpointCopy(column: TripleTextBlockColumn): boolean {
function TripleTextUseCasesColumn({ column }: { column: TripleTextBlockColumn }) { function TripleTextUseCasesColumn({ column }: { column: TripleTextBlockColumn }) {
return ( return (
<div className="flex w-full flex-col gap-[var(--spacing-scale-020)] lg:gap-0 xl:gap-[var(--spacing-scale-020)]"> <article className="flex w-full flex-col gap-[var(--spacing-scale-006)] md:gap-[var(--spacing-scale-008)] lg:gap-[var(--spacing-scale-004)] xl:gap-[var(--spacing-scale-008)]">
<div className="flex flex-col gap-[var(--spacing-scale-008)] lg:gap-[var(--spacing-scale-004)] xl:gap-[var(--spacing-scale-008)]"> <h3 className="text-left font-bricolage-grotesque text-[24px] font-medium leading-8 text-[var(--color-content-default-primary,white)] md:text-[32px] md:leading-[1.1] lg:text-[18px] lg:leading-[var(--spacing-scale-022)] xl:text-[32px] xl:leading-[1.1]">
<h3 className="text-left font-bricolage-grotesque text-[24px] font-medium leading-8 text-[var(--color-content-default-primary,white)] md:text-[32px] md:leading-[1.1] lg:text-[18px] lg:leading-[var(--spacing-scale-022)] xl:text-[32px] xl:leading-[1.1]"> {column.title}
{column.title} </h3>
</h3> <div className="flex flex-col font-inter text-[16px] font-normal leading-6 text-[var(--color-content-default-secondary)] md:text-[24px] md:leading-8 lg:text-[14px] lg:leading-5 xl:text-[24px] xl:leading-8">
<div className="flex flex-col gap-[var(--spacing-scale-024)] font-inter text-[16px] font-normal leading-6 text-[var(--color-content-default-secondary)] md:text-[24px] md:leading-8 lg:gap-[var(--spacing-scale-020)] lg:text-[14px] lg:leading-5 xl:gap-[var(--spacing-scale-032)] xl:text-[24px] xl:leading-8"> <p>{column.description}</p>
<p>{column.description}</p> {column.descriptionSecondary ? (
{column.descriptionSecondary ? ( <p className="mt-[var(--spacing-scale-024)] md:mt-[var(--spacing-scale-032)] lg:mt-0 lg:pt-[var(--spacing-scale-020)] xl:mt-[var(--spacing-scale-032)]">
<p>{column.descriptionSecondary}</p> {column.descriptionSecondary}
) : null} </p>
</div> ) : null}
</div> </div>
</div> </article>
); );
} }
@@ -82,7 +82,7 @@ function TripleTextBlockColumnLockup({
* Section horizontal padding adds **+ Scale/096** below `xl` (outer frame inset); **use cases `xl`** uses **Scale/160** only ([22085:860414](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22085-860414&m=dev)). * Section horizontal padding adds **+ Scale/096** below `xl` (outer frame inset); **use cases `xl`** uses **Scale/160** only ([22085:860414](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22085-860414&m=dev)).
* *
* Figma: use cases **`lg`** [22037:26994](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22037-26994&m=dev); * Figma: use cases **`lg`** [22037:26994](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22037-26994&m=dev);
* **`md`** [22085:862437](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22085-862437&m=dev); stacked **22137:890676**; * baseline **22112:871529** / **22085:860366**; **`md`** [22085:862437](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22085-862437&m=dev); stacked **22137:890676**;
* lg 3-col **22128:888715**; xl **22135:889705** (default preset). * lg 3-col **22128:888715**; xl **22135:889705** (default preset).
*/ */
function TripleTextBlockView({ function TripleTextBlockView({
@@ -102,11 +102,11 @@ function TripleTextBlockView({
<section <section
{...(isUseCases ? { "data-figma-node": "22085-860414" } : {})} {...(isUseCases ? { "data-figma-node": "22085-860414" } : {})}
aria-labelledby={hasSectionTitle ? headingId : undefined} aria-labelledby={hasSectionTitle ? headingId : undefined}
className={`bg-black px-[calc(var(--spacing-scale-032)+var(--spacing-scale-096))] py-[var(--spacing-scale-064)] md:px-[calc(var(--spacing-scale-096)+var(--spacing-scale-096))] md:py-[var(--spacing-scale-064)] lg:px-[calc(var(--spacing-scale-096)+var(--spacing-scale-096))] lg:py-[var(--spacing-scale-064)] ${ className={`bg-black py-[var(--spacing-scale-064)] xl:py-[var(--spacing-scale-064)] ${
isUseCases isUseCases
? "xl:px-[var(--spacing-scale-160)]" ? "px-[var(--spacing-scale-032)] md:px-[var(--spacing-scale-096)] lg:px-[calc(var(--spacing-scale-096)+var(--spacing-scale-096))] xl:px-[var(--spacing-scale-160)]"
: "xl:px-[calc(var(--spacing-scale-160)+var(--spacing-scale-096))]" : "px-[calc(var(--spacing-scale-032)+var(--spacing-scale-096))] md:px-[calc(var(--spacing-scale-096)+var(--spacing-scale-096))] lg:px-[calc(var(--spacing-scale-096)+var(--spacing-scale-096))] xl:px-[calc(var(--spacing-scale-160)+var(--spacing-scale-096))]"
} xl:py-[var(--spacing-scale-064)] ${className}`.trim()} } ${className}`.trim()}
> >
<div <div
className={ className={
+38
View File
@@ -609,6 +609,44 @@ export async function duplicatePublishedRule(
} }
} }
export async function duplicateUseCaseTemplate(
slug: string,
): Promise<DuplicateRuleResult> {
try {
const res = await fetch(
`/api/use-cases/${encodeURIComponent(slug)}/duplicate`,
{
method: "POST",
credentials: "include",
},
);
const data = (await safeParseJsonResponse(res)) as {
rule?: { id: string; title: string };
} | null;
const rule = data && typeof data === "object" ? data.rule : undefined;
if (!res.ok || !rule) {
const fromBody =
data && typeof data === "object" ? readApiErrorMessage(data) : null;
const msg =
fromBody && fromBody !== "Request failed"
? fromBody
: PUBLISH_FAILED_FALLBACK;
return {
ok: false as const,
error: msg,
status: res.status,
};
}
return { ok: true, id: rule.id, title: rule.title };
} catch {
return {
ok: false as const,
error: DRAFT_SAVE_NETWORK_ERROR,
status: 0,
};
}
}
export type DeleteAccountResult = { ok: true } | { ok: false; error: string }; export type DeleteAccountResult = { ok: true } | { ok: false; error: string };
/** /**
+9 -1
View File
@@ -352,5 +352,13 @@ export function parseDocumentSectionsForDisplay(
if (!document || typeof document !== "object") return []; if (!document || typeof document !== "object") return [];
const sections = (document as Record<string, unknown>).sections; const sections = (document as Record<string, unknown>).sections;
if (!Array.isArray(sections)) return []; if (!Array.isArray(sections)) return [];
return sections.filter(isDocumentSection); return sections
.filter(isDocumentSection)
.map((section) => ({
...section,
entries: section.entries.map((entry) => ({
...entry,
body: typeof entry.body === "string" ? entry.body : "",
})),
}));
} }
+9 -1
View File
@@ -19,11 +19,19 @@ export function isDocumentEntry(x: unknown): x is CommunityRuleEntry {
if (typeof o.title !== "string" || o.title.trim().length === 0) { if (typeof o.title !== "string" || o.title.trim().length === 0) {
return false; return false;
} }
if (typeof o.body !== "string") return false;
if (o.blocks !== undefined) { if (o.blocks !== undefined) {
if (!Array.isArray(o.blocks) || !o.blocks.every(isLabeledBlock)) { if (!Array.isArray(o.blocks) || !o.blocks.every(isLabeledBlock)) {
return false; return false;
} }
} }
const blocks = Array.isArray(o.blocks) ? o.blocks : [];
const hasBlocks = blocks.length > 0;
if (hasBlocks) {
if (o.body !== undefined && typeof o.body !== "string") {
return false;
}
return true;
}
if (typeof o.body !== "string") return false;
return true; return true;
} }
+21
View File
@@ -170,6 +170,27 @@ export function coreValuePresetFor(chipId: string): CoreValueDetailEntry {
}; };
} }
/**
* Preset chip id for a core value label (`"1"` … `"n"` from
* `CoreValuesSelectScreen`), or `null` when the label is bespoke.
*/
export function resolveCoreValueChipIdFromLabel(label: string): string | null {
const t = label.trim();
if (!t) return null;
const values = (coreValuesMessages as { values?: unknown }).values;
if (!Array.isArray(values)) return null;
for (let i = 0; i < values.length; i++) {
const row = values[i];
if (typeof row === "string" && row.trim() === t) return String(i + 1);
if (!row || typeof row !== "object") continue;
const o = row as Record<string, unknown>;
if (typeof o.label === "string" && o.label.trim() === t) {
return String(i + 1);
}
}
return null;
}
/** Match `coreValues.json` row by trimmed label (custom chip id / drift fallbacks). */ /** Match `coreValues.json` row by trimmed label (custom chip id / drift fallbacks). */
export function coreValuePresetForLabel(label: string): CoreValueDetailEntry { export function coreValuePresetForLabel(label: string): CoreValueDetailEntry {
const t = label.trim(); const t = label.trim();
@@ -0,0 +1,331 @@
import type { CommunityRuleSection } from "../../app/components/type/CommunityRule/CommunityRule.types";
import type { CommunityRuleLabeledBlock } from "../../app/components/type/CommunityRule/CommunityRule.types";
import type { PublishedMethodSelections } from "./buildPublishPayload";
import { parseDocumentSectionsForDisplay } from "./buildPublishPayload";
import { resolveMethodPresetIdFromLabel } from "./buildFinalReviewCategories";
import { resolveCoreValueChipIdFromLabel } from "./finalReviewChipPresets";
import {
communicationPresetFor,
conflictManagementPresetFor,
decisionApproachPresetFor,
membershipPresetFor,
mergeCoreValueDetailWithPresets,
} from "./finalReviewChipPresets";
import { templateCategoryToGroupKey } from "./templateReviewMapping";
import type { TemplateFacetGroupKey } from "./templateReviewMapping";
import { RULE_SECTION_CATEGORY } from "./ruleSectionsFromMethodSelections";
const COMM_LABELS: Record<string, string> = {
corePrinciple: "Core Principle & Scope",
logisticsAdmin: "Logistics, Admin & Norms",
codeOfConduct: "Code of Conduct",
};
const MEM_LABELS: Record<string, string> = {
eligibility: "Eligibility & Philosophy",
joiningProcess: "Joining Process",
expectations: "Expectations & Removal",
};
const DEC_LABELS: Record<string, string> = {
corePrinciple: "Core Principle",
applicableScope: "Applicable Scope",
stepByStepInstructions: "Step-by-Step Instructions",
consensusLevel: "Consensus Level",
objectionsDeadlocks: "Objections & Deadlocks",
};
const CM_LABELS: Record<string, string> = {
corePrinciple: "Core Principle",
applicableScope: "Applicable Scope",
processProtocol: "Process Protocol",
restorationFallbacks: "Restoration & Fallbacks",
};
const LABELS_BY_GROUP: Record<
Exclude<TemplateFacetGroupKey, "coreValues">,
Record<string, string>
> = {
communication: COMM_LABELS,
membership: MEM_LABELS,
decisionApproaches: DEC_LABELS,
conflictManagement: CM_LABELS,
};
function slugifyId(label: string): string {
const base = label
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
return base.length > 0 ? base : "custom-method";
}
function keyForLabel(
label: string,
labelByKey: Record<string, string>,
): string | null {
const trimmed = label.trim();
for (const [key, displayLabel] of Object.entries(labelByKey)) {
if (displayLabel === trimmed) return key;
}
return null;
}
function parseConsensusPercent(body: string): number | null {
const m = body.trim().match(/^(\d+)\s*%?$/);
if (!m) return null;
const n = Number(m[1]);
return Number.isFinite(n) ? n : null;
}
function sectionsRecordFromBlocks(
blocks: CommunityRuleLabeledBlock[],
labelByKey: Record<string, string>,
options?: { consensusLevelKey?: string },
): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const block of blocks) {
const key = keyForLabel(block.label, labelByKey);
if (!key) continue;
const body = block.body.trim();
if (options?.consensusLevelKey === key) {
const pct = parseConsensusPercent(body);
if (pct !== null) out[key] = pct;
continue;
}
if (key === "applicableScope" || key === "selectedApplicableScope") {
const parts = body
.split(",")
.map((s) => s.trim())
.filter((s) => s.length > 0);
if (parts.length > 0) {
out.selectedApplicableScope = parts;
out.applicableScope = parts;
}
continue;
}
if (body.length > 0) out[key] = body;
}
return out;
}
function presetForMethod(
groupKey: Exclude<TemplateFacetGroupKey, "coreValues">,
id: string,
): Record<string, unknown> {
switch (groupKey) {
case "communication":
return { ...communicationPresetFor(id) } as Record<string, unknown>;
case "membership":
return { ...membershipPresetFor(id) } as Record<string, unknown>;
case "decisionApproaches":
return { ...decisionApproachPresetFor(id) } as Record<string, unknown>;
case "conflictManagement":
return { ...conflictManagementPresetFor(id) } as Record<string, unknown>;
}
}
function sectionsRecordFromEntry(
entry: CommunityRuleSection["entries"][number],
groupKey: Exclude<TemplateFacetGroupKey, "coreValues">,
presetId: string,
): Record<string, unknown> {
const labelByKey = LABELS_BY_GROUP[groupKey];
const consensusKey =
groupKey === "decisionApproaches" ? "consensusLevel" : undefined;
if (entry.blocks && entry.blocks.length > 0) {
const fromBlocks = sectionsRecordFromBlocks(entry.blocks, labelByKey, {
consensusLevelKey: consensusKey,
});
if (Object.keys(fromBlocks).length > 0) {
return { ...presetForMethod(groupKey, presetId), ...fromBlocks };
}
}
const body = (entry.body ?? "").trim();
if (body.length === 0) {
return presetForMethod(groupKey, presetId);
}
return { ...presetForMethod(groupKey, presetId), corePrinciple: body };
}
function coreValuesFromValuesSection(
section: CommunityRuleSection,
): Array<{ chipId: string; label: string; meaning: string; signals: string }> {
const out: Array<{
chipId: string;
label: string;
meaning: string;
signals: string;
}> = [];
for (const entry of section.entries) {
const label = entry.title.trim();
if (!label) continue;
const body = (entry.body ?? "").trim();
const parts = body.length > 0 ? body.split(/\n\n+/) : [];
const meaning = (parts[0] ?? "").trim();
const signals = parts.slice(1).join("\n\n").trim();
const merged = mergeCoreValueDetailWithPresets("", label, {
meaning,
signals,
});
const chipId =
resolveCoreValueChipIdFromLabel(label) ??
`hydrated-${label.toLowerCase()}`;
out.push({
chipId,
label,
meaning: merged.meaning,
signals: merged.signals,
});
}
return out;
}
type PublishedMethodRow = {
id: string;
label: string;
sections: Record<string, unknown>;
};
function methodSelectionsFromDisplaySections(
sections: CommunityRuleSection[],
): PublishedMethodSelections {
const out: PublishedMethodSelections = {};
const pushGroup = (
key: keyof PublishedMethodSelections,
groupKey: Exclude<TemplateFacetGroupKey, "coreValues">,
section: CommunityRuleSection,
) => {
const rows: PublishedMethodRow[] = [];
for (const entry of section.entries) {
const label = entry.title.trim();
if (!label) continue;
const id =
resolveMethodPresetIdFromLabel(label, groupKey) ??
`custom-${slugifyId(label)}`;
rows.push({
id,
label,
sections: sectionsRecordFromEntry(entry, groupKey, id),
});
}
if (rows.length > 0) {
switch (key) {
case "communication":
out.communication = rows as NonNullable<
PublishedMethodSelections["communication"]
>;
break;
case "membership":
out.membership = rows as NonNullable<
PublishedMethodSelections["membership"]
>;
break;
case "decisionApproaches":
out.decisionApproaches = rows as NonNullable<
PublishedMethodSelections["decisionApproaches"]
>;
break;
case "conflictManagement":
out.conflictManagement = rows as NonNullable<
PublishedMethodSelections["conflictManagement"]
>;
break;
default:
break;
}
}
};
for (const section of sections) {
const groupKey = templateCategoryToGroupKey(section.categoryName);
if (!groupKey || groupKey === "coreValues") continue;
switch (groupKey) {
case "communication":
pushGroup("communication", groupKey, section);
break;
case "membership":
pushGroup("membership", groupKey, section);
break;
case "decisionApproaches":
pushGroup("decisionApproaches", groupKey, section);
break;
case "conflictManagement":
pushGroup("conflictManagement", groupKey, section);
break;
default:
break;
}
}
return out;
}
function hasMethodSelections(ms: PublishedMethodSelections): boolean {
return Boolean(
ms.communication?.length ||
ms.membership?.length ||
ms.decisionApproaches?.length ||
ms.conflictManagement?.length,
);
}
/**
* Ensures a stored published `document` includes `methodSelections` and
* `coreValues` derived from display `sections` when missing (e.g. use-case
* template duplicates). Idempotent when the document is already normalized.
*/
export function normalizePublishedDocumentForEdit(
document: unknown,
): Record<string, unknown> {
if (!document || typeof document !== "object" || Array.isArray(document)) {
return {};
}
const doc = { ...(document as Record<string, unknown>) };
const sections = parseDocumentSectionsForDisplay(doc);
const existingMs = doc.methodSelections;
const hasMs =
existingMs &&
typeof existingMs === "object" &&
!Array.isArray(existingMs) &&
hasMethodSelections(existingMs as PublishedMethodSelections);
const existingCv = doc.coreValues;
const hasCv = Array.isArray(existingCv) && existingCv.length > 0;
if (!hasCv) {
const valuesSection = sections.find(
(s) =>
s.categoryName === RULE_SECTION_CATEGORY.values ||
templateCategoryToGroupKey(s.categoryName) === "coreValues",
);
if (valuesSection) {
const coreValues = coreValuesFromValuesSection(valuesSection);
if (coreValues.length > 0) {
doc.coreValues = coreValues;
}
}
}
if (!hasMs && sections.length > 0) {
const methodSelections = methodSelectionsFromDisplaySections(sections);
if (hasMethodSelections(methodSelections)) {
doc.methodSelections = methodSelections;
}
}
if (!Array.isArray(doc.sections) || doc.sections.length === 0) {
doc.sections = sections;
}
return doc;
}
@@ -8,6 +8,7 @@ import {
} from "./customRuleFacets"; } from "./customRuleFacets";
import type { PublishedMethodSelections } from "./buildPublishPayload"; import type { PublishedMethodSelections } from "./buildPublishPayload";
import type { StoredLastPublishedRule } from "./lastPublishedRule"; import type { StoredLastPublishedRule } from "./lastPublishedRule";
import { normalizePublishedDocumentForEdit } from "./normalizePublishedDocumentForEdit";
import { methodLabelFor } from "./finalReviewChipPresets"; import { methodLabelFor } from "./finalReviewChipPresets";
import type { TemplateFacetGroupKey } from "./templateReviewMapping"; import type { TemplateFacetGroupKey } from "./templateReviewMapping";
@@ -120,7 +121,9 @@ export function methodSectionsPinsFromPublishedHydratePatch(
export function createFlowStateFromPublishedRule( export function createFlowStateFromPublishedRule(
rule: StoredLastPublishedRule, rule: StoredLastPublishedRule,
): Partial<CreateFlowState> { ): Partial<CreateFlowState> {
const doc = rule.document; const doc = normalizePublishedDocumentForEdit(
rule.document,
) as StoredLastPublishedRule["document"];
const out: Partial<CreateFlowState> = { const out: Partial<CreateFlowState> = {
title: rule.title, title: rule.title,
editingPublishedRuleId: rule.id, editingPublishedRuleId: rule.id,
+15
View File
@@ -0,0 +1,15 @@
/**
* Routes that render product chrome only (`CreateFlowTopNav`), not marketing `Top`.
* Keep in sync with `ConditionalNavigationClient`.
*/
export function isChromelessNavigationPath(
pathname: string | null | undefined,
): boolean {
if (!pathname) {
return false;
}
if (pathname.startsWith("/create") || pathname === "/login") {
return true;
}
return /^\/use-cases\/[^/]+\/rule\/?$/.test(pathname);
}
+52
View File
@@ -0,0 +1,52 @@
import type { CommunityRuleSection } from "../app/components/type/CommunityRule/CommunityRule.types";
import { parsePublishedDocumentForCommunityRuleDisplay } from "./create/publishedDocumentToDisplaySections";
import type useCasesCompletedRules from "../messages/en/pages/useCasesCompletedRules.json";
import {
isUseCaseDetailSlug,
useCaseContentKeyForSlug,
type UseCaseDetailSlug,
} from "./useCaseSyntheticPost";
export type UseCasesCompletedRulesMessages = typeof useCasesCompletedRules;
export type UseCaseCompletedRuleFixture =
UseCasesCompletedRulesMessages[keyof UseCasesCompletedRulesMessages];
export function getUseCaseCompletedRuleFixture(
slug: UseCaseDetailSlug,
completedRules: UseCasesCompletedRulesMessages,
): UseCaseCompletedRuleFixture {
const contentKey = useCaseContentKeyForSlug(slug);
return completedRules[contentKey];
}
export function buildUseCaseCompletedRuleSections(
fixture: UseCaseCompletedRuleFixture,
): CommunityRuleSection[] {
return parsePublishedDocumentForCommunityRuleDisplay(fixture.document);
}
export function resolveUseCaseCompletedRule(
slug: string,
completedRules: UseCasesCompletedRulesMessages,
):
| {
slug: UseCaseDetailSlug;
fixture: UseCaseCompletedRuleFixture;
sections: CommunityRuleSection[];
}
| null {
if (!isUseCaseDetailSlug(slug)) {
return null;
}
const fixture = getUseCaseCompletedRuleFixture(slug, completedRules);
const sections = buildUseCaseCompletedRuleSections(fixture);
if (sections.length === 0) {
return null;
}
return {
slug,
fixture,
sections,
};
}
+7
View File
@@ -0,0 +1,7 @@
/** Title for a rule duplicated from a use-case completed demo (profile list). */
export function useCaseTemplateDuplicateTitle(sourceTitle: string): string {
const trimmed = sourceTitle.trim();
return trimmed.length > 0
? `${trimmed} Template (Copy)`
: "Community Rule Template (Copy)";
}
@@ -15,6 +15,11 @@
"emptyHint": "Add a community description", "emptyHint": "Add a community description",
"ariaEditDescription": "Edit community description" "ariaEditDescription": "Edit community description"
}, },
"titleEditModal": {
"title": "Community name",
"description": "Update the name shown on your public CommunityRule.",
"ariaEditTitle": "Edit community name"
},
"categories": [ "categories": [
{ {
"name": "Values", "name": "Values",
+2
View File
@@ -11,6 +11,8 @@
"exportAriaLabel": "Export", "exportAriaLabel": "Export",
"editAriaLabel": "Edit", "editAriaLabel": "Edit",
"manageStakeholdersAriaLabel": "Manage Stakeholders", "manageStakeholdersAriaLabel": "Manage Stakeholders",
"moreOptionsAriaLabel": "More options",
"actionsMenuAriaLabel": "Create flow actions",
"leaveConfirmLoss": "Leave create flow? Your progress will be lost.", "leaveConfirmLoss": "Leave create flow? Your progress will be lost.",
"draftSaveBannerTitle": "Couldn't save draft", "draftSaveBannerTitle": "Couldn't save draft",
"postLoginSaveFailedWithReason": "Could not save your draft to your account. Your progress is still stored on this device.\n\n{reason}" "postLoginSaveFailedWithReason": "Could not save your draft to your account. Your progress is still stored on this device.\n\n{reason}"
+4
View File
@@ -18,6 +18,8 @@ import learn from "./pages/learn.json";
import about from "./pages/about.json"; import about from "./pages/about.json";
import useCases from "./pages/useCases.json"; import useCases from "./pages/useCases.json";
import useCasesDetail from "./pages/useCasesDetail.json"; import useCasesDetail from "./pages/useCasesDetail.json";
import useCasesCompletedRules from "./pages/useCasesCompletedRules.json";
import useCasesCompletedRule from "./pages/useCasesCompletedRule.json";
import howItWorks from "./pages/howItWorks.json"; import howItWorks from "./pages/howItWorks.json";
import monitor from "./pages/monitor.json"; import monitor from "./pages/monitor.json";
import login from "./pages/login.json"; import login from "./pages/login.json";
@@ -83,6 +85,8 @@ export default {
about, about,
useCases, useCases,
useCasesDetail, useCasesDetail,
useCasesCompletedRules,
useCasesCompletedRule,
howItWorks, howItWorks,
monitor, monitor,
login, login,
+17
View File
@@ -37,6 +37,23 @@
"keywords": ["street medics", "use case", "community governance", "operating manual"] "keywords": ["street medics", "use case", "community governance", "operating manual"]
} }
}, },
"useCasesCompletedRule": {
"mutualAidColorado": {
"title": "Mutual Aid Colorado community rule — CommunityRule",
"description": "Read the completed community rule Mutual Aid Colorado built with CommunityRule.",
"keywords": ["mutual aid", "community rule", "operating manual", "use case"]
},
"foodNotBombs": {
"title": "Food Not Bombs Boulder community rule — CommunityRule",
"description": "Read the completed community rule Food Not Bombs Boulder built with CommunityRule.",
"keywords": ["food not bombs", "community rule", "operating manual", "use case"]
},
"boulderCountyStreetMedics": {
"title": "Boulder County Street Medics community rule — CommunityRule",
"description": "Read the completed community rule Boulder County Street Medics built with CommunityRule.",
"keywords": ["street medics", "community rule", "operating manual", "use case"]
}
},
"howItWorks": { "howItWorks": {
"title": "A Guide to CommunityRule — CommunityRule", "title": "A Guide to CommunityRule — CommunityRule",
"description": "CommunityRule is a modular governance toolkit designed to help democratic groups build, customize, and publish their own Operating Manual.", "description": "CommunityRule is a modular governance toolkit designed to help democratic groups build, customize, and publish their own Operating Manual.",
@@ -0,0 +1,15 @@
{
"topNav": {
"duplicate": "Duplicate",
"return": "Return",
"duplicateAriaLabel": "Save a copy of this community rule to your profile",
"duplicateFailedTitle": "Could not duplicate",
"duplicateNotFoundDescription": "This use case template is no longer available.",
"returnAriaLabel": "Return to use case",
"shareLinkCopiedTitle": "Link copied",
"shareLinkCopiedDescription": "The link to this community rule is on your clipboard.",
"shareCopyFailedTitle": "Could not copy link",
"shareCopyFailedDescription": "Copy the URL from your browser address bar instead."
},
"ruleCardLinkAriaLabel": "View {title} community rule"
}
@@ -0,0 +1,644 @@
{
"mutualAidColorado": {
"pageBackground": "var(--color-surface-invert-brand-lavender)",
"title": "Mutual Aid Colorado",
"summary": "Mutual Aid Colorado is a statewide network that empowers frontline community efforts by connecting independent mutual aid groups and building shared logistical infrastructure like supply chains and print shops.",
"document": {
"sections": [
{
"categoryName": "Values",
"entries": [
{
"title": "Mutual Aid, Not Charity",
"body": "We act in political solidarity rather than charity. We are building systems of community care and resilience, recognizing that our liberation is bound together.\n\n“Change needs all of us.”\n\nFood Not Bombs is not a charity. It is a project of solidarity. Charity is vertical. It moves from those who have to those who have not and maintains the hierarchy between them. Solidarity is horizontal. It moves between equals who recognize that our liberation is bound together. We do not help the poor. We share resources among community members because access to food is a human right rather than a privilege of wealth."
},
{
"title": "Local Autonomy",
"body": "We fiercely protect the autonomy of individual local chapters. We exist to support and connect local work, not to dictate it, ensuring we do not transform into the very top-down bureaucracies we aim to subvert.\n\n“Everyone contributes, no one controls.”\n\nWe operate without bosses or managers. This does not mean we are disorganized. It means we are self-organized. Authority in this chapter is temporary and task-specific rather than permanent or personal. We believe the people doing the work should make the decisions about that work. By distributing responsibility we prevent burnout and ensure the movement survives beyond any single leader."
},
{
"title": "Action Over Bureaucracy",
"body": "We prioritize moving physical goods and supporting local groups efficiently over unnecessary administration. We use our explicit agreements to prevent operational bottlenecks and keep the work moving.\n\n“Relationships over algorithms.”\n\nWe use digital tools to coordinate but we build power in the physical world. An algorithm cannot cook a meal and a group chat cannot look someone in the eye. We prioritize face-to-face interaction and physical presence at distributions. We resist the temptation to let digital metrics replace tangible impact."
},
{
"title": "Durable Commons",
"body": "We explicitly document our resource sharing protocols and logistical agreements. This ensures that knowledge regarding supply routes and shared assets is accessible to any participating group, functioning as a true commons rather than being hoarded by a few central organizers.\n\n“Rescued food doesn't add carbon.”\n\nOur logistics are an ecological intervention. Food waste is a major driver of climate change. By intercepting food that would otherwise be discarded and redirecting it to hungry neighbors we close the loop. We view food recovery as stewarding a resource that the industrial food system has abandoned. Every pound of food we rescue is a pound of carbon kept out of the atmosphere."
},
{
"title": "Decentralized Power",
"body": "We are committed to building massive material power without centralizing authority. We engineer our structures to distribute responsibility and prevent the consolidation of control within the network.\n\n“Collective giving builds collective power.”\n\nOur budget is labor rather than capital. We rely on the time and care of our volunteers rather than large grants or corporate sponsorships. This independence gives us the political freedom to operate according to our values. When we pool our small individual capacities we create a collective power that money cannot buy."
}
]
},
{
"categoryName": "Membership",
"entries": [
{
"title": "Membership Agreement or Pledge",
"blocks": [
{
"label": "Eligibility & Philosophy",
"body": "We are a small, experimental group building civic infrastructure to connect mutual aid groups across Colorado. Access to shared physical infrastructure (like the print shop and supply vehicles) comes with the responsibility of stewardship."
},
{
"label": "Joining Process",
"body": "Participating groups and core organizers gain access by agreeing to follow the modular vocabulary of agreements we have designed."
},
{
"label": "Expectations & Removal",
"body": "Members are expected to actively steward and maintain these shared assets. This ensures that the equipment remains in good working order for the next autonomous group that needs it."
}
]
},
{
"title": "Contribution Based",
"blocks": [
{
"label": "Eligibility & Philosophy",
"body": "Roles are tiered between Volunteers and Core Members. Core Membership is not a status symbol, it grants access privileges (keys, door codes, independent pickups, internal Signal chats) required to facilitate logistics."
},
{
"label": "Joining Process",
"body": "Local groups and regional hubs become part of the network by actively participating in the supply chains, contributing labor to the print shops, or sharing their logistical resources."
},
{
"label": "Expectations & Removal",
"body": "Membership is inherently tied to active contribution. Maintaining standing requires continuous participation in the statewide infrastructure and shared operations."
}
]
}
]
},
{
"categoryName": "Decision-Making",
"entries": [
{
"title": "Consensus Decision-Making",
"blocks": [
{
"label": "Core Principle",
"body": "We make the big decisions together. For strategic changes, the core organizing team takes the time to have deep conversations and build genuine agreement. This ensures our infrastructure is guided by our collective values, rather than just being driven by whoever speaks the loudest."
},
{
"label": "Applicable Scope",
"body": "Strategic decisions, shared network values, and organization-wide policy changes."
},
{
"label": "Consensus Level",
"body": "100%"
},
{
"label": "Step-by-Step Instructions",
"body": "Core members openly discuss strategic issues and collaboratively shape proposals. Once a proposal is drafted, it is presented to the full core organizing team. We deliberate, address concerns, and refine the proposal together until every core member can consent to its adoption."
},
{
"label": "Objections & Deadlocks",
"body": "If a core member objects to a proposal, the group must make a good faith effort to resolve their concerns and modify the plan. However, we do not let the perfect be the enemy of the good. If multiple good faith attempts to find consensus fail, and a decision must be made to keep the infrastructure functioning, we trigger a fallback mechanism: the proposal can be passed with a 75% supermajority vote of the core organizing team."
}
]
},
{
"title": "Delegated Do-ocracy",
"blocks": [
{
"label": "Core Principle",
"body": "To manage a statewide logistics operation without bottlenecks, authority must be distributed. By formalizing structural relationships and empowering the volunteers running the shared infrastructure, we ensure the network can move quickly and resiliently."
},
{
"label": "Applicable Scope",
"body": "Statewide logistics, specialized operational domains, and day-to-day physical infrastructure (e.g., operating the print shop, driving supply routes, managing the warehouse floor)."
},
{
"label": "Step-by-Step Instructions",
"body": "We explicitly map our arenas of operation so specialized working groups have full authority over their specific domains. Within these domains, individual volunteers are empowered to make immediate operational choices (like organizing inventory or adjusting a supply route) to keep goods moving efficiently, without waiting for a statewide assembly's approval."
},
{
"label": "Objections & Deadlocks",
"body": "Authority has limits based on impact. If an individual's logistical experiment is irreversible, highly costly, or could permanently alter the shared infrastructure, they must pause and seek guidance from the relevant Working Group. If a Working Group's decision will have a material, network-wide impact or disrupt another team, they must coordinate with the affected groups or escalate to a broader coordination body."
}
]
}
]
},
{
"categoryName": "Conflict Management",
"entries": [
{
"title": "Peer Mediation",
"blocks": [
{
"label": "Core Principle",
"body": "Conflict and miscommunication are inevitable when coordinating logistics across a large region. The goal is to manage behavior and communication so the supply chain flows smoothly and infrastructure remains functional, without requiring everyone to be close friends."
},
{
"label": "Applicable Scope",
"body": "Day-to-day friction, miscommunications, or minor interpersonal disputes over operational tasks (e.g., scheduling conflicts, route overlaps, or minor workflow disagreements)."
},
{
"label": "Process Protocol",
"body": "We handle friction at the lowest possible level to prevent disrupting the supply chain.\n\nLevel 1: Direct Engagement between the organizers involved to clarify misunderstandings quickly.\n\nLevel 2: Supported Conversation with neutral witnesses acting purely to keep the conversation calm, clear, and focused on operational solutions."
},
{
"label": "Restoration & Fallbacks",
"body": "If the friction cannot be resolved at these lower levels and begins to threaten operational continuity, it escalates to Level 3 (Formal Facilitation by a designated Conflict Management Working Group)."
}
]
},
{
"title": "Conflict Resolution Council",
"blocks": [
{
"label": "Core Principle",
"body": "The Conflict Management Working Group acts as facilitators rather than judges. They prioritize the integrity of the shared infrastructure and the sustainable capacity of the volunteer base."
},
{
"label": "Applicable Scope",
"body": "Level 3 conflicts, severe organizer burnout, and the misuse of shared regional assets."
},
{
"label": "Process Protocol",
"body": "If a conflict reaches Level 3, the working group investigates the behavioral facts and how they are impacting operations. They draft a boundary plan and present a factual, redacted summary to the rest of the core organizers for collective ratification.\n\nCapacity & Burnout Clause: Operating statewide infrastructure can lead to severe exhaustion. The working group can mandate a temporary leave of absence for a core organizer as a mechanism of care to address burnout, preventing them from dropping the ball on critical logistics or becoming overwhelmed."
},
{
"label": "Restoration & Fallbacks",
"body": "Misuse of shared statewide assets for personal gain, hoarding supplies, or intentional sabotage of the supply chain bypasses the escalation ladder entirely. These severe breaches of trust trigger an Immediate Precautionary Suspension, temporarily restricting the organizer's access to warehouses, vehicles, and coordination channels while a formal investigation takes place."
}
]
}
]
},
{
"categoryName": "Communication",
"entries": [
{
"title": "Signal",
"blocks": [
{
"label": "Core Principle & Scope",
"body": "All communication for Mutual Aid Colorado happens on Signal. Protecting our network and local chapters is vital."
},
{
"label": "Logistics, Admin & Norms",
"body": "We maintain highly structured channels to keep our digital workspace organized. A designated Announcements channel serves as our persistent library for documenting explicit architecture, resource-sharing protocols, and agreements. Day-to-day management of regional supply chains (moving physical goods, coordinating print shop shifts, handling logistical emergencies) happens in specific, encrypted group chats or shared platforms."
},
{
"label": "Code of Conduct",
"body": "Tactical channels must be kept absolutely clear of policy debates or social chatter. This ensures drivers and logistics volunteers can quickly access the real-time information they need to keep people safe and supplied."
}
]
}
]
}
]
}
},
"foodNotBombs": {
"pageBackground": "var(--color-surface-invert-secondary)",
"title": "Food Not Bombs Boulder",
"summary": "Food Not Bombs Boulder is a mutual aid collective that recovers surplus food to share free, public meals with the community, protesting war and poverty while advocating for food as a fundamental human right.",
"document": {
"sections": [
{
"categoryName": "Values",
"entries": [
{
"title": "Solidarity Forever",
"body": "“Change needs all of us.”\n\nFood Not Bombs is not a charity. It is a project of solidarity. Charity is vertical. It moves from those who have to those who have not and maintains the hierarchy between them. Solidarity is horizontal. It moves between equals who recognize that our liberation is bound together. We do not help the poor. We share resources among community members because access to food is a human right rather than a privilege of wealth."
},
{
"title": "Shared Leadership",
"body": "We operate without bosses or managers. This does not mean we are disorganized. It means we are self-organized. Authority in this chapter is temporary and task-specific rather than permanent or personal. We believe the people doing the work should make the decisions about that work. By distributing responsibility we prevent burnout and ensure the movement survives beyond any single leader.\n\n“Everyone contributes, no one controls.”\n\nWe operate without bosses or managers. This does not mean we are disorganized. It means we are self-organized. Authority in this chapter is temporary and task-specific rather than permanent or personal. We believe the people doing the work should make the decisions about that work. By distributing responsibility we prevent burnout and ensure the movement survives beyond any single leader."
},
{
"title": "Organizing Offline",
"body": "We use digital tools to coordinate but we build power in the physical world. An algorithm cannot cook a meal and a group chat cannot look someone in the eye. We prioritize face-to-face interaction and physical presence at distributions. We resist the temptation to let digital metrics replace tangible impact.\n\n“Relationships over algorithms.”\n\nWe use digital tools to coordinate but we build power in the physical world. An algorithm cannot cook a meal and a group chat cannot look someone in the eye. We prioritize face-to-face interaction and physical presence at distributions. We resist the temptation to let digital metrics replace tangible impact."
},
{
"title": "Circular Food Systems",
"body": "Our logistics are an ecological intervention. Food waste is a major driver of climate change. By intercepting food that would otherwise be discarded and redirecting it to hungry neighbors we close the loop. We view food recovery as stewarding a resource that the industrial food system has abandoned. Every pound of food we rescue is a pound of carbon kept out of the atmosphere.\n\n“Rescued food doesn't add carbon.”\n\nOur logistics are an ecological intervention. Food waste is a major driver of climate change. By intercepting food that would otherwise be discarded and redirecting it to hungry neighbors we close the loop. We view food recovery as stewarding a resource that the industrial food system has abandoned. Every pound of food we rescue is a pound of carbon kept out of the atmosphere."
},
{
"title": "Powered by People",
"body": "“Collective giving builds collective power.”\n\nOur budget is labor rather than capital. We rely on the time and care of our volunteers rather than large grants or corporate sponsorships. This independence gives us the political freedom to operate according to our values. When we pool our small individual capacities we create a collective power that money cannot buy."
}
]
},
{
"categoryName": "Membership",
"entries": [
{
"title": "Consensus or Vote-Based Approval",
"blocks": [
{
"label": "Eligibility & Philosophy",
"body": "Access to critical resources is restricted to safeguard the project. Safety and accountability are prioritized."
},
{
"label": "Joining Process",
"body": "Volunteers who have completed two full Saturday distributions can submit a request via the Stewardship & Process Working Group form. The request is subject to a 15-day lazy consensus period. If an existing Core Member raises a concern, the volunteer must address it. If the objection stands, the volunteer can reapply after 15 days."
},
{
"label": "Expectations & Removal",
"body": "Any single Core Member may immediately ask a participant to leave for physical safety threats. For non-emergent issues:\n\nVolunteers: Exclusion requires agreement from at least five Core Members or a ratified conflict management plan.\n\nCore Members: Revoking membership requires a 3/4 supermajority vote of the full Core Membership, excluding the member in question."
}
]
},
{
"title": "Weighted or Tiered Membership",
"blocks": [
{
"label": "Eligibility & Philosophy",
"body": "Roles are tiered between Volunteers and Core Members. Core Membership is not a status symbol, it grants access privileges (keys, door codes, independent pickups, internal Signal chats) required to facilitate logistics."
},
{
"label": "Joining Process",
"body": "Volunteers advance to Core Membership through sustained contribution. Once a Core Member, individuals can sign up to \"bottom-line\" (take responsibility for) shifts via the roll call channel in the week leading up to a distribution."
},
{
"label": "Expectations & Removal",
"body": "Labor Requirements: Core Members must complete at least one shift, pickup, or two hours of labor (distribution, cleaning, admin, or working group participation) every month to maintain standing.\n\nShift Coverage & Bottom-Lining: Ensuring coverage is a collective responsibility; members must proactively monitor the schedule for gaps. Bottom-liners (experienced Core Members) must ensure all operational checklists are completed.\n\nHand-off Protocol: The last Core Member on-site must not leave until a new bottom-liner is confirmed or all checklist items are complete. If leaving early, they must ping the chat immediately to request coverage.\n\nLoss of Standing: The Stewardship & Process Working Group posts potentially inactive members to the chat. If an individual's activity is not verified, they revert to volunteer status and must restart the entry process."
}
]
}
]
},
{
"categoryName": "Decision-Making",
"entries": [
{
"title": "Lazy Consensus",
"blocks": [
{
"label": "Core Principle",
"body": "Consensus is assumed if no specific concerns are raised within a set timeframe."
},
{
"label": "Applicable Scope",
"body": "Decisions that cannot be easily reverted, Amendments to the governance manuial"
},
{
"label": "Consensus Level",
"body": "100%"
},
{
"label": "Step-by-Step Instructions",
"body": "Any Core Member can propose an amendment or global decision by posting it to the Core Member chat. If no concern is raised within 15 days, it is adopted. To protect shared time, facilitators strictly enforce this model during meetings, redirecting complex policy debates to async written deliberation or authorized ad-hoc breakout meetings."
},
{
"label": "Objections & Deadlocks",
"body": "If objections occur, the group must make a good faith effort to resolve them. If attempts fail and the decision is critical, the Stewardship & Process Working Group must certify that efforts are exhausted and organize a vote. A 3/4 supermajority of the full Core Membership is required to override the objection and pass the decision."
}
]
},
{
"title": "Do-ocracy",
"blocks": [
{
"label": "Core Principle",
"body": "Distribute authority and operate on trust to move efficiently. Decisions are made by those doing the work or by specific empowered groups. It is better to try a solution and adjust later than to do nothing."
},
{
"label": "Applicable Scope",
"body": "Reversible Implementation Details, Low-Risk Experiments"
},
{
"label": "Consensus Level",
"body": "0%"
},
{
"label": "Step-by-Step Instructions",
"body": "Core Members and Bottom-liners possess a broad mandate to act and can immediately try new approaches to solve problems. Simultaneously, Working Groups execute binding decisions within their domains independently, without seeking chapter-wide consent."
},
{
"label": "Objections & Deadlocks",
"body": "If an individual's experiment is permanent, costly, or hard to undo, they must seek advice first. Working Groups must seek consent from the full Core Membership if a decision has a material impact on the broader group (e.g., unbudgeted Finance expenses over $500, or actions that fundamentally change chapter operations or public reputation)."
}
]
}
]
},
{
"categoryName": "Conflict Management",
"entries": [
{
"title": "Peer Mediation",
"blocks": [
{
"label": "Core Principle",
"body": "Manage behavior so the project functions safely, establishing boundaries rather than forcing friendship. Handle conflicts at the lowest possible level to preserve capacity for serious issues."
},
{
"label": "Applicable Scope",
"body": "Lower-level disputes, initial conflicts."
},
{
"label": "Process Protocol",
"body": "Level 1 requires Direct Engagement to communicate clearly without triangulation. Level 2 utilizes Supported Conversations where each person brings a sympathetic third-party witness to help them remain calm and clear, not to argue or advocate."
},
{
"label": "Restoration & Fallbacks",
"body": "If direct engagement feels unsafe, or if third-party witnesses begin to argue, the meeting is considered a failure and the process escalates immediately to Formal Facilitation."
}
]
},
{
"title": "Judicial Committees",
"blocks": [
{
"label": "Core Principle",
"body": "The Conflict Management Working Group acts as facilitators and investigators, not judges. They do not unilaterally punish, but propose solutions for the broader group to ratify."
},
{
"label": "Applicable Scope",
"body": "Serious violations, conflicts that fail lower-level resolution."
},
{
"label": "Process Protocol",
"body": "The Working Group interviews parties to establish a behavioral timeline, focusing on facts rather than blame. They draft a Conflict Management Plan outlining specific consequences or boundaries. A dry, factual Redacted Summary, explicitly stating any disputed facts—is presented to the Core Membership for ratification under a standard 15-day lazy consensus."
},
{
"label": "Restoration & Fallbacks",
"body": "The Working Group can issue an immediate Precautionary Suspension (restricting access to keys/chats) if safety is a concern. If a plan faces unresolved objections or recommends removing a Core Member, ratification requires a 3/4 supermajority vote of the full Core Membership."
}
]
}
]
},
{
"categoryName": "Communication",
"entries": [
{
"title": "Signal",
"blocks": [
{
"label": "Core Principle & Scope",
"body": "Signal is the primary platform for operational communication. Group chats are strictly for logistics, sensitive coordination, and governance. Social conversations and community building should happen elsewhere."
},
{
"label": "Logistics, Admin & Norms",
"body": "Structure: Official channels are prepended with the 🥗 emoji. Public Channels are for active volunteers; Core Channels are restricted to Core Members.\n\nAdministration: All Core Members are admins (strictly a technical role). All channels use a standard 4-week disappearing message timer.\n\nAccess: The Stewardship & Process Working Group manages rosters, removing inactive volunteers with a \"soft touch\" message so they know they are welcome back.\n\nNorms: Use emoji reactions for acknowledgments to prevent notification spam. Only join channels where you actively contribute; do not lurk."
},
{
"label": "Code of Conduct",
"body": "The In-person Code of Conduct applies equally to all off-platform and synchronous interactions. Always assume good intentions and give others the benefit of the doubt before reacting."
}
]
},
{
"title": "In-Person Meetings",
"blocks": [
{
"label": "Core Principle & Scope",
"body": "Synchronous mediums are the dedicated spaces for social connection, community building, and navigating emotionally nuanced conversations."
},
{
"label": "Logistics, Admin & Norms",
"body": "Members are strongly encouraged to meet up outside of official operations to build friendship. If a text chat in Signal becomes heated or controversial, members must immediately transition the conversation to a synchronous medium (phone, video chat, or in-person meeting)."
},
{
"label": "Code of Conduct",
"body": "We don't have strict rules but we aspire to operate within these principles:\n\n1. We don't need to see eye to eye on everything, but the core point of unity that ties this group together is the belief that the world can be improved by both individual and collective action.\n\n2. Willfully spreading obviously false or misleading information will not be tolerated.\n\n3. We have zero tolerance for racism, sexism, and bigotry. We explicitly reject all identity-based hierarchies. No group is singled out for priority, special classification, or unique levels of responsibility. We do not create segregated spaces based on race, gender, or sexuality.\n\n4. Aspire to do no harm, especially to members of this community. We distinguish actual harm from intellectual discomfort. Vigorous disagreement is encouraged and does not constitute harm. We face difficult topics directly and minimize the use of content warnings."
}
]
}
]
}
]
}
},
"boulderCountyStreetMedics": {
"pageBackground": "var(--color-surface-invert-brand-red)",
"title": "BoCo Street Medics",
"summary": "Boulder County Street Medics is a grassroots, volunteer-run organization focused on providing first aid and medical support to marginalized communities and activists in the Boulder area.",
"document": {
"sections": [
{
"categoryName": "Values",
"entries": [
{
"title": "Radical Solidarity",
"body": "We provide care as an act of political solidarity rather than charity. We are not apolitical. Our presence is a commitment to community liberation.\n\nWe provide care as an act of political solidarity rather than charity. We are not apolitical. Our presence is a commitment to community liberation."
},
{
"title": "Integrity of Skill",
"body": "Good intentions do not stop bleeding. We only provide care we are trained to provide. Operating outside our scope is dangerous.\n\nGood intentions do not stop bleeding. We only provide care we are trained to provide. Operating outside our scope is dangerous."
},
{
"title": "Caring for Caregivers",
"body": "You cannot pour from an empty flush. We reject the martyr complex. Rest is mandatory rather than a weakness.\n\nYou cannot pour from an empty flush. We reject the martyr complex. Rest is mandatory rather than a weakness."
},
{
"title": "Consent and Autonomy",
"body": "We prioritize bodily autonomy. We secure consent before touching anyone.\n\nWe prioritize bodily autonomy. We secure consent before touching anyone."
},
{
"title": "Inter-Organizational Solidarity",
"body": "We coordinate with organizers but retain ultimate tactical control. We are healers rather than security guards.\n\nWe coordinate with organizers but retain ultimate tactical control. We are healers rather than security guards."
}
]
},
{
"categoryName": "Membership",
"entries": [
{
"title": "Tiered Membership",
"blocks": [
{
"label": "Eligibility & Philosophy",
"body": "Access is restricted to ensure safety; trust is verified rather than assumed. Roles are tiered based on age and experience. Field operations and gear cache access (which are strictly for logistical work, not social hubs) are limited to individuals 18 or older. Minors are restricted to off-site logistics. Access is a responsibility to steward resources, not a status symbol."
},
{
"label": "Joining Process",
"body": "New volunteers enter the pipeline as Provisional Members for a skill verification phase. Once proven, they can advance to Core Members, who steward the collective. Experienced Core Members can act as Bottom-Liners, serving as the tactical safety anchors who ensure buddy pairs, proper PPE, and comms."
},
{
"label": "Expectations & Removal",
"body": "Provisional Members: Never deploy alone. They must be tethered to a Core Member, operate from collective trainee bags, and do not vote or hold keys.\n\nBottom-Liners & Safety: The Bottom-Liner has absolute authority to pull a team out of danger and will immediately remove anyone under the influence of drugs or alcohol (zero-tolerance policy).\n\nRoster Maintenance: The Membership Working Group monitors activity to prevent burnout and ghosting. They manage sabbaticals, send check-in messages to inactive members, and facilitate graceful offboarding (retrieving keys/gear) if there is no response."
}
]
},
{
"title": "Skill-Based Evaluation",
"blocks": [
{
"label": "Eligibility & Philosophy",
"body": "Roles are tiered between Volunteers and Core Members. Core Membership is not a status symbol, it grants access privileges (keys, door codes, independent pickups, internal Signal chats) required to facilitate logistics."
},
{
"label": "Joining Process",
"body": "To move from Provisional to Core status, a volunteer must pass three strict skill-verification prerequisites:\n\nField Experience: Complete 5 field operations shadowed by a Core Member.\n\nSkill Verification: Gain approval from existing Core Members confirming tactical and medical competency.\n\nProof of Certification: Present a valid medical credential (e.g., 20-hour Street Medic course certificate, WFA, EMT, Paramedic, or Nursing license)."
},
{
"label": "Expectations & Removal",
"body": "You may only deploy if your skills are current and practiced. If your certification or skills have lapsed, you must refresh them before deploying again or temporarily transition to non-deployment roles."
}
]
}
]
},
{
"categoryName": "Decision-Making",
"entries": [
{
"title": "Lazy Consensus",
"blocks": [
{
"label": "Core Principle",
"body": "Move slowly on policy to build deep agreement. This prevents burnout and ensures decisions are not made only by the loudest voices."
},
{
"label": "Applicable Scope",
"body": "Strategic decisions (peacetime manual amendments, onboarding, broad policy changes). Standard Operating Procedures (packing lists, radio frequencies) live in a separate \"Living Archive\" and are exempt, updating instantly."
},
{
"label": "Consensus Level",
"body": "100%"
},
{
"label": "Step-by-Step Instructions",
"body": "Standard policy proposals are subjected to the 15-day model for review and adoption."
},
{
"label": "Objections & Deadlocks",
"body": "Emergency policies are strictly temporary. If they are not ratified through this standard 15-day process within two weeks, they automatically expire."
}
]
},
{
"title": "Supermajority Rule",
"blocks": [
{
"label": "Core Principle",
"body": "Move fast on tactics and crises to keep people safe, without allowing emergency powers to become permanent policy."
},
{
"label": "Applicable Scope",
"body": "Urgent crises that cannot wait 15 days (e.g., legal threats, lease losses)."
},
{
"label": "Consensus Level",
"body": "75%"
},
{
"label": "Step-by-Step Instructions",
"body": "Any Core Member can trigger an Emergency Vote. Voting takes place within a 24-hour window."
},
{
"label": "Objections & Deadlocks",
"body": "To prevent abuse, any decision passed via Emergency Vote must be formally ratified by the standard 15-day lazy consensus process within two weeks, or it is automatically voided."
}
]
},
{
"title": "Delegated Decision-Making",
"blocks": [
{
"label": "Core Principle",
"body": "Comply on the street, debrief in the living room. Rely on specialized groups for daily operations and an Incident Command System (ICS) for tactical safety. We explicitly reject funding that compromises political autonomy."
},
{
"label": "Applicable Scope",
"body": "Tactical field operations and daily operations (e.g., Finance Working Group managing routine purchasing via ethical, grassroots crowdfunding)."
},
{
"label": "Step-by-Step Instructions",
"body": "The Bottom-Liner acts as Incident Commander during deployments. Specialized Working Groups independently handle daily operations. Every action requires a mandated, rigorous debrief so the collective can learn and adapt."
},
{
"label": "Objections & Deadlocks",
"body": "If the Bottom-Liner loses the confidence of the entire deployment team, they must immediately designate a new leader on the spot to safely evacuate."
}
]
}
]
},
{
"categoryName": "Conflict Management",
"entries": [
{
"title": "Peer Mediation",
"blocks": [
{
"label": "Core Principle",
"body": "Conflict is inevitable in high-stress environments. The goal is to manage behavior to keep the project functional rather than forcing friendship."
},
{
"label": "Applicable Scope",
"body": "Initial interpersonal friction, handled at the lowest level possible."
},
{
"label": "Process Protocol",
"body": "Level 1: Direct Engagement.\n\nLevel 2: Supported Conversation with neutral witnesses."
},
{
"label": "Restoration & Fallbacks",
"body": "If friction cannot be managed at lower levels, it escalates to Level 3 (Formal Facilitation)."
}
]
},
{
"title": "Conflict Resolution Council",
"blocks": [
{
"label": "Core Principle",
"body": "The Working Group acts as facilitators and a mechanism of care rather than judges, relying on collective ratification for boundary plans while maintaining strict zero-tolerance for severe clinical or ethical violations."
},
{
"label": "Applicable Scope",
"body": "Level 3 Formal Facilitation, burnout/trauma concerns, formal external reviews (community complaints against medics), and clinical malpractice."
},
{
"label": "Process Protocol",
"body": "The group investigates facts and drafts a boundary plan. This group can mandate a leave of absence to address secondary trauma and burnout. They also handle external reviews and grievances. Once drafted, a dry, factual Redacted Summary of the boundary plan is presented to the broader Core Membership for ratification through consensus."
},
{
"label": "Restoration & Fallbacks",
"body": "Operating outside of scope, practicing without a license, or violating patient privacy completely bypasses the standard escalation ladder. These severe violations trigger an Immediate Precautionary Suspension rather than standard mediation."
}
]
}
]
},
{
"categoryName": "Communication",
"entries": [
{
"title": "Signal",
"blocks": [
{
"label": "Core Principle & Scope",
"body": "Digital security is physical safety. We operate in a legally precarious environment; these protocols exist to protect our patients and ourselves from surveillance and criminalization."
},
{
"label": "Logistics, Admin & Norms",
"body": "Official channels use the 🩹 emoji. Deployments utilize a dedicated Off-Site Dispatcher who holds legal names and triggers jail support protocols if a medic misses a check-in."
},
{
"label": "Code of Conduct",
"body": "Patient Health Information is never broadcast unencrypted. Field photography is strictly forbidden."
}
]
},
{
"title": "In-Person Meetings",
"blocks": [
{
"label": "Core Principle & Scope",
"body": "Prioritize patient safety and maintain absolute operational security during active deployments."
},
{
"label": "Logistics, Admin & Norms",
"body": "Radio & Chatter: Radio operation requires proper training and licensing (inform the Bottom-Liner immediately if you cannot confidently use issued gear). Tactical channels must remain absolutely silent during deployments except for vital updates, as clutter hides calls for help.\n\nState EMS: The Bottom-Liner manages or delegates dispatch communication when activating state EMS to minimize police interception risks (unless it is a time-sensitive emergency)."
},
{
"label": "Code of Conduct",
"body": "Media: Strict No Comment policy in the field. Medics treat patients rather than give soundbites.\n\nLaw Enforcement: We do not consent to warrantless searches and we do not volunteer patient identities to police."
}
]
}
]
}
]
}
}
}
+4 -4
View File
@@ -41,10 +41,10 @@
"title": "Food Not Bombs Boulder", "title": "Food Not Bombs Boulder",
"description": "Food Not Bombs Boulder is a mutual aid collective that recovers surplus food to share free, public meals with the community, protesting war and poverty while advocating for food as a fundamental human right.", "description": "Food Not Bombs Boulder is a mutual aid collective that recovers surplus food to share free, public meals with the community, protesting war and poverty while advocating for food as a fundamental human right.",
"backgroundColor": "bg-[var(--color-surface-invert-secondary)]", "backgroundColor": "bg-[var(--color-surface-invert-secondary)]",
"iconPath": "assets/use-cases/case-study-food-not-bombs.png" "iconPath": "assets/case-study/case-study-food-not-bombs.svg"
}, },
"leadingImage": { "leadingImage": {
"src": "assets/use-cases/case-study-food-not-bombs.png", "src": "assets/case-study/case-study-food-not-bombs.svg",
"alt": "Food Not Bombs logo" "alt": "Food Not Bombs logo"
}, },
"bodyMarkdown": "Food Not Bombs operates on a fundamentally decentralized model. However it is a well documented phenomenon that organizations relying entirely on informal networks often develop unspoken power dynamics. When rules remain unstated the individuals with the most experience or social capital tend to direct operations by default. The Boulder chapter utilized CommunityRule to address this structural vulnerability. The platform offers a modular approach to organizational design that allows groups to select and adapt established governance patterns. By applying this toolkit the chapter successfully translated their implicit cultural norms into an explicit operating manual without adopting a rigid corporate hierarchy.\n\nA central advantage of this approach is the ability to construct a polycentric governance system. Rather than relying on a single central committee the organization distributes authority across multiple overlapping domains. CommunityRule helped the Boulder chapter map out these distinct operational needs by defining different speeds of decision making. For example broad constitutional amendments require a slow lazy consensus period to ensure comprehensive agreement. In contrast daily logistical tasks are delegated to autonomous working groups like the Finance team. This structural differentiation allows the chapter to maintain democratic participation while efficiently managing the routine demands of food recovery and distribution.\n\nFinally formalizing these operational agreements provides a critical foundation for organizational continuity. Grassroots initiatives frequently experience high participant turnover which can lead to a rapid loss of operational knowledge. By using CommunityRule to document their financial protocols and conflict escalation pathways in a legible format the chapter created a highly resilient shared resource. This explicit documentation functions as a stabilizing technology that outlasts the tenure of any individual founder or core member. It offers a practical template for how horizontal organizations can balance their ideological commitments to shared leadership with the practical necessity of maintaining a reliable infrastructure over time.", "bodyMarkdown": "Food Not Bombs operates on a fundamentally decentralized model. However it is a well documented phenomenon that organizations relying entirely on informal networks often develop unspoken power dynamics. When rules remain unstated the individuals with the most experience or social capital tend to direct operations by default. The Boulder chapter utilized CommunityRule to address this structural vulnerability. The platform offers a modular approach to organizational design that allows groups to select and adapt established governance patterns. By applying this toolkit the chapter successfully translated their implicit cultural norms into an explicit operating manual without adopting a rigid corporate hierarchy.\n\nA central advantage of this approach is the ability to construct a polycentric governance system. Rather than relying on a single central committee the organization distributes authority across multiple overlapping domains. CommunityRule helped the Boulder chapter map out these distinct operational needs by defining different speeds of decision making. For example broad constitutional amendments require a slow lazy consensus period to ensure comprehensive agreement. In contrast daily logistical tasks are delegated to autonomous working groups like the Finance team. This structural differentiation allows the chapter to maintain democratic participation while efficiently managing the routine demands of food recovery and distribution.\n\nFinally formalizing these operational agreements provides a critical foundation for organizational continuity. Grassroots initiatives frequently experience high participant turnover which can lead to a rapid loss of operational knowledge. By using CommunityRule to document their financial protocols and conflict escalation pathways in a legible format the chapter created a highly resilient shared resource. This explicit documentation functions as a stabilizing technology that outlasts the tenure of any individual founder or core member. It offers a practical template for how horizontal organizations can balance their ideological commitments to shared leadership with the practical necessity of maintaining a reliable infrastructure over time.",
@@ -71,10 +71,10 @@
"title": "BoCo Street Medics", "title": "BoCo Street Medics",
"description": "Boulder County Street Medics is a grassroots, volunteer-run organization focused on providing first aid and medical support to marginalized communities and activists in the Boulder area.", "description": "Boulder County Street Medics is a grassroots, volunteer-run organization focused on providing first aid and medical support to marginalized communities and activists in the Boulder area.",
"backgroundColor": "bg-[var(--color-surface-invert-brand-red)]", "backgroundColor": "bg-[var(--color-surface-invert-brand-red)]",
"iconPath": "assets/use-cases/case-study-boulder-county-street-medics.png" "iconPath": "assets/case-study/case-study-boulder-county-street-medics.svg"
}, },
"leadingImage": { "leadingImage": {
"src": "assets/use-cases/case-study-boulder-county-street-medics.png", "src": "assets/case-study/case-study-boulder-county-street-medics.svg",
"alt": "Boulder County Street Medics logo" "alt": "Boulder County Street Medics logo"
}, },
"bodyMarkdown": "When communities like the BoCo Street Medics operate in high-stakes, legally precarious environments, the classic \"tyranny of structurelessness\" isn't just an academic critique. It is a massive operational risk. What these medics recognized is that relying on implicit norms and unspoken hierarchies quickly leads to burnout, fragmented trust, and compromised safety. This is exactly where a tool like CommunityRule becomes vital. By providing a legible, modular framework for democratic design, it allowed the collective to step back from the adrenaline of the streets and intentionally translate their core values of radical solidarity into explicit, accountable processes. They didn't have to reinvent the wheel of governance because they could select and adapt proven patterns to fit their unique reality.\n\nWhat CommunityRule facilitates so beautifully is the understanding that a single community can and often must operate at different speeds of democracy. The BoCo Street Medics utilized this modular approach to map out their distinct operational modes, making it crystal clear to every member when to rely on the slow, deeply participatory \"15-Day Lazy Consensus\" for strategic policy, and when to pivot to a rigid Incident Command System for tactical safety during a deployment. By charting these different pathways, the medics created a constitution that balances the deep, deliberate work of egalitarian community-building with the rapid, authoritative action required to keep people safe on the ground.\n\nUltimately, formalizing these processes through CommunityRule is an act of institutional care. It shifts the burden of conflict management, onboarding, and decision-making away from the exhausted shoulders of a few founders and distributes it into a resilient, shared architecture. By defining clear boundaries around membership access, escalation ladders for conflict, and even mandated leaves of absence, the BoCo Street Medics have built a culture of true stewardship. They've proven that radical, grassroots work doesn't have to be chaotic. When we design our governance with intention, we build a solidarity that can actually outlast us.", "bodyMarkdown": "When communities like the BoCo Street Medics operate in high-stakes, legally precarious environments, the classic \"tyranny of structurelessness\" isn't just an academic critique. It is a massive operational risk. What these medics recognized is that relying on implicit norms and unspoken hierarchies quickly leads to burnout, fragmented trust, and compromised safety. This is exactly where a tool like CommunityRule becomes vital. By providing a legible, modular framework for democratic design, it allowed the collective to step back from the adrenaline of the streets and intentionally translate their core values of radical solidarity into explicit, accountable processes. They didn't have to reinvent the wheel of governance because they could select and adapt proven patterns to fit their unique reality.\n\nWhat CommunityRule facilitates so beautifully is the understanding that a single community can and often must operate at different speeds of democracy. The BoCo Street Medics utilized this modular approach to map out their distinct operational modes, making it crystal clear to every member when to rely on the slow, deeply participatory \"15-Day Lazy Consensus\" for strategic policy, and when to pivot to a rigid Incident Command System for tactical safety during a deployment. By charting these different pathways, the medics created a constitution that balances the deep, deliberate work of egalitarian community-building with the rapid, authoritative action required to keep people safe on the ground.\n\nUltimately, formalizing these processes through CommunityRule is an act of institutional care. It shifts the burden of conflict management, onboarding, and decision-making away from the exhausted shoulders of a few founders and distributes it into a resilient, shared architecture. By defining clear boundaries around membership access, escalation ladders for conflict, and even mandated leaves of absence, the BoCo Street Medics have built a culture of true stewardship. They've proven that radical, grassroots work doesn't have to be chaotic. When we design our governance with intention, we build a solidarity that can actually outlast us.",
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 27 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 94 KiB

+7 -1
View File
@@ -38,7 +38,13 @@ export default {
}, },
useCardStyle: { useCardStyle: {
control: "boolean", control: "boolean",
description: "When true, wraps the rule body in a white card with a teal bar", description:
"When true, wraps the rule body in a white card with a left accent bar",
},
cardAccentColor: {
control: "text",
description:
"Accent bar color when useCardStyle is true (match page background)",
}, },
}, },
}; };
+27 -3
View File
@@ -91,11 +91,35 @@ describe("AskOrganizer (behavioral tests)", () => {
variant="use-case-detail" variant="use-case-detail"
/>, />,
); );
expect( const section = container.querySelector('[data-figma-node="22015-42624"]');
container.querySelector('[data-figma-node="22015-42624"]'), expect(section).toBeInTheDocument();
).toBeInTheDocument(); expect(section).toHaveClass("py-[var(--spacing-scale-032)]");
expect(section).not.toHaveClass("py-[var(--spacing-scale-096)]");
expect( expect(
screen.getByRole("heading", { name: "Still have questions?" }), screen.getByRole("heading", { name: "Still have questions?" }),
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
it("centered variant uses baseline section padding per Figma 17487-12288", () => {
const { container } = render(
<AskOrganizer
title="Still have questions?"
subtitle="Get answers from an experienced organizer"
variant="centered"
/>,
);
const section = container.querySelector('[data-figma-node="18116-15960"]');
expect(section).toHaveClass(
"py-[var(--spacing-scale-040)]",
"px-[var(--spacing-scale-032)]",
);
expect(section).not.toHaveClass("py-[var(--spacing-scale-096)]");
});
it("does not force 358px min-width below md (fits 320px baseline)", () => {
const { container } = render(<AskOrganizer title="Test Title" />);
const inner = container.querySelector("section > div");
expect(inner).toHaveClass("min-w-0", "md:min-w-[358px]");
expect(inner?.className).not.toContain(" min-w-[358px]");
});
}); });
+17 -1
View File
@@ -1,4 +1,5 @@
import { describe } from "vitest"; import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import { import {
componentTestSuite, componentTestSuite,
type ComponentTestSuiteConfig, type ComponentTestSuiteConfig,
@@ -34,4 +35,19 @@ const config: ComponentTestSuiteConfig<Props> = {
describe("CommunityRule", () => { describe("CommunityRule", () => {
componentTestSuite<Props>(config); componentTestSuite<Props>(config);
it("uses cardAccentColor for the card left border when useCardStyle is true", () => {
const { container } = render(
<CommunityRule
sections={sampleSections}
useCardStyle
cardAccentColor="var(--color-surface-invert-brand-lavender)"
/>,
);
const root = container.firstElementChild as HTMLElement;
expect(root.style.boxShadow).toBe(
"inset 4px 0 0 0 var(--color-surface-invert-brand-lavender)",
);
expect(screen.getByText("How proposals pass")).toBeInTheDocument();
});
}); });
+33 -6
View File
@@ -62,6 +62,28 @@ describe("ContentBanner", () => {
expect(screen.getByText("Test description")).toBeInTheDocument(); expect(screen.getByText("Test description")).toBeInTheDocument();
}); });
it("renders useCase variant rule preview as link when href is set", () => {
const { container } = render(
<ContentBanner
post={mockPost}
variant="useCase"
rulePreview={{
title: "Sample Operating Manual",
description: "Governance preview for the case study.",
backgroundColor: "bg-[var(--color-surface-invert-brand-lavender)]",
iconPath: "assets/case-study/case-study-mutual-aid.svg",
href: "/use-cases/mutual-aid-colorado/rule",
}}
/>,
);
const link = screen.getByRole("link", {
name: /view sample operating manual community rule/i,
});
expect(link).toHaveAttribute("href", "/use-cases/mutual-aid-colorado/rule");
expect(container.querySelector(".pointer-events-none")).toBeNull();
});
it("renders useCase variant with ContentContainer copy and rule preview", () => { it("renders useCase variant with ContentContainer copy and rule preview", () => {
const { container } = render( const { container } = render(
<ContentBanner <ContentBanner
@@ -76,13 +98,18 @@ describe("ContentBanner", () => {
/>, />,
); );
expect( const title = screen.getByRole("heading", { name: "Test Article" });
screen.getByRole("heading", { name: "Test Article" }), expect(title).toBeInTheDocument();
).toBeInTheDocument(); expect(title).toHaveClass("sm:text-[24px]", "md:text-[32px]");
expect(screen.getByText("Sample Operating Manual")).toBeInTheDocument(); expect(screen.getByText("Sample Operating Manual")).toBeInTheDocument();
expect( const copyColumn = container.querySelector('[data-node-id="19189:9171"]');
container.querySelector('[data-figma-node="22015:42621"]'), expect(copyColumn).toHaveClass("lg:max-w-[365px]");
).toBeInTheDocument(); expect(copyColumn).not.toHaveClass("max-w-[365px]");
const bannerRow = container.querySelector(
'[data-figma-node="22015:42621"]',
);
expect(bannerRow).toBeInTheDocument();
expect(bannerRow).toHaveClass("lg:grid-cols-2");
}); });
it("renders guide variant with left-aligned copy and logo mark", () => { it("renders guide variant with left-aligned copy and logo mark", () => {
+129 -2
View File
@@ -1,6 +1,10 @@
import React from "react"; import React from "react";
import { describe, it, expect, vi } from "vitest"; import { afterEach, beforeEach, describe, it, expect, vi } from "vitest";
import { renderWithProviders as render, screen } from "../utils/test-utils"; import {
renderWithProviders as render,
screen,
waitFor,
} from "../utils/test-utils";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
import CreateFlowTopNav from "../../app/components/navigation/CreateFlowTopNav"; import CreateFlowTopNav from "../../app/components/navigation/CreateFlowTopNav";
@@ -150,6 +154,25 @@ describe("CreateFlowTopNav (behavioral tests)", () => {
expect(handler).toHaveBeenCalledTimes(1); expect(handler).toHaveBeenCalledTimes(1);
}); });
it("renders Duplicate button when hasDuplicate is true", () => {
render(
<CreateFlowTopNav
hasDuplicate={true}
duplicateLabel="Duplicate"
duplicateAriaLabel="Duplicate"
onDuplicate={vi.fn()}
/>,
);
expect(
screen.getByRole("button", { name: "Duplicate" }),
).toBeInTheDocument();
});
it("uses exitLabel override when provided", () => {
render(<CreateFlowTopNav exitLabel="Return" />);
expect(screen.getByRole("button", { name: "Return" })).toBeInTheDocument();
});
it("calls onExit when Exit button is clicked", async () => { it("calls onExit when Exit button is clicked", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const handleExit = vi.fn(); const handleExit = vi.fn();
@@ -161,3 +184,107 @@ describe("CreateFlowTopNav (behavioral tests)", () => {
expect(handleExit).toHaveBeenCalledTimes(1); expect(handleExit).toHaveBeenCalledTimes(1);
}); });
}); });
describe("CreateFlowTopNav (viewport < sm2 / 440px)", () => {
const defaultInnerWidth = 1200;
beforeEach(() => {
Object.defineProperty(window, "innerWidth", {
writable: true,
configurable: true,
value: 320,
});
});
afterEach(() => {
Object.defineProperty(window, "innerWidth", {
writable: true,
configurable: true,
value: defaultInnerWidth,
});
});
const completedScreenProps = {
hasShare: true,
hasExport: true,
hasEdit: true,
saveDraftOnExit: true,
onShare: vi.fn(),
onSelectExportFormat: vi.fn(),
onEdit: vi.fn(),
onExit: vi.fn(),
} as const;
it("collapses secondary actions into a kebab menu", async () => {
render(<CreateFlowTopNav {...completedScreenProps} />);
await waitFor(() => {
expect(
screen.getByRole("button", { name: "More options" }),
).toBeInTheDocument();
});
expect(
screen.queryByRole("button", { name: "Share" }),
).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "Export" }),
).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Edit" })).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "Save & Exit" }),
).not.toBeInTheDocument();
});
it("opens kebab menu with share, export formats, edit, and save & exit", async () => {
const user = userEvent.setup();
render(<CreateFlowTopNav {...completedScreenProps} />);
const kebab = await screen.findByRole("button", { name: "More options" });
await user.click(kebab);
expect(screen.getByRole("menuitem", { name: "Share" })).toBeInTheDocument();
expect(
screen.getByRole("menuitem", { name: "Download PDF" }),
).toBeInTheDocument();
expect(
screen.getByRole("menuitem", { name: "Download CSV" }),
).toBeInTheDocument();
expect(
screen.getByRole("menuitem", { name: "Download Markdown" }),
).toBeInTheDocument();
expect(screen.getByRole("menuitem", { name: "Edit" })).toBeInTheDocument();
expect(
screen.getByRole("menuitem", { name: "Save & Exit" }),
).toBeInTheDocument();
});
it("invokes handlers from kebab menu items", async () => {
const user = userEvent.setup();
const handleShare = vi.fn();
const handleEdit = vi.fn();
const handleExit = vi.fn();
render(
<CreateFlowTopNav
{...completedScreenProps}
onShare={handleShare}
onEdit={handleEdit}
onExit={handleExit}
/>,
);
const kebab = await screen.findByRole("button", { name: "More options" });
await user.click(kebab);
await user.click(screen.getByRole("menuitem", { name: "Share" }));
expect(handleShare).toHaveBeenCalledTimes(1);
await user.click(kebab);
await user.click(screen.getByRole("menuitem", { name: "Edit" }));
expect(handleEdit).toHaveBeenCalledTimes(1);
await user.click(kebab);
await user.click(screen.getByRole("menuitem", { name: "Save & Exit" }));
expect(handleExit).toHaveBeenCalledTimes(1);
});
});
+33 -2
View File
@@ -702,17 +702,48 @@ function FinalReviewEditPublishedWithStateProbe({
return <FinalReviewScreen variant="editPublished" />; return <FinalReviewScreen variant="editPublished" />;
} }
describe("FinalReviewScreen — edit published description", () => { describe("FinalReviewScreen — edit published title and description", () => {
it("does not expose click-to-edit description on default final review", () => { it("does not expose click-to-edit title or description on default final review", () => {
render( render(
<FinalReviewWithFlowState <FinalReviewWithFlowState
title="Oak" title="Oak"
communityContext="Visible body" communityContext="Visible body"
/>, />,
); );
expect(screen.queryByTestId("rule-title-edit")).not.toBeInTheDocument();
expect(screen.queryByTestId("rule-description-edit")).not.toBeInTheDocument(); expect(screen.queryByTestId("rule-description-edit")).not.toBeInTheDocument();
}); });
it("opens Save modal from title click and updates title", async () => {
let latest: CreateFlowState = {};
render(
<FinalReviewEditPublishedWithStateProbe
onState={(s) => {
latest = s;
}}
initial={{
title: "Oak Park Commons",
communityContext: "Original",
selectedCommunicationMethodIds: ["signal"],
}}
/>,
);
fireEvent.click(await screen.findByTestId("rule-title-edit"));
const dialog = await screen.findByRole("dialog");
expect(within(dialog).getByText(/Community name/i)).toBeInTheDocument();
const input = within(dialog).getByRole("textbox");
fireEvent.change(input, { target: { value: "Renamed Commons" } });
fireEvent.click(within(dialog).getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
await waitFor(() => {
expect(latest.title).toBe("Renamed Commons");
});
});
it("opens Save modal from description click and updates communityContext + summary", async () => { it("opens Save modal from description click and updates communityContext + summary", async () => {
let latest: CreateFlowState = {}; let latest: CreateFlowState = {};
render( render(
+21
View File
@@ -1,4 +1,6 @@
import React from "react"; import React from "react";
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import SectionHeader from "../../app/components/type/SectionHeader"; import SectionHeader from "../../app/components/type/SectionHeader";
import { componentTestSuite } from "../utils/componentTestSuite"; import { componentTestSuite } from "../utils/componentTestSuite";
@@ -21,3 +23,22 @@ componentTestSuite<SectionHeaderProps>({
errorState: false, errorState: false,
}, },
}); });
describe("SectionHeader twoColumnsFromMd", () => {
it("splits rule stack header at md when twoColumnsFromMd is set", () => {
const { container } = render(
<SectionHeader
title="Popular templates"
subtitle="Start from a proven pattern."
variant="multi-line"
ruleStackDesktopTypeScale
twoColumnsFromMd
/>,
);
expect(
screen.getByRole("heading", { name: /popular templates/i }),
).toBeInTheDocument();
expect(container.firstElementChild).toHaveClass("md:flex-row");
});
});
@@ -66,9 +66,10 @@ describe("TripleTextBlock", () => {
/>, />,
); );
expect( const section = container.querySelector('[data-figma-node="22085-860414"]');
container.querySelector('[data-figma-node="22085-860414"]'), expect(section).toBeTruthy();
).toBeTruthy(); expect(section).toHaveClass("px-[var(--spacing-scale-032)]");
expect(section).not.toHaveClass("px-[calc(var(--spacing-scale-032)+var(--spacing-scale-096))]");
expect( expect(
screen.getByRole("heading", { screen.getByRole("heading", {
@@ -84,6 +85,9 @@ describe("TripleTextBlock", () => {
).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.getByText("First paragraph.")).toBeInTheDocument(); expect(screen.getByText("First paragraph.")).toBeInTheDocument();
expect(screen.getByText("Second paragraph.")).toBeInTheDocument(); expect(screen.getByText("Second paragraph.")).toBeInTheDocument();
expect(screen.getByText("Second paragraph.")).toHaveClass(
"mt-[var(--spacing-scale-024)]",
);
expect(screen.getByRole("link", { name: "Setup your community" })).toHaveAttribute( expect(screen.getByRole("link", { name: "Setup your community" })).toHaveAttribute(
"href", "href",
"/create", "/create",
@@ -0,0 +1,153 @@
import { describe, test, expect, vi } from "vitest";
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderWithProviders as render } from "../utils/test-utils";
import UseCaseCompletedRulePage from "../../app/(marketing-case-study)/use-cases/[slug]/rule/page";
import messages from "../../messages/en/index";
import { USE_CASE_DETAIL_SLUGS } from "../../lib/useCaseSyntheticPost";
const mockPush = vi.fn();
const mockOpenLogin = vi.fn();
const mockFetchAuthSession = vi.fn();
const mockDuplicateUseCaseTemplate = vi.fn();
vi.mock("next/navigation", () => ({
notFound: vi.fn(),
useRouter: () => ({ push: mockPush }),
usePathname: () => "/use-cases/food-not-bombs/rule",
}));
vi.mock("../../app/contexts/AuthModalContext", async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
useAuthModal: () => ({
openLogin: mockOpenLogin,
closeLogin: vi.fn(),
}),
};
});
vi.mock("../../lib/create/api", async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
fetchAuthSession: () => mockFetchAuthSession(),
duplicateUseCaseTemplate: (slug) => mockDuplicateUseCaseTemplate(slug),
};
});
vi.mock(
"../../app/(app)/create/hooks/useCreateFlowMdUp",
() => ({
useCreateFlowMdUp: () => true,
}),
);
describe("UseCaseCompletedRulePage", () => {
test.each(USE_CASE_DETAIL_SLUGS)(
"renders completed rule for %s",
async (slug) => {
const contentKey =
slug === "mutual-aid-colorado"
? "mutualAidColorado"
: slug === "food-not-bombs"
? "foodNotBombs"
: "boulderCountyStreetMedics";
const fixture = messages.pages.useCasesCompletedRules[contentKey];
render(
await UseCaseCompletedRulePage({
params: Promise.resolve({ slug }),
}),
);
expect(
screen.getByRole("heading", { name: fixture.title }),
).toBeInTheDocument();
if (slug === "mutual-aid-colorado") {
expect(
screen.getByText(/Food Not Bombs is not a charity/),
).toBeInTheDocument();
}
if (slug === "boulder-county-street-medics") {
expect(screen.getByText("Membership")).toBeInTheDocument();
expect(screen.getByText(/Tiered Membership/)).toBeInTheDocument();
}
expect(screen.getByText("Values")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /return/i }),
).toBeInTheDocument();
expect(
screen.getByRole("button", {
name: messages.pages.useCasesCompletedRule.topNav.duplicateAriaLabel,
}),
).toBeInTheDocument();
},
);
test("Duplicate opens login when signed out", async () => {
const user = userEvent.setup();
mockOpenLogin.mockClear();
mockFetchAuthSession.mockResolvedValue({ user: null });
render(
await UseCaseCompletedRulePage({
params: Promise.resolve({ slug: "food-not-bombs" }),
}),
);
await user.click(
screen.getByRole("button", {
name: messages.pages.useCasesCompletedRule.topNav.duplicateAriaLabel,
}),
);
expect(mockOpenLogin).toHaveBeenCalledWith(
expect.objectContaining({
nextPath: "/use-cases/food-not-bombs/rule",
}),
);
expect(mockDuplicateUseCaseTemplate).not.toHaveBeenCalled();
});
test("Duplicate saves to profile when signed in", async () => {
const user = userEvent.setup();
mockPush.mockClear();
mockFetchAuthSession.mockResolvedValue({
user: { id: "u1", email: "a@b.c" },
});
mockDuplicateUseCaseTemplate.mockResolvedValue({
ok: true,
id: "rule-copy",
title: "Food Not Bombs Boulder Template (Copy)",
});
render(
await UseCaseCompletedRulePage({
params: Promise.resolve({ slug: "food-not-bombs" }),
}),
);
await user.click(
screen.getByRole("button", {
name: messages.pages.useCasesCompletedRule.topNav.duplicateAriaLabel,
}),
);
expect(mockDuplicateUseCaseTemplate).toHaveBeenCalledWith("food-not-bombs");
expect(mockPush).toHaveBeenCalledWith("/profile");
});
test("Return navigates to use case detail", async () => {
const user = userEvent.setup();
mockPush.mockClear();
render(
await UseCaseCompletedRulePage({
params: Promise.resolve({ slug: "mutual-aid-colorado" }),
}),
);
await user.click(screen.getByRole("button", { name: /return/i }));
expect(mockPush).toHaveBeenCalledWith("/use-cases/mutual-aid-colorado");
});
});
+6
View File
@@ -18,6 +18,9 @@ vi.mock("../../app/components/sections/ContentBanner", () => ({
<> <>
<p>{rulePreview.title}</p> <p>{rulePreview.title}</p>
<p>{rulePreview.description}</p> <p>{rulePreview.description}</p>
{rulePreview.href ? (
<a href={rulePreview.href}>View community rule</a>
) : null}
</> </>
) : null} ) : null}
</section> </section>
@@ -61,6 +64,9 @@ describe("UseCaseDetailPage", () => {
screen.getByRole("heading", { name: entry.banner.title }), screen.getByRole("heading", { name: entry.banner.title }),
).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.getByText(entry.ruleCard.description)).toBeInTheDocument(); expect(screen.getByText(entry.ruleCard.description)).toBeInTheDocument();
expect(
screen.getByRole("link", { name: /view community rule/i }),
).toHaveAttribute("href", `/use-cases/${slug}/rule`);
const bodySnippet = const bodySnippet =
slug === "mutual-aid-colorado" slug === "mutual-aid-colorado"
+16
View File
@@ -106,6 +106,22 @@ describe("Rule Component", () => {
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
}); });
it("clicking editable title calls onTitleClick and does not fire card onClick", () => {
const onCard = vi.fn();
const onTitle = vi.fn();
render(
<Rule
{...defaultProps}
expanded={true}
onClick={onCard}
onTitleClick={onTitle}
/>,
);
fireEvent.click(screen.getByTestId("rule-title-edit"));
expect(onTitle).toHaveBeenCalledTimes(1);
expect(onCard).not.toHaveBeenCalled();
});
it("clicking editable description calls onDescriptionClick and does not fire card onClick", () => { it("clicking editable description calls onDescriptionClick and does not fire card onClick", () => {
const onCard = vi.fn(); const onCard = vi.fn();
const onDesc = vi.fn(); const onDesc = vi.fn();
+34
View File
@@ -314,6 +314,40 @@ describe("parseDocumentSectionsForDisplay", () => {
expect(parseDocumentSectionsForDisplay(doc)).toEqual(doc.sections); expect(parseDocumentSectionsForDisplay(doc)).toEqual(doc.sections);
}); });
it("accepts entries with labeled blocks and omits body in JSON (normalized to \"\")", () => {
const doc = {
sections: [
{
categoryName: "Membership",
entries: [
{
title: "Open membership",
blocks: [
{ label: "Eligibility", body: "Anyone may join." },
{ label: "Process", body: "Sign the sheet." },
],
},
],
},
],
};
expect(parseDocumentSectionsForDisplay(doc)).toEqual([
{
categoryName: "Membership",
entries: [
{
title: "Open membership",
body: "",
blocks: [
{ label: "Eligibility", body: "Anyone may join." },
{ label: "Process", body: "Sign the sheet." },
],
},
],
},
]);
});
it("accepts entries with labeled blocks and empty body", () => { it("accepts entries with labeled blocks and empty body", () => {
const doc = { const doc = {
sections: [ sections: [
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { isChromelessNavigationPath } from "../../../lib/navigationChromelessPath";
describe("isChromelessNavigationPath", () => {
it.each([
["/create", true],
["/create/completed", true],
["/login", true],
["/use-cases/mutual-aid-colorado/rule", true],
["/use-cases/food-not-bombs/rule/", true],
["/", false],
["/use-cases", false],
["/use-cases/mutual-aid-colorado", false],
["/use-cases/mutual-aid-colorado/rule/extra", false],
] as const)("returns %s -> %s", (pathname, expected) => {
expect(isChromelessNavigationPath(pathname)).toBe(expected);
});
it("returns false for null or undefined", () => {
expect(isChromelessNavigationPath(null)).toBe(false);
expect(isChromelessNavigationPath(undefined)).toBe(false);
});
});
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import useCasesCompletedRules from "../../messages/en/pages/useCasesCompletedRules.json";
import { createFlowStateFromPublishedRule } from "../../lib/create/publishedDocumentToCreateFlowState";
import { normalizePublishedDocumentForEdit } from "../../lib/create/normalizePublishedDocumentForEdit";
describe("normalizePublishedDocumentForEdit", () => {
it("derives methodSelections and coreValues from use-case display sections", () => {
const fixture = useCasesCompletedRules.mutualAidColorado;
const normalized = normalizePublishedDocumentForEdit(fixture.document);
expect(Array.isArray(normalized.coreValues)).toBe(true);
expect((normalized.coreValues as unknown[]).length).toBeGreaterThan(0);
const ms = normalized.methodSelections as Record<string, unknown>;
expect(Array.isArray(ms.membership)).toBe(true);
expect((ms.membership as unknown[]).length).toBeGreaterThan(0);
expect(Array.isArray(ms.decisionApproaches)).toBe(true);
expect((ms.decisionApproaches as unknown[]).length).toBeGreaterThan(0);
});
it("is idempotent when methodSelections already exist", () => {
const once = normalizePublishedDocumentForEdit(
useCasesCompletedRules.mutualAidColorado.document,
);
const twice = normalizePublishedDocumentForEdit(once);
expect(twice.methodSelections).toEqual(once.methodSelections);
expect(twice.coreValues).toEqual(once.coreValues);
});
});
describe("createFlowStateFromPublishedRule with section-only documents", () => {
it("hydrates method ids from normalized use-case duplicate shape", () => {
const doc = normalizePublishedDocumentForEdit(
useCasesCompletedRules.mutualAidColorado.document,
);
const patch = createFlowStateFromPublishedRule({
id: "rule-1",
title: "Mutual Aid Colorado Template (Copy)",
summary: "Summary",
document: doc as Record<string, unknown>,
});
expect(patch.selectedMembershipMethodIds?.length).toBeGreaterThan(0);
expect(patch.selectedDecisionApproachIds?.length).toBeGreaterThan(0);
expect(patch.selectedCoreValueIds?.length).toBeGreaterThan(0);
expect(patch.sections).toEqual([]);
});
});
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { useCaseTemplateDuplicateTitle } from "../../lib/useCaseTemplateDuplicate";
describe("useCaseTemplateDuplicateTitle", () => {
it("appends Template (Copy) to the source title", () => {
expect(useCaseTemplateDuplicateTitle("BoCo Street Medics")).toBe(
"BoCo Street Medics Template (Copy)",
);
});
it("falls back when the source title is empty", () => {
expect(useCaseTemplateDuplicateTitle(" ")).toBe(
"Community Rule Template (Copy)",
);
});
});
+98
View File
@@ -0,0 +1,98 @@
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
const isDatabaseConfiguredMock = vi.fn();
const createMock = vi.fn();
const getSessionUserMock = vi.fn();
vi.mock("../../lib/server/env", () => ({
isDatabaseConfigured: () => isDatabaseConfiguredMock(),
}));
vi.mock("../../lib/server/db", () => ({
prisma: {
publishedRule: {
create: (...args: unknown[]) => createMock(...args),
},
},
}));
vi.mock("../../lib/server/session", () => ({
getSessionUser: () => getSessionUserMock(),
}));
import { POST } from "../../app/api/use-cases/[slug]/duplicate/route";
function makeContext(slug: string) {
return { params: Promise.resolve({ slug }) };
}
beforeEach(() => {
isDatabaseConfiguredMock.mockReset();
createMock.mockReset();
getSessionUserMock.mockReset();
});
describe("POST /api/use-cases/[slug]/duplicate", () => {
it("returns 401 when not signed in", async () => {
isDatabaseConfiguredMock.mockReturnValue(true);
getSessionUserMock.mockResolvedValue(null);
const res = await POST(
new NextRequest("https://x.test/api/use-cases/food-not-bombs/duplicate"),
makeContext("food-not-bombs"),
);
expect(res.status).toBe(401);
});
it("returns 404 for an unknown slug", async () => {
isDatabaseConfiguredMock.mockReturnValue(true);
getSessionUserMock.mockResolvedValue({ id: "u1", email: "a@b.c" });
const res = await POST(
new NextRequest("https://x.test/api/use-cases/unknown/duplicate"),
makeContext("unknown"),
);
expect(res.status).toBe(404);
expect(createMock).not.toHaveBeenCalled();
});
it("creates a published rule from the use-case fixture", async () => {
isDatabaseConfiguredMock.mockReturnValue(true);
getSessionUserMock.mockResolvedValue({ id: "u1", email: "a@b.c" });
createMock.mockResolvedValueOnce({
id: "r-new",
title: "Food Not Bombs Boulder Template (Copy)",
summary: "Summary",
createdAt: new Date("2026-01-01T00:00:00Z"),
updatedAt: new Date("2026-01-01T00:00:00Z"),
});
const res = await POST(
new NextRequest(
"https://x.test/api/use-cases/food-not-bombs/duplicate",
),
makeContext("food-not-bombs"),
);
expect(res.status).toBe(200);
const body = (await res.json()) as { rule: { id: string; title: string } };
expect(body.rule.id).toBe("r-new");
expect(createMock).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
userId: "u1",
title: "Food Not Bombs Boulder Template (Copy)",
document: expect.objectContaining({
methodSelections: expect.objectContaining({
membership: expect.arrayContaining([
expect.objectContaining({ id: expect.any(String), label: expect.any(String) }),
]),
}),
coreValues: expect.arrayContaining([
expect.objectContaining({ chipId: expect.any(String), label: expect.any(String) }),
]),
}),
}),
}),
);
});
});