/** * The questionnaire surface. * * Transport-free by design: it takes a definition, the current draft and a set * of callbacks. That is what lets the same view serve the chat card, where the * draft is a live CRDT, and a plan widget, where it is a resolved record. * * Everyone edits one shared draft, so controls reflect other people's choices * as they arrive rather than tracking local state. */ import { useCallback, useEffect, useId, useReducer, useRef, useState } from "react"; import { CheckIcon, CloseIcon } from "@chopin/icons"; import { answered } from "../draft"; import * as limits from "../limits"; import { AddOption } from "./add-option"; import { ResolvedActions } from "./resolved-actions"; import { projectSuggestion, reduceSuggestionEditState } from "./project-suggestion"; import type { KeyboardEvent, ReactNode } from "react"; import type { Draft, Drafts } from "../draft"; import type { Answer, Definition, Item } from "../schema"; import type { VisibleSuggestion } from "./project-suggestion"; export type Collaborator = { /** Stable per connection, so one person on two devices shows twice. */ client: string; handle: string; question?: string; }; export type QuestionStepRenderProps = { children: ReactNode; question: string; }; export type PreviousAnswer = { labels: string[]; by: string }; export type QuestionViewProps = { definition: Definition; drafts: Drafts; /** Keeps open-card actions visible when the host withholds mutation callbacks. */ showActions?: boolean; /** Absent once resolved: a decision is not re-opened, a new question is asked. */ onChange?: (question: string, change: Partial) => void; onSubmit?: (visibleSuggestion?: VisibleSuggestion) => void; /** The current advisory card option; it never changes the shared draft. */ suggested?: VisibleSuggestion; /** Adds an option through the record that owns this decision. */ onAddOption?: (label: string) => Promise<{ ok: true } | { ok: false; message: string }>; maxOptions?: number; onCancel?: () => void; onDiscard?: () => void; /** Reopen a resolved decision without opening a shared draft in this view. */ onReopen?: () => void; /** The answer a reopened decision replaces, keyed for legacy multi-question cards. */ previous?: PreviousAnswer | Record; disabled?: boolean; submitting?: boolean; status?: "open" | "answered" | "cancelled" | "discarded"; /** Shown instead of controls once the questionnaire has resolved. */ answers?: Answer[]; resolver?: string; onQuestionEnter?: (question: string) => void; onQuestionLeave?: (question: string) => void; /** Goes to the prose the decision lives in. */ onQuestionSelect?: (question: string) => void; /** * How many passages each decision lives in, by question. * * Absent where nothing links the two — the chat card, or a decision still * waiting to be anchored. Without a destination the text stays inert prose * rather than advertising a jump that would do nothing. */ places?: Record; collaborators?: Collaborator[]; /** Validation or synchronisation problem, announced to assistive tech. */ error?: string; /** Host-owned presentation class for an error entering the view. */ errorClassName?: string; /** The Planner is updating this open decision card. */ refining?: boolean; /** Rendered beside the heading; hosts use it for counts and provenance. */ aside?: ReactNode; /** Lets a host retain bounded steps for presentation without owning question state. */ renderStep?: (props: QuestionStepRenderProps) => ReactNode; }; export function currentQuestion( definition: Definition, active: string | undefined, ): Item { return definition.questions.find(question => question.id === active) ?? definition.questions[0]!; } /** Find a previous decision without applying one question's answer to another. */ export function previousFor( previous: QuestionViewProps["previous"], question: string, ): PreviousAnswer | undefined { if (!previous) return undefined; if (Array.isArray((previous as PreviousAnswer).labels)) return previous as PreviousAnswer; return (previous as Record)[question]; } function Badges({ people }: { people: Collaborator[] }) { if (people.length === 0) return null; return ( {people.map(person => ( @{person.handle} ))} ); } function DecisionHeading({ refining }: { refining?: boolean } = {}) { return ( <>
{refining && (

Chopin is refining…

)} ); } function Choices( { question, draft, disabled, name, onChange, suggestedOptionId }: { question: Item; draft: Draft | undefined; disabled: boolean; name: string; onChange?: (change: Partial) => void; suggestedOptionId?: string; }, ) { let custom = draft?.mode === "custom"; return (
{question.header} {question.options.map(option => { let selected = question.multiple ? !!draft?.options[option.id] : draft?.choice === option.id; return ( ); })}
); } function Custom( { question, draft, disabled, name, onChange }: { question: Item; draft: Draft | undefined; disabled: boolean; name: string; onChange?: (change: Partial) => void; }, ) { let active = draft?.mode === "custom"; let textarea = useRef(null); let focusOnReveal = useRef(false); useEffect(() => { if (!active || !focusOnReveal.current) return; focusOnReveal.current = false; textarea.current?.focus(); }, [active]); useEffect(() => { let viewport = window.visualViewport; if (!viewport) return; let height = viewport.height; let reveal = () => { let previous = height; height = viewport.height; let control = textarea.current; if (height >= previous || document.activeElement !== control || !control) return; let bounds = control.getBoundingClientRect(); let top = viewport.offsetTop; let bottom = top + viewport.height; if (bounds.top >= top && bounds.bottom <= bottom) return; requestAnimationFrame(() => control.scrollIntoView({ block: "nearest" })); }; viewport.addEventListener("resize", reveal); return () => viewport.removeEventListener("resize", reveal); }, []); return (
{active && (