From ec9c7bb374982ba455253ba915e5505ef52190da Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Sun, 11 Oct 2026 07:20:45 +0100 Subject: [PATCH 1/7] Answer decisions on phones from one pane with a save bar and a closure On a phone, open decisions in the document become one-line chips that open Decisions focused on that card. Decisions drops its top inset, moves Save to a bar that appears only with unsaved answers, and shows a brief Decided confirmation once the server publishes the card as decided before folding it into resolved history. Desktop is unchanged. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/room-workspace.tsx | 8 + apps/web/src/tokens.test.ts | 3 +- e2e/decisions-phone.e2e.ts | 85 +++++++ e2e/discarded-mobile-geometry.e2e.ts | 2 +- e2e/responsive-decisions.e2e.ts | 8 +- packages/editor/src/decisions.tsx | 144 ++++++++++- packages/editor/src/plan-editor.tsx | 5 + packages/editor/src/save-bar.test.ts | 47 ++++ packages/editor/src/save-bar.tsx | 107 ++++++++ packages/editor/src/styles.css | 233 ++++++++++++++++++ packages/editor/src/widget-options.ts | 2 + packages/editor/src/widgets/questionnaire.tsx | 45 +++- packages/question/src/react/index.ts | 1 + packages/question/src/react/question-view.tsx | 78 +++++- packages/visuals/theme.css | 4 + 15 files changed, 753 insertions(+), 19 deletions(-) create mode 100644 e2e/decisions-phone.e2e.ts create mode 100644 packages/editor/src/save-bar.test.ts create mode 100644 packages/editor/src/save-bar.tsx diff --git a/apps/web/src/room-workspace.tsx b/apps/web/src/room-workspace.tsx index b82c2d21d..cc1e57e8d 100644 --- a/apps/web/src/room-workspace.tsx +++ b/apps/web/src/room-workspace.tsx @@ -616,6 +616,13 @@ export function RoomWorkspace( if (destination !== "build") selectView(destination, mode === "split"); dispatch({ type: "set-chat", open: false }); }; + let latestSelectDestination = useRef(selectDestination); + latestSelectDestination.current = selectDestination; + // Stable, because the editor rebuilds its plugins when a callback changes. + let showDecision = useCallback((questionnaireId: string) => { + latestSelectDestination.current("decisions"); + setReveal({ widget: questionnaireId, token: Date.now() }); + }, []); useEffect(() => { setSourceDestination(undefined); @@ -1192,6 +1199,7 @@ export function RoomWorkspace( evidence={showEvidence} onCardSource={showCardSource} hasCardSource={hasCardSource} + onShowDecision={showDecision} planner={agent} commentPresentation={mode === "split" ? "popover" : "sheet"} connection={status === "deleted" ? "closed" : status} diff --git a/apps/web/src/tokens.test.ts b/apps/web/src/tokens.test.ts index 1fc7db814..316428353 100644 --- a/apps/web/src/tokens.test.ts +++ b/apps/web/src/tokens.test.ts @@ -814,7 +814,8 @@ describe("migration", () => { action: "Discard", marker: "setConfirming(true)", size: "btn-sm", - tiers: ["btn-outline"], + // Ghost where a phone's save bar owns Save and this is the card's quiet way out. + tiers: ["btn-outline", "btn-ghost"], }], ["packages/question/src/react/question-view.tsx", { action: "Save", diff --git a/e2e/decisions-phone.e2e.ts b/e2e/decisions-phone.e2e.ts new file mode 100644 index 000000000..39ccfa374 --- /dev/null +++ b/e2e/decisions-phone.e2e.ts @@ -0,0 +1,85 @@ +import { expect, test } from "./room"; + +import type { Page } from "@playwright/test"; + +const PHONE = { hasTouch: true, isMobile: true, viewport: { width: 390, height: 844 } }; +const PROMPT = "Where should room state live?"; + +function decisionsCard(page: Page, prompt = PROMPT) { + return page.locator('[data-document-view="decisions"] article[data-plan-sidecar-questionnaire]') + .filter({ has: page.getByRole("heading", { name: prompt }) }); +} + +async function showDecisions(page: Page) { + let decisions = page.getByRole("button", { name: /^Decisions/ }); + if (await decisions.getAttribute("aria-pressed") !== "true") await decisions.click(); +} + +test("phone: the save bar appears only with unsaved answers, and Save confirms before resolving", async ({ join, seed }) => { + await seed("Decisions on a phone.\n"); + let page = await join("ana", PHONE); + await showDecisions(page); + let card = decisionsCard(page); + await expect(card).toBeVisible(); + let bar = page.locator(".decision-save-bar"); + await expect(bar).not.toHaveAttribute("data-open"); + await expect(card.getByRole("button", { name: "Save", exact: true })).toHaveCount(0); + + await card.getByText("In SQLite", { exact: true }).click(); + let region = page.getByRole("region", { name: "Unsaved answers" }); + await expect(region.getByRole("status")).toHaveText("1 unsaved answer"); + let save = region.getByRole("button", { name: "Save", exact: true }); + let box = (await save.boundingBox())!; + expect(box.height).toBeGreaterThanOrEqual(44); + + await region.getByRole("button", { name: "Clear", exact: true }).click(); + await expect(bar).not.toHaveAttribute("data-open"); + await expect(card.getByRole("radio", { name: /In SQLite/ })).not.toBeChecked(); + + await card.getByText("In SQLite", { exact: true }).click(); + await save.click(); + let confirmed = page.locator("[data-decision-confirmed]"); + await expect(confirmed.getByRole("status")).toContainText("Decided"); + await expect(confirmed).toContainText("In SQLite"); + await expect(bar).not.toHaveAttribute("data-open"); + await expect(confirmed).toHaveCount(0, { timeout: 5_000 }); + await expect(page.getByRole("button", { name: "1 resolved" })).toBeVisible(); + await expect(decisionsCard(page)).toHaveCount(0); +}); + +test("phone: an open decision in the document is a chip that opens it in Decisions", async ({ join, seed }) => { + await seed("Decisions on a phone.\n"); + let page = await join("ana", PHONE); + let chip = page.getByRole("button", { name: `Decision: ${PROMPT}. Answer in Decisions` }); + await expect(chip).toBeVisible(); + await expect( + page.locator('[data-document-view="plan"] article[data-plan-sidecar-questionnaire]'), + ).toHaveCount(0); + let box = (await chip.boundingBox())!; + expect(box.height).toBeGreaterThanOrEqual(44); + + let id = await chip.getAttribute("data-plan-sidecar-questionnaire"); + await chip.click(); + await expect( + page.getByRole("navigation", { name: "Workspace view" }) + .getByRole("button", { name: /^Decisions/ }), + ).toHaveAttribute("aria-pressed", "true"); + await expect( + page.locator(`[data-document-view="decisions"] [data-plan-sidecar-questionnaire="${id}"]`), + ).toBeFocused(); +}); + +test("desktop keeps the in-card Save and the inline card", async ({ join, seed }) => { + await seed("Decisions on a desktop.\n"); + let page = await join("ana", { viewport: { width: 1440, height: 900 } }); + await expect( + page.locator('[data-document-view="plan"] article[data-plan-sidecar-questionnaire]').first(), + ).toBeVisible(); + await expect(page.locator(".decision-chip")).toHaveCount(0); + await showDecisions(page); + let card = decisionsCard(page); + await card.getByText("In SQLite", { exact: true }).click(); + await expect(card.getByRole("button", { name: "Save", exact: true })).toBeEnabled(); + await expect(card.getByRole("button", { name: "Discard", exact: true })).toBeVisible(); + await expect(page.locator(".decision-save-bar")).toHaveCount(0); +}); diff --git a/e2e/discarded-mobile-geometry.e2e.ts b/e2e/discarded-mobile-geometry.e2e.ts index 200fb5e26..edbe75d09 100644 --- a/e2e/discarded-mobile-geometry.e2e.ts +++ b/e2e/discarded-mobile-geometry.e2e.ts @@ -18,7 +18,7 @@ test("compact Decisions keeps a discarded decision reachable in resolved history let card = questionnaire(page).filter({ has: page.getByRole("heading", { name: "Where should room state live?" }), }); - await card.getByRole("button", { name: "Discard", exact: true }).click(); + await card.getByRole("button", { name: "Discard decision", exact: true }).click(); await expect(card.getByText("Discard this decision?", { exact: true })).toBeVisible(); await card.getByRole("button", { name: "Discard", exact: true }).click(); diff --git a/e2e/responsive-decisions.e2e.ts b/e2e/responsive-decisions.e2e.ts index cb62911a4..25cd4e3b3 100644 --- a/e2e/responsive-decisions.e2e.ts +++ b/e2e/responsive-decisions.e2e.ts @@ -121,9 +121,13 @@ for (let viewport of [{ width: 320, height: 568 }, { width: 390, height: 844 }]) await expect(secondChoice).toBeChecked(); await expect(firstChoice).toBeChecked(); + // A phone saves from the pane's bar; the card keeps only its quiet discard. + let saveBar = page.getByRole("region", { name: "Unsaved answers" }); + await expect(saveBar.getByRole("status")).toHaveText("2 unsaved answers"); + await expect(card.getByRole("button", { name: "Save", exact: true })).toHaveCount(0); let actions = [ - card.getByRole("button", { name: "Discard", exact: true }), - card.getByRole("button", { name: "Save", exact: true }), + card.getByRole("button", { name: "Discard decision", exact: true }), + saveBar.getByRole("button", { name: "Save", exact: true }), ]; for (let action of actions) { await action.scrollIntoViewIfNeeded(); diff --git a/packages/editor/src/decisions.tsx b/packages/editor/src/decisions.tsx index 2c637e6d8..6e87f2f60 100644 --- a/packages/editor/src/decisions.tsx +++ b/packages/editor/src/decisions.tsx @@ -6,14 +6,16 @@ * making a long-lived room open below the questions that still need an answer. */ -import { useEffect, useId, useRef, useState, useSyncExternalStore } from "react"; +import { useCallback, useEffect, useId, useRef, useState, useSyncExternalStore } from "react"; import { cardStatus } from "@chopin/dialect"; -import { ChevronIcon, DecisionIcon } from "@chopin/icons"; +import { CheckIcon, ChevronIcon, DecisionIcon } from "@chopin/icons"; import { EmptyState } from "@chopin/visuals"; import { MotionDisclosure, MotionDisclosureIcon } from "./disclosure-motion"; +import { usePhone } from "./widgets/phone"; import { useQuestionnaires } from "./questionnaires"; -import { QuestionnaireCard } from "./widgets/questionnaire"; +import { PendingAnswersContext, PendingAnswersStore, SaveBar } from "./save-bar"; +import { labels, QuestionnaireCard } from "./widgets/questionnaire"; import type { Question } from "@chopin/protocol"; import type { ReactNode } from "react"; @@ -82,6 +84,102 @@ function useHistory() { return [history, setHistory] as const; } +/** How long a just-decided card holds its confirmation before it folds away. */ +export const CONFIRM_MS = 1100; +/** Matches `--decision-closure-dur` on `.decision-confirmed-collapse`. */ +export const COLLAPSE_MS = 320; + +type Closing = "confirmed" | "collapsing"; + +/** + * Cards that just left the open list because they were decided. + * + * Driven by the card status the server publishes after its commit, never by a + * local Save press, so the confirmation cannot run ahead of persistence. + */ +export function newlyDecided( + before: ReadonlySet, + after: ReadonlySet, + decided: (id: string) => boolean, +): string[] { + return [...before].filter(id => !after.has(id) && decided(id)); +} + +function useClosing( + enabled: boolean, + open: ReadonlySet, + decided: (id: string) => boolean, + immediately: () => boolean, +) { + let [closing, setClosing] = useState>(new Map()); + let before = useRef | undefined>(undefined); + let timers = useRef(new Set()); + let key = [...open].join("\n"); + + useEffect(() => { + let previous = before.current; + before.current = open; + if (!enabled || !previous) return; + let fresh = newlyDecided(previous, open, decided); + if (fresh.length === 0) return; + let update = (id: string, phase: Closing | undefined) => + setClosing(current => { + let next = new Map(current); + if (phase) next.set(id, phase); + else next.delete(id); + return next; + }); + let later = (delay: number, action: () => void) => { + let timer = window.setTimeout(() => { + timers.current.delete(timer); + action(); + }, delay); + timers.current.add(timer); + }; + for (let id of fresh) { + update(id, "confirmed"); + if (immediately()) later(CONFIRM_MS, () => update(id, undefined)); + else { + later(CONFIRM_MS, () => update(id, "collapsing")); + later(CONFIRM_MS + COLLAPSE_MS, () => update(id, undefined)); + } + } + // `key` stands for `open`, which is rebuilt every render. + }, [enabled, key]); + + useEffect(() => () => { + for (let timer of timers.current) window.clearTimeout(timer); + }, []); + + return closing; +} + +function Confirmed({ entry, phase }: { entry: QuestionnaireEntry; phase: Closing }) { + let chosen = labels(entry.value); + let question = entry.value.questions.map(item => item.prompt).join(" · "); + return ( +
+
+
+ + + Decided + {question} + + {chosen.length ? chosen.join(", ") : "Saved decision"} + + +
+
+
+ ); +} + export function Decisions( { additional, @@ -157,8 +255,27 @@ export function Decisions( } else heading.current?.focus({ preventScroll: true }); }, [entries, metadata]); - let pending = entries.filter(entry => waiting(entry, metadata)); - let settled = entries.filter(entry => !waiting(entry, metadata)); + let phone = usePhone(); + let [pendingAnswers] = useState(() => new PendingAnswersStore()); + let open = new Set(entries.filter(entry => waiting(entry, metadata)).map(entry => entry.id)); + let closing = useClosing( + phone, + open, + id => metadata.get(id)?.status === "decided", + () => motionImmediately?.() ?? false, + ); + let leaveSaveBar = useCallback(() => { + let next = content.current?.querySelector( + "[data-plan-sidecar-questionnaire]", + ); + if (next) { + next.tabIndex = -1; + next.focus({ preventScroll: true }); + } else heading.current?.focus({ preventScroll: true }); + }, []); + + let pending = entries.filter(entry => open.has(entry.id) || closing.has(entry.id)); + let settled = entries.filter(entry => !open.has(entry.id) && !closing.has(entry.id)); let outstanding = pending.length; let resolved = settled.length; @@ -187,8 +304,8 @@ export function Decisions( /> ); - return ( -
+ let pane = ( +

Decisions

- {pending.map(question)} + {pending.map(entry => { + let phase = closing.get(entry.id); + return phase + ? + : question(entry); + })}
)} @@ -286,6 +408,12 @@ export function Decisions(
)}
+ {phone && } ); + return ( + + {pane} + + ); } diff --git a/packages/editor/src/plan-editor.tsx b/packages/editor/src/plan-editor.tsx index d93a4f4bb..db515c79c 100644 --- a/packages/editor/src/plan-editor.tsx +++ b/packages/editor/src/plan-editor.tsx @@ -89,6 +89,8 @@ export type PlanEditorProps = { onCardSource?: (questionnaireId: string) => void; /** Whether a card has a Chat message to go back to; without one it offers no jump. */ hasCardSource?: (questionnaireId: string) => boolean; + /** Open a decision in the Decisions pane; phones show open decisions there only. */ + onShowDecision?: (questionnaireId: string) => void; /** False when no Planner will review where decisions live. */ planner?: boolean; evidence?: (questionnaireId: string) => ReactNode | null; @@ -151,6 +153,7 @@ export function PlanEditor( cardMeta, onCardSource, hasCardSource, + onShowDecision, evidence, planner, readOnly, @@ -379,6 +382,7 @@ export function PlanEditor( cardMeta, onCardSource, hasCardSource, + onShowDecision, evidence, planner, research, @@ -410,6 +414,7 @@ export function PlanEditor( cardMeta, onCardSource, hasCardSource, + onShowDecision, evidence, planner, research, diff --git a/packages/editor/src/save-bar.test.ts b/packages/editor/src/save-bar.test.ts new file mode 100644 index 000000000..67574ebde --- /dev/null +++ b/packages/editor/src/save-bar.test.ts @@ -0,0 +1,47 @@ +import { expect, test } from "bun:test"; + +import { newlyDecided } from "./decisions"; +import { PendingAnswersStore, summarize, unsavedLabel } from "./save-bar"; + +import type { PendingAnswers } from "@chopin/question/react"; + +function pending(change: Partial = {}): PendingAnswers { + return { answered: 1, complete: true, submitting: false, save() {}, clear() {}, ...change }; +} + +test("the bar counts answers but saves only complete cards that are not already saving", () => { + expect(summarize([ + pending(), + pending({ answered: 1, complete: false }), + pending({ submitting: true }), + ])).toEqual({ answered: 3, saveable: 1, submitting: true }); + expect(summarize([])).toEqual({ answered: 0, saveable: 0, submitting: false }); +}); + +test("the count reads naturally", () => { + expect(unsavedLabel(1)).toBe("1 unsaved answer"); + expect(unsavedLabel(2)).toBe("2 unsaved answers"); +}); + +test("the store publishes a new snapshot only when a card changes", () => { + let store = new PendingAnswersStore(); + let calls = 0; + store.subscribe(() => calls++); + let first = store.snapshot(); + store.set("a", undefined); + expect(calls).toBe(0); + expect(store.snapshot()).toBe(first); + store.set("a", pending()); + expect(store.snapshot().size).toBe(1); + store.set("a", undefined); + expect(store.snapshot().size).toBe(0); + expect(calls).toBe(2); +}); + +test("only a card that left the open list as decided gets a confirmation", () => { + let status: Record = { a: "decided", b: "discarded", c: "open" }; + expect( + newlyDecided(new Set(["a", "b", "c"]), new Set(["c"]), id => status[id] === "decided"), + ).toEqual(["a"]); + expect(newlyDecided(new Set(["c"]), new Set(["c"]), () => true)).toEqual([]); +}); diff --git a/packages/editor/src/save-bar.tsx b/packages/editor/src/save-bar.tsx new file mode 100644 index 000000000..f36c57cfb --- /dev/null +++ b/packages/editor/src/save-bar.tsx @@ -0,0 +1,107 @@ +/** + * Save for every open decision card at once, from the bottom of the pane. + * + * Each card still saves itself: the bar only calls the card's own submit, so + * the shared-draft claim and persistence-before-publication stay per card. + */ + +import { createContext, useEffect, useRef, useSyncExternalStore } from "react"; + +import type { PendingAnswers } from "@chopin/question/react"; + +export class PendingAnswersStore { + #cards = new Map(); + #snapshot: ReadonlyMap = new Map(); + #listeners = new Set<() => void>(); + + set = (card: string, pending: PendingAnswers | undefined): void => { + if (pending) this.#cards.set(card, pending); + else if (!this.#cards.delete(card)) return; + this.#snapshot = new Map(this.#cards); + for (let listener of this.#listeners) listener(); + }; + + subscribe = (listener: () => void): () => void => { + this.#listeners.add(listener); + return () => this.#listeners.delete(listener); + }; + + snapshot = (): ReadonlyMap => this.#snapshot; +} + +/** Present only where a save bar is shown; cards elsewhere keep their own Save. */ +export const PendingAnswersContext = createContext(undefined); + +export type SaveBarSummary = { + answered: number; + saveable: number; + submitting: boolean; +}; + +export function summarize(cards: Iterable): SaveBarSummary { + let summary = { answered: 0, saveable: 0, submitting: false }; + for (let card of cards) { + summary.answered += card.answered; + if (card.submitting) summary.submitting = true; + else if (card.complete) summary.saveable++; + } + return summary; +} + +export function unsavedLabel(answered: number): string { + return answered === 1 ? "1 unsaved answer" : `${answered} unsaved answers`; +} + +export function SaveBar( + { onFocusLeave, store }: { onFocusLeave?: () => void; store: PendingAnswersStore }, +) { + let cards = useSyncExternalStore(store.subscribe, store.snapshot, store.snapshot); + let summary = summarize(cards.values()); + let open = summary.answered > 0 || summary.submitting; + let bar = useRef(null); + // The bar leaves with the words it arrived with rather than emptying first. + let shown = useRef(summary); + if (open) shown.current = summary; + let { answered, saveable, submitting } = shown.current; + + useEffect(() => { + if (open || !bar.current?.contains(document.activeElement)) return; + onFocusLeave?.(); + }, [open, onFocusLeave]); + + return ( +
+ + {unsavedLabel(answered)} + + + +
+ ); +} diff --git a/packages/editor/src/styles.css b/packages/editor/src/styles.css index 4c96d5a79..6dcdf76aa 100644 --- a/packages/editor/src/styles.css +++ b/packages/editor/src/styles.css @@ -1827,6 +1827,239 @@ body:has(.plan-comment-sheet-popup[data-swiping]) padding: var(--document-decisions-top-inset) var(--plan-gutter); } +/* Decisions on a phone -------------------------------------------------- */ + +/* `data-phone` is set from the one phone-class query in phone.ts. */ +.plan-decisions[data-phone] { + position: relative; + --plan-gutter: calc(var(--spacing) * 4); +} + +.plan-decisions[data-phone] .plan-decisions-content { + padding-block-start: calc(var(--spacing) * 4); +} + +/* Save lives in the bar, so the card footer keeps only a quiet way out. */ +.plan-decisions[data-phone] .question-discard-quiet { + margin-inline-end: auto; + color: var(--color-text-tertiary); +} + +/* Leave the hint and the next row visible above the keyboard. */ +.plan-decisions[data-phone] .question-field { + scroll-margin-block-end: calc(var(--keyboard-inset, 0px) + var(--spacing) * 20); +} + +.decision-save-bar { + display: none; +} + +/* `--phone-bottom-chrome` is the height of any bar below this one, above the safe area. */ +.plan-decisions[data-phone] .decision-save-bar { + position: absolute; + z-index: 2; + inset-inline: calc(var(--spacing) * 3); + bottom: calc( + var(--phone-bottom-chrome, 0px) + env(safe-area-inset-bottom, 0px) + var(--spacing) * 3 + ); + display: flex; + align-items: center; + gap: calc(var(--spacing) * 1); + padding: calc(var(--spacing) * 1.5); + padding-inline-start: calc(var(--spacing) * 4); + border-radius: var(--radius-panel); + background: var(--color-page); + border: var(--edge-width) solid var(--color-edge); + box-shadow: var(--shadow-overlay); + opacity: 0; + pointer-events: none; + transform: translateY(calc(100% + var(--spacing) * 6)); + transition: + transform var(--save-bar-close-dur) var(--ease-out), + opacity var(--save-bar-close-dur) var(--ease-out); +} + +.plan-decisions[data-phone] .decision-save-bar[data-open] { + opacity: 1; + pointer-events: auto; + transform: none; + transition: + transform var(--save-bar-open-dur) var(--motion-smooth-out), + opacity var(--save-bar-open-dur) var(--motion-smooth-out); +} + +/* A text field has the keyboard up; the bar would only crowd it. */ +.plan-decisions[data-phone]:has(.question-field:focus) .decision-save-bar[data-open] { + opacity: 0; + pointer-events: none; + transform: translateY(calc(100% + var(--spacing) * 6)); +} + +.decision-save-bar-count { + flex: 1; + min-width: 0; + font-size: var(--text-sm); + color: var(--color-text-secondary); + font-variant-numeric: tabular-nums; +} + +.plan-decisions[data-phone] .decision-save-bar-button { + min-height: var(--control-target-coarse); + padding-inline: calc(var(--spacing) * 4); + font-size: var(--text-sm); +} + +/* A decided card holds a confirmation, then folds into the resolved group. */ +.decision-confirmed-collapse { + display: grid; + grid-template-rows: 1fr; + transition: + grid-template-rows var(--decision-closure-dur) var(--motion-move), + opacity var(--decision-closure-dur) var(--ease-out); +} + +.decision-confirmed-collapse.is-closing { + grid-template-rows: 0fr; + opacity: 0; +} + +/* The list's gap would otherwise outlive the card it separated. */ +.decision-confirmed-collapse.is-closing:not(:first-child) { + margin-block-start: calc(var(--spacing) * -3); + transition: + grid-template-rows var(--decision-closure-dur) var(--motion-move), + opacity var(--decision-closure-dur) var(--ease-out), + margin var(--decision-closure-dur) var(--motion-move); +} + +.decision-confirmed { + display: flex; + align-items: center; + gap: calc(var(--spacing) * 3); + padding: calc(var(--spacing) * 3.5) calc(var(--spacing) * 4); + border-radius: var(--radius-md); + background: var(--color-success-wash); + border: var(--edge-width) solid var(--color-success-outline-subtle); +} + +.decision-confirmed-mark { + display: grid; + flex: none; + width: var(--icon-button-size); + height: var(--icon-button-size); + place-items: center; + border-radius: var(--radius-full); + background: var(--color-success); + color: var(--color-page); +} + +/* A coloured holder: the global icon colour would otherwise win. */ +.decision-confirmed-mark [data-nucleo-icon] { + color: inherit; +} + +.decision-confirmed-text { + display: flex; + min-width: 0; + flex-direction: column; + gap: calc(var(--spacing) / 2); +} + +.decision-confirmed-label { + font-size: var(--text-xs); + font-weight: 600; + color: var(--color-success-ink); +} + +.decision-confirmed-question { + font-size: var(--text-sm); + font-weight: 500; + color: var(--color-text-primary); +} + +.decision-confirmed-answer { + font-size: var(--text-sm); + color: var(--color-text-secondary); +} + +@media (prefers-reduced-motion: no-preference) { + .decision-confirmed { + animation: decision-confirm var(--save-bar-open-dur) var(--motion-smooth-out); + } +} + +@keyframes decision-confirm { + from { + opacity: 0.4; + transform: scale(0.98); + } +} + +@media (prefers-reduced-motion: reduce) { + .plan-decisions[data-phone] .decision-save-bar, + .plan-decisions[data-phone] .decision-save-bar[data-open], + .plan-decisions[data-phone]:has(.question-field:focus) .decision-save-bar[data-open] { + transform: none; + transition: opacity var(--duration-fast) var(--ease-out); + } + + .decision-confirmed-collapse { + transition: none; + } +} + +/* An open decision in the document on a phone: one line into Decisions. */ +.decision-chip { + display: flex; + width: 100%; + min-height: var(--control-target-coarse); + align-items: center; + gap: calc(var(--spacing) * 2); + padding: calc(var(--spacing) * 2) calc(var(--spacing) * 3); + border: var(--edge-width) solid var(--color-edge); + border-radius: var(--radius-md); + background: var(--color-inset); + color: var(--color-text-secondary); + font: inherit; + font-size: var(--text-sm); + text-align: left; + cursor: pointer; +} + +.decision-chip:active { + background: var(--color-selected); +} + +.decision-chip-icon { + flex: none; + color: var(--color-brand-ink); +} + +.decision-chip-kind { + flex: none; + font-weight: 500; + color: var(--color-text-primary); +} + +.decision-chip-kind::after { + content: "·"; + margin-inline-start: calc(var(--spacing) * 2); + color: var(--color-text-tertiary); +} + +.decision-chip-question { + min-width: 0; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.decision-chip-chevron { + flex: none; + color: var(--color-text-tertiary); +} + /* Decorator hosts are block-level plan content, not zero-size anchors. */ .plan .plan-content [data-plan-questionnaire], .plan .plan-content [data-plan-decision] { diff --git a/packages/editor/src/widget-options.ts b/packages/editor/src/widget-options.ts index 29c243930..30b5bf6d6 100644 --- a/packages/editor/src/widget-options.ts +++ b/packages/editor/src/widget-options.ts @@ -68,6 +68,8 @@ export type WidgetOptions = { onCardSource?: (questionnaireId: string) => void; /** Whether a card has a Chat message to go back to. */ hasCardSource?: (questionnaireId: string) => boolean; + /** Open a decision in the Decisions pane; phones show open decisions there only. */ + onShowDecision?: (questionnaireId: string) => void; /** False when no Planner will review where decisions live. */ planner?: boolean; evidence?: (questionnaireId: string) => ReactNode | null; diff --git a/packages/editor/src/widgets/questionnaire.tsx b/packages/editor/src/widgets/questionnaire.tsx index b2b4b4f8d..f1b875e6b 100644 --- a/packages/editor/src/widgets/questionnaire.tsx +++ b/packages/editor/src/widgets/questionnaire.tsx @@ -7,9 +7,9 @@ * the source reads correctly on its own. */ -import { useEffect, useRef, useState } from "react"; +import { useContext, useEffect, useRef, useState } from "react"; import { cardStatus } from "@chopin/dialect"; -import { DecisionIcon, MessageForwardIcon } from "@chopin/icons"; +import { ChevronIcon, DecisionIcon, MessageForwardIcon } from "@chopin/icons"; import { cardRelation, NOT_LINKED, @@ -24,6 +24,8 @@ import { useCardMeta } from "../card-meta"; import { ContentSwapLayer } from "../content-swap"; import { EvidenceHover, EvidenceTrigger } from "./evidence-hover"; import { PresenceFaces, withoutSelf } from "../presence-faces"; +import { usePhone } from "./phone"; +import { PendingAnswersContext } from "../save-bar"; import { useRelations } from "../questionnaires"; import { widgets$ } from "../widget-options"; import { useTransitionPresence } from "../transition-presence"; @@ -316,6 +318,7 @@ function Undecided( let answerable = connected && !!state.definition; let editable = canEdit && answerable; let previous = previousAnswers(value); + let pending = useContext(PendingAnswersContext); return ( pending.set(value.id, answers))} {...pointing} /> @@ -393,7 +398,8 @@ function previousAnswers(value: Questionnaire) { return Object.keys(previous).length === 0 ? undefined : previous; } -function labels(value: Questionnaire): string[] { +/** The chosen answers' labels, as the document projection records them. */ +export function labels(value: Questionnaire): string[] { return value.questions.flatMap(question => { let selected = question.choices?.flatMap(id => { let option = question.options.find(candidate => candidate.id === id); @@ -595,7 +601,13 @@ function InlineQuestionnaire({ value }: { value: Questionnaire }) { : null; // Re-render when anchors arrive: whether the card collapses depends on them. useRelations(options.questions); + let phone = usePhone(); let places = options.questions?.counts(value.id); + if ( + phone && options.onShowDecision && cardPresentation(value, meta, "inline") === "open" + ) { + return ; + } if ( (!meta || meta.status === "decided") && carriedByMarkers(value, places) && !options.questions?.proseKey(value.id) @@ -625,6 +637,33 @@ function InlineQuestionnaire({ value }: { value: Questionnaire }) { ); } +/** + * An open decision in the document, on a phone. + * + * Phones answer decisions in the Decisions pane only; the prose keeps a + * one-line pointer so the card is not shown, and answered, twice. + */ +function DecisionChip( + { onShow, value }: { onShow: (questionnaireId: string) => void; value: Questionnaire }, +) { + let question = value.questions.map(item => item.prompt).join(" · "); + return ( + + ); +} + export function renderQuestionnaire(node: QuestionnaireNode) { // React renders decorators after Lexical's read transaction has ended. return ; diff --git a/packages/question/src/react/index.ts b/packages/question/src/react/index.ts index 9752bc524..e4067db1d 100644 --- a/packages/question/src/react/index.ts +++ b/packages/question/src/react/index.ts @@ -18,6 +18,7 @@ export { QuestionView } from "./question-view"; export type { AddOptionResult, Collaborator, + PendingAnswers, QuestionStepRenderProps, QuestionViewProps, } from "./question-view"; diff --git a/packages/question/src/react/question-view.tsx b/packages/question/src/react/question-view.tsx index 5f708be41..5a294db0d 100644 --- a/packages/question/src/react/question-view.tsx +++ b/packages/question/src/react/question-view.tsx @@ -43,6 +43,18 @@ export type QuestionStepRenderProps = { export type PreviousAnswer = { labels: string[]; by: string }; +/** Unsaved answers on an open card, for a host that saves them from outside the card. */ +export type PendingAnswers = { + /** Questions with a selected answer that is not saved yet. */ + answered: number; + /** Every question has an answer, so Save can submit this card. */ + complete: boolean; + submitting: boolean; + save: () => void; + /** Deselect the unsaved answers in the shared draft. */ + clear: () => void; +}; + export type QuestionViewProps = { definition: Definition; drafts: Drafts; @@ -101,6 +113,13 @@ export type QuestionViewProps = { headerActions?: ReactNode; /** Lets a host retain bounded steps for presentation without owning question state. */ renderStep?: (props: QuestionStepRenderProps) => ReactNode; + /** + * The host shows Save outside the card, so the footer keeps only navigation + * and the quieter discard. + */ + deferSave?: boolean; + /** Reports this card's unsaved answers; undefined when there is nothing to save. */ + onPending?: (pending: PendingAnswers | undefined) => void; }; export function currentQuestion( @@ -314,7 +333,9 @@ function AddOption( if (height >= previous || document.activeElement !== control || !control) return; let bounds = control.getBoundingClientRect(); let top = viewport.offsetTop; - let bottom = top + viewport.height; + // The field's scroll margin is the room it wants below it, such as its hint. + let room = parseFloat(getComputedStyle(control).scrollMarginBlockEnd) || 0; + let bottom = top + viewport.height - room; if (bounds.top >= top && bounds.bottom <= bottom) return; requestAnimationFrame(() => control.scrollIntoView({ block: "nearest" })); }; @@ -712,6 +733,8 @@ export function QuestionView(props: QuestionViewProps) { onQuestionFocus, onQuestionSelect, renderStep, + deferSave = false, + onPending, } = props; let [suggestionEdits, dispatchSuggestionEdit] = useReducer(reduceSuggestionEditState, { @@ -773,6 +796,51 @@ export function QuestionView(props: QuestionViewProps) { ) : { draft: drafts[current.id] }; + let answeredCount = + definition.questions.filter(question => + answered(question, question.id === current.id ? projection.draft : drafts[question.id]) + ).length; + let complete = answeredCount === definition.questions.length; + let pendingEnabled = status === "open" && !disabled && !!onSubmit && !!onChange; + let latest = useRef({ onChange, onSubmit, projection, markHumanEdit, drafts, definition }); + latest.current = { onChange, onSubmit, projection, markHumanEdit, drafts, definition }; + let reporting = useRef(onPending); + reporting.current = onPending; + useEffect(() => { + let report = reporting.current; + if (!report) return; + if (!pendingEnabled || (answeredCount === 0 && !submitting)) { + report(undefined); + return; + } + report({ + answered: answeredCount, + complete, + submitting, + save: () => latest.current.onSubmit?.(latest.current.projection.suggestion), + clear: () => { + let { definition, drafts, markHumanEdit, onChange } = latest.current; + markHumanEdit(); + for (let question of definition.questions) { + let draft = drafts[question.id]; + if (!draft || !answered(question, draft)) continue; + onChange?.( + question.id, + question.multiple + ? { + mode: "choices", + options: Object.fromEntries( + Object.keys(draft.options).map(option => [option, false]), + ), + } + : { mode: "choices", choice: null }, + ); + } + }, + }); + }, [answeredCount, complete, pendingEnabled, submitting]); + useEffect(() => () => reporting.current?.(undefined), []); + if (status === "discarded") { return (
@@ -1050,9 +1118,11 @@ export function QuestionView(props: QuestionViewProps) { type="button" onClick={() => setConfirming(true)} disabled={disabled || submitting} - className="btn btn-sm btn-outline" + className={deferSave + ? "btn btn-sm btn-ghost question-discard-quiet" + : "btn btn-sm btn-outline"} > - Discard + {deferSave ? "Discard decision" : "Discard"} )} {multiple && !last && ( @@ -1066,7 +1136,7 @@ export function QuestionView(props: QuestionViewProps) { Next )} - {(onSubmit || showActions) && (!multiple || last) && ( + {(onSubmit || showActions) && (!multiple || last) && !deferSave && ( - + {waiting + ? ( + + ) + : ( + + )}
); } diff --git a/packages/editor/src/styles.css b/packages/editor/src/styles.css index 6dcdf76aa..98838b83c 100644 --- a/packages/editor/src/styles.css +++ b/packages/editor/src/styles.css @@ -1841,8 +1841,10 @@ body:has(.plan-comment-sheet-popup[data-swiping]) /* Save lives in the bar, so the card footer keeps only a quiet way out. */ .plan-decisions[data-phone] .question-discard-quiet { - margin-inline-end: auto; + margin-inline: calc(var(--spacing) * -2) auto; color: var(--color-text-tertiary); + font-size: var(--text-xs); + font-weight: 400; } /* Leave the hint and the next row visible above the keyboard. */ @@ -1903,6 +1905,10 @@ body:has(.plan-comment-sheet-popup[data-swiping]) font-variant-numeric: tabular-nums; } +.decision-save-bar-count[data-failed] { + color: var(--color-danger-text); +} + .plan-decisions[data-phone] .decision-save-bar-button { min-height: var(--control-target-coarse); padding-inline: calc(var(--spacing) * 4); @@ -1942,6 +1948,21 @@ body:has(.plan-comment-sheet-popup[data-swiping]) border: var(--edge-width) solid var(--color-success-outline-subtle); } +/* Someone else's decision folds away without claiming the reader's attention. */ +.decision-confirmed-collapse:not([data-mine]) .decision-confirmed { + border-color: var(--color-edge); + background: var(--color-inset); + animation: none; +} + +.decision-confirmed-collapse:not([data-mine]) .decision-confirmed-mark { + background: var(--color-text-tertiary); +} + +.decision-confirmed-collapse:not([data-mine]) .decision-confirmed-label { + color: var(--color-text-tertiary); +} + .decision-confirmed-mark { display: grid; flex: none; @@ -2035,24 +2056,15 @@ body:has(.plan-comment-sheet-popup[data-swiping]) color: var(--color-brand-ink); } -.decision-chip-kind { - flex: none; - font-weight: 500; - color: var(--color-text-primary); -} - -.decision-chip-kind::after { - content: "·"; - margin-inline-start: calc(var(--spacing) * 2); - color: var(--color-text-tertiary); -} - .decision-chip-question { + display: -webkit-box; min-width: 0; flex: 1; overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + line-clamp: 2; + color: var(--color-text-primary); } .decision-chip-chevron { diff --git a/packages/editor/src/widgets/questionnaire.tsx b/packages/editor/src/widgets/questionnaire.tsx index f1b875e6b..b57df37b9 100644 --- a/packages/editor/src/widgets/questionnaire.tsx +++ b/packages/editor/src/widgets/questionnaire.tsx @@ -357,7 +357,12 @@ function Undecided( onDiscard={editable ? state.discard : undefined} onChange={editable ? state.change : undefined} onQuestionFocus={editable ? state.focusQuestion : undefined} - onSubmit={editable ? state.submit : undefined} + onSubmit={editable + ? suggestion => { + pending?.submitted(value.id); + state.submit(suggestion); + } + : undefined} renderPeople={people => ( void; value: Questionnaire }, @@ -649,7 +655,7 @@ function DecisionChip( let question = value.questions.map(item => item.prompt).join(" · "); return ( diff --git a/packages/question/src/react/question-view.tsx b/packages/question/src/react/question-view.tsx index 5a294db0d..3e123f3fa 100644 --- a/packages/question/src/react/question-view.tsx +++ b/packages/question/src/react/question-view.tsx @@ -50,6 +50,8 @@ export type PendingAnswers = { /** Every question has an answer, so Save can submit this card. */ complete: boolean; submitting: boolean; + /** Why the last save or edit failed, shown on the card itself. */ + error?: string; save: () => void; /** Deselect the unsaved answers in the shared draft. */ clear: () => void; @@ -750,6 +752,9 @@ export function QuestionView(props: QuestionViewProps) { let markHumanEdit = () => { if (suggested) dispatchSuggestionEdit({ type: "answer-edited" }); }; + // Which cards this person chose answers on, so a host's pane-wide Save + // never acts on a draft only a collaborator touched. + let [touched, setTouched] = useState(false); let markComposerEdit = () => { dispatchSuggestionEdit({ type: "composer-edited" }); @@ -796,14 +801,15 @@ export function QuestionView(props: QuestionViewProps) { ) : { draft: drafts[current.id] }; + // Only a draft this person chose counts. A visible suggestion is not an + // answer until someone accepts it on the card, and a collaborator's choice + // on a card this person never touched is theirs to save. let answeredCount = - definition.questions.filter(question => - answered(question, question.id === current.id ? projection.draft : drafts[question.id]) - ).length; + definition.questions.filter(question => answered(question, drafts[question.id])).length; let complete = answeredCount === definition.questions.length; - let pendingEnabled = status === "open" && !disabled && !!onSubmit && !!onChange; - let latest = useRef({ onChange, onSubmit, projection, markHumanEdit, drafts, definition }); - latest.current = { onChange, onSubmit, projection, markHumanEdit, drafts, definition }; + let pendingEnabled = status === "open" && !disabled && !!onSubmit && !!onChange && touched; + let latest = useRef({ onChange, onSubmit, markHumanEdit, drafts, definition }); + latest.current = { onChange, onSubmit, markHumanEdit, drafts, definition }; let reporting = useRef(onPending); reporting.current = onPending; useEffect(() => { @@ -817,10 +823,12 @@ export function QuestionView(props: QuestionViewProps) { answered: answeredCount, complete, submitting, - save: () => latest.current.onSubmit?.(latest.current.projection.suggestion), + error, + save: () => latest.current.onSubmit?.(), clear: () => { let { definition, drafts, markHumanEdit, onChange } = latest.current; markHumanEdit(); + setTouched(false); for (let question of definition.questions) { let draft = drafts[question.id]; if (!draft || !answered(question, draft)) continue; @@ -838,7 +846,7 @@ export function QuestionView(props: QuestionViewProps) { } }, }); - }, [answeredCount, complete, pendingEnabled, submitting]); + }, [answeredCount, complete, error, pendingEnabled, submitting]); useEffect(() => () => reporting.current?.(undefined), []); if (status === "discarded") { @@ -923,6 +931,9 @@ export function QuestionView(props: QuestionViewProps) { // Nothing chosen, nothing to save or move on with. Read-only hosts have no // drafts to fill, so they keep free navigation. let ready = answered(current, projection.draft); + // Accepting a suggestion stays a deliberate choice on its own card. + let suggestion = projection.suggestion; + let acceptSuggestion = () => onSubmit?.(suggestion); let discard = onDiscard ?? onCancel; let previousAnswer = previousFor(priorAnswers, current.id); let step = (offset: number, from?: "primary") => { @@ -1010,6 +1021,7 @@ export function QuestionView(props: QuestionViewProps) { name={`${base}-${current.id}`} onChange={change => { markHumanEdit(); + setTouched(true); onChange?.(current.id, change); }} suggestedOptionId={projection.suggestion?.optionId} @@ -1147,6 +1159,16 @@ export function QuestionView(props: QuestionViewProps) { {submitting ? "Saving…" : error ? "Try again" : "Save"} )} + {deferSave && onSubmit && suggestion && ( + + )} )} From 146d53180b8b6165cf77b9387cab5abf6f3bff4e Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Sun, 11 Oct 2026 08:32:04 +0100 Subject: [PATCH 4/7] Remember a phone's unsaved decision answers across remounts Which cards this person answered now lives in a session store rather than the card, so leaving Decisions or reloading keeps the answer in the save bar. It is forgotten on Clear, once the card resolves, or when the selection is gone from the draft. A card whose selection the bar does not count keeps its own Save, so no answer is left without a way to save it. Co-Authored-By: Claude Opus 5.5 --- e2e/decisions-phone.e2e.ts | 35 ++++++++++++ packages/editor/src/decisions.tsx | 7 +++ packages/editor/src/touched.test.ts | 11 ++++ packages/editor/src/touched.ts | 54 +++++++++++++++++++ packages/editor/src/widgets/questionnaire.tsx | 4 ++ packages/question/src/react/question-view.tsx | 40 +++++++++++++- 6 files changed, 150 insertions(+), 1 deletion(-) create mode 100644 packages/editor/src/touched.test.ts create mode 100644 packages/editor/src/touched.ts diff --git a/e2e/decisions-phone.e2e.ts b/e2e/decisions-phone.e2e.ts index f1871ae4d..cd41d7142 100644 --- a/e2e/decisions-phone.e2e.ts +++ b/e2e/decisions-phone.e2e.ts @@ -47,6 +47,41 @@ test("phone: the save bar appears only with unsaved answers, and Save confirms b await expect(decisionsCard(page)).toHaveCount(0); }); +test("phone: an unsaved answer survives leaving Decisions and can still be saved", async ({ join, seed }) => { + await seed("Decisions on a phone.\n"); + let page = await join("ana", PHONE); + await showDecisions(page); + await decisionsCard(page).getByText("In SQLite", { exact: true }).click(); + let region = page.getByRole("region", { name: "Unsaved answers" }); + await expect(region.getByRole("status")).toHaveText("1 unsaved answer"); + + let nav = page.getByRole("navigation", { name: "Workspace view" }); + await nav.getByRole("button", { name: "Document", exact: true }).click(); + await nav.getByRole("button", { name: /^Decisions/ }).click(); + await expect(region.getByRole("status")).toHaveText("1 unsaved answer"); + await page.reload(); + await showDecisions(page); + await expect(decisionsCard(page).getByRole("radio", { name: /In SQLite/ })).toBeChecked(); + await expect(region.getByRole("status")).toHaveText("1 unsaved answer"); + + await region.getByRole("button", { name: "Save", exact: true }).click(); + await expect(page.locator("[data-decision-confirmed]").getByRole("status")) + .toContainText("Decided · You"); + await expect(page.getByRole("button", { name: "1 resolved" })).toBeVisible(); +}); + +test("phone: a selection the bar is not counting keeps the card's own Save", async ({ join, seed }) => { + await seed("Decisions on a phone.\n"); + let octocat = await join("octocat", PHONE); + let hubot = await join("hubot", PHONE); + for (let page of [octocat, hubot]) await showDecisions(page); + await decisionsCard(octocat).getByText("In SQLite", { exact: true }).click(); + let card = decisionsCard(hubot); + await expect(card.getByRole("radio", { name: /In SQLite/ })).toBeChecked(); + await expect(hubot.locator(".decision-save-bar")).not.toHaveAttribute("data-open"); + await expect(card.getByRole("button", { name: "Save", exact: true })).toBeVisible(); +}); + test("phone: the bar counts, clears and saves only this person's answers", async ({ join, seed }) => { await seed("Decisions on a phone.\n"); let octocat = await join("octocat", PHONE); diff --git a/packages/editor/src/decisions.tsx b/packages/editor/src/decisions.tsx index 3f02ede33..8a66d0ffe 100644 --- a/packages/editor/src/decisions.tsx +++ b/packages/editor/src/decisions.tsx @@ -15,6 +15,7 @@ import { MotionDisclosure, MotionDisclosureIcon } from "./disclosure-motion"; import { usePhone } from "./widgets/phone"; import { useQuestionnaires } from "./questionnaires"; import { PendingAnswersContext, PendingAnswersStore, SaveBar } from "./save-bar"; +import { setTouched } from "./touched"; import { labels, QuestionnaireCard } from "./widgets/questionnaire"; import type { Question } from "@chopin/protocol"; @@ -315,6 +316,12 @@ export function Decisions( } else heading.current?.focus({ preventScroll: true }); }, []); + // A decided or discarded card has nothing left of this person's to save. + let settledKey = entries.filter(entry => !open.has(entry.id)).map(entry => entry.id).join("\n"); + useEffect(() => { + for (let id of settledKey.split("\n")) if (id) setTouched(id, false); + }, [settledKey]); + let pending = entries.filter(entry => open.has(entry.id) || closing.has(entry.id)); let settled = entries.filter(entry => !open.has(entry.id) && !closing.has(entry.id)); diff --git a/packages/editor/src/touched.test.ts b/packages/editor/src/touched.test.ts new file mode 100644 index 000000000..c0719b076 --- /dev/null +++ b/packages/editor/src/touched.test.ts @@ -0,0 +1,11 @@ +import { expect, test } from "bun:test"; + +import { isTouched, setTouched } from "./touched"; + +test("touched cards are remembered for the session, even without storage", () => { + expect(isTouched("card")).toBe(false); + setTouched("card", true); + expect(isTouched("card")).toBe(true); + setTouched("card", false); + expect(isTouched("card")).toBe(false); +}); diff --git a/packages/editor/src/touched.ts b/packages/editor/src/touched.ts new file mode 100644 index 000000000..fa3196a08 --- /dev/null +++ b/packages/editor/src/touched.ts @@ -0,0 +1,54 @@ +import { useSyncExternalStore } from "react"; + +/** + * Decision cards this person chose answers on, for the session. + * + * Kept outside the card so a remount (a tab switch, a reload) does not drop a + * selection out of the save bar, which on a phone is the only way to save it. + * Card ids are unique across documents. Storage can be unavailable, so it is + * an optional mirror of the in-memory set rather than the source of truth. + */ +const KEY = "chopin:decisions:touched"; + +function load(): Set { + try { + let stored = JSON.parse(sessionStorage.getItem(KEY) ?? "[]") as unknown; + return new Set(Array.isArray(stored) ? stored.filter(id => typeof id === "string") : []); + } catch { + return new Set(); + } +} + +let cards: Set | undefined; +let listeners = new Set<() => void>(); + +function current(): Set { + cards ??= load(); + return cards; +} + +export function isTouched(card: string): boolean { + return current().has(card); +} + +export function setTouched(card: string, touched: boolean): void { + let set = current(); + if (set.has(card) === touched) return; + if (touched) set.add(card); + else set.delete(card); + try { + sessionStorage.setItem(KEY, JSON.stringify([...set])); + } catch { + // Remembered for this page only. + } + for (let listener of listeners) listener(); +} + +function subscribe(listener: () => void) { + listeners.add(listener); + return () => listeners.delete(listener); +} + +export function useTouched(card: string): boolean { + return useSyncExternalStore(subscribe, () => isTouched(card), () => false); +} diff --git a/packages/editor/src/widgets/questionnaire.tsx b/packages/editor/src/widgets/questionnaire.tsx index b57df37b9..c316cf516 100644 --- a/packages/editor/src/widgets/questionnaire.tsx +++ b/packages/editor/src/widgets/questionnaire.tsx @@ -26,6 +26,7 @@ import { EvidenceHover, EvidenceTrigger } from "./evidence-hover"; import { PresenceFaces, withoutSelf } from "../presence-faces"; import { usePhone } from "./phone"; import { PendingAnswersContext } from "../save-bar"; +import { setTouched, useTouched } from "../touched"; import { useRelations } from "../questionnaires"; import { widgets$ } from "../widget-options"; import { useTransitionPresence } from "../transition-presence"; @@ -319,6 +320,7 @@ function Undecided( let editable = canEdit && answerable; let previous = previousAnswers(value); let pending = useContext(PendingAnswersContext); + let touched = useTouched(value.id); return ( setTouched(value.id, next) : undefined} onPending={pending && (answers => pending.set(value.id, answers))} {...pointing} /> diff --git a/packages/question/src/react/question-view.tsx b/packages/question/src/react/question-view.tsx index 3e123f3fa..a88c9c1c4 100644 --- a/packages/question/src/react/question-view.tsx +++ b/packages/question/src/react/question-view.tsx @@ -122,6 +122,12 @@ export type QuestionViewProps = { deferSave?: boolean; /** Reports this card's unsaved answers; undefined when there is nothing to save. */ onPending?: (pending: PendingAnswers | undefined) => void; + /** + * Whether this person chose answers here, when the host keeps that beyond + * this view's lifetime. Without it the view remembers only while mounted. + */ + touched?: boolean; + onTouchedChange?: (touched: boolean) => void; }; export function currentQuestion( @@ -737,6 +743,8 @@ export function QuestionView(props: QuestionViewProps) { renderStep, deferSave = false, onPending, + touched: hostTouched, + onTouchedChange, } = props; let [suggestionEdits, dispatchSuggestionEdit] = useReducer(reduceSuggestionEditState, { @@ -754,7 +762,12 @@ export function QuestionView(props: QuestionViewProps) { }; // Which cards this person chose answers on, so a host's pane-wide Save // never acts on a draft only a collaborator touched. - let [touched, setTouched] = useState(false); + let [ownTouched, setOwnTouched] = useState(false); + let touched = hostTouched ?? ownTouched; + let setTouched = (value: boolean) => { + setOwnTouched(value); + onTouchedChange?.(value); + }; let markComposerEdit = () => { dispatchSuggestionEdit({ type: "composer-edited" }); @@ -848,6 +861,18 @@ export function QuestionView(props: QuestionViewProps) { }); }, [answeredCount, complete, error, pendingEnabled, submitting]); useEffect(() => () => reporting.current?.(undefined), []); + // A selection that is gone (cleared here or by someone else) is no longer this person's. + let forgetTouch = useRef(setTouched); + forgetTouch.current = setTouched; + // Only a selection that went away is forgotten: a choice just made is not in + // the draft yet, and a draft still syncing (disabled) looks empty too. + let hadAnswers = useRef(false); + useEffect(() => { + if (disabled) return; + let had = hadAnswers.current; + hadAnswers.current = answeredCount > 0; + if (touched && had && answeredCount === 0 && !submitting) forgetTouch.current(false); + }, [answeredCount, disabled, submitting, touched]); if (status === "discarded") { return ( @@ -934,6 +959,7 @@ export function QuestionView(props: QuestionViewProps) { // Accepting a suggestion stays a deliberate choice on its own card. let suggestion = projection.suggestion; let acceptSuggestion = () => onSubmit?.(suggestion); + let saveHere = () => onSubmit?.(); let discard = onDiscard ?? onCancel; let previousAnswer = previousFor(priorAnswers, current.id); let step = (offset: number, from?: "primary") => { @@ -1159,6 +1185,18 @@ export function QuestionView(props: QuestionViewProps) { {submitting ? "Saving…" : error ? "Try again" : "Save"} )} + {deferSave && onSubmit && !suggestion && !touched && answeredCount > 0 + && (!multiple || last) && ( + // A selection the bar is not counting still needs a way to be saved. + + )} {deferSave && onSubmit && suggestion && (