Compare commits

..
10 Commits
Author SHA1 Message Date
adilallo d9751d7964 Send FeatureGrid Learn more to the Learn page instead of a dead hash link. 2026-08-25 18:54:54 -06:00
adilalloandCursor 63e0f77998 Show both books on About with matching covers and downloads.
The section mixed Structure Before Crisis art with the Community Rules booklet; pairing each title to its own cover and PDF, and cropping the Figma cover padding, keeps the two cards consistent.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 16:18:40 -06:00
adilalloandCursor 8bdd5040c6 Stop treating login/save-progress as a 48-character email limit.
The overlay was failing Zod on preset method-card support text attached with the draft; raise that cap, allow RFC-length emails, and drop Back to home from the overlay.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 16:17:36 -06:00
adilalloandCursor 712437edc7 Align custom policy details with the Figma create modal so the add-field control, copy, and labels match the intended styling.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 09:49:10 -06:00
adilalloandCursor 963fa03c5c Close method-card Save like first add, keep key-resource chips on the approach they were chosen for, and leave expanded See-all stacks in catalog order.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 09:21:07 -06:00
adilalloandCursor b1ca1a748e Let decision-approaches wrap at narrow widths and sync key-resource scopes.
The info-box checkboxes overflowed in a squeezed column; wrapping them and offering those scopes as chips on every approach keeps the sidebar and Applicable Scope selection in sync without publishing unchecked keys as defaults.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-24 16:27:06 -06:00
adilalloandCursor 38fe0e7e9b Start Add value in the empty custom-policy wizard so Finalize can save a selected chip named from the title.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-21 16:58:52 -06:00
adilalloandCursor e416f37940 Treat review-page value modals as unchanged when field blocks match the open snapshot so close does not ask to discard a view-only visit.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-21 16:41:03 -06:00
adilalloandCursor 440b6a9657 Hide unused helper marks on selection cards and modal section labels until tooltip behavior ships.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-21 16:28:38 -06:00
adilalloandCursor ef60175805 Keep See-all method cards at the compact 142px tile height so expanded rows do not stretch with badges and wrapping copy.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-21 16:14:27 -06:00
85 changed files with 1660 additions and 395 deletions
+3
View File
@@ -47,6 +47,9 @@ npm-cache/
# Per-article body ornaments (Figma Content page Template image fills) # Per-article body ornaments (Figma Content page Template image fills)
!public/content/blog/*-ornament-*.png !public/content/blog/*-ornament-*.png
# Marketing book cover (raster; see docs/guides/static-assets.md)
!public/assets/marketing/community-rules-cover.png
# Visual regression snapshots (allow these) # Visual regression snapshots (allow these)
!tests/e2e/visual-regression.spec.ts-snapshots/ !tests/e2e/visual-regression.spec.ts-snapshots/
!tests/e2e/visual-regression.spec.ts-snapshots/*.png !tests/e2e/visual-regression.spec.ts-snapshots/*.png
@@ -70,7 +70,7 @@ function ApplicableScopeFieldComponent({
return ( return (
<div className={`flex flex-col gap-2 ${className}`.trim()}> <div className={`flex flex-col gap-2 ${className}`.trim()}>
<InputLabel label={label} helpIcon size="s" palette="default" /> <InputLabel label={label} size="s" palette="default" />
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{scopes.map((scope) => { {scopes.map((scope) => {
const isSelected = selectedScopes.includes(scope); const isSelected = selectedScopes.includes(scope);
@@ -84,6 +84,7 @@ function ApplicableScopeFieldComponent({
disabled={readOnly} disabled={readOnly}
onClick={() => !readOnly && onToggleScope(scope)} onClick={() => !readOnly && onToggleScope(scope)}
ariaLabel={`${isSelected ? "Deselect" : "Select"} ${scope}`} ariaLabel={`${isSelected ? "Deselect" : "Select"} ${scope}`}
className="max-w-full"
/> />
); );
})} })}
@@ -60,7 +60,6 @@ function CustomMethodCardFieldBlocksSummaryViewComponent({
<div key={block.id} className="flex flex-col gap-2"> <div key={block.id} className="flex flex-col gap-2">
<InputLabel <InputLabel
label={block.blockTitle} label={block.blockTitle}
helpIcon
size="s" size="s"
palette="default" palette="default"
/> />
@@ -123,7 +122,6 @@ function CustomMethodCardFieldBlocksSummaryViewComponent({
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<InputLabel <InputLabel
label={block.blockTitle} label={block.blockTitle}
helpIcon
size="s" size="s"
palette="default" palette="default"
/> />
@@ -169,6 +167,7 @@ function CustomMethodCardFieldBlocksSummaryViewComponent({
<IncrementerBlock <IncrementerBlock
key={block.id} key={block.id}
label={block.blockTitle} label={block.blockTitle}
helpIcon={false}
value={block.defaultPercent} value={block.defaultPercent}
min={1} min={1}
max={100} max={100}
@@ -30,7 +30,6 @@ function CustomMethodCardUploadBlockRowViewComponent({
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<InputLabel <InputLabel
label={block.blockTitle} label={block.blockTitle}
helpIcon
size="s" size="s"
palette="default" palette="default"
/> />
@@ -77,6 +76,7 @@ function CustomMethodCardUploadBlockRowViewComponent({
) : ( ) : (
<Upload <Upload
active={!busy} active={!busy}
showHelpIcon={false}
hintText={busy ? uploadingHint : uploadHint} hintText={busy ? uploadingHint : uploadHint}
onClick={onUploadClick} onClick={onUploadClick}
/> />
@@ -388,6 +388,7 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
const handleSelectFieldType = useCallback((ft: AddCustomFieldType) => { const handleSelectFieldType = useCallback((ft: AddCustomFieldType) => {
setEditingBlockId(null); setEditingBlockId(null);
setAddFieldExpanded(false);
resetFieldTypeDrafts(); resetFieldTypeDrafts();
setFieldTypeModal(ft); setFieldTypeModal(ft);
fieldModalSnapshotRef.current = JSON.stringify({ fieldModalSnapshotRef.current = JSON.stringify({
@@ -531,6 +532,7 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
}); });
setFieldTypeModal(null); setFieldTypeModal(null);
setEditingBlockId(null); setEditingBlockId(null);
setAddFieldExpanded(false);
fieldModalSnapshotRef.current = null; fieldModalSnapshotRef.current = null;
}, [ }, [
badgeBlockTitle, badgeBlockTitle,
@@ -90,7 +90,7 @@ function CustomMethodCardWizardViewComponent({
/> />
) : null} ) : null}
{!fieldTypeModal && wizardStep === 3 ? ( {!fieldTypeModal && wizardStep === 3 ? (
<div className="flex w-full flex-col gap-4 pt-1"> <div className="flex w-full flex-col gap-6">
{draftFieldBlocks.length > 0 ? ( {draftFieldBlocks.length > 0 ? (
<CustomMethodCardWizardBlocksList <CustomMethodCardWizardBlocksList
blocks={draftFieldBlocks} blocks={draftFieldBlocks}
@@ -43,7 +43,7 @@ function CustomMethodCardWizardBlocksListViewComponent({
}: CustomMethodCardWizardBlocksListViewProps) { }: CustomMethodCardWizardBlocksListViewProps) {
return ( return (
<ul <ul
className="flex list-none flex-col gap-2 p-0 pt-1" className="flex list-none flex-col gap-2 p-0"
aria-label={listLabel} aria-label={listLabel}
> >
{blocks.map((block, index) => { {blocks.map((block, index) => {
@@ -51,15 +51,11 @@ function CustomMethodCardWizardFieldBodiesViewComponent({
value={textBlockTitle} value={textBlockTitle}
onChange={onTextBlockTitleChange} onChange={onTextBlockTitleChange}
maxLength={CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS} maxLength={CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS}
showHelpIcon
/> />
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<InputLabel <label className="text-[14px] leading-[20px] font-medium text-[var(--color-content-default-primary)]">
label={copy.text.placeholderLabel} {copy.text.placeholderLabel}
helpIcon </label>
size="s"
palette="default"
/>
<TextArea <TextArea
formHeader={false} formHeader={false}
appearance="embedded" appearance="embedded"
@@ -82,7 +78,6 @@ function CustomMethodCardWizardFieldBodiesViewComponent({
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<InputLabel <InputLabel
label={copy.badges.blockTitleLabel} label={copy.badges.blockTitleLabel}
helpIcon
helperText={copy.requiredHint} helperText={copy.requiredHint}
size="s" size="s"
palette="default" palette="default"
@@ -127,7 +122,6 @@ function CustomMethodCardWizardFieldBodiesViewComponent({
value={uploadBlockTitle} value={uploadBlockTitle}
onChange={onUploadBlockTitleChange} onChange={onUploadBlockTitleChange}
maxLength={CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS} maxLength={CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS}
showHelpIcon
/> />
{hasUploadPreview ? ( {hasUploadPreview ? (
<div className="relative inline-block max-w-full"> <div className="relative inline-block max-w-full">
@@ -158,6 +152,7 @@ function CustomMethodCardWizardFieldBodiesViewComponent({
) : ( ) : (
<Upload <Upload
active={!uploadPersisting} active={!uploadPersisting}
showHelpIcon={false}
hintText={ hintText={
uploadPersisting && uploadBusyHint uploadPersisting && uploadBusyHint
? uploadBusyHint ? uploadBusyHint
@@ -188,10 +183,10 @@ function CustomMethodCardWizardFieldBodiesViewComponent({
value={proportionBlockTitle} value={proportionBlockTitle}
onChange={onProportionBlockTitleChange} onChange={onProportionBlockTitleChange}
maxLength={CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS} maxLength={CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS}
showHelpIcon
/> />
<IncrementerBlock <IncrementerBlock
label={copy.proportion.defaultLabel} label={copy.proportion.defaultLabel}
helpIcon={false}
value={proportionDefault} value={proportionDefault}
min={1} min={1}
max={100} max={100}
@@ -422,7 +422,7 @@ export function FinalReviewChipEditModal({
true, true,
coreCustomizeSnapshotRef.current, coreCustomizeSnapshotRef.current,
draft?.groupKey === "coreValues" ? draft.value : null, draft?.groupKey === "coreValues" ? draft.value : null,
null, draftFieldBlocks,
customizeHeaderDraft, customizeHeaderDraft,
)) ))
) { ) {
@@ -605,7 +605,7 @@ export function FinalReviewChipEditModal({
true, true,
coreCustomizeSnapshotRef.current, coreCustomizeSnapshotRef.current,
draft?.groupKey === "coreValues" ? draft.value : null, draft?.groupKey === "coreValues" ? draft.value : null,
null, draftFieldBlocks,
customizeHeaderDraft, customizeHeaderDraft,
)) ))
) { ) {
@@ -622,6 +622,7 @@ export function FinalReviewChipEditModal({
confirmDiscard, confirmDiscard,
customizeHeaderDraft, customizeHeaderDraft,
draft, draft,
draftFieldBlocks,
finalizeModalClose, finalizeModalClose,
onInteract, onInteract,
replaceState, replaceState,
@@ -647,7 +648,7 @@ export function FinalReviewChipEditModal({
true, true,
coreCustomizeSnapshotRef.current, coreCustomizeSnapshotRef.current,
draft.value, draft.value,
null, draftFieldBlocks,
customizeHeaderDraft, customizeHeaderDraft,
)) ))
) { ) {
@@ -696,6 +697,7 @@ export function FinalReviewChipEditModal({
confirmDiscard, confirmDiscard,
customizeHeaderDraft, customizeHeaderDraft,
draft, draft,
draftFieldBlocks,
modalKebabMenu.duplicateTitleSuffix, modalKebabMenu.duplicateTitleSuffix,
onEditTargetChange, onEditTargetChange,
onInteract, onInteract,
@@ -790,7 +792,7 @@ export function FinalReviewChipEditModal({
}); });
coreCustomizeSnapshotRef.current = captureMethodCardCustomizeSnapshot( coreCustomizeSnapshotRef.current = captureMethodCardCustomizeSnapshot(
draft.value, draft.value,
null, draftFieldBlocks,
customizeHeaderDraft ?? { customizeHeaderDraft ?? {
title: target.chipLabel, title: target.chipLabel,
description: "", description: "",
@@ -802,6 +804,7 @@ export function FinalReviewChipEditModal({
coreCustomizeSaveDisabled, coreCustomizeSaveDisabled,
customizeHeaderDraft, customizeHeaderDraft,
draft, draft,
draftFieldBlocks,
onInteract, onInteract,
onSave, onSave,
state.customMethodCardFieldBlocksById, state.customMethodCardFieldBlocksById,
@@ -38,6 +38,7 @@ export default function MethodCardCustomizeModalHeader({
value={titleValue} value={titleValue}
onChange={(e) => onTitleChange(e.target.value)} onChange={(e) => onTitleChange(e.target.value)}
inputSize="medium" inputSize="medium"
showHelpIcon={false}
/> />
{showDescription ? ( {showDescription ? (
<ModalTextAreaField <ModalTextAreaField
@@ -2,8 +2,9 @@
/** /**
* Shared "labelled text area" field used by every create flow modal section. * Shared "labelled text area" field used by every create flow modal section.
* Pairs an `InputLabel` (with help icon) with a `TextArea` set to the embedded * Pairs an `InputLabel` with a `TextArea` set to the embedded appearance —
* appearance — matching the Figma "Control / Text Area" pattern. * matching the Figma "Control / Text Area" pattern. Section help marks stay
* off until tooltip behavior ships.
*/ */
import { memo, useId } from "react"; import { memo, useId } from "react";
@@ -13,7 +14,7 @@ import InputLabel from "../../../components/type/InputLabel";
export interface ModalTextAreaFieldProps { export interface ModalTextAreaFieldProps {
/** Label rendered above the text area. */ /** Label rendered above the text area. */
label: string; label: string;
/** Show the help "?" icon next to the label (default `true`). */ /** Show the help "?" icon next to the label. Off until tooltip behavior ships. */
helpIcon?: boolean; helpIcon?: boolean;
/** Current text value. */ /** Current text value. */
value: string; value: string;
@@ -30,7 +31,7 @@ export interface ModalTextAreaFieldProps {
function ModalTextAreaFieldComponent({ function ModalTextAreaFieldComponent({
label, label,
helpIcon = true, helpIcon = false,
value, value,
onChange, onChange,
rows = 4, rows = 4,
@@ -7,12 +7,13 @@
* `markCreateFlowInteraction` live in the parent. * `markCreateFlowInteraction` live in the parent.
*/ */
import { memo, useCallback } from "react"; import { memo, useCallback, useMemo } from "react";
import { useMessages } from "../../../../contexts/MessagesContext"; import { useMessages } from "../../../../contexts/MessagesContext";
import ModalTextAreaField from "../ModalTextAreaField"; import ModalTextAreaField from "../ModalTextAreaField";
import ApplicableScopeField from "../ApplicableScopeField"; import ApplicableScopeField from "../ApplicableScopeField";
import IncrementerBlock from "../../../../components/controls/IncrementerBlock"; import IncrementerBlock from "../../../../components/controls/IncrementerBlock";
import type { DecisionApproachDetailEntry } from "../../types"; import type { DecisionApproachDetailEntry } from "../../types";
import { withDecisionApproachKeyResourceScopes } from "../../../../../lib/create/decisionApproachKeyResources";
export interface DecisionApproachEditFieldsProps { export interface DecisionApproachEditFieldsProps {
value: DecisionApproachDetailEntry; value: DecisionApproachDetailEntry;
@@ -32,6 +33,11 @@ function DecisionApproachEditFieldsComponent({
const m = useMessages(); const m = useMessages();
const t = m.create.customRule.decisionApproaches; const t = m.create.customRule.decisionApproaches;
const scopes = useMemo(
() => withDecisionApproachKeyResourceScopes(value.applicableScope),
[value.applicableScope],
);
const patch = useCallback( const patch = useCallback(
<K extends keyof DecisionApproachDetailEntry>( <K extends keyof DecisionApproachDetailEntry>(
key: K, key: K,
@@ -53,7 +59,7 @@ function DecisionApproachEditFieldsComponent({
<ApplicableScopeField <ApplicableScopeField
label={t.sectionHeadings.applicableScope} label={t.sectionHeadings.applicableScope}
addLabel={t.scopeAddButtonLabel} addLabel={t.scopeAddButtonLabel}
scopes={value.applicableScope} scopes={scopes}
selectedScopes={value.selectedApplicableScope} selectedScopes={value.selectedApplicableScope}
readOnly={readOnly} readOnly={readOnly}
onToggleScope={(scope) => onToggleScope={(scope) =>
@@ -76,6 +82,7 @@ function DecisionApproachEditFieldsComponent({
/> />
<IncrementerBlock <IncrementerBlock
label={t.sectionHeadings.consensusLevel} label={t.sectionHeadings.consensusLevel}
helpIcon={false}
value={value.consensusLevel} value={value.consensusLevel}
min={CONSENSUS_LEVEL_MIN} min={CONSENSUS_LEVEL_MIN}
max={CONSENSUS_LEVEL_MAX} max={CONSENSUS_LEVEL_MAX}
@@ -1,10 +1,7 @@
"use client"; "use client";
import { useMemo } from "react"; import { useMemo } from "react";
import { import { mergeCompactCardIdsWithPinnedSelected } from "../../../../lib/create/methodCardDisplayOrder";
mergeCompactCardIdsWithPinnedSelected,
orderRankedMethodsWithPinnedSelection,
} from "../../../../lib/create/methodCardDisplayOrder";
import { import {
deriveCompactCards, deriveCompactCards,
rankMethodsByScore, rankMethodsByScore,
@@ -15,10 +12,10 @@ import {
type MethodEntry = { id: string; label: string; supportText: string }; type MethodEntry = { id: string; label: string; supportText: string };
/** /**
* Applies score ranking, compact-slot rules, then surfaces selected ids first in * Applies score ranking and compact-slot rules. Expanded CardStack order stays
* `selected*Ids` order (most-recent add at index 0 via * the ranked catalog (selected cards keep their place). Compact slots still
* {@link moveFacetSelectionIdToFront}). Selection-first applies whenever the facet * pin selected ids first so a pick outside the unpinned top-N remains visible
* has any selection — not only after footer Confirm (`methodSectionsPinCommitted`). * when the stack is collapsed.
*/ */
export function useMethodCardDeckOrdering( export function useMethodCardDeckOrdering(
section: RecommendationSection, section: RecommendationSection,
@@ -35,16 +32,7 @@ export function useMethodCardDeckOrdering(
); );
const selectionShowcaseActive = selectedIds.length > 0; const selectionShowcaseActive = selectedIds.length > 0;
const displayMethods = rankedMethods;
const displayMethods = useMemo(
() =>
orderRankedMethodsWithPinnedSelection(
rankedMethods,
selectedIds,
selectionShowcaseActive,
),
[rankedMethods, selectedIds, selectionShowcaseActive],
);
const { compactCardIds: baseCompactCardIds, recommendedIds } = useMemo( const { compactCardIds: baseCompactCardIds, recommendedIds } = useMemo(
() => () =>
@@ -60,13 +48,13 @@ export function useMethodCardDeckOrdering(
const compactCardIds = useMemo( const compactCardIds = useMemo(
() => () =>
mergeCompactCardIdsWithPinnedSelected( mergeCompactCardIdsWithPinnedSelected(
displayMethods.map((m) => m.id), rankedMethods.map((m) => m.id),
baseCompactCardIds, baseCompactCardIds,
selectedIds, selectedIds,
selectionShowcaseActive, selectionShowcaseActive,
5, 5,
), ),
[displayMethods, baseCompactCardIds, selectedIds, selectionShowcaseActive], [rankedMethods, baseCompactCardIds, selectedIds, selectionShowcaseActive],
); );
const sampleCards = useMemo( const sampleCards = useMemo(
@@ -643,16 +643,9 @@ export function CommunicationMethodsScreen() {
}, },
}); });
} }
if (pendingDraft) { pendingEphemeralDuplicateIdRef.current = null;
customizeSnapshotRef.current = captureMethodCardCustomizeSnapshot( customizeSnapshotRef.current = null;
pendingDraft, void handleCreateModalClose();
persistWizardBlocks ? draftFieldBlocks : null,
customizeSnapshotRef.current?.headerDraft ?? {
title: "",
description: "",
},
);
}
return; return;
} }
@@ -740,6 +733,7 @@ export function CommunicationMethodsScreen() {
title={modalConfig.title} title={modalConfig.title}
description={modalConfig.description} description={modalConfig.description}
nextButtonText={modalConfig.nextButtonText} nextButtonText={modalConfig.nextButtonText}
showBackButton={false}
showNextButton={showMethodModalPrimary} showNextButton={showMethodModalPrimary}
backdropVariant="blurredYellow" backdropVariant="blurredYellow"
kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel} kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel}
@@ -644,16 +644,9 @@ export function ConflictManagementScreen() {
}, },
}); });
} }
if (pendingDraft) { pendingEphemeralDuplicateIdRef.current = null;
customizeSnapshotRef.current = captureMethodCardCustomizeSnapshot( customizeSnapshotRef.current = null;
pendingDraft, void handleCreateModalClose();
persistWizardBlocks ? draftFieldBlocks : null,
customizeSnapshotRef.current?.headerDraft ?? {
title: "",
description: "",
},
);
}
return; return;
} }
@@ -741,6 +734,7 @@ export function ConflictManagementScreen() {
title={modalConfig.title} title={modalConfig.title}
description={modalConfig.description} description={modalConfig.description}
nextButtonText={modalConfig.nextButtonText} nextButtonText={modalConfig.nextButtonText}
showBackButton={false}
showNextButton={showMethodModalPrimary} showNextButton={showMethodModalPrimary}
backdropVariant="blurredYellow" backdropVariant="blurredYellow"
kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel} kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel}
@@ -637,16 +637,9 @@ export function MembershipMethodsScreen() {
}, },
}); });
} }
if (pendingDraft) { pendingEphemeralDuplicateIdRef.current = null;
customizeSnapshotRef.current = captureMethodCardCustomizeSnapshot( customizeSnapshotRef.current = null;
pendingDraft, void handleCreateModalClose();
persistWizardBlocks ? draftFieldBlocks : null,
customizeSnapshotRef.current?.headerDraft ?? {
title: "",
description: "",
},
);
}
return; return;
} }
@@ -734,6 +727,7 @@ export function MembershipMethodsScreen() {
title={modalConfig.title} title={modalConfig.title}
description={modalConfig.description} description={modalConfig.description}
nextButtonText={modalConfig.nextButtonText} nextButtonText={modalConfig.nextButtonText}
showBackButton={false}
showNextButton={showMethodModalPrimary} showNextButton={showMethodModalPrimary}
backdropVariant="blurredYellow" backdropVariant="blurredYellow"
kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel} kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel}
@@ -33,6 +33,11 @@ import { DecisionApproachEditFields } from "../../components/methodEditFields";
import CustomMethodCardWizard from "../../components/CustomMethodCardWizard"; import CustomMethodCardWizard from "../../components/CustomMethodCardWizard";
import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer"; import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer";
import { decisionApproachPresetFor } from "../../../../../lib/create/finalReviewChipPresets"; import { decisionApproachPresetFor } from "../../../../../lib/create/finalReviewChipPresets";
import {
applyDecisionApproachKeyResources,
decisionApproachKeyResourceCheckboxIds,
selectedKeyResourceLabelsFromCheckedIds,
} from "../../../../../lib/create/decisionApproachKeyResources";
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks"; import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
import { mergePresetMethodsWithCustom } from "../../../../../lib/create/mergePresetMethodsWithCustom"; import { mergePresetMethodsWithCustom } from "../../../../../lib/create/mergePresetMethodsWithCustom";
import { moveFacetSelectionIdToFront } from "../../../../../lib/create/methodCardSelectionOrder"; import { moveFacetSelectionIdToFront } from "../../../../../lib/create/methodCardSelectionOrder";
@@ -71,9 +76,6 @@ export function DecisionApproachesScreen() {
const customizeSnapshotRef = useRef< const customizeSnapshotRef = useRef<
MethodCardCustomizeSnapshot<DecisionApproachDetailEntry> | null MethodCardCustomizeSnapshot<DecisionApproachDetailEntry> | null
>(null); >(null);
const [messageBoxCheckedIds, setMessageBoxCheckedIds] = useState<string[]>(
[],
);
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const [createModalOpen, setCreateModalOpen] = useState(false); const [createModalOpen, setCreateModalOpen] = useState(false);
const [pendingCardId, setPendingCardId] = useState<string | null>(null); const [pendingCardId, setPendingCardId] = useState<string | null>(null);
@@ -90,6 +92,12 @@ export function DecisionApproachesScreen() {
>(null); >(null);
const selectedIds = state.selectedDecisionApproachIds ?? []; const selectedIds = state.selectedDecisionApproachIds ?? [];
const messageBoxCheckedIds = decisionApproachKeyResourceCheckboxIds({
detailsById: state.decisionApproachDetailsById,
selectedApproachIds: selectedIds,
reminderIds: state.selectedDecisionKeyResourceIds,
openDraft: pendingDraft,
});
const messageBoxItems: InfoMessageBoxItem[] = useMemo( const messageBoxItems: InfoMessageBoxItem[] = useMemo(
() => () =>
@@ -137,22 +145,34 @@ export function DecisionApproachesScreen() {
const handleMessageBoxCheckboxChange = useCallback( const handleMessageBoxCheckboxChange = useCallback(
(id: string, checked: boolean) => { (id: string, checked: boolean) => {
markCreateFlowInteraction(); markCreateFlowInteraction();
setMessageBoxCheckedIds((prev) => const nextCheckedIds = checked
checked ? [...prev, id] : prev.filter((x) => x !== id), ? [...messageBoxCheckedIds, id]
); : messageBoxCheckedIds.filter((x) => x !== id);
const nextLabels =
selectedKeyResourceLabelsFromCheckedIds(nextCheckedIds);
if (pendingDraft) {
setPendingDraft(
applyDecisionApproachKeyResources(pendingDraft, nextLabels),
);
return;
}
updateState({
selectedDecisionKeyResourceIds: nextCheckedIds,
});
}, },
[markCreateFlowInteraction], [
markCreateFlowInteraction,
messageBoxCheckedIds,
pendingDraft,
updateState,
],
); );
const seedDraft = useCallback( const seedDraft = useCallback(
(id: string): DecisionApproachDetailEntry => { (id: string): DecisionApproachDetailEntry => {
const saved = state.decisionApproachDetailsById?.[id]; const saved = state.decisionApproachDetailsById?.[id];
if (saved) { if (saved) {
return { return structuredClone(saved);
...saved,
applicableScope: [...saved.applicableScope],
selectedApplicableScope: [...saved.selectedApplicableScope],
};
} }
return decisionApproachPresetFor(id); return decisionApproachPresetFor(id);
}, },
@@ -625,31 +645,30 @@ export function DecisionApproachesScreen() {
modalUsesWizardFieldBlocksBody && draftFieldBlocks !== null; modalUsesWizardFieldBlocksBody && draftFieldBlocks !== null;
if (selectedIds.includes(pendingCardId)) { if (selectedIds.includes(pendingCardId)) {
if (persistWizardBlocks) { replaceState((prev) => {
updateState({ if (persistWizardBlocks) {
customMethodCardFieldBlocksById: { return {
...(state.customMethodCardFieldBlocksById ?? {}), ...prev,
[pendingCardId]: structuredClone(draftFieldBlocks ?? []), customMethodCardFieldBlocksById: {
}, ...(prev.customMethodCardFieldBlocksById ?? {}),
}); [pendingCardId]: structuredClone(draftFieldBlocks ?? []),
} else if (pendingDraft) { },
updateState({ };
}
if (!pendingDraft) {
return prev;
}
return {
...prev,
decisionApproachDetailsById: { decisionApproachDetailsById: {
...(state.decisionApproachDetailsById ?? {}), ...(prev.decisionApproachDetailsById ?? {}),
[pendingCardId]: pendingDraft, [pendingCardId]: structuredClone(pendingDraft),
}, },
}); };
} });
if (pendingDraft) { pendingEphemeralDuplicateIdRef.current = null;
customizeSnapshotRef.current = captureMethodCardCustomizeSnapshot( customizeSnapshotRef.current = null;
pendingDraft, void handleCreateModalClose();
persistWizardBlocks ? draftFieldBlocks : null,
customizeSnapshotRef.current?.headerDraft ?? {
title: "",
description: "",
},
);
}
return; return;
} }
@@ -657,24 +676,25 @@ export function DecisionApproachesScreen() {
void handleCreateModalClose(); void handleCreateModalClose();
return; return;
} }
updateState({ replaceState((prev) => ({
...prev,
selectedDecisionApproachIds: moveFacetSelectionIdToFront( selectedDecisionApproachIds: moveFacetSelectionIdToFront(
selectedIds, prev.selectedDecisionApproachIds ?? [],
pendingCardId, pendingCardId,
), ),
decisionApproachDetailsById: { decisionApproachDetailsById: {
...(state.decisionApproachDetailsById ?? {}), ...(prev.decisionApproachDetailsById ?? {}),
[pendingCardId]: pendingDraft, [pendingCardId]: structuredClone(pendingDraft),
}, },
...(persistWizardBlocks ...(persistWizardBlocks
? { ? {
customMethodCardFieldBlocksById: { customMethodCardFieldBlocksById: {
...(state.customMethodCardFieldBlocksById ?? {}), ...(prev.customMethodCardFieldBlocksById ?? {}),
[pendingCardId]: structuredClone(draftFieldBlocks ?? []), [pendingCardId]: structuredClone(draftFieldBlocks ?? []),
}, },
} }
: {}), : {}),
}); }));
pendingEphemeralDuplicateIdRef.current = null; pendingEphemeralDuplicateIdRef.current = null;
customizeSnapshotRef.current = null; customizeSnapshotRef.current = null;
void handleCreateModalClose(); void handleCreateModalClose();
@@ -685,9 +705,8 @@ export function DecisionApproachesScreen() {
modalUsesWizardFieldBlocksBody, modalUsesWizardFieldBlocksBody,
pendingCardId, pendingCardId,
pendingDraft, pendingDraft,
replaceState,
selectedIds, selectedIds,
state,
updateState,
]); ]);
const modalConfig = pendingCardId const modalConfig = pendingCardId
@@ -778,6 +797,7 @@ export function DecisionApproachesScreen() {
title={modalConfig.title} title={modalConfig.title}
description={modalConfig.description} description={modalConfig.description}
nextButtonText={modalConfig.nextButtonText} nextButtonText={modalConfig.nextButtonText}
showBackButton={false}
showNextButton={showMethodModalPrimary} showNextButton={showMethodModalPrimary}
backdropVariant="blurredYellow" backdropVariant="blurredYellow"
kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel} kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel}
@@ -25,6 +25,8 @@ import {
MAX_SELECTED_CORE_VALUES, MAX_SELECTED_CORE_VALUES,
removeCoreValueChipFromDraft, removeCoreValueChipFromDraft,
} from "../../../../../lib/create/coreValueChipFacet"; } from "../../../../../lib/create/coreValueChipFacet";
import { methodCardMetaWithCustomizeHeader } from "../../../../../lib/create/methodCardCustomizeMetaPatch";
import { moveFacetSelectionIdToFront } from "../../../../../lib/create/methodCardSelectionOrder";
import { import {
buildMethodCardWizardInitialValues, buildMethodCardWizardInitialValues,
coreValueDetailsFromWizardFieldBlocks, coreValueDetailsFromWizardFieldBlocks,
@@ -41,11 +43,13 @@ const MAX_CORE_VALUES = MAX_SELECTED_CORE_VALUES;
* *
* - `pending` — preset chip just selected; modal opened to capture * - `pending` — preset chip just selected; modal opened to capture
* meaning/signals. Close (X) confirms, then unselects the chip. * meaning/signals. Close (X) confirms, then unselects the chip.
* - `customPending` — brand-new custom chip just created via the Add * - `customPending` — leftover inline custom-chip drafts (older sessions).
* value flow; modal opened with empty fields. Dismiss = drop the * Dismiss = drop the chip entirely.
* chip entirely (it was never confirmed via the Add Value button).
* - `editing` — chip already exists & is selected; modal reopened to * - `editing` — chip already exists & is selected; modal reopened to
* tweak meaning/signals. Dismiss = no-op (chip stays as-is). * tweak meaning/signals. Dismiss = no-op (chip stays as-is).
*
* **Add value** (and the header "add" link) opens an empty custom-policy
* wizard. Finalize writes a selected chip named from the wizard title.
*/ */
type ModalSession = "pending" | "customPending" | "editing"; type ModalSession = "pending" | "customPending" | "editing";
@@ -489,12 +493,57 @@ export function CoreValuesSelectScreen() {
description: string; description: string;
fieldBlocks: CustomMethodCardFieldBlock[]; fieldBlocks: CustomMethodCardFieldBlock[];
}) => { }) => {
const chipId = wizardCustomizeChipId ?? activeModalChipId;
if (!chipId) return;
markCreateFlowInteraction();
pendingEphemeralCoreDuplicateRef.current = null;
const trimmedTitle = title.trim(); const trimmedTitle = title.trim();
const trimmedDescription = description.trim(); const trimmedDescription = description.trim();
if (!trimmedTitle) return;
markCreateFlowInteraction();
pendingEphemeralCoreDuplicateRef.current = null;
const existingId = wizardCustomizeChipId;
if (!existingId) {
const id = crypto.randomUUID();
const nextDetails = {
...coreValueDetailsFromWizardFieldBlocks(fieldBlocks, EMPTY_DETAIL),
...(trimmedDescription.length > 0
? { supportText: trimmedDescription }
: {}),
};
replaceState((prev) => {
const sel = prev.selectedCoreValueIds ?? [];
if (sel.length >= MAX_CORE_VALUES) {
return prev;
}
const snap = [...(prev.coreValuesChipsSnapshot ?? [])];
snap.push({
id,
label: trimmedTitle,
state: "selected",
});
return {
...prev,
selectedCoreValueIds: moveFacetSelectionIdToFront(sel, id),
coreValuesChipsSnapshot: snap,
coreValueDetailsByChipId: {
...(prev.coreValueDetailsByChipId ?? {}),
[id]: nextDetails,
},
customMethodCardFieldBlocksById: {
...(prev.customMethodCardFieldBlocksById ?? {}),
[id]: structuredClone(fieldBlocks),
},
customMethodCardMetaById: methodCardMetaWithCustomizeHeader(
prev.customMethodCardMetaById,
id,
{ title: trimmedTitle, description: trimmedDescription },
),
};
});
setAddCustomWizardOpen(false);
setWizardCustomizeChipId(null);
setWizardInitialValues(null);
return;
}
const nextDetails = { const nextDetails = {
...coreValueDetailsFromWizardFieldBlocks(fieldBlocks, draft), ...coreValueDetailsFromWizardFieldBlocks(fieldBlocks, draft),
...(trimmedDescription.length > 0 ...(trimmedDescription.length > 0
@@ -503,8 +552,8 @@ export function CoreValuesSelectScreen() {
}; };
replaceState((prev) => { replaceState((prev) => {
const snap = [...(prev.coreValuesChipsSnapshot ?? [])]; const snap = [...(prev.coreValuesChipsSnapshot ?? [])];
const i = snap.findIndex((r) => r.id === chipId); const i = snap.findIndex((r) => r.id === existingId);
if (i >= 0 && trimmedTitle.length > 0) { if (i >= 0) {
snap[i] = { ...snap[i], label: trimmedTitle }; snap[i] = { ...snap[i], label: trimmedTitle };
} }
return { return {
@@ -512,11 +561,11 @@ export function CoreValuesSelectScreen() {
coreValuesChipsSnapshot: snap, coreValuesChipsSnapshot: snap,
coreValueDetailsByChipId: { coreValueDetailsByChipId: {
...(prev.coreValueDetailsByChipId ?? {}), ...(prev.coreValueDetailsByChipId ?? {}),
[chipId]: nextDetails, [existingId]: nextDetails,
}, },
customMethodCardFieldBlocksById: { customMethodCardFieldBlocksById: {
...(prev.customMethodCardFieldBlocksById ?? {}), ...(prev.customMethodCardFieldBlocksById ?? {}),
[chipId]: structuredClone(fieldBlocks), [existingId]: structuredClone(fieldBlocks),
}, },
}; };
}); });
@@ -526,13 +575,7 @@ export function CoreValuesSelectScreen() {
setWizardCustomizeChipId(null); setWizardCustomizeChipId(null);
setWizardInitialValues(null); setWizardInitialValues(null);
}, },
[ [draft, markCreateFlowInteraction, replaceState, wizardCustomizeChipId],
activeModalChipId,
draft,
markCreateFlowInteraction,
replaceState,
wizardCustomizeChipId,
],
); );
const kebabMenuItems = useMemo(() => { const kebabMenuItems = useMemo(() => {
@@ -587,15 +630,14 @@ export function CoreValuesSelectScreen() {
const addHandlers = { const addHandlers = {
onAddClick: () => { onAddClick: () => {
const selectedCount = coreValueOptions.filter(
(o) => o.state === "selected",
).length;
if (selectedCount >= MAX_CORE_VALUES) return;
markCreateFlowInteraction(); markCreateFlowInteraction();
setCoreValueOptions((prev) => { setWizardCustomizeChipId(null);
const next: ChipOption[] = [ setWizardInitialValues(null);
...prev, setAddCustomWizardOpen(true);
{ id: crypto.randomUUID(), label: "", state: "custom" },
];
queueMicrotask(() => syncCoreValuesToDraft(next));
return next;
});
}, },
onCustomChipConfirm: (chipId: string, value: string) => { onCustomChipConfirm: (chipId: string, value: string) => {
markCreateFlowInteraction(); markCreateFlowInteraction();
+6
View File
@@ -165,6 +165,12 @@ export interface CreateFlowState {
>; >;
membershipMethodDetailsById?: Record<string, MembershipMethodDetailEntry>; membershipMethodDetailsById?: Record<string, MembershipMethodDetailEntry>;
decisionApproachDetailsById?: Record<string, DecisionApproachDetailEntry>; decisionApproachDetailsById?: Record<string, DecisionApproachDetailEntry>;
/**
* Checked “key resource” ids from the decision-approaches InfoMessageBox
* (`amend`, `finances`, `project`, `discipline`). Selecting one also selects
* the matching Applicable Scope chip on every decision approach.
*/
selectedDecisionKeyResourceIds?: string[];
conflictManagementDetailsById?: Record< conflictManagementDetailsById?: Record<
string, string,
ConflictManagementDetailEntry ConflictManagementDetailEntry
+19 -5
View File
@@ -1,5 +1,10 @@
import messages from "../../../messages/en/index"; import messages from "../../../messages/en/index";
import { getAssetPath, governanceBookletPath } from "../../../lib/assetUtils"; import {
ASSETS,
getAssetPath,
governanceBookletPath,
structureBeforeCrisisPath,
} from "../../../lib/assetUtils";
import { getTranslation } from "../../../lib/i18n/getTranslation"; import { getTranslation } from "../../../lib/i18n/getTranslation";
import AboutHeader from "../../components/type/AboutHeader"; import AboutHeader from "../../components/type/AboutHeader";
import type { AboutHeaderSegment } from "../../components/type/AboutHeader"; import type { AboutHeaderSegment } from "../../components/type/AboutHeader";
@@ -53,11 +58,20 @@ export default function AboutPage() {
/> />
<TripleTextBlock columns={tripleColumns} /> <TripleTextBlock columns={tripleColumns} />
<Book <Book
title={page.book.title} title={page.structureBeforeCrisis.title}
description={page.book.description} description={page.structureBeforeCrisis.description}
buttonText={page.book.buttonText} buttonText={page.structureBeforeCrisis.buttonText}
buttonHref={getAssetPath(structureBeforeCrisisPath())}
imageSrc={getAssetPath(ASSETS.STRUCTURE_BEFORE_CRISIS_COVER)}
imageAlt={page.structureBeforeCrisis.imageAlt}
/>
<Book
title={page.communityRules.title}
description={page.communityRules.description}
buttonText={page.communityRules.buttonText}
buttonHref={getAssetPath(governanceBookletPath())} buttonHref={getAssetPath(governanceBookletPath())}
imageAlt={page.book.imageAlt} imageSrc={getAssetPath(ASSETS.COMMUNITY_RULES_COVER)}
imageAlt={page.communityRules.imageAlt}
/> />
<FaqAccordion title={page.faq.title} items={faqItems} /> <FaqAccordion title={page.faq.title} items={faqItems} />
<QuoteBlock <QuoteBlock
+1 -1
View File
@@ -28,7 +28,7 @@ function VerticalComponent({
"data-testid": dataTestId, "data-testid": dataTestId,
}: VerticalProps) { }: VerticalProps) {
const base = const base =
"box-border flex w-[90px] shrink-0 cursor-pointer flex-col items-center gap-[var(--spacing-scale-008)] rounded-[var(--spacing-scale-004)] border border-solid border-[var(--color-border-default-brand-primary)] bg-transparent px-[var(--spacing-scale-008)] py-[var(--spacing-scale-012)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-invert-primary)] disabled:cursor-not-allowed disabled:opacity-60"; "box-border flex w-full min-w-[90px] cursor-pointer flex-col items-center gap-[var(--spacing-scale-008)] rounded-[var(--spacing-scale-004)] border border-solid border-[var(--color-border-default-brand-primary)] bg-transparent px-[var(--spacing-scale-008)] py-[var(--spacing-scale-012)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-invert-primary)] disabled:cursor-not-allowed disabled:opacity-60";
return ( return (
<button <button
@@ -11,6 +11,8 @@ const DEFAULT_TOGGLE_LABEL = "See all communication approaches";
* Figma: "Utility / CardStack"; canonical code under `cards/`. * Figma: "Utility / CardStack"; canonical code under `cards/`.
* Selectable stack of cards with * Selectable stack of cards with
* an optional "see all"/"show less" expand toggle. * an optional "see all"/"show less" expand toggle.
* Expanded default layout matches Flow — Expanded Card Stack (`20768:14820`):
* 281×142 CardSelection tiles in two columns.
*/ */
const CardStackContainer = memo<CardStackProps>( const CardStackContainer = memo<CardStackProps>(
({ ({
@@ -20,7 +20,7 @@ export interface CardStackProps {
showLessLabel?: string; showLessLabel?: string;
title?: string; title?: string;
description?: ReactNode; description?: ReactNode;
/** "default" = compact grid/column + expanded grid; "singleStack" = always one column, expand shows more in same stack */ /** "default" = compact grid/column + expanded 2-col 142px tiles; "singleStack" = always one column, expand shows more in same stack */
layout?: "default" | "singleStack"; layout?: "default" | "singleStack";
/** /**
* Max recommended cards in compact (non-expanded) mode. Default 5; Figma compact stack uses 3. * Max recommended cards in compact (non-expanded) mode. Default 5; Figma compact stack uses 3.
@@ -6,6 +6,10 @@ import type { HeaderLockupSizeValue } from "../../type/HeaderLockup/HeaderLockup
import Selection from "../Selection"; import Selection from "../Selection";
import type { CardStackViewProps } from "./CardStack.types"; import type { CardStackViewProps } from "./CardStack.types";
/** Figma Card / CardSelection on Compact + Expanded Card Stack (`281×142`). */
const FIGMA_CARD_SELECTION_TILE_CLASS =
"h-[142px] min-h-[142px] max-h-[142px] w-[281px] max-w-[281px] shrink-0";
function CardStackHeaderLockup({ function CardStackHeaderLockup({
title, title,
description, description,
@@ -144,7 +148,7 @@ export function CardStackView({
recommended={item.recommended ?? false} recommended={item.recommended ?? false}
selected={isSelected(item.id)} selected={isSelected(item.id)}
orientation="vertical" orientation="vertical"
showInfoIcon={true} showInfoIcon={false}
onClick={() => onCardSelect(item.id)} onClick={() => onCardSelect(item.id)}
/> />
))} ))}
@@ -175,24 +179,46 @@ export function CardStackView({
/> />
{expanded ? ( {expanded ? (
<div className="mx-auto grid w-full max-w-[min(100%,860px)] grid-cols-1 gap-x-4 gap-y-6 md:grid-cols-2"> <>
{cards.map((item) => ( <div className="flex w-full flex-col gap-2 md:hidden">
<Selection {cards.map((item) => (
key={item.id} <Selection
id={item.id} key={item.id}
label={item.label} id={item.id}
supportText={item.supportText} label={item.label}
recommended={item.recommended ?? false} supportText={item.supportText}
selected={isSelected(item.id)} recommended={item.recommended ?? false}
orientation="vertical" selected={isSelected(item.id)}
showInfoIcon={true} orientation="horizontal"
onClick={() => onCardSelect(item.id)} showInfoIcon={false}
/> className="min-h-[142px]"
))} onClick={() => onCardSelect(item.id)}
{addTile ? ( />
<div className="min-w-0 md:col-span-2">{addTile}</div> ))}
) : null} {addTile}
</div> </div>
<div className="mx-auto hidden w-full max-w-[min(100%,570px)] flex-wrap justify-center gap-2 md:flex">
{cards.map((item) => (
<Selection
key={item.id}
id={item.id}
label={item.label}
supportText={item.supportText}
recommended={item.recommended ?? false}
selected={isSelected(item.id)}
orientation="horizontal"
showInfoIcon={false}
className={FIGMA_CARD_SELECTION_TILE_CLASS}
onClick={() => onCardSelect(item.id)}
/>
))}
{addTile ? (
<div className="flex w-full min-w-0 shrink-0 justify-center md:flex-[1_1_100%]">
{addTile}
</div>
) : null}
</div>
</>
) : compactDesktopLayout === "pyramidFive" ? ( ) : compactDesktopLayout === "pyramidFive" ? (
<> <>
<div className="flex w-full flex-col gap-2 md:hidden"> <div className="flex w-full flex-col gap-2 md:hidden">
@@ -230,7 +256,7 @@ export function CardStackView({
selected={isSelected(item.id)} selected={isSelected(item.id)}
orientation="horizontal" orientation="horizontal"
showInfoIcon={false} showInfoIcon={false}
className="h-[142px] min-h-[142px] max-h-[142px] w-[281px] max-w-[281px] shrink-0" className={FIGMA_CARD_SELECTION_TILE_CLASS}
onClick={() => onCardSelect(item.id)} onClick={() => onCardSelect(item.id)}
/> />
))} ))}
@@ -249,7 +275,7 @@ export function CardStackView({
selected={isSelected(item.id)} selected={isSelected(item.id)}
orientation="horizontal" orientation="horizontal"
showInfoIcon={false} showInfoIcon={false}
className="h-[142px] min-h-[142px] max-h-[142px] w-[281px] max-w-[281px] shrink-0" className={FIGMA_CARD_SELECTION_TILE_CLASS}
onClick={() => onCardSelect(item.id)} onClick={() => onCardSelect(item.id)}
/> />
))} ))}
@@ -268,7 +294,7 @@ export function CardStackView({
selected={isSelected(item.id)} selected={isSelected(item.id)}
orientation="horizontal" orientation="horizontal"
showInfoIcon={false} showInfoIcon={false}
className="h-[142px] min-h-[142px] max-h-[142px] w-[281px] max-w-[281px] shrink-0" className={FIGMA_CARD_SELECTION_TILE_CLASS}
onClick={() => onCardSelect(item.id)} onClick={() => onCardSelect(item.id)}
/> />
))} ))}
@@ -284,7 +310,7 @@ export function CardStackView({
selected={isSelected(item.id)} selected={isSelected(item.id)}
orientation="horizontal" orientation="horizontal"
showInfoIcon={false} showInfoIcon={false}
className="h-[142px] min-h-[142px] max-h-[142px] w-[281px] max-w-[281px] shrink-0" className={FIGMA_CARD_SELECTION_TILE_CLASS}
onClick={() => onCardSelect(item.id)} onClick={() => onCardSelect(item.id)}
/> />
))} ))}
@@ -299,7 +325,7 @@ export function CardStackView({
selected={isSelected(compactCards[4].id)} selected={isSelected(compactCards[4].id)}
orientation="horizontal" orientation="horizontal"
showInfoIcon={false} showInfoIcon={false}
className="h-[142px] min-h-[142px] max-h-[142px] w-[281px] max-w-[281px] shrink-0" className={FIGMA_CARD_SELECTION_TILE_CLASS}
onClick={() => onCardSelect(compactCards[4].id)} onClick={() => onCardSelect(compactCards[4].id)}
/> />
</div> </div>
@@ -346,7 +372,7 @@ export function CardStackView({
selected={isSelected(item.id)} selected={isSelected(item.id)}
orientation="horizontal" orientation="horizontal"
showInfoIcon={false} showInfoIcon={false}
className="h-[142px] min-h-[142px] max-h-[142px] w-[281px] max-w-[281px] shrink-0" className={FIGMA_CARD_SELECTION_TILE_CLASS}
onClick={() => onCardSelect(item.id)} onClick={() => onCardSelect(item.id)}
/> />
))} ))}
@@ -360,7 +386,7 @@ export function CardStackView({
selected={isSelected(compactCards[2].id)} selected={isSelected(compactCards[2].id)}
orientation="horizontal" orientation="horizontal"
showInfoIcon={false} showInfoIcon={false}
className="h-[142px] min-h-[142px] max-h-[142px] w-[281px] max-w-[281px] shrink-0" className={FIGMA_CARD_SELECTION_TILE_CLASS}
onClick={() => onCardSelect(compactCards[2].id)} onClick={() => onCardSelect(compactCards[2].id)}
/> />
</div> </div>
@@ -379,7 +405,7 @@ export function CardStackView({
selected={isSelected(item.id)} selected={isSelected(item.id)}
orientation="horizontal" orientation="horizontal"
showInfoIcon={false} showInfoIcon={false}
className="h-[142px] min-h-[142px] max-h-[142px] w-[281px] max-w-[281px] shrink-0" className={FIGMA_CARD_SELECTION_TILE_CLASS}
onClick={() => onCardSelect(item.id)} onClick={() => onCardSelect(item.id)}
/> />
))} ))}
@@ -431,7 +457,7 @@ export function CardStackView({
recommended={item.recommended ?? false} recommended={item.recommended ?? false}
selected={isSelected(item.id)} selected={isSelected(item.id)}
orientation="vertical" orientation="vertical"
showInfoIcon={true} showInfoIcon={false}
onClick={() => onCardSelect(item.id)} onClick={() => onCardSelect(item.id)}
/> />
))} ))}
@@ -6,7 +6,7 @@ import type { SelectionProps } from "./Selection.types";
/** /**
* Figma: "Card / CardSelection" — stacked tile e.g. `16775:28762` (recommended + label + supportText). * Figma: "Card / CardSelection" — stacked tile e.g. `16775:28762` (recommended + label + supportText).
* `orientation="horizontal"` selects that vertical stack; `"vertical"` is label + optional info icon with tag on the right (CardStack expanded / single-column). * `orientation="horizontal"` selects that vertical stack; `"vertical"` is label + tag on the right (CardStack `singleStack` / right-rail). Helper `?` stays off until tooltip behavior ships.
*/ */
const SelectionContainer = memo<SelectionProps>( const SelectionContainer = memo<SelectionProps>(
({ ({
@@ -4,6 +4,7 @@ export interface SelectionProps {
recommended?: boolean; recommended?: boolean;
selected?: boolean; selected?: boolean;
orientation: "horizontal" | "vertical"; orientation: "horizontal" | "vertical";
/** Off in product until tooltip behavior ships. */
showInfoIcon?: boolean; showInfoIcon?: boolean;
/** Optional id for the root (e.g. `data-card-id` for focus after modal close). */ /** Optional id for the root (e.g. `data-card-id` for focus after modal close). */
id?: string; id?: string;
@@ -84,14 +84,14 @@ export function SelectionView({
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
> >
<div className="flex min-w-0 flex-1 flex-col gap-1"> <div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex items-center gap-1"> <div className="flex min-w-0 items-center gap-1">
<span className="text-medium-label text-[var(--color-content-invert-secondary)]"> <span className="min-w-0 break-words text-medium-label text-[var(--color-content-invert-secondary)]">
{label} {label}
</span> </span>
{showInfoIcon ? <InfoIcon /> : null} {showInfoIcon ? <InfoIcon /> : null}
</div> </div>
{supportText ? ( {supportText ? (
<p className="text-x-small-paragraph text-[var(--color-content-invert-tertiary)]"> <p className="min-w-0 break-words text-x-small-paragraph text-[var(--color-content-invert-tertiary)]">
{supportText} {supportText}
</p> </p>
) : null} ) : null}
@@ -291,7 +291,7 @@ function ReadOnlyScopeField({
}) { }) {
return ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<InputLabel label={label} helpIcon size="s" palette="default" /> <InputLabel label={label} size="s" palette="default" />
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{scopes.map((scope) => ( {scopes.map((scope) => (
<Chip <Chip
@@ -317,7 +317,7 @@ function ReadOnlyValueField({
}) { }) {
return ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<InputLabel label={label} helpIcon size="s" palette="default" /> <InputLabel label={label} size="s" palette="default" />
<span className="text-medium-label text-[color:var(--color-content-default-primary)]"> <span className="text-medium-label text-[color:var(--color-content-default-primary)]">
{value} {value}
</span> </span>
@@ -7,7 +7,7 @@ import type { AddCustomFieldProps, AddCustomFieldType } from "./AddCustomField.t
/** /**
* Figma: "Add Custom Field" control — Community Rule System (`20235:12994`). * Figma: "Add Custom Field" control — Community Rule System (`20235:12994`).
* Collapsed CTA expands to a 2×2 field-type picker (per-type modals deferred). * Collapsed CTA expands to a four-tile field-type picker.
*/ */
const AddCustomFieldContainer = memo<AddCustomFieldProps>( const AddCustomFieldContainer = memo<AddCustomFieldProps>(
({ active, onPressAdd, onSelectFieldType, className = "" }) => { ({ active, onPressAdd, onSelectFieldType, className = "" }) => {
@@ -11,7 +11,7 @@ export const ADD_CUSTOM_FIELD_TYPE_ICONS = {
} as const satisfies Record<AddCustomFieldType, IconName>; } as const satisfies Record<AddCustomFieldType, IconName>;
export interface AddCustomFieldProps { export interface AddCustomFieldProps {
/** When true, show the 2×2 field-type grid; when false, show the primary CTA. */ /** When true, show the four field-type tiles; when false, show the primary CTA. */
active: boolean; active: boolean;
onPressAdd?: () => void; onPressAdd?: () => void;
onSelectFieldType?: (type: AddCustomFieldType) => void; onSelectFieldType?: (type: AddCustomFieldType) => void;
@@ -38,15 +38,6 @@ function FieldTypeButton({
); );
} }
/**
* Stable block height for collapsed vs expanded so the Create dialog (`top-1/2 -translate-y-1/2`)
* does not shrink and re-center when toggling `active`.
*
* - Collapsed CTA: `py-12` (48+48) + inner row (`py-3` + 20px icon/line) ≈ 140px border-box.
* - Expanded: inner `p-4` (32) + Vertical tile (py 12+12, gap 8, 32px icon, 18px label) ≈ 114px — shorter without this floor.
*/
const ADD_CUSTOM_FIELD_SHELL_MIN_H_PX = 140;
function AddCustomFieldViewComponent({ function AddCustomFieldViewComponent({
active, active,
onPressAdd, onPressAdd,
@@ -55,17 +46,12 @@ function AddCustomFieldViewComponent({
fieldTypeLabels, fieldTypeLabels,
className, className,
}: AddCustomFieldViewProps) { }: AddCustomFieldViewProps) {
const shellStyle = {
minHeight: ADD_CUSTOM_FIELD_SHELL_MIN_H_PX,
} as const;
if (!active) { if (!active) {
return ( return (
<button <button
type="button" type="button"
onClick={onPressAdd} onClick={onPressAdd}
style={shellStyle} className={`flex h-[88px] w-full shrink-0 cursor-pointer items-center justify-center rounded-[var(--measures-radius-medium,8px)] bg-[var(--color-surface-default-secondary)] px-6 text-medium-label text-[var(--color-content-default-primary)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-invert-primary)] ${className ?? ""}`.trim()}
className={`flex w-full shrink-0 cursor-pointer items-center justify-center rounded-[var(--measures-radius-medium,8px)] bg-[var(--color-surface-default-secondary)] px-6 py-12 text-medium-label text-[var(--color-content-default-primary)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-invert-primary)] ${className ?? ""}`.trim()}
> >
<span className="flex items-center gap-[var(--spacing-scale-006)] rounded-full px-4 py-3"> <span className="flex items-center gap-[var(--spacing-scale-006)] rounded-full px-4 py-3">
<svg <svg
@@ -89,35 +75,33 @@ function AddCustomFieldViewComponent({
); );
} }
const expandedShellClasses = ["flex w-full shrink-0 flex-col", className ?? ""] const expandedShellClasses = ["flex w-full shrink-0", className ?? ""]
.join(" ") .join(" ")
.trim(); .trim();
return ( return (
<div className={expandedShellClasses} style={shellStyle}> <div className={expandedShellClasses}>
<div className="flex w-full flex-col gap-3 rounded-[var(--measures-radius-medium,8px)] bg-[var(--color-surface-default-secondary)] p-4"> <div className="grid w-full grid-cols-4 gap-3">
<div className="flex w-full flex-row flex-nowrap justify-center gap-3 overflow-x-auto max-sm:justify-start"> <FieldTypeButton
<FieldTypeButton type="text"
type="text" label={fieldTypeLabels.text}
label={fieldTypeLabels.text} onSelect={onSelectFieldType}
onSelect={onSelectFieldType} />
/> <FieldTypeButton
<FieldTypeButton type="badges"
type="badges" label={fieldTypeLabels.badges}
label={fieldTypeLabels.badges} onSelect={onSelectFieldType}
onSelect={onSelectFieldType} />
/> <FieldTypeButton
<FieldTypeButton type="upload"
type="upload" label={fieldTypeLabels.upload}
label={fieldTypeLabels.upload} onSelect={onSelectFieldType}
onSelect={onSelectFieldType} />
/> <FieldTypeButton
<FieldTypeButton type="proportion"
type="proportion" label={fieldTypeLabels.proportion}
label={fieldTypeLabels.proportion} onSelect={onSelectFieldType}
onSelect={onSelectFieldType} />
/>
</div>
</div> </div>
</div> </div>
); );
@@ -17,7 +17,7 @@ export function CheckboxView({
}: CheckboxViewProps) { }: CheckboxViewProps) {
return ( return (
<label <label
className={`inline-flex items-center gap-[8px] cursor-pointer select-none ${ className={`inline-flex max-w-full min-w-0 items-center gap-[8px] cursor-pointer select-none ${
disabled ? "opacity-60 cursor-not-allowed" : "" disabled ? "opacity-60 cursor-not-allowed" : ""
} ${className}`} } ${className}`}
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
@@ -51,7 +51,7 @@ export function CheckboxView({
{label && ( {label && (
<span <span
id={labelId} id={labelId}
className="text-[14px] leading-[18px]" className="min-w-0 flex-1 text-[14px] leading-[18px] whitespace-normal break-words"
style={{ color: labelColor }} style={{ color: labelColor }}
> >
{label} {label}
+2 -1
View File
@@ -99,6 +99,7 @@ function ChipView({
const baseClasses = ` const baseClasses = `
inline-flex inline-flex
max-w-full
items-center items-center
justify-center justify-center
rounded-[var(--measures-radius-full,9999px)] rounded-[var(--measures-radius-full,9999px)]
@@ -264,7 +265,7 @@ function ChipView({
onClick={handleClick} onClick={handleClick}
{...sharedA11y} {...sharedA11y}
> >
<span className="flex items-center justify-center">{label}</span> <span className="min-w-0 truncate">{label}</span>
{onRemove && !isDisabled && ( {onRemove && !isDisabled && (
<button <button
type="button" type="button"
@@ -45,30 +45,30 @@ function InfoMessageBoxView({
return ( return (
<div <div
className={`flex flex-col gap-[12px] p-[var(--spacing-measures-spacing-500,20px)] rounded-[var(--measures-radius-300,12px)] border-l-2 border-solid border-[var(--color-border-default-secondary,#1f1f1f)] bg-[var(--color-content-inverse-secondary,#1f1f1f)] w-full min-w-0 ${className}`} className={`flex w-full min-w-0 items-start gap-[var(--measures-spacing-200,8px)] p-[var(--spacing-measures-spacing-500,20px)] rounded-[var(--measures-radius-300,12px)] border-l-2 border-solid border-[var(--color-border-default-secondary,#1f1f1f)] bg-[var(--color-content-inverse-secondary,#1f1f1f)] ${className}`}
role="region" role="region"
aria-label={title} aria-label={title}
> >
<div className="flex items-center gap-[var(--measures-spacing-200,8px)] min-w-0"> <div
<div className="relative shrink-0 size-6 flex items-center justify-center"
className="relative shrink-0 size-6 flex items-center justify-center" data-name="Asset / Icon / exclamation"
data-name="Asset / Icon / exclamation" >
> {icon ?? <ExclamationIconInline />}
{icon ?? <ExclamationIconInline />} </div>
</div> <div className="flex min-w-0 flex-1 flex-col gap-[12px]">
<p className="text-small-label text-[var(--color-content-default-primary,white)] min-w-0"> <p className="text-small-label text-[var(--color-content-default-primary,white)] min-w-0 break-words">
{title} {title}
</p> </p>
</div> <div className="flex min-w-0 w-full flex-col gap-[12px] [&_label]:w-full [&_label]:min-w-0 [&_label]:gap-[6px] [&_label_span]:text-x-small-paragraph [&_label_span]:opacity-80">
<div className="flex flex-col gap-[12px] [&_label]:gap-[6px] [&_label_span]:text-x-small-paragraph [&_label_span]:opacity-80 pl-8"> <CheckboxGroup
<CheckboxGroup mode="standard"
mode="standard" value={checkedIds}
value={checkedIds} onChange={handleChange}
onChange={handleChange} options={options}
options={options} aria-label={title}
aria-label={title} className="flex w-full min-w-0 flex-col gap-[12px] !space-y-0"
className="flex flex-col gap-[12px] !space-y-0" />
/> </div>
</div> </div>
</div> </div>
); );
+1 -1
View File
@@ -59,7 +59,7 @@ export function CreateView({
{headerContent !== undefined ? ( {headerContent !== undefined ? (
<div className="shrink-0">{headerContent}</div> <div className="shrink-0">{headerContent}</div>
) : title || description ? ( ) : title || description ? (
<div className="bg-[var(--color-surface-default-primary)] px-[24px] py-[12px] shrink-0"> <div className="bg-[var(--color-surface-default-primary)] px-[24px] shrink-0">
<ContentLockup <ContentLockup
title={title} title={title}
description={description} description={description}
@@ -9,6 +9,7 @@ import TextInput from "../../controls/TextInput";
import ContentLockup from "../../type/ContentLockup"; import ContentLockup from "../../type/ContentLockup";
import Alert from "../Alert"; import Alert from "../Alert";
import { requestMagicLink } from "../../../../lib/create/api"; import { requestMagicLink } from "../../../../lib/create/api";
import { EMAIL_MAX_LEN } from "../../../../lib/create/isValidCreateFlowSaveEmail";
import { buildCreateFlowDraftPayload } from "../../../../lib/create/buildCreateFlowDraftPayload"; import { buildCreateFlowDraftPayload } from "../../../../lib/create/buildCreateFlowDraftPayload";
import { safeInternalPath } from "../../../../lib/safeInternalPath"; import { safeInternalPath } from "../../../../lib/safeInternalPath";
import { import {
@@ -232,6 +233,7 @@ export default function LoginForm({
disabled={submitting} disabled={submitting}
error={Boolean(emailError)} error={Boolean(emailError)}
showHelpIcon showHelpIcon
maxLength={EMAIL_MAX_LEN}
/> />
{emailError ? ( {emailError ? (
<p <p
+2 -1
View File
@@ -19,7 +19,8 @@ function BookView({
headingId, headingId,
className = "", className = "",
}: BookViewProps) { }: BookViewProps) {
const coverSrc = imageSrc ?? getAssetPath(ASSETS.COMMUNITYRULES_COVER); const coverSrc =
imageSrc ?? getAssetPath(ASSETS.STRUCTURE_BEFORE_CRISIS_COVER);
return ( return (
<section <section
@@ -94,9 +94,9 @@ function ContentLockupView({
)} )}
{/* Link for feature variant */} {/* Link for feature variant */}
{variant === "feature" && linkText && ( {variant === "feature" && linkText && linkHref && (
<a <a
href={linkHref || "#"} href={linkHref}
className="text-medium-underline underline text-[var(--color-content-default-primary)] hover:text-[var(--color-content-default-secondary)] transition-colors focus:outline-none focus:ring-2 focus:ring-[var(--color-surface-default-brand-royal)] focus:ring-offset-2 focus:ring-offset-[var(--color-surface-default-secondary)] rounded-sm px-1 py-0.5" className="text-medium-underline underline text-[var(--color-content-default-primary)] hover:text-[var(--color-content-default-secondary)] transition-colors focus:outline-none focus:ring-2 focus:ring-[var(--color-surface-default-brand-royal)] focus:ring-offset-2 focus:ring-offset-[var(--color-surface-default-secondary)] rounded-sm px-1 py-0.5"
> >
{linkText} {linkText}
-12
View File
@@ -8,10 +8,8 @@ import {
useState, useState,
type ReactNode, type ReactNode,
} from "react"; } from "react";
import Link from "next/link";
import Login from "../components/modals/Login"; import Login from "../components/modals/Login";
import LoginForm from "../components/modals/Login/LoginForm"; import LoginForm from "../components/modals/Login/LoginForm";
import { useTranslation } from "./MessagesContext";
export type AuthModalLoginVariant = "default" | "saveProgress"; export type AuthModalLoginVariant = "default" | "saveProgress";
@@ -34,7 +32,6 @@ const AuthModalContext = createContext<AuthModalContextValue | null>(null);
export function AuthModalProvider({ children }: { children: ReactNode }) { export function AuthModalProvider({ children }: { children: ReactNode }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [opts, setOpts] = useState<OpenLoginOptions>({}); const [opts, setOpts] = useState<OpenLoginOptions>({});
const t = useTranslation("pages.login");
const openLogin = useCallback((o?: OpenLoginOptions) => { const openLogin = useCallback((o?: OpenLoginOptions) => {
setOpts(o ?? {}); setOpts(o ?? {});
@@ -62,15 +59,6 @@ export function AuthModalProvider({ children }: { children: ReactNode }) {
backdropVariant={backdropVariant} backdropVariant={backdropVariant}
usePortal usePortal
ariaLabelledBy="login-modal-heading" ariaLabelledBy="login-modal-heading"
belowCard={
<Link
href="/"
className="text-small-paragraph text-[var(--color-content-invert-tertiary,#2d2d2d)] text-center hover:opacity-90"
onClick={() => closeLogin()}
>
{t("backToHome")}
</Link>
}
> >
<LoginForm <LoginForm
variant={opts.variant ?? "default"} variant={opts.variant ?? "default"}
+4 -1
View File
@@ -65,7 +65,10 @@ stage. Raster → SVG conversion is tracked in
| `marketing/section-number-*.svg` (×3) | SectionNumber | **Done** — SVG | | `marketing/section-number-*.svg` (×3) | SectionNumber | **Done** — SVG |
| `marketing/avatar-*.svg` (×3) | Avatar / ASSETS | **Done** — SVG | | `marketing/avatar-*.svg` (×3) | Avatar / ASSETS | **Done** — SVG |
| `marketing/hero-image.png` | HeroBanner | **Design review** — likely keep raster | | `marketing/hero-image.png` | HeroBanner | **Design review** — likely keep raster |
| `marketing/governance-booklet.pdf` | About / Book | **Done**PDF (`governanceBookletPath()`) | | `marketing/communityrules-cover.svg` | About / Book | **Done**Structure Before Crisis cover (`ASSETS.STRUCTURE_BEFORE_CRISIS_COVER`; Figma **22137:891197**) |
| `marketing/community-rules-cover.png` | About / Book | **Done** — Community Rules (2021) cover (`ASSETS.COMMUNITY_RULES_COVER`; gitignore exception) |
| `marketing/governance-booklet.pdf` | About / Book | **Done** — Community Rules (2021) PDF (`governanceBookletPath()`) |
| `marketing/structure-before-crisis.pdf` | About / Book | **Done** — Structure Before Crisis (2025) PDF (`structureBeforeCrisisPath()`) |
| `logos/community-rule.svg` | Logo + favicon (`ASSETS.LOGO`) | **Done** — SVG | | `logos/community-rule.svg` | Logo + favicon (`ASSETS.LOGO`) | **Done** — SVG |
| `share/*.svg` (×5) | Share modal | **Done** — SVG (`shareIconPath()`) | | `share/*.svg` (×5) | Share modal | **Done** — SVG (`shareIconPath()`) |
| `logos/gitlab.svg` | Footer / social | **Done** — SVG | | `logos/gitlab.svg` | Footer / social | **Done** — SVG |
+11 -3
View File
@@ -97,11 +97,16 @@ export function sectionNumberPath(n: 1 | 2 | 3): string {
return `assets/marketing/section-number-${n}.svg`; return `assets/marketing/section-number-${n}.svg`;
} }
/** Downloadable governance booklet PDF (About / Sections / Book). */ /** Community Rules (2021) booklet PDF (About / Sections / Book). */
export function governanceBookletPath(): string { export function governanceBookletPath(): string {
return "assets/marketing/governance-booklet.pdf"; return "assets/marketing/governance-booklet.pdf";
} }
/** Structure Before Crisis (2025) PDF (About / Sections / Book). */
export function structureBeforeCrisisPath(): string {
return "assets/marketing/structure-before-crisis.pdf";
}
/** Home feature grid panel art in `public/assets/marketing/`. */ /** Home feature grid panel art in `public/assets/marketing/`. */
export type FeaturePanelKey = "support" | "exercises" | "guidance" | "tools"; export type FeaturePanelKey = "support" | "exercises" | "guidance" | "tools";
@@ -245,8 +250,11 @@ export const ASSETS = {
/** Quote block default avatar. */ /** Quote block default avatar. */
QUOTE_AVATAR: "assets/marketing/quote-avatar.svg", QUOTE_AVATAR: "assets/marketing/quote-avatar.svg",
/** Sections / Book cover (Figma **22137:891197**). */ /** Structure Before Crisis cover (Figma **22137:891197**; file name is historical). */
COMMUNITYRULES_COVER: "assets/marketing/communityrules-cover.svg", STRUCTURE_BEFORE_CRISIS_COVER: "assets/marketing/communityrules-cover.svg",
/** Community Rules (2021) cover. */
COMMUNITY_RULES_COVER: "assets/marketing/community-rules-cover.png",
// Marketing // Marketing
HERO_IMAGE: "assets/marketing/hero-image.png", HERO_IMAGE: "assets/marketing/hero-image.png",
@@ -2,7 +2,7 @@
export const CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS = 48; export const CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS = 48;
/** /**
* Max length for the policy description. Wider than the title so Customize can * Max length for persisted method-card `supportText`. Wider than the title so
* seed existing card support text without blocking Next. * Customize can seed existing card copy and catalog blurbs without blocking Next.
*/ */
export const CUSTOM_METHOD_CARD_WIZARD_MAX_DESCRIPTION_CHARS = 200; export const CUSTOM_METHOD_CARD_WIZARD_MAX_DESCRIPTION_CHARS = 200;
+4 -1
View File
@@ -157,7 +157,10 @@ const decisionRow = {
selectionIds: (s: CreateFlowState) => s.selectedDecisionApproachIds ?? [], selectionIds: (s: CreateFlowState) => s.selectedDecisionApproachIds ?? [],
selectedIdsStateKey: "selectedDecisionApproachIds", selectedIdsStateKey: "selectedDecisionApproachIds",
detailOverridesStateKey: "decisionApproachDetailsById", detailOverridesStateKey: "decisionApproachDetailsById",
stripSelectionKeys: ["selectedDecisionApproachIds"] as const, stripSelectionKeys: [
"selectedDecisionApproachIds",
"selectedDecisionKeyResourceIds",
] as const,
apiMethodSectionId: "decisionApproaches", apiMethodSectionId: "decisionApproaches",
} satisfies CustomRuleFacetRow; } satisfies CustomRuleFacetRow;
+177
View File
@@ -0,0 +1,177 @@
import type { DecisionApproachDetailEntry } from "../../app/(app)/create/types";
import decisionApproachesMessages from "../../messages/en/create/customRule/decisionApproaches.json";
export type DecisionApproachKeyResourceItem = {
id: string;
label: string;
};
function uniquePreserveOrder(values: readonly string[]): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const value of values) {
if (seen.has(value)) continue;
seen.add(value);
out.push(value);
}
return out;
}
function isKeyResourceItem(value: unknown): value is DecisionApproachKeyResourceItem {
if (!value || typeof value !== "object") return false;
const row = value as { id?: unknown; label?: unknown };
return typeof row.id === "string" && typeof row.label === "string";
}
/**
* Sidebar “key resource” checkboxes on the decision-approaches step.
* These labels are also offered as Applicable Scope chips on every approach.
*/
export function decisionApproachKeyResourceItems(): DecisionApproachKeyResourceItem[] {
const items = (
decisionApproachesMessages as {
messageBox?: { items?: unknown };
}
).messageBox?.items;
if (!Array.isArray(items)) return [];
return items.filter(isKeyResourceItem);
}
export function decisionApproachKeyResourceLabels(): string[] {
return decisionApproachKeyResourceItems().map((item) => item.label);
}
export function selectedKeyResourceLabelsFromCheckedIds(
checkedIds: readonly string[],
): string[] {
const idSet = new Set(checkedIds);
return decisionApproachKeyResourceItems()
.filter((item) => idSet.has(item.id))
.map((item) => item.label);
}
export function decisionApproachKeyResourceIdsFromLabels(
labels: readonly string[],
): string[] {
const labelSet = new Set(labels);
return decisionApproachKeyResourceItems()
.filter((item) => labelSet.has(item.label))
.map((item) => item.id);
}
/**
* Sidebar checkboxes follow the open approach while its modal is up.
* With no modal they show key resources assigned to any selected approach,
* plus extra reminder checks that are not painted onto chips.
*/
export function decisionApproachKeyResourceCheckboxIds(params: {
detailsById: Record<string, DecisionApproachDetailEntry> | undefined;
selectedApproachIds: readonly string[];
reminderIds?: readonly string[];
openDraft?: DecisionApproachDetailEntry | null;
}): string[] {
if (params.openDraft) {
return decisionApproachKeyResourceIdsFromLabels(
params.openDraft.selectedApplicableScope,
);
}
const assignedLabels: string[] = [];
for (const id of params.selectedApproachIds) {
const entry = params.detailsById?.[id];
if (!entry) continue;
assignedLabels.push(...entry.selectedApplicableScope);
}
return uniquePreserveOrder([
...decisionApproachKeyResourceIdsFromLabels(assignedLabels),
...(params.reminderIds ?? []),
]);
}
export function stringArraysEqual(
a: readonly string[],
b: readonly string[],
): boolean {
if (a.length !== b.length) return false;
return a.every((value, index) => value === b[index]);
}
/**
* Offer the four sidebar labels as Applicable Scope chips without persisting
* them onto `applicableScope` (unchecked keys must not publish as defaults).
*/
export function withDecisionApproachKeyResourceScopes(
scopes: readonly string[],
): string[] {
return uniquePreserveOrder([
...scopes,
...decisionApproachKeyResourceLabels(),
]);
}
/**
* Keep non-key chip selections, then select the key-resource labels whose
* sidebar boxes are checked.
*/
export function applyDecisionApproachKeyResources(
entry: DecisionApproachDetailEntry,
selectedKeyResourceLabels: readonly string[],
): DecisionApproachDetailEntry {
const keyLabels = decisionApproachKeyResourceLabels();
const keySet = new Set(keyLabels);
const selectedSet = new Set(selectedKeyResourceLabels);
return {
...entry,
selectedApplicableScope: uniquePreserveOrder([
...entry.selectedApplicableScope.filter((scope) => !keySet.has(scope)),
...keyLabels.filter((label) => selectedSet.has(label)),
]),
};
}
export function withoutDecisionApproachKeyResourceLabels(
scopes: readonly string[],
): string[] {
const keySet = new Set(decisionApproachKeyResourceLabels());
return scopes.filter((scope) => !keySet.has(scope));
}
/**
* Publish selected original scopes (or all originals when none are picked)
* plus any checked key-resource labels. Checking a key resource must not drop
* unselected default scopes.
*/
export function decisionApproachScopeForPublish(
applicableScope: readonly unknown[],
selectedApplicableScope: readonly unknown[],
): string[] {
const asStrings = (values: readonly unknown[]): string[] =>
values.filter((value): value is string => typeof value === "string");
const keySet = new Set(decisionApproachKeyResourceLabels());
const original = withoutDecisionApproachKeyResourceLabels(
asStrings(applicableScope),
);
const selected = asStrings(selectedApplicableScope);
const selectedOther = withoutDecisionApproachKeyResourceLabels(selected);
const selectedKeys = selected.filter((scope) => keySet.has(scope));
return uniquePreserveOrder([
...(selectedOther.length > 0 ? selectedOther : original),
...selectedKeys,
]);
}
export function syncDecisionApproachKeyResourceDetails(
detailsById: Record<string, DecisionApproachDetailEntry> | undefined,
extraIds: readonly string[],
selectedKeyResourceLabels: readonly string[],
seed: (_id: string) => DecisionApproachDetailEntry,
): Record<string, DecisionApproachDetailEntry> {
const next: Record<string, DecisionApproachDetailEntry> = {};
const ids = new Set<string>([...Object.keys(detailsById ?? {}), ...extraIds]);
for (const id of ids) {
next[id] = applyDecisionApproachKeyResources(
detailsById?.[id] ?? seed(id),
selectedKeyResourceLabels,
);
}
return next;
}
+2 -1
View File
@@ -1,4 +1,5 @@
const EMAIL_MAX_LEN = 254; /** RFC 5321 max mailbox length (no angle brackets). */
export const EMAIL_MAX_LEN = 254;
/** Pragmatic check for the create-flow “save progress” email field (draft + footer enablement). */ /** Pragmatic check for the create-flow “save progress” email field (draft + footer enablement). */
export function isValidCreateFlowSaveEmail(value: unknown): boolean { export function isValidCreateFlowSaveEmail(value: unknown): boolean {
+2 -3
View File
@@ -1,7 +1,6 @@
/** /**
* Reorders facet-ranked method presets so explicitly confirmed selections pin * Compact CardStack slots pin confirmed selections to the front while the
* to the top while the remainder keeps score-based ranking (recommended before * remainder keeps score-based ranking. Expanded order stays the ranked catalog.
* default).
*/ */
/** Selected ids first (selection array order); then tail in `ranked` order. */ /** Selected ids first (selection array order); then tail in `ranked` order. */
@@ -10,6 +10,7 @@ import {
decisionApproachPresetFor, decisionApproachPresetFor,
membershipPresetFor, membershipPresetFor,
} from "./finalReviewChipPresets"; } from "./finalReviewChipPresets";
import { withoutDecisionApproachKeyResourceLabels } from "./decisionApproachKeyResources";
function stringArraysEqual(a: readonly string[], b: readonly string[]): boolean { function stringArraysEqual(a: readonly string[], b: readonly string[]): boolean {
if (a.length !== b.length) return false; if (a.length !== b.length) return false;
@@ -51,8 +52,14 @@ export function decisionApproachFacetMatchesPreset(
const p = decisionApproachPresetFor(cardId); const p = decisionApproachPresetFor(cardId);
return ( return (
details.corePrinciple === p.corePrinciple && details.corePrinciple === p.corePrinciple &&
stringArraysEqual(details.applicableScope, p.applicableScope) && stringArraysEqual(
stringArraysEqual(details.selectedApplicableScope, p.selectedApplicableScope) && withoutDecisionApproachKeyResourceLabels(details.applicableScope),
withoutDecisionApproachKeyResourceLabels(p.applicableScope),
) &&
stringArraysEqual(
withoutDecisionApproachKeyResourceLabels(details.selectedApplicableScope),
withoutDecisionApproachKeyResourceLabels(p.selectedApplicableScope),
) &&
details.stepByStepInstructions === p.stepByStepInstructions && details.stepByStepInstructions === p.stepByStepInstructions &&
details.consensusLevel === p.consensusLevel && details.consensusLevel === p.consensusLevel &&
details.objectionsDeadlocks === p.objectionsDeadlocks details.objectionsDeadlocks === p.objectionsDeadlocks
+2 -2
View File
@@ -1,7 +1,7 @@
/** /**
* Canonical ordering for method-card facet `selected*Ids` when the user adds a card: * Canonical ordering for method-card facet `selected*Ids` when the user adds a card:
* most recently confirmed id is index 0 so stack / compact layouts stay consistent * most recently confirmed id is index 0 so compact layouts stay consistent
* with {@link orderRankedMethodsWithPinnedSelection}. * with {@link mergeCompactCardIdsWithPinnedSelected}.
*/ */
export function moveFacetSelectionIdToFront( export function moveFacetSelectionIdToFront(
prev: readonly string[], prev: readonly string[],
@@ -5,6 +5,7 @@ import type {
} from "../../app/components/type/CommunityRule/CommunityRule.types"; } from "../../app/components/type/CommunityRule/CommunityRule.types";
import type { PublishedMethodSelections } from "./buildPublishPayload"; import type { PublishedMethodSelections } from "./buildPublishPayload";
import type { CustomMethodCardFieldBlock } from "./customMethodCardFieldBlocks"; import type { CustomMethodCardFieldBlock } from "./customMethodCardFieldBlocks";
import { decisionApproachScopeForPublish } from "./decisionApproachKeyResources";
import { templateCategoryToGroupKey } from "./templateReviewMapping"; import { templateCategoryToGroupKey } from "./templateReviewMapping";
/** Uses filename extension and/or URL path so uploads render as `<img>` vs file link on read-only surfaces. */ /** Uses filename extension and/or URL path so uploads render as `<img>` vs file link on read-only surfaces. */
@@ -243,9 +244,14 @@ export function sectionFromDecision(
for (const m of ms) { for (const m of ms) {
const sec = m.sections as unknown as Record<string, unknown>; const sec = m.sections as unknown as Record<string, unknown>;
const merged: Record<string, unknown> = { ...sec }; const merged: Record<string, unknown> = { ...sec };
const scope = const scope = formatScopePayload(
formatScopePayload(sec.selectedApplicableScope) ?? decisionApproachScopeForPublish(
formatScopePayload(sec.applicableScope); Array.isArray(sec.applicableScope) ? sec.applicableScope : [],
Array.isArray(sec.selectedApplicableScope)
? sec.selectedApplicableScope
: [],
),
);
if (scope) merged.applicableScope = scope; if (scope) merged.applicableScope = scope;
delete merged.selectedApplicableScope; delete merged.selectedApplicableScope;
const e = communityRuleEntryFromMethodChip(m.label, merged, DEC_LABELS, { const e = communityRuleEntryFromMethodChip(m.label, merged, DEC_LABELS, {
+9 -3
View File
@@ -1,6 +1,11 @@
import { z } from "zod"; import { z } from "zod";
import { FLOW_STEP_ORDER } from "../../../app/(app)/create/utils/flowSteps"; import { FLOW_STEP_ORDER } from "../../../app/(app)/create/utils/flowSteps";
import { customMethodCardFieldBlocksByIdSchema } from "../../../lib/create/customMethodCardFieldBlocks"; import { customMethodCardFieldBlocksByIdSchema } from "../../../lib/create/customMethodCardFieldBlocks";
import {
CUSTOM_METHOD_CARD_WIZARD_MAX_DESCRIPTION_CHARS,
CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS,
} from "../../../lib/create/customMethodCardWizardConstants";
import { EMAIL_MAX_LEN } from "../../../lib/create/isValidCreateFlowSaveEmail";
import { MAX_STAKEHOLDER_EMAILS } from "../../../lib/create/stakeholderLimits"; import { MAX_STAKEHOLDER_EMAILS } from "../../../lib/create/stakeholderLimits";
import { assertPlainJsonValue, DEFAULT_PLAIN_JSON_LIMITS } from "./plainJson"; import { assertPlainJsonValue, DEFAULT_PLAIN_JSON_LIMITS } from "./plainJson";
@@ -62,8 +67,8 @@ const conflictManagementDetailEntrySchema = z.object({
}); });
const customMethodCardMetaEntrySchema = z.object({ const customMethodCardMetaEntrySchema = z.object({
label: z.string().max(48), label: z.string().max(CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS),
supportText: z.string().max(48), supportText: z.string().max(CUSTOM_METHOD_CARD_WIZARD_MAX_DESCRIPTION_CHARS),
}); });
/** Normalized (trim + lowercase) stakeholder email for drafts + publish. */ /** Normalized (trim + lowercase) stakeholder email for drafts + publish. */
@@ -117,6 +122,7 @@ export const createFlowStateSchema = z
selectedCommunicationMethodIds: z.array(z.string()).max(200).optional(), selectedCommunicationMethodIds: z.array(z.string()).max(200).optional(),
selectedMembershipMethodIds: z.array(z.string()).max(200).optional(), selectedMembershipMethodIds: z.array(z.string()).max(200).optional(),
selectedDecisionApproachIds: z.array(z.string()).max(200).optional(), selectedDecisionApproachIds: z.array(z.string()).max(200).optional(),
selectedDecisionKeyResourceIds: z.array(z.string().max(80)).max(20).optional(),
selectedConflictManagementIds: z.array(z.string()).max(200).optional(), selectedConflictManagementIds: z.array(z.string()).max(200).optional(),
communicationMethodDetailsById: z communicationMethodDetailsById: z
.record(communicationMethodDetailEntrySchema) .record(communicationMethodDetailEntrySchema)
@@ -225,7 +231,7 @@ export const putDraftBodySchema = z.object({
export type CreateFlowStateValidated = z.infer<typeof createFlowStateSchema>; export type CreateFlowStateValidated = z.infer<typeof createFlowStateSchema>;
export const magicLinkRequestBodySchema = z.object({ export const magicLinkRequestBodySchema = z.object({
email: z.string(), email: z.string().max(EMAIL_MAX_LEN),
next: z.string().optional(), next: z.string().optional(),
draft: createFlowStateSchema.optional(), draft: createFlowStateSchema.optional(),
}); });
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"_comment": "FeatureGrid component defaults (shared across pages). linkHref is a stub until destination pages ship.", "_comment": "FeatureGrid component defaults (shared across pages). Learn more goes to /learn; feature cards stay non-interactive until destinations are decided.",
"linkText": "Learn more", "linkText": "Learn more",
"linkHref": "#", "linkHref": "/learn",
"ariaLabel": "Feature tools and services", "ariaLabel": "Feature tools and services",
"features": { "features": {
"decisionMaking": { "decisionMaking": {
@@ -24,7 +24,7 @@
"dragHandleAriaLabel": "Drag to reorder this field" "dragHandleAriaLabel": "Drag to reorder this field"
}, },
"footer": { "footer": {
"finalize": "Finalize" "finalize": "Finalize policy"
}, },
"editModal": { "editModal": {
"noCustomFieldsYet": "No custom fields yet.", "noCustomFieldsYet": "No custom fields yet.",
@@ -36,7 +36,7 @@
"clearFileLabel": "Clear file" "clearFileLabel": "Clear file"
}, },
"addCustomField": { "addCustomField": {
"cta": "Add", "cta": "Add Custom Field",
"fieldTypes": { "fieldTypes": {
"text": "Text", "text": "Text",
"badges": "Badges", "badges": "Badges",
+9 -3
View File
@@ -1,5 +1,5 @@
{ {
"_comment": "About page content. Book download href is wired in app/(marketing)/about/page.tsx via governanceBookletPath().", "_comment": "About page content. Book download hrefs are wired in app/(marketing)/about/page.tsx (structureBeforeCrisisPath / governanceBookletPath).",
"aboutHeader": { "aboutHeader": {
"segments": [ "segments": [
{ "type": "word", "text": "CommunityRule" }, { "type": "word", "text": "CommunityRule" },
@@ -69,11 +69,17 @@
} }
] ]
}, },
"book": { "structureBeforeCrisis": {
"title": "Get Structure Before Crisis",
"description": "Structure Before Crisis is a collection of organizing patterns that help mutual aid groups lay the groundwork to build new worlds.",
"buttonText": "Download Book",
"imageAlt": "Structure Before Crisis book cover"
},
"communityRules": {
"title": "Get the Community Rules Book", "title": "Get the Community Rules Book",
"description": "Community Rules is a simple tool to help make great communities even better and healthier. It includes nine templates for organizational structures that communities can choose from, combine, or react against.", "description": "Community Rules is a simple tool to help make great communities even better and healthier. It includes nine templates for organizational structures that communities can choose from, combine, or react against.",
"buttonText": "Download Book", "buttonText": "Download Book",
"imageAlt": "Structure Before Crisis book cover" "imageAlt": "Community Rules book cover"
}, },
"faq": { "faq": {
"title": "Get answers to your questions", "title": "Get answers to your questions",
Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

@@ -1,18 +1,6 @@
<svg width="256" height="370" viewBox="0 0 256 370" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> <svg width="208" height="322" viewBox="24 24 208 321.707" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g filter="url(#filter0_d_22851_36281)">
<rect x="24" y="24" width="208" height="321.707" rx="4" fill="url(#pattern0_22851_36281)" shape-rendering="crispEdges"/> <rect x="24" y="24" width="208" height="321.707" rx="4" fill="url(#pattern0_22851_36281)" shape-rendering="crispEdges"/>
</g>
<defs> <defs>
<filter id="filter0_d_22851_36281" x="0" y="0" width="256" height="369.707" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset/>
<feGaussianBlur stdDeviation="12"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_22851_36281"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_22851_36281" result="shape"/>
</filter>
<pattern id="pattern0_22851_36281" patternContentUnits="objectBoundingBox" width="1" height="1"> <pattern id="pattern0_22851_36281" patternContentUnits="objectBoundingBox" width="1" height="1">
<use xlink:href="#image0_22851_36281" transform="matrix(0.00252723 0 0 0.00163399 -0.000392154 0)"/> <use xlink:href="#image0_22851_36281" transform="matrix(0.00252723 0 0 0.00163399 -0.000392154 0)"/>
</pattern> </pattern>

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.
Binary file not shown.
+2 -1
View File
@@ -97,7 +97,8 @@ export const Expanded = {
parameters: { parameters: {
docs: { docs: {
description: { description: {
story: "Expanded list layout with vertical cards and Show less toggle.", story:
"Expanded 2-column stack of 142px CardSelection tiles and Show less toggle.",
}, },
}, },
}, },
+6 -5
View File
@@ -36,7 +36,8 @@ export default {
}, },
showInfoIcon: { showInfoIcon: {
control: { type: "boolean" }, control: { type: "boolean" },
description: "Show info icon next to label (typically in vertical)", description:
"Optional helper ? next to the label. Off in product until tooltip behavior ships.",
}, },
onClick: { action: "clicked" }, onClick: { action: "clicked" },
}, },
@@ -82,7 +83,7 @@ export const VerticalRecommended = {
recommended: true, recommended: true,
selected: false, selected: false,
orientation: "vertical", orientation: "vertical",
showInfoIcon: true, showInfoIcon: false,
}, },
}; };
@@ -93,7 +94,7 @@ export const VerticalSelected = {
recommended: false, recommended: false,
selected: true, selected: true,
orientation: "vertical", orientation: "vertical",
showInfoIcon: true, showInfoIcon: false,
}, },
}; };
@@ -134,7 +135,7 @@ export const AllVariants = {
recommended={true} recommended={true}
selected={false} selected={false}
orientation="vertical" orientation="vertical"
showInfoIcon={true} showInfoIcon={false}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@@ -147,7 +148,7 @@ export const AllVariants = {
recommended={false} recommended={false}
selected={true} selected={true}
orientation="vertical" orientation="vertical"
showInfoIcon={true} showInfoIcon={false}
/> />
</div> </div>
</div> </div>
@@ -9,7 +9,7 @@ export default {
docs: { docs: {
description: { description: {
component: component:
"Shared 'labelled text area' field used by every create-flow modal section. Pairs `InputLabel` (with help icon) with a `TextArea` set to the `embedded` appearance.", "Shared 'labelled text area' field used by every create-flow modal section. Pairs `InputLabel` with a `TextArea` set to the `embedded` appearance. Section help marks stay off until tooltip behavior ships.",
}, },
}, },
}, },
@@ -35,7 +35,7 @@ export const Default = {
}, },
args: { args: {
label: "Description", label: "Description",
helpIcon: true, helpIcon: false,
placeholder: "What does this rule cover?", placeholder: "What does this rule cover?",
rows: 4, rows: 4,
}, },
+1 -9
View File
@@ -95,7 +95,7 @@ export const HeaderOverlayBlurred = {
docs: { docs: {
description: { description: {
story: story:
'Same as **Log in** from the site header: `backdropVariant="blurredYellow"`, `usePortal`, card + “Back to home” below.', 'Same as **Log in** from the site header: `backdropVariant="blurredYellow"`, `usePortal`, no “Back to home” under the card.',
}, },
}, },
}, },
@@ -107,14 +107,6 @@ export const HeaderOverlayBlurred = {
backdropVariant="blurredYellow" backdropVariant="blurredYellow"
usePortal usePortal
ariaLabelledBy="login-modal-heading" ariaLabelledBy="login-modal-heading"
belowCard={
<a
href="/"
className="text-center text-small-paragraph text-[var(--color-content-invert-tertiary)] hover:opacity-90"
>
{backToHome}
</a>
}
> >
<Suspense fallback={<p className="p-6 text-small-paragraph">Loading</p>}> <Suspense fallback={<p className="p-6 text-small-paragraph">Loading</p>}>
<LoginForm /> <LoginForm />
+26 -6
View File
@@ -1,5 +1,10 @@
import Book from "../../app/components/sections/Book"; import Book from "../../app/components/sections/Book";
import { getAssetPath, governanceBookletPath } from "../../lib/assetUtils"; import {
ASSETS,
getAssetPath,
governanceBookletPath,
structureBeforeCrisisPath,
} from "../../lib/assetUtils";
import messages from "../../messages/en/pages/about.json"; import messages from "../../messages/en/pages/about.json";
export default { export default {
@@ -11,12 +16,27 @@ export default {
}, },
}; };
const structureBeforeCrisis = messages.structureBeforeCrisis;
const communityRules = messages.communityRules;
export const Default = { export const Default = {
args: { args: {
title: messages.book.title, title: structureBeforeCrisis.title,
description: messages.book.description, description: structureBeforeCrisis.description,
buttonText: messages.book.buttonText, buttonText: structureBeforeCrisis.buttonText,
buttonHref: getAssetPath(governanceBookletPath()), buttonHref: getAssetPath(structureBeforeCrisisPath()),
imageAlt: messages.book.imageAlt, imageSrc: getAssetPath(ASSETS.STRUCTURE_BEFORE_CRISIS_COVER),
imageAlt: structureBeforeCrisis.imageAlt,
},
};
export const CommunityRules = {
args: {
title: communityRules.title,
description: communityRules.description,
buttonText: communityRules.buttonText,
buttonHref: getAssetPath(governanceBookletPath()),
imageSrc: getAssetPath(ASSETS.COMMUNITY_RULES_COVER),
imageAlt: communityRules.imageAlt,
}, },
}; };
+1 -1
View File
@@ -53,7 +53,7 @@ export const FeatureWithLink = {
"Use our toolkit to improve, document, and evolve your organization.", "Use our toolkit to improve, document, and evolve your organization.",
variant: "feature", variant: "feature",
linkText: "Learn more", linkText: "Learn more",
linkHref: "#", linkHref: "/learn",
}, },
}; };
@@ -119,4 +119,10 @@ describe("ApplicableScopeField behavior", () => {
screen.queryByRole("textbox", { name: /Add Applicable Scope/i }), screen.queryByRole("textbox", { name: /Add Applicable Scope/i }),
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
}); });
it("does not render a section help icon", () => {
renderWithProviders(<ApplicableScopeField {...baseProps} />);
expect(screen.queryByAltText("Help")).not.toBeInTheDocument();
});
}); });
@@ -77,6 +77,7 @@ describe("CommunicationMethodsScreen — Add Platform persistence", () => {
); );
const dialog = await screen.findByRole("dialog"); const dialog = await screen.findByRole("dialog");
expect(within(dialog).queryByAltText("Help")).not.toBeInTheDocument();
const textboxes = within(dialog).getAllByRole("textbox"); const textboxes = within(dialog).getAllByRole("textbox");
expect(textboxes.length).toBe(3); expect(textboxes.length).toBe(3);
const corePrincipleField = textboxes[0] as HTMLTextAreaElement; const corePrincipleField = textboxes[0] as HTMLTextAreaElement;
@@ -160,6 +161,44 @@ describe("CommunicationMethodsScreen — Add Platform persistence", () => {
expect(textareas[2].value).toBe("Saved coc"); expect(textareas[2].value).toBe("Saved coc");
}); });
it("persists edits and closes when Save is clicked on an already-selected platform", async () => {
let latest: CreateFlowState = {};
render(
<ScreenWithStateProbe
onState={(s) => {
latest = s;
}}
initial={{
selectedCommunicationMethodIds: ["signal"],
communicationMethodDetailsById: {
signal: {
corePrinciple: "Saved principle",
logisticsAdmin: "Saved logistics",
codeOfConduct: "Saved coc",
},
},
}}
/>,
);
fireEvent.click(
screen.getAllByRole("button", { name: /Signal: Encrypted messaging/ })[0],
);
const dialog = await screen.findByRole("dialog");
const textareas = within(dialog).getAllByRole(
"textbox",
) as HTMLTextAreaElement[];
fireEvent.change(textareas[0], { target: { value: "Edited principle" } });
fireEvent.click(within(dialog).getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
expect(
latest.communicationMethodDetailsById?.signal?.corePrinciple,
).toBe("Edited principle");
});
it("keeps catalog section editors when a title override exists and there are no custom fields", async () => { it("keeps catalog section editors when a title override exists and there are no custom fields", async () => {
const details = communicationPresetFor("video-meetings"); const details = communicationPresetFor("video-meetings");
const noFieldsHint = const noFieldsHint =
@@ -403,7 +442,7 @@ describe("CommunicationMethodsScreen — Add Platform persistence", () => {
expect( expect(
screen.getByRole("button", { name: "Core Principle & Scope" }), screen.getByRole("button", { name: "Core Principle & Scope" }),
).toBeInTheDocument(); ).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Finalize" })); fireEvent.click(screen.getByRole("button", { name: "Finalize policy" }));
await waitFor(() => { await waitFor(() => {
expect(latest.customMethodCardMetaById?.[customId]?.label).toBe( expect(latest.customMethodCardMetaById?.[customId]?.label).toBe(
@@ -454,7 +493,7 @@ describe("CommunicationMethodsScreen — Add Platform persistence", () => {
}); });
fireEvent.click(screen.getByRole("button", { name: "Next" })); fireEvent.click(screen.getByRole("button", { name: "Next" }));
fireEvent.click(screen.getByRole("button", { name: "Next" })); fireEvent.click(screen.getByRole("button", { name: "Next" }));
fireEvent.click(screen.getByRole("button", { name: "Finalize" })); fireEvent.click(screen.getByRole("button", { name: "Finalize policy" }));
await waitFor(() => { await waitFor(() => {
expect(latest.customMethodCardMetaById?.signal?.label).toBe( expect(latest.customMethodCardMetaById?.signal?.label).toBe(
@@ -507,7 +546,7 @@ describe("CommunicationMethodsScreen — Add Platform persistence", () => {
fireEvent.dragStart(rows[0], { dataTransfer }); fireEvent.dragStart(rows[0], { dataTransfer });
fireEvent.drop(rows[2], { dataTransfer }); fireEvent.drop(rows[2], { dataTransfer });
fireEvent.click(screen.getByRole("button", { name: "Finalize" })); fireEvent.click(screen.getByRole("button", { name: "Finalize policy" }));
await waitFor(() => { await waitFor(() => {
expect( expect(
@@ -235,7 +235,7 @@ describe("CoreValuesSelectScreen", () => {
fireEvent.dragStart(rows[0], { dataTransfer }); fireEvent.dragStart(rows[0], { dataTransfer });
fireEvent.drop(rows[1], { dataTransfer }); fireEvent.drop(rows[1], { dataTransfer });
fireEvent.click(screen.getByRole("button", { name: "Finalize" })); fireEvent.click(screen.getByRole("button", { name: "Finalize policy" }));
await waitFor(() => { await waitFor(() => {
expect( expect(
@@ -256,25 +256,12 @@ describe("CoreValuesSelectScreen", () => {
expect(labels[1]).toMatch(/What does this value mean to your group/); expect(labels[1]).toMatch(/What does this value mean to your group/);
}); });
// The "Add value" → custom-chip → modal flow uses a `customPending` // The "Add value" control opens an empty custom-policy wizard. Dismissing
// session: dismissing the modal must drop the brand-new chip entirely // without Finalize leaves the chip list unchanged. Finalize writes a
// (not just unselect it), because the user never confirmed it via // selected chip named from the wizard title.
// the modal's Add Value button. Clicking Add Value keeps the chip describe("Add value wizard", () => {
// as a selected entry. These two tests pin both halves of the
// contract so the screens stay in sync with the create-flow draft.
describe("custom chip — confirmed vs dismissed", () => {
// Use a label guaranteed to NOT collide with any preset value
// (we'd otherwise get two matching chips and false positives).
const CUSTOM_LABEL = "ZZTopBespokeValue"; const CUSTOM_LABEL = "ZZTopBespokeValue";
async function addCustomChipNamed(label: string) {
fireEvent.click(screen.getByRole("button", { name: "Add value" }));
const input = await screen.findByPlaceholderText("Type to add");
fireEvent.change(input, { target: { value: label } });
fireEvent.click(screen.getByRole("button", { name: "Confirm" }));
return screen.findByRole("dialog");
}
/** /**
* The label can also appear in the modal header while the modal * The label can also appear in the modal header while the modal
* is open, and as the chip's "Remove" button aria-label. Scope to * is open, and as the chip's "Remove" button aria-label. Scope to
@@ -290,33 +277,94 @@ describe("CoreValuesSelectScreen", () => {
).length; ).length;
} }
it("removes the custom chip when its modal is dismissed without Add Value", async () => { async function completeAddValueWizard(label: string) {
renderWithProviders(<CoreValuesSelectScreen />); fireEvent.click(screen.getByRole("button", { name: "Add value" }));
await addCustomChipNamed(CUSTOM_LABEL); const nameInput = await screen.findByPlaceholderText("Policy name");
expect(countCustomChips(CUSTOM_LABEL)).toBe(1); fireEvent.change(nameInput, { target: { value: label } });
fireEvent.click(screen.getByRole("button", { name: "Next" }));
const descriptionInput =
await screen.findByPlaceholderText("Policy description");
fireEvent.change(descriptionInput, {
target: { value: "A community-authored value." },
});
fireEvent.click(screen.getByRole("button", { name: "Next" }));
expect(
await screen.findByText("Custom policy details"),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Add Custom Field" }),
).toBeInTheDocument();
fireEvent.click(await screen.findByRole("button", { name: "Finalize policy" }));
}
fireEvent.keyDown(document, { key: "Escape" }); it("collapses the field-type picker after adding a custom field", async () => {
await waitFor(() => { renderWithProviders(<CoreValuesSelectScreen />);
expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Add value" }));
}); fireEvent.change(await screen.findByPlaceholderText("Policy name"), {
// Chip must be gone — not just unselected. If it were merely target: { value: CUSTOM_LABEL },
// unselected the chip button would still render. Wrap in waitFor
// because the chip removal flushes through `updateState` →
// `useEffect` → `setCoreValueOptions` and isn't synchronous with
// the dialog close.
await waitFor(() => {
expect(countCustomChips(CUSTOM_LABEL)).toBe(0);
}); });
fireEvent.click(screen.getByRole("button", { name: "Next" }));
fireEvent.change(
await screen.findByPlaceholderText("Policy description"),
{ target: { value: "A community-authored value." } },
);
fireEvent.click(screen.getByRole("button", { name: "Next" }));
expect(
await screen.findByText("Custom policy details"),
).toBeInTheDocument();
const wizard = screen.getByRole("dialog");
fireEvent.click(
within(wizard).getByRole("button", { name: "Add Custom Field" }),
);
fireEvent.click(within(wizard).getByRole("button", { name: "Text" }));
fireEvent.change(
await screen.findByPlaceholderText("Add your text block title"),
{ target: { value: "Policy title" } },
);
fireEvent.click(screen.getByRole("button", { name: "Add field" }));
expect(
await within(wizard).findByRole("button", { name: "Add Custom Field" }),
).toBeInTheDocument();
expect(
within(wizard).queryByRole("button", { name: "Text" }),
).not.toBeInTheDocument();
expect(
within(wizard).getByRole("button", { name: "Policy title" }),
).toBeInTheDocument();
}); });
it("keeps the custom chip selected when Add Value is clicked", async () => { it("opens the empty custom-policy wizard from Add value", async () => {
renderWithProviders(<CoreValuesSelectScreen />); renderWithProviders(<CoreValuesSelectScreen />);
const dialog = await addCustomChipNamed(CUSTOM_LABEL); fireEvent.click(screen.getByRole("button", { name: "Add value" }));
fireEvent.click( expect(
within(dialog).getByRole("button", { name: "Add Value" }), await screen.findByPlaceholderText("Policy name"),
); ).toHaveValue("");
expect(screen.queryByPlaceholderText("Type to add")).not.toBeInTheDocument();
});
it("leaves no chip when the Add value wizard is dismissed", async () => {
renderWithProviders(<CoreValuesSelectScreen />);
fireEvent.click(screen.getByRole("button", { name: "Add value" }));
await screen.findByPlaceholderText("Policy name");
fireEvent.keyDown(document, { key: "Escape" });
await waitFor(() => { await waitFor(() => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); expect(
screen.queryByPlaceholderText("Policy name"),
).not.toBeInTheDocument();
});
expect(countCustomChips(CUSTOM_LABEL)).toBe(0);
expect(screen.queryByPlaceholderText("Type to add")).not.toBeInTheDocument();
});
it("adds a selected chip named from the wizard title after Finalize", async () => {
renderWithProviders(<CoreValuesSelectScreen />);
await completeAddValueWizard(CUSTOM_LABEL);
await waitFor(() => {
expect(
screen.queryByPlaceholderText("Policy name"),
).not.toBeInTheDocument();
}); });
expect(countCustomChips(CUSTOM_LABEL)).toBe(1); expect(countCustomChips(CUSTOM_LABEL)).toBe(1);
}); });
@@ -45,6 +45,7 @@ describe("CustomMethodCardFieldBlocksSummary", () => {
}), }),
).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Upload" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Upload" })).not.toBeInTheDocument();
expect(screen.queryByAltText("Help")).not.toBeInTheDocument();
}); });
it("after remove, parent can pass cleared blocks and Upload shows again", () => { it("after remove, parent can pass cleared blocks and Upload shows again", () => {
+8
View File
@@ -57,6 +57,14 @@ describe("FeatureGrid (behavioral tests)", () => {
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
}); });
it("sends Learn more to the Learn page", () => {
render(<FeatureGrid title="Test" subtitle="Test" />);
expect(screen.getByRole("link", { name: "Learn more" })).toHaveAttribute(
"href",
"/learn",
);
});
it("does not apply a focus ring to the entire grid shell", () => { it("does not apply a focus ring to the entire grid shell", () => {
render(<FeatureGrid title="Test" subtitle="Test" />); render(<FeatureGrid title="Test" subtitle="Test" />);
const shell = document.querySelector('[data-figma-node="18847-22410"]'); const shell = document.querySelector('[data-figma-node="18847-22410"]');
+82 -2
View File
@@ -438,6 +438,86 @@ describe("FinalReviewScreen — chip detail modal", () => {
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
it("closes a values chip without discard when nothing changed", async () => {
render(
<FinalReviewWithStateProbe
onState={() => {
/* noop */
}}
initial={{
selectedCoreValueIds: ["1"],
coreValuesChipsSnapshot: [
{ id: "1", label: "Accessibility", state: "selected" },
],
coreValueDetailsByChipId: {
"1": {
meaning: "Everyone can participate.",
signals: "Captions and ramps.",
},
},
customMethodCardFieldBlocksById: {
"1": [
{
kind: "text",
id: "facet-meaning",
blockTitle: "What this value means",
placeholderText: "Everyone can participate.",
},
{
kind: "text",
id: "facet-signals",
blockTitle: "Signals",
placeholderText: "Captions and ramps.",
},
],
},
}}
/>,
);
fireEvent.click(
await screen.findByRole("button", { name: "Accessibility" }),
);
const valuesDialog = await screen.findByRole("dialog");
expect(within(valuesDialog).getAllByRole("textbox").length).toBeGreaterThan(
0,
);
fireEvent.keyDown(document, { key: "Escape" });
await waitFor(() => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
expect(
screen.queryByRole("button", { name: "Keep editing" }),
).not.toBeInTheDocument();
});
it("closes a method chip without discard when nothing changed", async () => {
render(
<FinalReviewWithStateProbe
onState={() => {
/* noop */
}}
initial={{
title: "Oak Park Commons",
selectedCommunicationMethodIds: ["signal"],
}}
/>,
);
fireEvent.click(await screen.findByRole("button", { name: "Signal" }));
const methodDialog = await screen.findByRole("dialog");
expect(within(methodDialog).getAllByRole("textbox").length).toBeGreaterThan(
0,
);
fireEvent.keyDown(document, { key: "Escape" });
await waitFor(() => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
expect(
screen.queryByRole("button", { name: "Keep editing" }),
).not.toBeInTheDocument();
});
}); });
/** /**
@@ -749,7 +829,7 @@ describe("FinalReviewScreen — chip edit modal save semantics", () => {
fireEvent.change(nameInput, { target: { value: "Custom Signal header" } }); fireEvent.change(nameInput, { target: { value: "Custom Signal header" } });
fireEvent.click(screen.getByRole("button", { name: "Next" })); fireEvent.click(screen.getByRole("button", { name: "Next" }));
fireEvent.click(screen.getByRole("button", { name: "Next" })); fireEvent.click(screen.getByRole("button", { name: "Next" }));
fireEvent.click(screen.getByRole("button", { name: "Finalize" })); fireEvent.click(screen.getByRole("button", { name: "Finalize policy" }));
await waitFor(() => { await waitFor(() => {
expect(latest.customMethodCardMetaById?.signal?.label).toBe( expect(latest.customMethodCardMetaById?.signal?.label).toBe(
@@ -803,7 +883,7 @@ describe("FinalReviewScreen — chip edit modal save semantics", () => {
fireEvent.dragStart(rows[0], { dataTransfer }); fireEvent.dragStart(rows[0], { dataTransfer });
fireEvent.drop(rows[2], { dataTransfer }); fireEvent.drop(rows[2], { dataTransfer });
fireEvent.click(screen.getByRole("button", { name: "Finalize" })); fireEvent.click(screen.getByRole("button", { name: "Finalize policy" }));
await waitFor(() => { await waitFor(() => {
expect( expect(
+7
View File
@@ -34,4 +34,11 @@ describe("InfoMessageBox", () => {
await u.click(checkbox); await u.click(checkbox);
expect(onCheckboxChange).toHaveBeenCalled(); expect(onCheckboxChange).toHaveBeenCalled();
}); });
it("keeps the checkbox column shrinkable so labels wrap", () => {
render(<InfoMessageBox title="Important" items={items} />);
const region = screen.getByRole("region", { name: "Important" });
expect(region).toHaveClass("min-w-0");
expect(region).toHaveClass("w-full");
});
}); });
+20 -1
View File
@@ -43,6 +43,7 @@ vi.mock("../../app/(app)/create/utils/anonymousDraftStorage", async (importOrigi
}; };
}); });
import { EMAIL_MAX_LEN } from "../../lib/create/isValidCreateFlowSaveEmail";
import { requestMagicLink } from "../../lib/create/api"; import { requestMagicLink } from "../../lib/create/api";
import { setTransferPendingFlag } from "../../app/(app)/create/utils/anonymousDraftStorage"; import { setTransferPendingFlag } from "../../app/(app)/create/utils/anonymousDraftStorage";
@@ -70,7 +71,7 @@ describe("LoginForm", () => {
).toBeInTheDocument(); ).toBeInTheDocument();
expect( expect(
screen.getByRole("textbox", { name: /email address/i }), screen.getByRole("textbox", { name: /email address/i }),
).toBeInTheDocument(); ).toHaveAttribute("maxLength", String(EMAIL_MAX_LEN));
expect( expect(
screen.getByRole("button", { name: /send me a magic link/i }), screen.getByRole("button", { name: /send me a magic link/i }),
).toBeInTheDocument(); ).toBeInTheDocument();
@@ -126,6 +127,24 @@ describe("LoginForm", () => {
expect(screen.getByText(/we sent a sign-in link/i)).toBeInTheDocument(); expect(screen.getByText(/we sent a sign-in link/i)).toBeInTheDocument();
}); });
it("submits a long email without treating length as invalid", async () => {
const user = userEvent.setup();
const email =
"very.long.local-part.for.testing@subdomain.example.communityrule.org";
vi.mocked(requestMagicLink).mockResolvedValue({ ok: true });
renderLoginForm();
await user.type(
screen.getByRole("textbox", { name: /email address/i }),
email,
);
await user.click(
screen.getByRole("button", { name: /send me a magic link/i }),
);
await waitFor(() => {
expect(requestMagicLink).toHaveBeenCalledWith(email, "/", undefined);
});
});
it("saveProgress variant uses magicLinkNextPath and sets transfer pending on success", async () => { it("saveProgress variant uses magicLinkNextPath and sets transfer pending on success", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
vi.mocked(requestMagicLink).mockResolvedValue({ ok: true }); vi.mocked(requestMagicLink).mockResolvedValue({ ok: true });
@@ -85,4 +85,29 @@ describe("ModalTextAreaField behavior", () => {
expect(screen.getByRole("textbox", { name: /Notes/i })).toBeDisabled(); expect(screen.getByRole("textbox", { name: /Notes/i })).toBeDisabled();
}); });
it("does not render a section help icon by default", () => {
renderWithProviders(
<ModalTextAreaField
label="Core principle"
value=""
onChange={() => {}}
/>,
);
expect(screen.queryByAltText("Help")).not.toBeInTheDocument();
});
it("renders a section help icon when helpIcon is true", () => {
renderWithProviders(
<ModalTextAreaField
label="Core principle"
helpIcon
value=""
onChange={() => {}}
/>,
);
expect(screen.getByAltText("Help")).toBeInTheDocument();
});
}); });
+2 -2
View File
@@ -96,8 +96,8 @@ describe("AuthModalProvider (header overlay)", () => {
screen.getByRole("heading", { name: /log in to communityrule/i }), screen.getByRole("heading", { name: /log in to communityrule/i }),
).toBeInTheDocument(); ).toBeInTheDocument();
expect( expect(
screen.getByRole("link", { name: /back to home/i }), screen.queryByRole("link", { name: /back to home/i }),
).toBeInTheDocument(); ).not.toBeInTheDocument();
}); });
it("closes overlay when closeLogin is called", async () => { it("closes overlay when closeLogin is called", async () => {
+4 -1
View File
@@ -131,7 +131,10 @@ test.describe("Critical User Journeys", () => {
await expect( await expect(
featureSection.locator('a[href="#decision-making"]'), featureSection.locator('a[href="#decision-making"]'),
).toHaveCount(0); ).toHaveCount(0);
await expect(featureSection.getByRole("link", { name: "Learn more" })).toBeVisible(); await expect(featureSection.getByRole("link", { name: "Learn more" })).toHaveAttribute(
"href",
"/learn",
);
}); });
test("header navigation functionality", async ({ page }) => { test("header navigation functionality", async ({ page }) => {
+66
View File
@@ -0,0 +1,66 @@
import { afterEach, describe, expect, test } from "vitest";
import { within } from "@testing-library/react";
import {
cleanup,
renderWithProviders as render,
screen,
} from "../utils/test-utils";
import AboutPage from "../../app/(marketing)/about/page";
import {
ASSETS,
getAssetPath,
governanceBookletPath,
structureBeforeCrisisPath,
} from "../../lib/assetUtils";
import messages from "../../messages/en/index";
afterEach(() => {
cleanup();
});
function bookSection(title: string) {
const heading = screen.getByRole("heading", { name: title });
const section = heading.closest("section");
expect(section).not.toBeNull();
return within(section as HTMLElement);
}
describe("AboutPage", () => {
test("pairs each book cover, heading, alt, and download with the same title", () => {
render(<AboutPage />);
const page = messages.pages.about;
const structureBeforeCrisis = bookSection(page.structureBeforeCrisis.title);
expect(
structureBeforeCrisis.getByText(page.structureBeforeCrisis.description),
).toBeInTheDocument();
expect(
structureBeforeCrisis.getByRole("img", {
name: page.structureBeforeCrisis.imageAlt,
}),
).toHaveAttribute(
"src",
getAssetPath(ASSETS.STRUCTURE_BEFORE_CRISIS_COVER),
);
expect(
structureBeforeCrisis.getByRole("link", {
name: page.structureBeforeCrisis.buttonText,
}),
).toHaveAttribute("href", getAssetPath(structureBeforeCrisisPath()));
const communityRules = bookSection(page.communityRules.title);
expect(
communityRules.getByText(page.communityRules.description),
).toBeInTheDocument();
expect(
communityRules.getByRole("img", {
name: page.communityRules.imageAlt,
}),
).toHaveAttribute("src", getAssetPath(ASSETS.COMMUNITY_RULES_COVER));
expect(
communityRules.getByRole("link", {
name: page.communityRules.buttonText,
}),
).toHaveAttribute("href", getAssetPath(governanceBookletPath()));
});
});
+343
View File
@@ -3,6 +3,7 @@ import {
screen, screen,
cleanup, cleanup,
within, within,
waitFor,
} from "../utils/test-utils"; } from "../utils/test-utils";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { describe, test, expect, afterEach } from "vitest"; import { describe, test, expect, afterEach } from "vitest";
@@ -161,6 +162,45 @@ describe("Create flow decision-approaches page", () => {
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
test("selecting an approach in the expanded list does not move it to the top", async () => {
const user = userEvent.setup();
render(<DecisionApproachesScreen />);
await user.click(
screen.getByRole("button", { name: "See all decision approaches" }),
);
const cardLabels = () =>
screen
.getAllByRole("button")
.map((el) => el.getAttribute("aria-label") || "")
.filter((label) => label.includes(": "));
const labelsBefore = cardLabels();
expect(labelsBefore[0]).toMatch(/^Lazy Consensus:/);
expect(labelsBefore.some((label) => label.startsWith("Sociocracy:"))).toBe(
true,
);
await user.click(screen.getByRole("button", { name: /^Sociocracy:/ }));
await user.click(
within(await screen.findByRole("dialog")).getByRole("button", {
name: "Add Approach",
}),
);
await waitFor(() => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
expect(
screen.getByRole("button", { name: "Show less" }),
).toBeInTheDocument();
expect(cardLabels()).toEqual(labelsBefore);
expect(
screen.getByRole("button", { name: /^Sociocracy:/ }),
).toHaveTextContent("SELECTED");
});
test("clicking a card opens the create modal and confirming selects it", async () => { test("clicking a card opens the create modal and confirming selects it", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<DecisionApproachesScreen />); render(<DecisionApproachesScreen />);
@@ -212,6 +252,33 @@ describe("Create flow decision-approaches page", () => {
expect(screen.getByRole("menuitem", { name: "Remove" })).toBeInTheDocument(); expect(screen.getByRole("menuitem", { name: "Remove" })).toBeInTheDocument();
}); });
test("Save on a selected approach persists the edit and closes", async () => {
const user = userEvent.setup();
render(<DecisionApproachesScreen />);
const card = screen.getByRole("button", {
name: /Lazy Consensus: A decision is assumed approved/,
});
await user.click(card);
const dialog = await screen.findByRole("dialog");
await user.click(
within(dialog).getByRole("button", { name: "Add Approach" }),
);
await user.click(card);
const dialogAgain = await screen.findByRole("dialog");
const principleField = within(dialogAgain).getByRole("textbox", {
name: /core principle/i,
});
await user.clear(principleField);
await user.type(principleField, "Edited principle");
await user.click(within(dialogAgain).getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
});
test("Remove from the kebab deselects the approach", async () => { test("Remove from the kebab deselects the approach", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<DecisionApproachesScreen />); render(<DecisionApproachesScreen />);
@@ -272,4 +339,280 @@ describe("Create flow decision-approaches page", () => {
await user.click(amendCheckbox); await user.click(amendCheckbox);
expect(amendCheckbox).toBeChecked(); expect(amendCheckbox).toBeChecked();
}); });
test("each approach modal includes the key-resource applicable-scope chips", async () => {
const user = userEvent.setup();
render(<DecisionApproachesScreen />);
await user.click(
screen.getByRole("button", {
name: /Lazy Consensus: A decision is assumed approved/,
}),
);
const dialog = await screen.findByRole("dialog");
expect(
within(dialog).getByRole("button", {
name: "Select Amend your CommunityRule",
}),
).toBeInTheDocument();
expect(
within(dialog).getByRole("button", { name: "Select Steward finances" }),
).toBeInTheDocument();
expect(
within(dialog).getByRole("button", {
name: "Select Project level decisions",
}),
).toBeInTheDocument();
expect(
within(dialog).getByRole("button", {
name: "Select Discipline and member termination",
}),
).toBeInTheDocument();
});
test("checking a key-resource box does not highlight that chip on an unselected approach", async () => {
const user = userEvent.setup();
render(<DecisionApproachesScreen />);
await user.click(
screen.getByRole("checkbox", { name: "Steward finances" }),
);
await user.click(
screen.getByRole("button", {
name: /Lazy Consensus: A decision is assumed approved/,
}),
);
const lazyDialog = await screen.findByRole("dialog");
expect(
within(lazyDialog).getByRole("button", {
name: "Select Steward finances",
}),
).toBeInTheDocument();
});
test("applicable-scope chips stay on the approach they were chosen for", async () => {
const user = userEvent.setup();
render(<DecisionApproachesScreen />);
await user.click(
screen.getByRole("button", {
name: /Lazy Consensus: A decision is assumed approved/,
}),
);
const lazyDialog = await screen.findByRole("dialog");
await user.click(
within(lazyDialog).getByRole("button", {
name: "Select Steward finances",
}),
);
expect(
screen.getByRole("checkbox", { name: "Steward finances" }),
).toBeChecked();
await user.click(
within(lazyDialog).getByRole("button", { name: "Add Approach" }),
);
await waitFor(() => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
await user.click(
screen.getByRole("button", {
name: /Do-ocracy: Decisions are made by those who take initiative/,
}),
);
const doocracyDialog = await screen.findByRole("dialog");
expect(
within(doocracyDialog).getByRole("button", {
name: "Select Steward finances",
}),
).toBeInTheDocument();
expect(
within(doocracyDialog).queryByRole("button", {
name: "Deselect Steward finances",
}),
).not.toBeInTheDocument();
expect(
screen.getByRole("checkbox", { name: "Steward finances" }),
).not.toBeChecked();
await user.click(
within(doocracyDialog).getByRole("button", { name: "Close dialog" }),
);
await waitFor(() => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
expect(
screen.getByRole("checkbox", { name: "Steward finances" }),
).toBeChecked();
await user.click(
screen.getByRole("button", {
name: /Lazy Consensus: A decision is assumed approved/,
}),
);
const lazyAgain = await screen.findByRole("dialog");
expect(
within(lazyAgain).getByRole("button", {
name: "Deselect Steward finances",
}),
).toBeInTheDocument();
expect(
screen.getByRole("checkbox", { name: "Steward finances" }),
).toBeChecked();
});
test("checking a key-resource box with a modal open selects that chip on the open approach only", async () => {
const user = userEvent.setup();
render(<DecisionApproachesScreen />);
await user.click(
screen.getByRole("button", {
name: /Lazy Consensus: A decision is assumed approved/,
}),
);
const lazyDialog = await screen.findByRole("dialog");
await user.click(
within(lazyDialog).getByRole("button", { name: "Add Approach" }),
);
await waitFor(() => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
await user.click(
screen.getByRole("button", {
name: /Do-ocracy: Decisions are made by those who take initiative/,
}),
);
const doocracyDialog = await screen.findByRole("dialog");
await user.click(
screen.getByRole("checkbox", { name: "Steward finances" }),
);
expect(
within(doocracyDialog).getByRole("button", {
name: "Deselect Steward finances",
}),
).toBeInTheDocument();
await user.click(
within(doocracyDialog).getByRole("button", { name: "Close dialog" }),
);
await user.click(await screen.findByRole("button", { name: "Discard" }));
await waitFor(() => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
await user.click(
screen.getByRole("button", {
name: /Lazy Consensus: A decision is assumed approved/,
}),
);
const lazyAgain = await screen.findByRole("dialog");
expect(
within(lazyAgain).getByRole("button", {
name: "Select Steward finances",
}),
).toBeInTheDocument();
});
test("highlighting a key-resource chip on a second approach does not rewrite the first", async () => {
const user = userEvent.setup();
render(<DecisionApproachesScreen />);
await user.click(
screen.getByRole("button", {
name: /Lazy Consensus: A decision is assumed approved/,
}),
);
const lazyDialog = await screen.findByRole("dialog");
await user.click(
within(lazyDialog).getByRole("button", {
name: "Select Steward finances",
}),
);
expect(
screen.getByRole("checkbox", { name: "Steward finances" }),
).toBeChecked();
await user.click(
within(lazyDialog).getByRole("button", { name: "Add Approach" }),
);
await waitFor(() => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
await user.click(
screen.getByRole("button", {
name: /Do-ocracy: Decisions are made by those who take initiative/,
}),
);
const doocracyDialog = await screen.findByRole("dialog");
await user.click(
within(doocracyDialog).getByRole("button", {
name: "Select Project level decisions",
}),
);
expect(
screen.getByRole("checkbox", { name: "Project level decisions" }),
).toBeChecked();
expect(
screen.getByRole("checkbox", { name: "Steward finances" }),
).not.toBeChecked();
expect(
within(doocracyDialog).getByRole("button", {
name: "Select Steward finances",
}),
).toBeInTheDocument();
await user.click(
within(doocracyDialog).getByRole("button", { name: "Add Approach" }),
);
await waitFor(() => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
expect(
screen.getByRole("checkbox", { name: "Steward finances" }),
).toBeChecked();
expect(
screen.getByRole("checkbox", { name: "Project level decisions" }),
).toBeChecked();
await user.click(
screen.getByRole("button", {
name: /Lazy Consensus: A decision is assumed approved/,
}),
);
const lazyAgain = await screen.findByRole("dialog");
expect(
within(lazyAgain).getByRole("button", {
name: "Deselect Steward finances",
}),
).toBeInTheDocument();
expect(
within(lazyAgain).getByRole("button", {
name: "Select Project level decisions",
}),
).toBeInTheDocument();
await user.click(
within(lazyAgain).getByRole("button", { name: "Close dialog" }),
);
await waitFor(() => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
await user.click(
screen.getByRole("button", {
name: /Do-ocracy: Decisions are made by those who take initiative/,
}),
);
const doocracyAgain = await screen.findByRole("dialog");
expect(
within(doocracyAgain).getByRole("button", {
name: "Deselect Project level decisions",
}),
).toBeInTheDocument();
expect(
within(doocracyAgain).getByRole("button", {
name: "Select Steward finances",
}),
).toBeInTheDocument();
});
}); });
+2
View File
@@ -120,6 +120,8 @@ describe("Page", () => {
"Use our toolkit to improve, document, and evolve your organization.", "Use our toolkit to improve, document, and evolve your organization.",
).length, ).length,
).toBeGreaterThan(0); ).toBeGreaterThan(0);
const learnMore = screen.getAllByRole("link", { name: "Learn more" });
expect(learnMore[0]).toHaveAttribute("href", "/learn");
}); });
test("renders ask organizer section with correct data", () => { test("renders ask organizer section with correct data", () => {
+32 -3
View File
@@ -60,9 +60,38 @@ describe("CardStack Component", () => {
test("renders all cards in expanded (list) mode", () => { test("renders all cards in expanded (list) mode", () => {
render(<CardStack cards={SAMPLE_CARDS} expanded={true} />); render(<CardStack cards={SAMPLE_CARDS} expanded={true} />);
expect(screen.getByText("Option A")).toBeInTheDocument(); expect(screen.getAllByText("Option A").length).toBeGreaterThanOrEqual(1);
expect(screen.getByText("Option B")).toBeInTheDocument(); expect(screen.getAllByText("Option B").length).toBeGreaterThanOrEqual(1);
expect(screen.getByText("Option C")).toBeInTheDocument(); expect(screen.getAllByText("Option C").length).toBeGreaterThanOrEqual(1);
});
test("hides helper icons on compact, expanded, and single-stack cards", () => {
const { unmount } = render(
<CardStack cards={SAMPLE_CARDS} expanded={false} />,
);
expect(screen.queryByText("?")).not.toBeInTheDocument();
unmount();
const expanded = render(<CardStack cards={SAMPLE_CARDS} expanded={true} />);
expect(screen.queryByText("?")).not.toBeInTheDocument();
expanded.unmount();
render(<CardStack cards={SAMPLE_CARDS} layout="singleStack" />);
expect(screen.queryByText("?")).not.toBeInTheDocument();
});
test("expanded tiles use the compact 142px CardSelection height", () => {
render(<CardStack cards={SAMPLE_CARDS} expanded={true} />);
const tiles = screen.getAllByRole("button", {
name: "Option A: Description A",
});
expect(tiles.length).toBeGreaterThan(0);
for (const tile of tiles) {
expect(tile.className).toMatch(/142px/);
expect(tile).toHaveClass("flex-col");
expect(tile).not.toHaveClass("flex-row");
}
}); });
test("shows See all toggle when hasMore is true", () => { test("shows See all toggle when hasMore is true", () => {
+18
View File
@@ -54,6 +54,24 @@ describe("Selection Component", () => {
expect(card).toHaveClass("flex-row"); expect(card).toHaveClass("flex-row");
}); });
it("does not render a helper icon by default", () => {
render(<Selection {...defaultProps} orientation="vertical" />);
expect(screen.queryByText("?")).not.toBeInTheDocument();
});
it("renders a helper icon when showInfoIcon is true", () => {
render(
<Selection
{...defaultProps}
orientation="vertical"
showInfoIcon={true}
/>,
);
expect(screen.getByText("?")).toBeInTheDocument();
});
it("handles click events", () => { it("handles click events", () => {
const handleClick = vi.fn(); const handleClick = vi.fn();
render(<Selection {...defaultProps} onClick={handleClick} />); render(<Selection {...defaultProps} onClick={handleClick} />);
@@ -113,6 +113,63 @@ describe("POST /api/auth/magic-link/request", () => {
); );
}); });
it("accepts an email longer than 48 characters", async () => {
const email =
"very.long.local-part.for.testing@subdomain.example.communityrule.org";
expect(email.length).toBeGreaterThan(48);
const res = await POST(
new NextRequest("https://x.test/api/auth/magic-link/request", {
method: "POST",
body: JSON.stringify({ email }),
headers: { "content-type": "application/json" },
}),
undefined,
);
expect(res.status).toBe(200);
expect(sendMagicLinkEmailMock).toHaveBeenCalledWith(
email,
expect.stringContaining("/api/auth/magic-link/verify?token="),
);
});
it("rejects an email longer than 254 characters", async () => {
const email = `${"a".repeat(243)}@example.com`;
expect(email.length).toBeGreaterThan(254);
const res = await POST(
new NextRequest("https://x.test/api/auth/magic-link/request", {
method: "POST",
body: JSON.stringify({ email }),
headers: { "content-type": "application/json" },
}),
undefined,
);
expect(res.status).toBe(400);
expect(createMock).not.toHaveBeenCalled();
});
it("accepts a draft whose method-card support text is longer than 48 characters", async () => {
const res = await POST(
new NextRequest("https://x.test/api/auth/magic-link/request", {
method: "POST",
body: JSON.stringify({
email: "a@b.c",
draft: {
customMethodCardMetaById: {
"00000000-0000-4000-8000-000000000001": {
label: "Signal",
supportText:
"A decision is assumed approved unless objections are raised within a specified timeframe.",
},
},
},
}),
headers: { "content-type": "application/json" },
}),
undefined,
);
expect(res.status).toBe(200);
});
it("returns 502 and rolls back the token when mail fails", async () => { it("returns 502 and rolls back the token when mail fails", async () => {
sendMagicLinkEmailMock.mockRejectedValueOnce(new Error("smtp down")); sendMagicLinkEmailMock.mockRejectedValueOnce(new Error("smtp down"));
const res = await POST( const res = await POST(
+28
View File
@@ -1,4 +1,5 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { CUSTOM_METHOD_CARD_WIZARD_MAX_DESCRIPTION_CHARS } from "../../lib/create/customMethodCardWizardConstants";
import { import {
assertPlainJsonValue, assertPlainJsonValue,
DEFAULT_PLAIN_JSON_LIMITS, DEFAULT_PLAIN_JSON_LIMITS,
@@ -87,6 +88,33 @@ describe("createFlowStateSchema", () => {
expect(r.success).toBe(false); expect(r.success).toBe(false);
}); });
it("accepts custom method card support text longer than 48 characters", () => {
const r = createFlowStateSchema.safeParse({
customMethodCardMetaById: {
"00000000-0000-4000-8000-000000000001": {
label: "Signal",
supportText:
"A decision is assumed approved unless objections are raised within a specified timeframe.",
},
},
});
expect(r.success).toBe(true);
});
it("rejects custom method card support text over the description max", () => {
const r = createFlowStateSchema.safeParse({
customMethodCardMetaById: {
"00000000-0000-4000-8000-000000000001": {
label: "Signal",
supportText: "x".repeat(
CUSTOM_METHOD_CARD_WIZARD_MAX_DESCRIPTION_CHARS + 1,
),
},
},
});
expect(r.success).toBe(false);
});
it("accepts communityStructureChipSnapshots with custom chip rows", () => { it("accepts communityStructureChipSnapshots with custom chip rows", () => {
const r = createFlowStateSchema.safeParse({ const r = createFlowStateSchema.safeParse({
communityStructureChipSnapshots: { communityStructureChipSnapshots: {
@@ -0,0 +1,169 @@
import { describe, expect, it } from "vitest";
import {
applyDecisionApproachKeyResources,
decisionApproachKeyResourceCheckboxIds,
decisionApproachKeyResourceIdsFromLabels,
decisionApproachScopeForPublish,
decisionApproachKeyResourceItems,
decisionApproachKeyResourceLabels,
selectedKeyResourceLabelsFromCheckedIds,
stringArraysEqual,
syncDecisionApproachKeyResourceDetails,
withDecisionApproachKeyResourceScopes,
} from "../../lib/create/decisionApproachKeyResources";
import type { DecisionApproachDetailEntry } from "../../app/(app)/create/types";
const emptyEntry = (): DecisionApproachDetailEntry => ({
corePrinciple: "p",
applicableScope: ["Daily Operations"],
selectedApplicableScope: [],
stepByStepInstructions: "s",
consensusLevel: 75,
objectionsDeadlocks: "o",
});
describe("decisionApproachKeyResources", () => {
it("reads the four key-resource items from messages", () => {
expect(decisionApproachKeyResourceItems()).toEqual([
{ id: "amend", label: "Amend your CommunityRule" },
{ id: "finances", label: "Steward finances" },
{ id: "project", label: "Project level decisions" },
{ id: "discipline", label: "Discipline and member termination" },
]);
});
it("selects checked key-resource labels without rewriting applicableScope", () => {
const next = applyDecisionApproachKeyResources(emptyEntry(), [
"Steward finances",
]);
expect(next.applicableScope).toEqual(["Daily Operations"]);
expect(next.selectedApplicableScope).toEqual(["Steward finances"]);
});
it("keeps non-key chip selections when syncing sidebar checks", () => {
const next = applyDecisionApproachKeyResources(
{
...emptyEntry(),
applicableScope: ["Steward finances", "Daily Operations"],
selectedApplicableScope: ["Daily Operations"],
},
["Steward finances"],
);
expect(next.applicableScope).toEqual([
"Steward finances",
"Daily Operations",
]);
expect(next.selectedApplicableScope).toEqual([
"Daily Operations",
"Steward finances",
]);
});
it("publishes default scopes plus checked key resources", () => {
expect(
decisionApproachScopeForPublish(
["Daily Operations", "Minor Expenditures"],
["Steward finances"],
),
).toEqual([
"Daily Operations",
"Minor Expenditures",
"Steward finances",
]);
expect(
decisionApproachScopeForPublish(
["Daily Operations", "Minor Expenditures"],
["Daily Operations", "Steward finances"],
),
).toEqual(["Daily Operations", "Steward finances"]);
expect(
decisionApproachScopeForPublish(
["Daily Operations", "Minor Expenditures"],
[],
),
).toEqual(["Daily Operations", "Minor Expenditures"]);
});
it("maps checkbox ids to labels and back", () => {
expect(selectedKeyResourceLabelsFromCheckedIds(["amend", "project"])).toEqual(
["Amend your CommunityRule", "Project level decisions"],
);
expect(
decisionApproachKeyResourceIdsFromLabels([
"Project level decisions",
"Unknown",
"Amend your CommunityRule",
]),
).toEqual(["amend", "project"]);
});
it("derives sidebar checks from the open draft or selected approaches", () => {
const detailsById = {
"lazy-consensus": {
...emptyEntry(),
selectedApplicableScope: ["Steward finances"],
},
"do-ocracy": {
...emptyEntry(),
selectedApplicableScope: ["Project level decisions"],
},
};
expect(
decisionApproachKeyResourceCheckboxIds({
detailsById,
selectedApproachIds: ["lazy-consensus", "do-ocracy"],
reminderIds: ["discipline"],
}),
).toEqual(["finances", "project", "discipline"]);
expect(
decisionApproachKeyResourceCheckboxIds({
detailsById,
selectedApproachIds: ["lazy-consensus", "do-ocracy"],
reminderIds: ["discipline"],
openDraft: {
...emptyEntry(),
selectedApplicableScope: ["Amend your CommunityRule"],
},
}),
).toEqual(["amend"]);
});
it("syncs existing and selected approach details", () => {
const next = syncDecisionApproachKeyResourceDetails(
{ "lazy-consensus": emptyEntry() },
["do-ocracy"],
["Amend your CommunityRule"],
(id) => ({
...emptyEntry(),
corePrinciple: id,
applicableScope: ["Volunteer Tasks"],
}),
);
expect(next["lazy-consensus"]?.selectedApplicableScope).toEqual([
"Amend your CommunityRule",
]);
expect(next["do-ocracy"]?.corePrinciple).toBe("do-ocracy");
expect(next["do-ocracy"]?.applicableScope).toEqual(["Volunteer Tasks"]);
expect(next["do-ocracy"]?.selectedApplicableScope).toEqual([
"Amend your CommunityRule",
]);
});
it("compares string arrays by order", () => {
expect(stringArraysEqual(["a", "b"], ["a", "b"])).toBe(true);
expect(stringArraysEqual(["a", "b"], ["b", "a"])).toBe(false);
expect(decisionApproachKeyResourceLabels()).toHaveLength(4);
});
it("unions key-resource labels onto an existing scope list", () => {
expect(
withDecisionApproachKeyResourceScopes(["Daily Operations"]),
).toEqual([
"Daily Operations",
"Amend your CommunityRule",
"Steward finances",
"Project level decisions",
"Discipline and member termination",
]);
});
});
+14 -11
View File
@@ -1,18 +1,21 @@
import { existsSync, readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { getAssetPath, governanceBookletPath } from "../../../lib/assetUtils"; import {
governanceBookletPath,
structureBeforeCrisisPath,
} from "../../../lib/assetUtils";
describe("governance booklet", () => { const publicRoot = join(process.cwd(), "public");
it("ships a PDF at the About Book download path", () => {
const relative = governanceBookletPath();
expect(relative).toBe("assets/marketing/governance-booklet.pdf");
expect(getAssetPath(relative)).toBe("/assets/marketing/governance-booklet.pdf");
const abs = join(process.cwd(), "public", relative); function assertPdf(relativePath: string) {
expect(existsSync(abs)).toBe(true); const bytes = readFileSync(join(publicRoot, relativePath));
expect(bytes.subarray(0, 5).toString("ascii")).toBe("%PDF-");
}
const header = readFileSync(abs).subarray(0, 5).toString("latin1"); describe("marketing book PDFs", () => {
expect(header).toBe("%PDF-"); it("ships both About book downloads as PDFs", () => {
assertPdf(governanceBookletPath());
assertPdf(structureBeforeCrisisPath());
}); });
}); });
@@ -1,6 +1,12 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { communicationPresetFor } from "../../lib/create/finalReviewChipPresets"; import {
import { communicationMethodFacetMatchesPreset } from "../../lib/create/methodCardFacetMatchesPresetForId"; communicationPresetFor,
decisionApproachPresetFor,
} from "../../lib/create/finalReviewChipPresets";
import {
communicationMethodFacetMatchesPreset,
decisionApproachFacetMatchesPreset,
} from "../../lib/create/methodCardFacetMatchesPresetForId";
const uuid = "550e8400-e29b-41d4-a716-446655440000"; const uuid = "550e8400-e29b-41d4-a716-446655440000";
@@ -19,4 +25,23 @@ describe("methodCardFacetMatchesPresetForId", () => {
), ),
).toBe(false); ).toBe(false);
}); });
it("decision approaches: ignores key-resource chip selections", () => {
const p = decisionApproachPresetFor(uuid);
expect(
decisionApproachFacetMatchesPreset(
{
...p,
selectedApplicableScope: ["Steward finances"],
},
uuid,
),
).toBe(true);
expect(
decisionApproachFacetMatchesPreset(
{ ...p, corePrinciple: "edited" },
uuid,
),
).toBe(false);
});
}); });
@@ -12,6 +12,7 @@ describe("stripCustomRuleSelectionFields", () => {
selectedCommunicationMethodIds: ["signal"], selectedCommunicationMethodIds: ["signal"],
selectedMembershipMethodIds: ["x"], selectedMembershipMethodIds: ["x"],
selectedDecisionApproachIds: ["y"], selectedDecisionApproachIds: ["y"],
selectedDecisionKeyResourceIds: ["amend"],
selectedConflictManagementIds: ["z"], selectedConflictManagementIds: ["z"],
methodSectionsPinCommitted: { communication: true }, methodSectionsPinCommitted: { communication: true },
coreValueDetailsByChipId: { "1": { meaning: "", signals: "" } }, coreValueDetailsByChipId: { "1": { meaning: "", signals: "" } },
@@ -38,6 +39,7 @@ describe("stripCustomRuleSelectionFields", () => {
expect(out.selectedCommunicationMethodIds).toBeUndefined(); expect(out.selectedCommunicationMethodIds).toBeUndefined();
expect(out.selectedMembershipMethodIds).toBeUndefined(); expect(out.selectedMembershipMethodIds).toBeUndefined();
expect(out.selectedDecisionApproachIds).toBeUndefined(); expect(out.selectedDecisionApproachIds).toBeUndefined();
expect(out.selectedDecisionKeyResourceIds).toBeUndefined();
expect(out.selectedConflictManagementIds).toBeUndefined(); expect(out.selectedConflictManagementIds).toBeUndefined();
expect(out.methodSectionsPinCommitted).toBeUndefined(); expect(out.methodSectionsPinCommitted).toBeUndefined();
expect(out.coreValueDetailsByChipId).toBeUndefined(); expect(out.coreValueDetailsByChipId).toBeUndefined();