Edit flow configured

This commit is contained in:
adilallo
2026-04-29 18:29:16 -06:00
parent 3a9727bceb
commit fc845d8308
39 changed files with 681 additions and 165 deletions
+56 -8
View File
@@ -7,7 +7,7 @@ import {
useState,
type ReactNode,
} from "react";
import { usePathname, useRouter } from "next/navigation";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { CreateFlowProvider, useCreateFlow } from "./context/CreateFlowContext";
import { useCreateFlowNavigation } from "./hooks/useCreateFlowNavigation";
import { useCreateFlowExit } from "./hooks/useCreateFlowExit";
@@ -15,7 +15,7 @@ import { useCreateFlowFinalize } from "./hooks/useCreateFlowFinalize";
import { useTemplateReviewActions } from "./hooks/useTemplateReviewActions";
import CreateFlowFooter from "../../components/navigation/CreateFlowFooter";
import CreateFlowTopNav from "../../components/navigation/CreateFlowTopNav";
import { getNextStep, getStepIndex } from "./utils/flowSteps";
import { getNextStep, getStepIndex, parseReviewReturnSearchParam, CREATE_FLOW_REVIEW_RETURN_QUERY_KEY } from "./utils/flowSteps";
import { getProportionBarProgressForCreateFlowStep } from "./utils/createFlowProportionProgress";
import {
createFlowStepUsesCenteredTextLayout,
@@ -42,6 +42,7 @@ import {
} from "./utils/createFlowFooterClassNames";
import {
CUSTOM_RULE_CONFIRM_FOOTER_STEP_BY_STEP,
methodCardFacetSectionForConfirmStep,
type CustomRuleConfirmFooterStep,
} from "./utils/customRuleConfirmFooterSteps";
import { getDefaultFooterLabel } from "./utils/createFlowFooterLabels";
@@ -111,6 +112,8 @@ function CreateFlowLayoutContent({
const tLogin = useTranslation("pages.login");
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const reviewReturnTarget = parseReviewReturnSearchParam(searchParams);
const { openLogin } = useAuthModal();
const skipCommunitySave = sessionResolved && Boolean(sessionUser);
const {
@@ -123,7 +126,7 @@ function CreateFlowLayoutContent({
} = useCreateFlowNavigation(
skipCommunitySave ? { skipCommunitySave: true } : undefined,
);
const { state, clearState, updateState, resetCustomRuleSelections } =
const { state, clearState, updateState, resetCustomRuleSelections, setMethodSectionsPinCommitted } =
useCreateFlow();
const { draftSaveBannerMessage, setDraftSaveBannerMessage } =
useCreateFlowDraftSaveBanner();
@@ -253,13 +256,19 @@ function CreateFlowLayoutContent({
if (titleOk && editingId === last.id && sectionsClear) {
return;
}
updateState(createFlowStateFromPublishedRule(last));
updateState({
...createFlowStateFromPublishedRule(last),
/** Keep UI-only facet pin flags across published re-hydration (wizard draft field; not stored on publish). */
methodSectionsPinCommitted: state.methodSectionsPinCommitted,
});
}, [
currentStep,
router,
updateState,
state.editingPublishedRuleId,
state.title,
state.methodSectionsPinCommitted,
state.sections?.length,
]);
const handleCommunitySaveMagicLinkSubmit = useCallback(async () => {
@@ -348,6 +357,13 @@ function CreateFlowLayoutContent({
currentStep != null
? CUSTOM_RULE_CONFIRM_FOOTER_STEP_BY_STEP.get(currentStep)
: undefined;
/** Method-card steps tolerate `reviewReturn={edit-rule}` when `edit-rule ∉ FLOW_STEP_ORDER` makes `nextStep` null. Core values stay gated on linear `nextStep`. */
const showCustomRuleFooterConfirm =
Boolean(customRuleConfirmFooter) &&
(nextStep != null ||
(reviewReturnTarget != null &&
methodCardFacetSectionForConfirmStep(customRuleConfirmFooter.step) !=
undefined));
/**
* Top banner stack rendered above the main column when any of the
@@ -590,7 +606,8 @@ function CreateFlowLayoutContent({
{footer.createFromTemplate}
</Button>
</div>
) : customRuleConfirmFooter && nextStep ? (
) : showCustomRuleFooterConfirm &&
customRuleConfirmFooter ? (
<Button
buttonType="filled"
palette="default"
@@ -601,6 +618,24 @@ function CreateFlowLayoutContent({
}
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
onClick={() => {
const cf = customRuleConfirmFooter;
const facet = methodCardFacetSectionForConfirmStep(cf.step);
if (facet != null && cf.selectionIds(state).length > 0) {
setMethodSectionsPinCommitted(facet, true);
}
if (reviewReturnTarget) {
const params = new URLSearchParams(
searchParams?.toString() ?? "",
);
params.delete(CREATE_FLOW_REVIEW_RETURN_QUERY_KEY);
const qs = params.toString();
router.push(
qs.length > 0
? `/create/${reviewReturnTarget}?${qs}`
: `/create/${reviewReturnTarget}`,
);
return;
}
goToNextStep();
}}
>
@@ -638,9 +673,22 @@ function CreateFlowLayoutContent({
? "/create/review"
: "/",
)
: previousStep
? goToPreviousStep
: undefined
: reviewReturnTarget
? () => {
const params = new URLSearchParams(
searchParams?.toString() ?? "",
);
params.delete(CREATE_FLOW_REVIEW_RETURN_QUERY_KEY);
const qs = params.toString();
router.push(
qs.length > 0
? `/create/${reviewReturnTarget}?${qs}`
: `/create/${reviewReturnTarget}`,
);
}
: previousStep
? goToPreviousStep
: undefined
}
/>
)}
@@ -77,6 +77,12 @@ export function SignedInDraftHydration({
return;
}
const urlStep = parseCreateFlowScreenFromPathname(pathname ?? null);
/** Owner “view published rule” shell — never merge server draft or redirect to `currentStep`. */
if (urlStep === "completed") {
return;
}
let cancelled = false;
setLoadingHydration(true);
@@ -10,6 +10,7 @@ import {
type ReactNode,
} from "react";
import type {
CreateFlowMethodCardFacetSection,
CreateFlowState,
CreateFlowContextValue,
CreateFlowStep,
@@ -137,6 +138,19 @@ export function CreateFlowProvider({
setInteractionTouched(true);
}, []);
const setMethodSectionsPinCommitted = useCallback(
(section: CreateFlowMethodCardFacetSection, committed: boolean) => {
setState((prevState) => ({
...prevState,
methodSectionsPinCommitted: {
...(prevState.methodSectionsPinCommitted ?? {}),
[section]: committed,
},
}));
},
[],
);
const updateState = useCallback((updates: Partial<CreateFlowState>) => {
setState((prevState) => {
const merged: CreateFlowState = { ...prevState, ...updates };
@@ -179,6 +193,7 @@ export function CreateFlowProvider({
selectedMembershipMethodIds: _e,
selectedDecisionApproachIds: _f,
selectedConflictManagementIds: _g,
methodSectionsPinCommitted: _h,
...rest
} = prev;
return rest;
@@ -195,6 +210,7 @@ export function CreateFlowProvider({
replaceState,
clearState,
resetCustomRuleSelections,
setMethodSectionsPinCommitted,
interactionTouched,
markCreateFlowInteraction,
};
@@ -6,6 +6,8 @@ import { useCreateFlow } from "../context/CreateFlowContext";
import type { CreateFlowStep } from "../types";
import {
type CreateFlowNavigationOptions,
type CreateFlowReviewReturnTarget,
CREATE_FLOW_REVIEW_RETURN_QUERY_KEY,
buildTemplateReviewHref,
getNextStep,
getPreviousStep,
@@ -46,7 +48,10 @@ export function useCreateFlowNavigation(
currentStep: CreateFlowStep | null;
goToNextStep: () => void;
goToPreviousStep: () => void;
goToStep: (_step: CreateFlowStep) => void;
goToStep: (
_step: CreateFlowStep,
_navOpts?: { reviewReturn?: CreateFlowReviewReturnTarget },
) => void;
canGoNext: () => boolean;
canGoBack: () => boolean;
nextStep: CreateFlowStep | null;
@@ -122,11 +127,21 @@ export function useCreateFlowNavigation(
);
const goToStep = useCallback(
(step: CreateFlowStep) => {
(
step: CreateFlowStep,
navOpts?: { reviewReturn?: CreateFlowReviewReturnTarget },
) => {
blurActiveElement();
router.push(`/create/${step}`);
const params = new URLSearchParams(searchParams?.toString() ?? "");
if (navOpts?.reviewReturn != null) {
params.set(CREATE_FLOW_REVIEW_RETURN_QUERY_KEY, navOpts.reviewReturn);
} else {
params.delete(CREATE_FLOW_REVIEW_RETURN_QUERY_KEY);
}
const qs = params.toString();
router.push(qs.length > 0 ? `/create/${step}?${qs}` : `/create/${step}`);
},
[router],
[router, searchParams],
);
const canGoNext = useCallback(() => nextStep !== null, [nextStep]);
@@ -0,0 +1,109 @@
"use client";
import { useEffect, useMemo } from "react";
import { useCreateFlow } from "../context/CreateFlowContext";
import type { CreateFlowMethodCardFacetSection } from "../types";
import {
mergeCompactCardIdsWithPinnedSelected,
orderRankedMethodsWithPinnedSelection,
} from "../../../../lib/create/methodCardDisplayOrder";
import {
deriveCompactCards,
rankMethodsByScore,
useFacetRecommendations,
type RecommendationSection,
} from "./useFacetRecommendations";
type MethodEntry = { id: string; label: string; supportText: string };
/**
* Applies score ranking, compact-slot rules, optional “pinned selection” showcase
* order, and clears the pin draft flag when a section loses all selections.
*/
export function useMethodCardDeckOrdering(
section: RecommendationSection,
methods: readonly MethodEntry[],
selectedIds: readonly string[],
) {
const { state, setMethodSectionsPinCommitted } = useCreateFlow();
const facetKey = section as CreateFlowMethodCardFacetSection;
const { scoresBySlug, hasAnyFacets } = useFacetRecommendations(section);
const pinStored =
state.methodSectionsPinCommitted?.[facetKey] === true;
const pinActive = Boolean(pinStored && selectedIds.length > 0);
useEffect(() => {
if (selectedIds.length > 0) return;
if (!pinStored) return;
setMethodSectionsPinCommitted(facetKey, false);
}, [
facetKey,
pinStored,
selectedIds.length,
setMethodSectionsPinCommitted,
]);
const rankedMethods = useMemo(
() => rankMethodsByScore(methods, scoresBySlug),
[methods, scoresBySlug],
);
const displayMethods = useMemo(
() =>
orderRankedMethodsWithPinnedSelection(
rankedMethods,
selectedIds,
pinActive,
),
[rankedMethods, selectedIds, pinActive],
);
const { compactCardIds: baseCompactCardIds, recommendedIds } = useMemo(
() =>
deriveCompactCards(
rankedMethods,
scoresBySlug,
hasAnyFacets,
/* limit */ 5,
),
[rankedMethods, scoresBySlug, hasAnyFacets],
);
const compactCardIds = useMemo(
() =>
mergeCompactCardIdsWithPinnedSelected(
displayMethods.map((m) => m.id),
baseCompactCardIds,
selectedIds,
pinActive,
5,
),
[displayMethods, baseCompactCardIds, selectedIds, pinActive],
);
const sampleCards = useMemo(
() =>
displayMethods.map((entry) => ({
id: entry.id,
label: entry.label,
supportText: entry.supportText,
recommended: recommendedIds.has(entry.id),
})),
[displayMethods, recommendedIds],
);
const methodById = useMemo(
() => new Map(rankedMethods.map((entry) => [entry.id, entry])),
[rankedMethods],
);
return {
rankedMethods,
displayMethods,
compactCardIds,
recommendedIds,
sampleCards,
methodById,
};
}
@@ -14,15 +14,11 @@
* the chip selection and any user edits as a `communicationMethodDetailsById[id]` override.
*/
import { useState, useCallback, useMemo } from "react";
import { useState, useCallback } from "react";
import { useMessages } from "../../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import {
deriveCompactCards,
rankMethodsByScore,
useFacetRecommendations,
} from "../../hooks/useFacetRecommendations";
import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import CardStack from "../../../../components/cards/CardStack";
import Create from "../../../../components/modals/Create";
@@ -49,32 +45,10 @@ export function CommunicationMethodsScreen() {
const selectedIds = state.selectedCommunicationMethodIds ?? [];
const { scoresBySlug, hasAnyFacets } =
useFacetRecommendations("communication");
const rankedMethods = useMemo(
() => rankMethodsByScore(comm.methods, scoresBySlug),
[comm.methods, scoresBySlug],
);
const { compactCardIds, recommendedIds } = useMemo(
() => deriveCompactCards(rankedMethods, scoresBySlug, hasAnyFacets, 5),
[rankedMethods, scoresBySlug, hasAnyFacets],
);
const sampleCards = useMemo(
() =>
rankedMethods.map((entry) => ({
id: entry.id,
label: entry.label,
supportText: entry.supportText,
recommended: recommendedIds.has(entry.id),
})),
[rankedMethods, recommendedIds],
);
const methodById = useMemo(
() => new Map(rankedMethods.map((entry) => [entry.id, entry])),
[rankedMethods],
const { sampleCards, compactCardIds, methodById } = useMethodCardDeckOrdering(
"communication",
comm.methods,
selectedIds,
);
const title = expanded ? comm.page.expandedTitle : comm.page.compactTitle;
@@ -12,15 +12,11 @@
* any user edits as a `conflictManagementDetailsById[id]` override.
*/
import { useState, useCallback, useMemo } from "react";
import { useState, useCallback } from "react";
import { useMessages } from "../../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import {
deriveCompactCards,
rankMethodsByScore,
useFacetRecommendations,
} from "../../hooks/useFacetRecommendations";
import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import CardStack from "../../../../components/cards/CardStack";
import Create from "../../../../components/modals/Create";
@@ -47,32 +43,10 @@ export function ConflictManagementScreen() {
const selectedIds = state.selectedConflictManagementIds ?? [];
const { scoresBySlug, hasAnyFacets } =
useFacetRecommendations("conflictManagement");
const rankedMethods = useMemo(
() => rankMethodsByScore(cm.methods, scoresBySlug),
[cm.methods, scoresBySlug],
);
const { compactCardIds, recommendedIds } = useMemo(
() => deriveCompactCards(rankedMethods, scoresBySlug, hasAnyFacets, 5),
[rankedMethods, scoresBySlug, hasAnyFacets],
);
const sampleCards = useMemo(
() =>
rankedMethods.map((entry) => ({
id: entry.id,
label: entry.label,
supportText: entry.supportText,
recommended: recommendedIds.has(entry.id),
})),
[rankedMethods, recommendedIds],
);
const methodById = useMemo(
() => new Map(rankedMethods.map((entry) => [entry.id, entry])),
[rankedMethods],
const { sampleCards, compactCardIds, methodById } = useMethodCardDeckOrdering(
"conflictManagement",
cm.methods,
selectedIds,
);
const title = expanded ? cm.page.expandedTitle : cm.page.compactTitle;
@@ -13,15 +13,11 @@
* DB-driven content.
*/
import { useState, useCallback, useMemo } from "react";
import { useState, useCallback } from "react";
import { useMessages } from "../../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import {
deriveCompactCards,
rankMethodsByScore,
useFacetRecommendations,
} from "../../hooks/useFacetRecommendations";
import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import CardStack from "../../../../components/cards/CardStack";
import Create from "../../../../components/modals/Create";
@@ -48,32 +44,10 @@ export function MembershipMethodsScreen() {
const selectedIds = state.selectedMembershipMethodIds ?? [];
const { scoresBySlug, hasAnyFacets } =
useFacetRecommendations("membership");
const rankedMethods = useMemo(
() => rankMethodsByScore(mem.methods, scoresBySlug),
[mem.methods, scoresBySlug],
);
const { compactCardIds, recommendedIds } = useMemo(
() => deriveCompactCards(rankedMethods, scoresBySlug, hasAnyFacets, 5),
[rankedMethods, scoresBySlug, hasAnyFacets],
);
const sampleCards = useMemo(
() =>
rankedMethods.map((entry) => ({
id: entry.id,
label: entry.label,
supportText: entry.supportText,
recommended: recommendedIds.has(entry.id),
})),
[rankedMethods, recommendedIds],
);
const methodById = useMemo(
() => new Map(rankedMethods.map((entry) => [entry.id, entry])),
[rankedMethods],
const { sampleCards, compactCardIds, methodById } = useMethodCardDeckOrdering(
"membership",
mem.methods,
selectedIds,
);
const title = expanded ? mem.page.expandedTitle : mem.page.compactTitle;
@@ -15,18 +15,31 @@ import {
type FinalReviewCategoryRowDetailed,
} from "../../../../../lib/create/buildFinalReviewCategories";
import { applyFinalReviewChipEditPatch } from "../../../../../lib/create/applyFinalReviewChipEditPatch";
import type { TemplateChipDetail } from "../../../../../lib/create/templateReviewMapping";
import type {
TemplateChipDetail,
TemplateFacetGroupKey,
} from "../../../../../lib/create/templateReviewMapping";
import {
FinalReviewChipEditModal,
type FinalReviewChipEditPatch,
type FinalReviewChipEditTarget,
} from "../../components/FinalReviewChipEditModal";
import { FinalReviewCommunityContextEditModal } from "../../components/FinalReviewCommunityContextEditModal";
import { useCreateFlowNavigation } from "../../hooks/useCreateFlowNavigation";
import { createFlowStepForFacetGroup } from "../../utils/facetGroupToCreateFlowStep";
import {
getAssetPath,
vectorMarkPath,
} from "../../../../../lib/assetUtils";
const FACET_FALLBACK_ORDER: readonly TemplateFacetGroupKey[] = [
"coreValues",
"communication",
"membership",
"decisionApproaches",
"conflictManagement",
] as const;
/**
* `finalReview.json.categories` ships a demo ordering + localized names
* (Values / Communication / Membership / Decision-making / Conflict
@@ -75,6 +88,7 @@ export function FinalReviewScreen({
variant?: "default" | "editPublished";
} = {}) {
const { state, updateState, markCreateFlowInteraction } = useCreateFlow();
const { goToStep } = useCreateFlowNavigation();
const mdUp = useCreateFlowMdUp();
const t = useTranslation("create.reviewAndComplete.finalReview");
const m = useMessages();
@@ -116,13 +130,23 @@ export function FinalReviewScreen({
const derived = buildFinalReviewCategoryRowsDetailed(state, names);
const rowsToRender: readonly FinalReviewCategoryRowDetailed[] =
derived.length > 0 ? derived : fallbackRows;
const usingFallbackRows = derived.length === 0;
const lookup = new Map<
string,
{ target: FinalReviewChipEditTarget | null; readOnly: TemplateChipDetail }
>();
const cats: Category[] = rowsToRender.map((row) => {
const cats: Category[] = rowsToRender.map((row, rowIndex) => {
const effectiveGroupKey: TemplateFacetGroupKey | null =
row.groupKey ??
(usingFallbackRows && rowIndex < FACET_FALLBACK_ORDER.length
? FACET_FALLBACK_ORDER[rowIndex]
: null);
const reviewReturn =
variant === "editPublished" ? ("edit-rule" as const) : ("final-review" as const);
const chipOptions = row.entries.map((entry, idx) => {
const chipId = `${row.name}-${idx}`;
const readOnly: TemplateChipDetail = {
@@ -150,6 +174,7 @@ export function FinalReviewScreen({
return {
name: row.name,
chipOptions,
addButton: effectiveGroupKey != null,
onChipClick: (_categoryName: string, chipId: string) => {
const hit = lookup.get(chipId);
if (!hit) return;
@@ -160,6 +185,15 @@ export function FinalReviewScreen({
setActiveReadOnlyDetail(hit.readOnly);
}
},
onAddClick:
effectiveGroupKey != null
? () => {
markCreateFlowInteraction();
goToStep(createFlowStepForFacetGroup(effectiveGroupKey), {
reviewReturn,
});
}
: undefined,
};
});
return { categories: cats, chipLookup: lookup };
@@ -167,6 +201,8 @@ export function FinalReviewScreen({
m.create.reviewAndComplete.finalReview.categories,
state,
markCreateFlowInteraction,
goToStep,
variant,
]);
void chipLookup;
@@ -23,15 +23,10 @@ import Create from "../../../../components/modals/Create";
import InlineTextButton from "../../../../components/buttons/InlineTextButton";
import InfoMessageBox from "../../../../components/controls/InfoMessageBox";
import type { InfoMessageBoxItem } from "../../../../components/controls/InfoMessageBox/InfoMessageBox.types";
import type { CardStackItem } from "../../../../components/cards/CardStack/CardStack.types";
import { useMessages } from "../../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import {
deriveCompactCards,
rankMethodsByScore,
useFacetRecommendations,
} from "../../hooks/useFacetRecommendations";
import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering";
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
import { DecisionApproachEditFields } from "../../components/methodEditFields";
import { decisionApproachPresetFor } from "../../../../../lib/create/finalReviewChipPresets";
@@ -62,32 +57,10 @@ export function DecisionApproachesScreen() {
[da.messageBox.items],
);
const { scoresBySlug, hasAnyFacets } =
useFacetRecommendations("decisionApproaches");
const rankedMethods = useMemo(
() => rankMethodsByScore(da.methods, scoresBySlug),
[da.methods, scoresBySlug],
);
const { compactCardIds, recommendedIds } = useMemo(
() => deriveCompactCards(rankedMethods, scoresBySlug, hasAnyFacets, 5),
[rankedMethods, scoresBySlug, hasAnyFacets],
);
const sampleCards: CardStackItem[] = useMemo(
() =>
rankedMethods.map((entry) => ({
id: entry.id,
label: entry.label,
supportText: entry.supportText,
recommended: recommendedIds.has(entry.id),
})),
[rankedMethods, recommendedIds],
);
const methodById = useMemo(
() => new Map(rankedMethods.map((entry) => [entry.id, entry])),
[rankedMethods],
const { sampleCards, compactCardIds, methodById } = useMethodCardDeckOrdering(
"decisionApproaches",
da.methods,
selectedIds,
);
const sidebarDescription = (
+23
View File
@@ -36,6 +36,13 @@ export type CreateFlowTextStateField =
| "communityContext"
| "communitySaveEmail";
/** Facet-backed method card stacks (`GET /api/create-flow/methods?section=`). */
export type CreateFlowMethodCardFacetSection =
| "communication"
| "membership"
| "decisionApproaches"
| "conflictManagement";
/**
* Serialized chip row for `community-structure` (preset + custom labels).
* Stored in drafts so custom chips survive refresh and server sync.
@@ -130,6 +137,14 @@ export interface CreateFlowState {
selectedDecisionApproachIds?: string[];
/** Create Custom — conflict management (`/create/conflict-management`); card ids from `create.customRule.conflictManagement` presets. */
selectedConflictManagementIds?: string[];
/**
* After **Confirm** on a method card step (`communication-methods`, etc.)
* with 1 selection, reorder UI with selected cards first until the pin is
* cleared by an empty selection (or resetting custom-rule state).
*/
methodSectionsPinCommitted?: Partial<
Record<CreateFlowMethodCardFacetSection, boolean>
>;
/**
* User edits from the `final-review` edit modal, keyed by preset method id
* (e.g. `"signal"`). Merged onto preset defaults at publish time so the
@@ -209,6 +224,14 @@ export interface CreateFlowContextValue {
* after a prior "Customize template" prefill.
*/
resetCustomRuleSelections: () => void;
/**
* Mark whether a facet method stack should pin the authors selections to
* the head of expanded + compact order (set from the footer Confirm).
*/
setMethodSectionsPinCommitted: (
section: CreateFlowMethodCardFacetSection,
committed: boolean,
) => void;
/**
* True after the user has edited any control inside the wizard. Screens flip
* it via {@link markCreateFlowInteraction} from their event handlers.
@@ -1,4 +1,8 @@
import type { CreateFlowState, CreateFlowStep } from "../types";
import type {
CreateFlowMethodCardFacetSection,
CreateFlowState,
CreateFlowStep,
} from "../types";
import type footerMessages from "../../../../messages/en/create/footer.json";
type FooterMessageKey = keyof typeof footerMessages;
@@ -67,3 +71,24 @@ export const CUSTOM_RULE_CONFIRM_FOOTER_STEP_BY_STEP: ReadonlyMap<
CreateFlowStep,
CustomRuleConfirmFooterStep
> = new Map(CUSTOM_RULE_CONFIRM_FOOTER_STEPS.map((e) => [e.step, e]));
/**
* Map a custom-rule Confirm footer step to the facet-backed method-card section
* (core values omit chip MultiSelect uses a different ordering model).
*/
export function methodCardFacetSectionForConfirmStep(
step: CustomRuleConfirmFooterStep["step"],
): CreateFlowMethodCardFacetSection | undefined {
switch (step) {
case "communication-methods":
return "communication";
case "membership-methods":
return "membership";
case "decision-approaches":
return "decisionApproaches";
case "conflict-management":
return "conflictManagement";
default:
return undefined;
}
}
@@ -0,0 +1,19 @@
import type { TemplateFacetGroupKey } from "../../../../lib/create/templateReviewMapping";
import type { CreateFlowStep } from "../types";
const MAP: Record<TemplateFacetGroupKey, CreateFlowStep> = {
coreValues: "core-values",
communication: "communication-methods",
membership: "membership-methods",
decisionApproaches: "decision-approaches",
conflictManagement: "conflict-management",
};
/**
* Custom-rule URL segment for a final-review category row (`+` navigation).
*/
export function createFlowStepForFacetGroup(
groupKey: TemplateFacetGroupKey,
): CreateFlowStep {
return MAP[groupKey];
}
+14
View File
@@ -153,6 +153,20 @@ export function parseCreateFlowScreenFromPathname(
export const TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY = "fromFlow" as const;
export const TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE = "1" as const;
/** `/create/{step}?reviewReturn=…` — set when opening a custom-rule step from final-review or edit-rule via + */
export const CREATE_FLOW_REVIEW_RETURN_QUERY_KEY = "reviewReturn" as const;
export type CreateFlowReviewReturnTarget = "final-review" | "edit-rule";
export function parseReviewReturnSearchParam(
searchParams: { get: (name: string) => string | null } | null | undefined,
): CreateFlowReviewReturnTarget | null {
if (!searchParams) return null;
const raw = searchParams.get(CREATE_FLOW_REVIEW_RETURN_QUERY_KEY);
if (raw === "final-review" || raw === "edit-rule") return raw;
return null;
}
/**
* `/create/review-template/{slug}` with optional marker so chrome can send
* footer Back to `/create/review` instead of marketing home.
@@ -351,6 +351,11 @@ export function ProfilePageView({
{
id: "view",
label: t("viewPublic"),
href: `/rules/${encodeURIComponent(rule.id)}`,
},
{
id: "manage",
label: t("manageRule"),
href: `/create/completed?ruleId=${encodeURIComponent(rule.id)}`,
},
{
+2 -1
View File
@@ -20,8 +20,9 @@ export const GET = apiRoute("rules.list", async (request: NextRequest) => {
const { searchParams } = new URL(request.url);
const take = Math.min(Number(searchParams.get("limit") ?? "50") || 50, 100);
/** Public catalog: mirror profile “my rules” recency semantics (last touched first). */
const rules = await prisma.publishedRule.findMany({
orderBy: { createdAt: "desc" },
orderBy: [{ updatedAt: "desc" }, { id: "asc" }],
take,
select: {
id: true,
@@ -42,6 +42,7 @@ const RuleContainer = memo<RuleProps>(
hasBottomLinks = false,
bottomStatusLabel,
bottomLinks,
recommended = false,
}) => {
const size = sizeProp ?? "L";
@@ -96,6 +97,7 @@ const RuleContainer = memo<RuleProps>(
hasBottomLinks={hasBottomLinks}
bottomStatusLabel={bottomStatusLabel}
bottomLinks={bottomLinks}
recommended={recommended}
/>
);
},
+9
View File
@@ -4,6 +4,8 @@ import type { RuleSizeValue } from "../../../../lib/propNormalization";
export interface Category {
name: string;
chipOptions: ChipOption[];
/** When `false`, hide the rows + affordance. Default: show when the Rule allows adds. */
addButton?: boolean;
onChipClick?: (categoryName: string, chipId: string) => void;
onAddClick?: (categoryName: string) => void;
onCustomChipConfirm?: (
@@ -58,6 +60,12 @@ export interface RuleProps {
/** Uppercase chip (e.g. IN PROGRESS); omit when no left badge. */
bottomStatusLabel?: string;
bottomLinks?: RuleBottomLink[];
/**
* When set and the card is collapsed (`expanded` false), show the
* RECOMMENDED tag above the title (e.g. templates index). Ignored when
* `expanded` Figma `22142:898446` compact `Card / Rule` only.
*/
recommended?: boolean;
}
export interface RuleViewProps {
@@ -81,4 +89,5 @@ export interface RuleViewProps {
hasBottomLinks?: boolean;
bottomStatusLabel?: string;
bottomLinks?: RuleBottomLink[];
recommended?: boolean;
}
+17 -2
View File
@@ -5,6 +5,7 @@ import { useTranslation } from "../../../contexts/MessagesContext";
import MultiSelect from "../../controls/MultiSelect";
import InlineTextButton from "../../buttons/InlineTextButton";
import NavigationLink from "../../navigation/Link";
import Tag from "../../utility/Tag";
import type { RuleBottomLink, RuleViewProps } from "./Rule.types";
export function RuleView({
@@ -28,6 +29,7 @@ export function RuleView({
hasBottomLinks = false,
bottomStatusLabel,
bottomLinks,
recommended = false,
}: RuleViewProps) {
const t = useTranslation("ruleCard");
const ariaLabel = t("ariaLabel")?.replace("{title}", title) || title;
@@ -90,6 +92,7 @@ export function RuleView({
`;
// Title typography - use CSS responsive classes
const showRecommendedTag = recommended && !expanded;
const titleClass = `
max-[639px]:font-inter max-[639px]:font-bold max-[639px]:text-[20px] max-[639px]:leading-[28px]
min-[640px]:max-[1023px]:font-bricolage-grotesque min-[640px]:max-[1023px]:font-bold min-[640px]:max-[1023px]:text-[28px] min-[640px]:max-[1023px]:leading-[36px]
@@ -256,12 +259,22 @@ export function RuleView({
{/* Inner container for header text with padding */}
<div
className={`
flex items-center justify-center w-full
flex w-full
${
showRecommendedTag
? "flex-col items-start justify-center gap-1"
: "items-center justify-center"
}
max-[639px]:pl-[8px] max-[639px]:py-[8px]
min-[640px]:max-[1023px]:pl-[12px] min-[640px]:max-[1023px]:py-[12px]
min-[1024px]:px-[16px] min-[1024px]:py-[24px]
`}
>
{showRecommendedTag ? (
<Tag variant="templateRecommended">
{t("recommendedLabel")}
</Tag>
) : null}
<h3
className={`${titleClass} cursor-inherit text-[var(--color-content-invert-primary)] overflow-hidden text-ellipsis w-full`}
>
@@ -384,7 +397,9 @@ export function RuleView({
onCustomChipClose={(chipId) => {
category.onCustomChipClose?.(category.name, chipId);
}}
addButton={!hideCategoryAddButton}
addButton={
!hideCategoryAddButton && category.addButton !== false
}
addButtonText="" // Empty text for icon-only circular button
className="w-full"
/>
@@ -57,6 +57,7 @@ export function GovernanceTemplateGrid({
key={card.slug}
title={card.title}
description={card.description}
recommended={card.recommended === true}
size={cardSize}
className={`
select-none
@@ -7,6 +7,7 @@ import type { TagProps } from "./Tag.types";
const DEFAULT_LABELS: Record<TagProps["variant"], string> = {
recommended: "RECOMMENDED",
selected: "SELECTED",
templateRecommended: "RECOMMENDED",
};
/**
+6 -2
View File
@@ -1,7 +1,11 @@
export type TagVariant = "recommended" | "selected";
export type TagVariant = "recommended" | "selected" | "templateRecommended";
export interface TagProps {
/** Visual variant: recommended (yellow) or selected (dark) */
/**
* Visual variant: recommended (yellow), selected (dark on light),
* or templateRecommended (dark pill on pastel `Card / Rule` Figma
* `22142:898446`).
*/
variant: TagVariant;
/** Tag text. Defaults to "RECOMMENDED" or "SELECTED" when not provided. */
children?: React.ReactNode;
+11 -6
View File
@@ -7,12 +7,17 @@ import type { TagViewProps } from "./Tag.types";
*/
export function TagView({ variant, children, className }: TagViewProps) {
const isRecommended = variant === "recommended";
const bgClass = isRecommended
? "bg-[var(--color-surface-inverse-brand-accent)]"
: "bg-[var(--color-gray-1000)]";
const textClass = isRecommended
? "text-[var(--color-content-inverse-brand-primary)]"
: "text-[var(--color-gray-000)]";
const isTemplateRecommended = variant === "templateRecommended";
const bgClass = isTemplateRecommended
? "bg-[var(--color-surface-default-tertiary)]"
: isRecommended
? "bg-[var(--color-surface-inverse-brand-accent)]"
: "bg-[var(--color-gray-1000)]";
const textClass = isTemplateRecommended
? "text-[var(--color-gray-000)]"
: isRecommended
? "text-[var(--color-content-inverse-brand-primary)]"
: "text-[var(--color-gray-000)]";
return (
<span
+2 -2
View File
@@ -292,8 +292,8 @@ export type MyPublishedRule = {
};
/**
* Lists the signed-in users published rules (newest first). Returns `null` on
* network failure or unauthenticated response.
* Lists the signed-in users published rules (**last updated first**, stable by id).
* Returns `null` on network failure or unauthenticated response.
*/
export async function fetchMyPublishedRules(): Promise<
MyPublishedRule[] | null
+2 -1
View File
@@ -1,6 +1,7 @@
/**
* Bridges final-review completed without query strings, and re-opens a rule
* from profile (`/create/completed?ruleId=…`) after GET /api/rules/[id].
* from profile (`/create/completed?ruleId=…`) after GET /api/rules/[id]. Profile
* "Manage" links here; "View" uses `/rules/[id]`.
*/
export const CREATE_FLOW_LAST_PUBLISHED_KEY = "createFlow.lastPublished";
+67
View File
@@ -0,0 +1,67 @@
/**
* Reorders facet-ranked method presets so explicitly confirmed selections pin
* to the top while the remainder keeps score-based ranking (recommended before
* default).
*/
/** Selected ids first (selection array order); then tail in `ranked` order. */
export function orderRankedMethodsWithPinnedSelection<T extends { id: string }>(
rankedMethods: readonly T[],
selectedIds: readonly string[],
pinActive: boolean,
): T[] {
if (!pinActive || selectedIds.length === 0) {
return [...rankedMethods];
}
const byId = new Map(rankedMethods.map((m) => [m.id, m] as const));
const head: T[] = [];
const picked = new Set<string>();
for (const id of selectedIds) {
const row = byId.get(id);
if (!row || picked.has(id)) continue;
picked.add(id);
head.push(row);
}
const tail = rankedMethods.filter((m) => !picked.has(m.id));
return [...head, ...tail];
}
/**
* Prefer selected ids in compact slots (up to `limit`), then facet-derived
* `baseCompact.compactCardIds`, then remaining methods in showcase order so
* selected cards surface even when they are outside the unpinned facet top-N.
*/
export function mergeCompactCardIdsWithPinnedSelected(
showcaseOrderIds: readonly string[],
baseCompactCardIds: readonly string[],
selectedIds: readonly string[],
pinActive: boolean,
limit: number,
): string[] {
if (!pinActive || selectedIds.length === 0) {
return [...baseCompactCardIds].slice(0, limit);
}
const valid = new Set(showcaseOrderIds);
const out: string[] = [];
const seen = new Set<string>();
for (const id of selectedIds) {
if (out.length >= limit) break;
if (!valid.has(id) || seen.has(id)) continue;
seen.add(id);
out.push(id);
}
for (const id of baseCompactCardIds) {
if (out.length >= limit) break;
if (!valid.has(id) || seen.has(id)) continue;
seen.add(id);
out.push(id);
}
for (const id of showcaseOrderIds) {
if (out.length >= limit) break;
if (seen.has(id)) continue;
seen.add(id);
out.push(id);
}
return out;
}
+3 -2
View File
@@ -65,7 +65,8 @@ export type OwnerPublishedRuleListItem = {
};
/**
* Lists published rules owned by the given user (alphabetical by title, then id).
* Lists published rules owned by the given user (**most recently updated first**,
* then stable `id` tie-break).
* Returns `null` when the database is not configured or the query throws.
*/
export async function listPublishedRulesForUser(
@@ -79,7 +80,7 @@ export async function listPublishedRulesForUser(
try {
return await prisma.publishedRule.findMany({
where: { userId },
orderBy: [{ title: "asc" }, { id: "asc" }],
orderBy: [{ updatedAt: "desc" }, { id: "asc" }],
take: clamped,
select: PUBLISHED_RULE_OWNER_LIST_SELECT,
});
@@ -112,6 +112,15 @@ export const createFlowStateSchema = z
conflictManagementDetailsById: z
.record(conflictManagementDetailEntrySchema)
.optional(),
methodSectionsPinCommitted: z
.object({
communication: z.boolean().optional(),
membership: z.boolean().optional(),
decisionApproaches: z.boolean().optional(),
conflictManagement: z.boolean().optional(),
})
.strict()
.optional(),
pendingTemplateAction: z
.object({
slug: z.string().max(200),
@@ -13,6 +13,12 @@ export type GovernanceTemplateCatalogEntry = {
backgroundColor: string;
/** Path under public/ for getAssetPath() — Figma Asset / Template Mark */
iconPath: string;
/**
* When true, the templates grid shows the RECOMMENDED tag (facet-based
* scores will set this in `ruleTemplateToGridEntry` when wired; catalog
* entries omit unless intentionally static).
*/
recommended?: boolean;
};
/** SVGs in `public/assets/template-mark/<slug>.svg` (kebab-case slug). */
+11 -2
View File
@@ -11,16 +11,24 @@ import {
export const TEMPLATE_GRID_FALLBACK_PRESENTATION = {
iconPath: governanceTemplateIconPath("consensus"),
backgroundColor: "bg-[var(--color-surface-invert-brand-teal)]",
recommended: false,
} as const;
export type TemplateGridCardEntry = GovernanceTemplateCatalogEntry;
function presentationForSlug(slug: string): Pick<
GovernanceTemplateCatalogEntry,
"iconPath" | "backgroundColor"
"iconPath" | "backgroundColor" | "recommended"
> {
const catalog = getGovernanceTemplateCatalogEntry(slug);
return catalog ?? TEMPLATE_GRID_FALLBACK_PRESENTATION;
if (catalog) {
return {
iconPath: catalog.iconPath,
backgroundColor: catalog.backgroundColor,
recommended: catalog.recommended === true,
};
}
return TEMPLATE_GRID_FALLBACK_PRESENTATION;
}
/**
@@ -35,6 +43,7 @@ export function ruleTemplateToGridEntry(template: RuleTemplateDto): TemplateGrid
description,
iconPath: pres.iconPath,
backgroundColor: pres.backgroundColor,
recommended: pres.recommended,
};
}
+2 -1
View File
@@ -1,3 +1,4 @@
{
"ariaLabel": "Learn more about {title} governance pattern"
"ariaLabel": "Learn more about {title} governance pattern",
"recommendedLabel": "RECOMMENDED"
}
+1
View File
@@ -23,6 +23,7 @@
"createTemplateDescription": "Browse templates and adapt one for your community.",
"createTemplateCta": "Browse templates",
"viewPublic": "View",
"manageRule": "Manage",
"duplicate": "Duplicate",
"deleteRule": "Delete",
"deleteRuleConfirm": "Delete this published rule? This cannot be undone.",
+18
View File
@@ -37,6 +37,11 @@ export default {
control: { type: "boolean" },
description: "Whether the card is in expanded state",
},
recommended: {
control: { type: "boolean" },
description:
"When true and collapsed, show RECOMMENDED tag above the title (templates grid)",
},
size: {
control: { type: "select" },
options: ["XS", "S", "M", "L", "xs", "s", "m", "l"],
@@ -54,6 +59,7 @@ export const Default = {
"Units called Circles have the ability to decide and act on matters in their domains, which their members agree on through a Council.",
backgroundColor: "bg-[var(--color-surface-default-brand-lime)]",
expanded: false,
recommended: false,
size: "L",
icon: (
<Image
@@ -67,6 +73,18 @@ export const Default = {
},
};
/** Figma `22142:898446` — compact `Card / Rule` with template recommendation tag. */
export const CollapsedWithRecommended = {
args: {
...Default.args,
title: "Do-ocracy",
description:
"Authority is granted to those doing the work. If you do the task, you decide how it gets done.",
backgroundColor: "bg-[var(--color-surface-invert-brand-royal)]",
recommended: true,
},
};
export const Expanded = {
args: {
title: "Mutual Aid Mondays",
+8 -2
View File
@@ -8,14 +8,14 @@ export default {
docs: {
description: {
component:
"Small status tag with recommended (yellow) or selected (dark) variant. Default labels are RECOMMENDED and SELECTED; pass children for custom text.",
"Small status tag: recommended (yellow), selected (dark on light), templateRecommended (dark on pastel `Card / Rule`). Default labels are RECOMMENDED/SELECTED; pass children for custom text.",
},
},
},
argTypes: {
variant: {
control: { type: "select" },
options: ["recommended", "selected"],
options: ["recommended", "selected", "templateRecommended"],
description: "Visual variant",
},
children: {
@@ -37,6 +37,12 @@ export const Selected = {
},
};
export const TemplateRecommended = {
args: {
variant: "templateRecommended",
},
};
export const CustomLabel = {
args: {
variant: "recommended",
+21 -1
View File
@@ -12,12 +12,22 @@ import {
* window globals. Keeps the test readable vs. threading refs everywhere.
*/
function Harness() {
const { state, updateState, resetCustomRuleSelections } = useCreateFlow();
const {
state,
updateState,
resetCustomRuleSelections,
setMethodSectionsPinCommitted,
} = useCreateFlow();
(window as unknown as { __updateState: typeof updateState }).__updateState =
updateState;
(
window as unknown as { __resetCustomRule: typeof resetCustomRuleSelections }
).__resetCustomRule = resetCustomRuleSelections;
(
window as unknown as {
__setPin: typeof setMethodSectionsPinCommitted;
}
).__setPin = setMethodSectionsPinCommitted;
return (
<>
<div data-testid="title">{state.title ?? ""}</div>
@@ -33,6 +43,9 @@ function Harness() {
<div data-testid="snapshot">
{(state.coreValuesChipsSnapshot ?? []).map((r) => r.id).join(",")}
</div>
<div data-testid="pin-method">
{state.methodSectionsPinCommitted?.communication ? "yes" : "no"}
</div>
</>
);
}
@@ -79,6 +92,12 @@ describe("CreateFlowContext — resetCustomRuleSelections", () => {
"consensus-decision-making",
);
act(() => {
(window as unknown as { __setPin: (s: unknown, v: boolean) => void })
.__setPin("communication", true);
});
expect(screen.getByTestId("pin-method").textContent).toBe("yes");
act(() => {
getResetCustomRule()();
});
@@ -88,6 +107,7 @@ describe("CreateFlowContext — resetCustomRuleSelections", () => {
expect(screen.getByTestId("comm").textContent).toBe("");
expect(screen.getByTestId("details").textContent).toBe("");
expect(screen.getByTestId("snapshot").textContent).toBe("");
expect(screen.getByTestId("pin-method").textContent).toBe("no");
});
it("is a no-op when no custom-rule selections were set", () => {
+12
View File
@@ -205,4 +205,16 @@ describe("Rule Component", () => {
expect(screen.getByText("CE")).toBeInTheDocument();
});
it("shows template recommended tag when collapsed and recommended is true", () => {
render(<Rule {...defaultProps} recommended />);
expect(screen.getByText("RECOMMENDED")).toBeInTheDocument();
});
it("does not show template recommended tag when expanded", () => {
render(<Rule {...defaultProps} recommended expanded categories={[]} />);
expect(screen.queryByText("RECOMMENDED")).not.toBeInTheDocument();
});
});
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { createFlowStepForFacetGroup } from "../../app/(app)/create/utils/facetGroupToCreateFlowStep";
describe("createFlowStepForFacetGroup", () => {
it("maps facet keys to custom-rule URL segments", () => {
expect(createFlowStepForFacetGroup("coreValues")).toBe("core-values");
expect(createFlowStepForFacetGroup("communication")).toBe(
"communication-methods",
);
expect(createFlowStepForFacetGroup("membership")).toBe("membership-methods");
expect(createFlowStepForFacetGroup("decisionApproaches")).toBe(
"decision-approaches",
);
expect(createFlowStepForFacetGroup("conflictManagement")).toBe(
"conflict-management",
);
});
});
+18
View File
@@ -6,6 +6,7 @@ import {
getPreviousStep,
isValidStep,
getStepIndex,
parseReviewReturnSearchParam,
resolveCreateFlowBackTarget,
} from "../../app/(app)/create/utils/flowSteps";
@@ -104,4 +105,21 @@ describe("flowSteps", () => {
"/create/review-template/mutual-aid?fromFlow=1",
);
});
it("parseReviewReturnSearchParam accepts only final-review and edit-rule", () => {
expect(
parseReviewReturnSearchParam(
new URLSearchParams("reviewReturn=final-review"),
),
).toBe("final-review");
expect(
parseReviewReturnSearchParam(
new URLSearchParams("reviewReturn=edit-rule"),
),
).toBe("edit-rule");
expect(
parseReviewReturnSearchParam(new URLSearchParams("reviewReturn=nope")),
).toBeNull();
expect(parseReviewReturnSearchParam(null)).toBeNull();
});
});
+80
View File
@@ -0,0 +1,80 @@
import { describe, it, expect } from "vitest";
import {
mergeCompactCardIdsWithPinnedSelected,
orderRankedMethodsWithPinnedSelection,
} from "../../lib/create/methodCardDisplayOrder";
describe("orderRankedMethodsWithPinnedSelection", () => {
const methods = [
{ id: "a", rank: "top" },
{ id: "b", rank: "mid" },
{ id: "c", rank: "low" },
];
it("returns ranked order when pinning is inactive", () => {
expect(
orderRankedMethodsWithPinnedSelection(methods, ["c", "b"], false),
).toEqual(methods);
});
it("returns ranked order when there are no selections", () => {
expect(orderRankedMethodsWithPinnedSelection(methods, [], true)).toEqual(
methods,
);
});
it("pins selected ids ahead of ranked tail while preserving ranking in each block", () => {
expect(
orderRankedMethodsWithPinnedSelection(methods, ["c", "a"], true),
).toEqual([
{ id: "c", rank: "low" },
{ id: "a", rank: "top" },
{ id: "b", rank: "mid" },
]);
});
it("dedupes repeated ids in the selection list", () => {
expect(
orderRankedMethodsWithPinnedSelection(methods, ["b", "b", "c"], true),
).toEqual([
{ id: "b", rank: "mid" },
{ id: "c", rank: "low" },
{ id: "a", rank: "top" },
]);
});
});
describe("mergeCompactCardIdsWithPinnedSelected", () => {
const showcaseOrder = ["x", "a", "b", "y", "c"];
const baseCompact = ["a", "y", "b"];
it("delegates when pinning is inactive", () => {
expect(
mergeCompactCardIdsWithPinnedSelected(
showcaseOrder,
baseCompact,
["x"],
false,
3,
),
).toEqual(["a", "y", "b"]);
});
it("pads selected-first then facet-derived compact slots", () => {
expect(
mergeCompactCardIdsWithPinnedSelected(
showcaseOrder,
baseCompact,
["c"],
true,
4,
),
).toEqual(["c", "a", "y", "b"]);
});
it("fills remaining compact slots from showcase tail when facets run short", () => {
expect(
mergeCompactCardIdsWithPinnedSelected(showcaseOrder, [], ["x"], true, 3),
).toEqual(["x", "a", "b"]);
});
});