Import the remade decks on stable ids and recommend one card per job, so a saved draft still loads after a chip is renamed.

The real-group profiles stay as a test, so a change that moves a pick fails.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
adilalloandCursor committed 2026-11-04 14:22:54 -07:00
1 parent 96ca15d8e3
commit bcbc6f30d9
89 files changed
+36555 -2828

No files matched your search

@@ -92,7 +92,15 @@ function DecisionApproachEditFieldsComponent({
generateDraftLabel={draftField.generateDraft}
generateDraftHint={draftField.generateDraftHint}
/>
{value.consensusLevel !== undefined ? (
{value.consensusLevel === undefined ? (
<ModalTextAreaField
label={t.sectionHeadings.consensusLevel}
value={t.consensusLevelDisplay.supermajorityShare}
onChange={() => {}}
disabled
rows={1}
/>
) : (
<IncrementerBlock
label={t.sectionHeadings.consensusLevel}
helpIcon={false}
@@ -101,12 +109,14 @@ function DecisionApproachEditFieldsComponent({
max={CONSENSUS_LEVEL_MAX}
step={CONSENSUS_LEVEL_STEP}
onChange={(next) => patch("consensusLevel", next)}
formatValue={(v) => `${v}%`}
formatValue={(v) =>
v === 0 ? t.consensusLevelDisplay.noSetShare : `${v}%`
}
decrementAriaLabel="Decrease consensus level"
incrementAriaLabel="Increase consensus level"
disabled={readOnly}
/>
) : null}
)}
<ModalTextAreaField
label={t.sectionHeadings.objectionsDeadlocks}
value={value.objectionsDeadlocks}
@@ -9,6 +9,12 @@ import {
loadFacetScores,
} from "../../../../lib/create/facetRecommendationsLoad";
import { useCreateFlow } from "../context/CreateFlowContext";
import { groupAnswersFromCreateFlowState } from "../../../../lib/create/groupAnswers";
import {
deckData,
recommendDeck,
} from "../../../../lib/create/deckRecommend";
import type { GroupAnswers } from "../../../../lib/create/deckTypes";
/**
* Card-deck section ids served by `/api/create-flow/methods` (CR-88 §9.2).
@@ -125,63 +131,46 @@ export function useFacetRecommendations(
}
/**
* Stable comparator for re-ranking a messages-file `methods[]` array. Higher
* `scoresBySlug[id]` first; ties fall back to authoring index, so a
* zero-facet user sees the original ordering verbatim.
* One card per job, then the display rule for the top `limit` cards.
* Cards marked every-rule carry no Recommended tag. With no facets, the
* first `limit` cards in deck order show and nothing is tagged.
*/
export function rankMethodsByScore<T extends { id: string }>(
methods: readonly T[],
scoresBySlug: Record<string, number>,
): T[] {
const indexById = new Map<string, number>();
methods.forEach((m, i) => indexById.set(m.id, i));
return [...methods].sort((a, b) => {
const sa = scoresBySlug[a.id] ?? 0;
const sb = scoresBySlug[b.id] ?? 0;
if (sa !== sb) return sb - sa;
return (indexById.get(a.id) ?? 0) - (indexById.get(b.id) ?? 0);
});
}
/**
* Picks (a) which method ids fill the compact card stack and (b) which of
* those should render with the "Recommended" tag. The messages JSON no
* longer carries a static `recommended` flag — both selections come
* entirely from facet scores (CR-88 §10).
*
* Behavior:
* - Facets selected & at least one method scored > 0 →
* `compactCardIds` = up to `limit` top-scored methods (1..limit cards;
* never padded with unrecommended fillers). All shown cards get the
* "Recommended" badge.
* - No facets selected, or every method scored 0 → `compactCardIds` =
* first `limit` in ranked/authoring order, `recommendedIds` empty (no
* badges shown — honest "no signal yet" fallback).
*
* `CardStack.view` is responsible for laying out variable-length compact
* arrays gracefully (uses `.map`/`.slice` and length-guarded indexing).
*/
export function deriveCompactCards<T extends { id: string }>(
rankedMethods: readonly T[],
scoresBySlug: Record<string, number>,
hasAnyFacets: boolean,
export function deriveCompactCards(
section: RecommendationSection,
answers: GroupAnswers,
limit: number,
): { compactCardIds: string[]; recommendedIds: Set<string> } {
const fallback = () => ({
compactCardIds: rankedMethods.slice(0, limit).map((m) => m.id),
recommendedIds: new Set<string>(),
});
if (!hasAnyFacets) return fallback();
const matched = rankedMethods.filter(
(m) => (scoresBySlug[m.id] ?? 0) > 0,
);
if (matched.length === 0) return fallback();
const top = matched.slice(0, limit);
): {
compactCardIds: string[];
recommendedIds: Set<string>;
orderedIds: string[];
} {
const recommendation = recommendDeck(deckData(section), answers, { limit });
const orderedIds = recommendation.fallback
? recommendation.seeAll
: [
...recommendation.shown.map((card) => card.id),
...recommendation.seeAll,
];
if (recommendation.fallback) {
return {
compactCardIds: orderedIds.slice(0, limit),
recommendedIds: new Set<string>(),
orderedIds,
};
}
return {
compactCardIds: top.map((m) => m.id),
recommendedIds: new Set(top.map((m) => m.id)),
compactCardIds: recommendation.shown.map((card) => card.id),
recommendedIds: new Set(
recommendation.recommended
.filter((card) => card.tag === "recommended")
.map((card) => card.id),
),
orderedIds,
};
}
export function groupAnswersForRecommendations(
state: Parameters<typeof groupAnswersFromCreateFlowState>[0],
): GroupAnswers {
return groupAnswersFromCreateFlowState(state);
}
@@ -4,68 +4,80 @@ import { useMemo } from "react";
import { mergeCompactCardIdsWithPinnedSelected } from "../../../../lib/create/methodCardDisplayOrder";
import {
deriveCompactCards,
rankMethodsByScore,
useFacetRecommendations,
groupAnswersForRecommendations,
type RecommendationSection,
} from "./useFacetRecommendations";
import { useCreateFlow } from "../context/CreateFlowContext";
type MethodEntry = { id: string; label: string; supportText: string };
function orderByIds<T extends { id: string }>(
methods: readonly T[],
orderedIds: readonly string[],
): T[] {
const byId = new Map(methods.map((method) => [method.id, method]));
const ordered = orderedIds.flatMap((id) => {
const method = byId.get(id);
return method ? [method] : [];
});
const seen = new Set(ordered.map((method) => method.id));
return [...ordered, ...methods.filter((method) => !seen.has(method.id))];
}
/**
* Applies score ranking and compact-slot rules. Expanded CardStack order stays
* the ranked catalog (selected cards keep their place). Compact slots still
* pin selected ids first so a pick outside the unpinned top-N remains visible
* when the stack is collapsed.
* Deck order and the compact stack come from the job rule. Current screens
* show five cards; a deck screen passes seven. Selected cards still pin
* into the compact stack.
*/
export function useMethodCardDeckOrdering(
section: RecommendationSection,
methods: readonly MethodEntry[],
selectedIds: readonly string[],
) {
const { scoresBySlug, hasAnyFacets, isReady } =
useFacetRecommendations(section);
const recommendationsReady = !hasAnyFacets || isReady;
const { state } = useCreateFlow();
const answers = useMemo(
() => groupAnswersForRecommendations(state),
[state],
);
const deck = useMemo(
() => deriveCompactCards(section, answers, 5),
[section, answers],
);
const rankedMethods = useMemo(
() => rankMethodsByScore(methods, scoresBySlug),
[methods, scoresBySlug],
() => orderByIds(methods, deck.orderedIds),
[methods, deck.orderedIds],
);
const selectionShowcaseActive = selectedIds.length > 0;
const displayMethods = rankedMethods;
const { compactCardIds: baseCompactCardIds, recommendedIds } = useMemo(
() =>
deriveCompactCards(
rankedMethods,
scoresBySlug,
hasAnyFacets,
/* limit */ 5,
),
[rankedMethods, scoresBySlug, hasAnyFacets],
);
const { recommendedIds } = deck;
const compactCardIds = useMemo(
() =>
mergeCompactCardIdsWithPinnedSelected(
rankedMethods.map((m) => m.id),
baseCompactCardIds,
rankedMethods.map((method) => method.id),
deck.compactCardIds,
selectedIds,
selectionShowcaseActive,
5,
),
[rankedMethods, baseCompactCardIds, selectedIds, selectionShowcaseActive],
[
rankedMethods,
deck.compactCardIds,
selectedIds,
selectionShowcaseActive,
],
);
const sampleCards = useMemo(
() =>
displayMethods.map((entry) => ({
rankedMethods.map((entry) => ({
id: entry.id,
label: entry.label,
supportText: entry.supportText,
recommended: recommendedIds.has(entry.id),
})),
[displayMethods, recommendedIds],
[rankedMethods, recommendedIds],
);
const methodById = useMemo(
@@ -75,11 +87,11 @@ export function useMethodCardDeckOrdering(
return {
rankedMethods,
displayMethods,
displayMethods: rankedMethods,
compactCardIds,
recommendedIds,
sampleCards,
methodById,
recommendationsReady,
recommendationsReady: true,
};
}
@@ -113,7 +113,10 @@ export function useTemplateReviewActions({
setTemplateReviewApplyError(loaded.message);
return;
}
const prefill = buildTemplateCustomizePrefill(loaded.template.body);
const prefill = buildTemplateCustomizePrefill(
loaded.template.body,
templateReviewSlug,
);
const pinPatch = methodSectionsPinsForHydratedSelections(prefill);
const hasCommunityName =
typeof state.title === "string" && state.title.trim().length > 0;
@@ -182,7 +185,10 @@ export function useTemplateReviewActions({
// (reset + update separately could leave selections undefined in Strict Mode).
replaceState((prev) => {
const base = stripCustomRuleSelectionFields(prev);
const customizePrefill = buildTemplateCustomizePrefill(doc);
const customizePrefill = buildTemplateCustomizePrefill(
doc,
templateReviewSlug ?? undefined,
);
const hasValuesSeed =
customizePrefill.selectedCoreValueIds !== undefined;
@@ -2,9 +2,11 @@
import { useCallback, useMemo, useState } from "react";
import Rule, { type Category } from "../../../../components/cards/Rule";
import Alert from "../../../../components/modals/Alert";
import { TemplateChipDetailModal } from "../../../../components/cards/TemplateReviewCard/TemplateChipDetailModal";
import { useMessages, useTranslation } from "../../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext";
import type { CreateFlowState } from "../../types";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import {
CREATE_FLOW_REVIEW_RULE_LAYOUT_CLASS,
@@ -39,6 +41,15 @@ import {
readDraftPreviewSurfaceColor,
} from "../../utils/resolvePreviewSurfaceFallback";
function bindingArbitrationNeedsSeparateYes(state: CreateFlowState): boolean {
const membership = state.selectedMembershipMethodIds ?? [];
const conflict = state.selectedConflictManagementIds ?? [];
const joinsWithoutAYes =
membership.includes("open-admission") ||
membership.includes("membership-by-token");
return joinsWithoutAYes && conflict.includes("binding-arbitration");
}
const FACET_FALLBACK_ORDER: readonly TemplateFacetGroupKey[] = [
"coreValues",
"communication",
@@ -284,6 +295,16 @@ export function FinalReviewScreen({
: t("description")
}
>
{bindingArbitrationNeedsSeparateYes(state) ? (
<Alert
type="banner"
status="warning"
size="m"
title={t("bindingArbitrationWarning.title")}
description={t("bindingArbitrationWarning.description")}
hasTrailingIcon={false}
/>
) : null}
<Rule
title={ruleCardTitle}
description={ruleCardDescription}
@@ -9,10 +9,10 @@ import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup"
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
function chipRowsFromLabels(
rows: readonly { label: string }[],
rows: readonly { id?: string; label: string }[],
): ChipOption[] {
return rows.map((row, i) => ({
id: String(i + 1),
id: row.id ?? String(i + 1),
label: row.label,
state: "unselected" as const,
}));
@@ -47,10 +47,10 @@ function createListCustomHandlers(
}
function chipRowsFromLabels(
rows: readonly { label: string }[],
rows: readonly { id?: string; label: string }[],
): ChipOption[] {
return rows.map((row, i) => ({
id: String(i + 1),
id: row.id ?? String(i + 1),
label: row.label,
state: "unselected" as const,
}));
@@ -54,12 +54,16 @@ const MAX_CORE_VALUES = MAX_SELECTED_CORE_VALUES;
*/
type ModalSession = "pending" | "customPending" | "editing";
/** Row in `coreValues.json` `values` — string (legacy) or `{ label, meaning, signals }`. */
type CoreValuePresetJson =
| string
| { label: string; meaning?: string; signals?: string };
/** Row in `coreValues.json` `values`. */
type CoreValuePresetJson = {
id: string;
label: string;
meaning?: string;
signals?: string;
};
type CoreValuePreset = {
id: string;
label: string;
meaning: string;
signals: string;
@@ -68,16 +72,12 @@ type CoreValuePreset = {
function normalizeCoreValuePresets(
values: readonly CoreValuePresetJson[],
): CoreValuePreset[] {
return values.map((v) => {
if (typeof v === "string") {
return { label: v, meaning: "", signals: "" };
}
return {
label: v.label,
meaning: typeof v.meaning === "string" ? v.meaning : "",
signals: typeof v.signals === "string" ? v.signals : "",
};
});
return values.map((v) => ({
id: v.id,
label: v.label,
meaning: typeof v.meaning === "string" ? v.meaning : "",
signals: typeof v.signals === "string" ? v.signals : "",
}));
}
function selectedIdsFromOptions(options: ChipOption[]): string[] {
@@ -98,7 +98,7 @@ function chipOptionsToSnapshotRows(
const EMPTY_DETAIL: CoreValueDetailEntry = { meaning: "", signals: "" };
/** Create Custom — Core Values (Figma `20264:68378`). Up to five selections; preset list + custom chips. */
/** Create Custom — Core Values (Figma `20264:68378`). Up to six selections; preset list + custom chips. */
export function CoreValuesSelectScreen() {
const m = useMessages();
const cv = m.create.customRule.coreValues;
+12 -2
View File
@@ -129,8 +129,18 @@ export interface CreateFlowState {
selectedOrganizationTypeIds?: string[];
/** Selected chip ids from `community-structure` (scale). */
selectedScaleIds?: string[];
/** Selected chip ids from `community-structure` (maturity). */
/** Selected chip ids from `community-structure` (maturity). One answer scores. */
selectedMaturityIds?: string[];
/**
* Setup answers that add or open cards. The setup screen writes these.
* Stored with the draft and never published.
*/
setupTraits?: Partial<
Record<
"network" | "nonvoters" | "employer" | "sponsor" | "records" | "risky",
boolean
>
>;
/**
* Full chip lists for `community-structure` (needed so custom chips round-trip in drafts).
* IDs alone are insufficient because custom rows are not reconstructible from copy JSON.
@@ -140,7 +150,7 @@ export interface CreateFlowState {
scale?: CommunityStructureChipSnapshotRow[];
maturity?: CommunityStructureChipSnapshotRow[];
};
/** Create Custom — core values step (max five `selectedCoreValueIds`). */
/** Create Custom — core values step (max six `selectedCoreValueIds`). */
selectedCoreValueIds?: string[];
/** Full chip rows for core values (custom labels). */
coreValuesChipsSnapshot?: CommunityStructureChipSnapshotRow[];
@@ -206,7 +206,13 @@ function resolveChipContent(
/>
<ReadOnlyValueField
label={da.sectionHeadings.consensusLevel}
value={`${preset.sections.consensusLevel}%`}
value={
preset.sections.consensusLevel === undefined
? da.consensusLevelDisplay.supermajorityShare
: preset.sections.consensusLevel === 0
? da.consensusLevelDisplay.noSetShare
: `${preset.sections.consensusLevel}%`
}
/>
<ModalTextAreaField
label={da.sectionHeadings.objectionsDeadlocks}
+1 -1
View File
@@ -17,7 +17,7 @@ const NOT_FOUND_TEMPLATE_SLUGS = [
"consensus",
"do-ocracy",
"devolution",
"quadratic-governance",
"solidarity-network",
] as const;
/**