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:
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;
|
||||
|
||||
@@ -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
@@ -17,7 +17,7 @@ const NOT_FOUND_TEMPLATE_SLUGS = [
|
||||
"consensus",
|
||||
"do-ocracy",
|
||||
"devolution",
|
||||
"quadratic-governance",
|
||||
"solidarity-network",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user