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..dde0fcfaa --- /dev/null +++ b/e2e/decisions-phone.e2e.ts @@ -0,0 +1,222 @@ +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 · You"); + 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 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); + let hubot = await join("hubot", PHONE); + for (let page of [octocat, hubot]) await showDecisions(page); + let storage = (page: Page) => decisionsCard(page); + let scope = (page: Page) => decisionsCard(page, "Which of these belong in the first cut?"); + let bar = (page: Page) => page.locator(".decision-save-bar"); + + await storage(octocat).getByText("In SQLite", { exact: true }).click(); + await expect(bar(octocat)).toHaveAttribute("data-open", ""); + await expect(storage(hubot).getByRole("radio", { name: /In SQLite/ })).toBeChecked(); + await expect(bar(hubot)).not.toHaveAttribute("data-open"); + + await scope(hubot).getByText("Anchors", { exact: true }).click(); + let region = hubot.getByRole("region", { name: "Unsaved answers" }); + await expect(region.getByRole("status")).toHaveText("1 unsaved answer"); + await region.getByRole("button", { name: "Clear", exact: true }).click(); + await expect(bar(hubot)).not.toHaveAttribute("data-open"); + await expect(scope(hubot).getByRole("checkbox", { name: /Anchors/ })).not.toBeChecked(); + await expect(storage(octocat).getByRole("radio", { name: /In SQLite/ })).toBeChecked(); + await expect(bar(octocat)).toHaveAttribute("data-open", ""); + + await scope(hubot).getByText("Anchors", { exact: true }).click(); + await region.getByRole("button", { name: "Save", exact: true }).click(); + await expect(scope(hubot)).toHaveCount(0); + await expect(scope(octocat)).toHaveCount(0); + // octocat's open answer was neither saved nor cleared by hubot. + await expect(storage(octocat).getByRole("radio", { name: /In SQLite/ })).toBeChecked(); + await expect(storage(hubot)).toBeVisible(); + await expect(octocat.getByRole("region", { name: "Unsaved answers" }).getByRole("status")) + .toHaveText("1 unsaved answer"); + // Someone else's decision folds away without announcing itself. + await expect(octocat.locator("[data-decision-confirmed] [role=status]")).toHaveCount(0); +}); + +for (let leave of [false, true]) { + test(`phone: a collaborator's later change leaves the bar${leave ? " while away" : ""}`, 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); + let bar = octocat.locator(".decision-save-bar"); + + await decisionsCard(octocat).getByText("In SQLite", { exact: true }).click(); + await expect(bar).toHaveAttribute("data-open", ""); + let nav = octocat.getByRole("navigation", { name: "Workspace view" }); + if (leave) await nav.getByRole("button", { name: "Document", exact: true }).click(); + + await decisionsCard(hubot).getByText("On disk as MDX", { exact: true }).click(); + if (leave) await nav.getByRole("button", { name: /^Decisions/ }).click(); + let card = decisionsCard(octocat); + await expect(card.getByRole("radio", { name: /On disk as MDX/ })).toBeChecked(); + // hubot's choice is never saved under octocat's name from the bar… + await expect(bar).not.toHaveAttribute("data-open"); + // …but octocat can still save it deliberately on the card. + await expect(card.getByRole("button", { name: "Save", exact: true })).toBeVisible(); + }); +} + +test("phone: a failed save is announced and its card stays in view after another card folds away", async ({ join, seed }) => { + await seed("Decisions on a phone.\n"); + let page = await join("ana", PHONE); + let failing = ""; + await page.context().routeWebSocket("**/ws?**", socket => { + let server = socket.connectToServer(); + socket.onMessage(message => { + let frame = typeof message === "string" ? JSON.parse(message) : undefined; + if (frame?.kind === "question:submit" && frame.id === failing) { + socket.send(JSON.stringify({ + kind: "question:submit", + rid: frame.rid, + ok: false, + message: "The server could not save this decision.", + })); + return; + } + server.send(message); + }); + }); + await page.reload(); + await showDecisions(page); + let scope = decisionsCard(page, "Which of these belong in the first cut?"); + failing = await scope.getAttribute("data-plan-sidecar-questionnaire") ?? ""; + await decisionsCard(page).getByText("In SQLite", { exact: true }).click(); + await scope.getByText("Anchors", { exact: true }).click(); + await page.locator("[data-plan-decisions-scroll]").evaluate(element => { + element.scrollTop = element.scrollHeight; + }); + let region = page.getByRole("region", { name: "Unsaved answers" }); + await region.getByRole("button", { name: "Save", exact: true }).click(); + + await expect(region.getByRole("alert")).toHaveText("Couldn’t save 1 answer"); + await expect(scope).toBeFocused(); + // The saved card's confirmation folds away above the failed one. + await expect(page.locator("[data-decision-confirmed]")).toHaveCount(0, { timeout: 5_000 }); + await expect(page.getByRole("button", { name: "1 resolved" })).toBeVisible(); + await expect(scope.getByRole("heading", { name: "Which of these belong in the first cut?" })) + .toBeInViewport(); + await expect(scope.getByText("The server could not save this decision.")).toBeInViewport(); +}); + +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/playwright.config.ts b/e2e/playwright.config.ts index feff81264..a644137d2 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -158,6 +158,7 @@ export default defineConfig({ join(ROOT, "e2e/conversation-plan-ui.e2e.ts"), join(ROOT, "e2e/document-tab-stability.e2e.ts"), join(ROOT, "e2e/discarded-mobile-geometry.e2e.ts"), + join(ROOT, "e2e/decisions-phone.e2e.ts"), join(ROOT, "e2e/sidecar-document-spacing.e2e.ts"), join(ROOT, "e2e/sidecar-evidence-absence.e2e.ts"), join(ROOT, "e2e/sidecar-draft-reopen.e2e.ts"), @@ -187,6 +188,7 @@ export default defineConfig({ "**/sidecar-evidence-absence.e2e.ts", "**/document-tab-stability.e2e.ts", "**/discarded-mobile-geometry.e2e.ts", + "**/decisions-phone.e2e.ts", "**/sidebar-decision-counts.e2e.ts", ], use: { ...devices["Desktop Chrome"], baseURL: `http://${HOST}:${FIXTURES}` }, 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..55d80fdaf 100644 --- a/packages/editor/src/decisions.tsx +++ b/packages/editor/src/decisions.tsx @@ -6,14 +6,17 @@ * 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 { forget } from "./touched"; +import { labels, QuestionnaireCard } from "./widgets/questionnaire"; import type { Question } from "@chopin/protocol"; import type { ReactNode } from "react"; @@ -82,6 +85,130 @@ 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; + +/** A decision this client saved holds a confirmation; anyone else's just folds away. */ +type Closing = { phase: "confirmed" | "leaving" | "collapsing"; mine: boolean }; + +/** + * 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, + mine: (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, closing: Closing | undefined) => + setClosing(current => { + let next = new Map(current); + if (closing) next.set(id, closing); + 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); + }; + let still = immediately(); + for (let id of fresh) { + let own = mine(id); + if (own) { + update(id, { phase: "confirmed", mine: true }); + if (still) later(CONFIRM_MS, () => update(id, undefined)); + else { + later(CONFIRM_MS, () => update(id, { phase: "collapsing", mine: true })); + later(CONFIRM_MS + COLLAPSE_MS, () => update(id, undefined)); + } + } else if (!still) { + // Someone else's decision: no hold, only the fold, so the list does not jump. + update(id, { phase: "leaving", mine: false }); + later(LEAVE_MS, () => update(id, { phase: "collapsing", mine: false })); + later(LEAVE_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; +} + +/** Long enough for the folding card to paint open before it collapses. */ +const LEAVE_MS = 50; + +/** "You" for this viewer, otherwise the resolver's handle; absent when unknown. */ +export function decidedBy(resolver: string | undefined, self: string | undefined) { + if (!resolver || resolver === "system") return undefined; + return resolver === self ? "You" : `@${resolver}`; +} + +function Confirmed( + { by, closing, entry }: { by?: string; closing: Closing; entry: QuestionnaireEntry }, +) { + let chosen = labels(entry.value); + let question = entry.value.questions.map(item => item.prompt).join(" · "); + return ( +
+
+
+ + + + {by ? `Decided · ${by}` : "Decided"} + + {question} + + {chosen.length ? chosen.join(", ") : "Saved decision"} + + +
+
+
+ ); +} + export function Decisions( { additional, @@ -157,11 +284,76 @@ 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", + id => { + let resolver = metadata.get(id)?.resolver; + return pendingAnswers.savedHere(id) && (!resolver || resolver === self); + }, + () => motionImmediately?.() ?? false, + ); + let following = useRef<() => void>(undefined); + useEffect(() => () => following.current?.(), []); + let showCard = useCallback((card: string) => { + let list = content.current; + let target = list?.querySelector( + `[data-plan-sidecar-questionnaire="${CSS.escape(card)}"]`, + ); + if (!list || !target) return; + target.scrollIntoView({ block: "center" }); + target.tabIndex = -1; + target.focus({ preventScroll: true }); + // A card saved alongside it can fold away above it afterwards and pull it + // out of view. Follow it through layout changes while it still shows its + // error or keeps focus. + following.current?.(); + let frame = 0; + let observer = new ResizeObserver(() => { + let attending = !!target.querySelector('[role="alert"]') + || document.activeElement === target; + if (!target.isConnected || !attending) { + following.current?.(); + return; + } + cancelAnimationFrame(frame); + frame = requestAnimationFrame(() => target.scrollIntoView({ block: "center" })); + }); + for (let child of list.children) observer.observe(child); + let expiry = window.setTimeout(() => following.current?.(), 10_000); + following.current = () => { + observer.disconnect(); + cancelAnimationFrame(frame); + window.clearTimeout(expiry); + following.current = undefined; + }; + }, []); + 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 }); + }, []); + + // 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) forget(id); + }, [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)); let outstanding = pending.length; - let resolved = settled.length; + // A folding card already counts as resolved, so this agrees with the tab's badge. + let resolved = settled.length + closing.size; let empty = outstanding === 0 && resolved === 0 && !hasAdditional; let question = (entry: QuestionnaireEntry) => ( @@ -187,8 +379,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 +490,19 @@ 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..947e664d4 --- /dev/null +++ b/packages/editor/src/save-bar.test.ts @@ -0,0 +1,85 @@ +import { expect, test } from "bun:test"; + +import { decidedBy, newlyDecided } from "./decisions"; +import { + failedLabel, + incompleteLabel, + newlyFailed, + 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 saves only complete cards that are not already saving", () => { + expect(summarize([ + ["a", pending()], + ["b", pending({ complete: false })], + ["c", pending({ submitting: true })], + ])).toEqual({ + answered: 3, + saveable: 1, + submitting: true, + failed: [], + failedAnswers: 0, + incomplete: ["b"], + }); +}); + +test("a failed card is reported once it stops saving", () => { + let cards = new Map([ + ["a", pending({ answered: 2, error: "Could not submit these answers." })], + ["b", pending({ submitting: true, error: "stale" })], + ]); + let summary = summarize(cards); + expect(summary.failed).toEqual(["a"]); + expect(summary.failedAnswers).toBe(2); + expect(newlyFailed(new Set(), cards)).toEqual(["a"]); + expect(newlyFailed(new Set(["a"]), cards)).toEqual([]); +}); + +test("the bar's words read naturally", () => { + expect(unsavedLabel(1)).toBe("1 unsaved answer"); + expect(unsavedLabel(2)).toBe("2 unsaved answers"); + expect(failedLabel(1)).toBe("Couldn’t save 1 answer"); + expect(incompleteLabel(1)).toBe("1 decision needs another answer"); + expect(incompleteLabel(2)).toBe("2 decisions need more answers"); +}); + +test("the store publishes a new snapshot only when a card changes, and remembers local saves", () => { + 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); + expect(store.savedHere("a")).toBe(false); + store.submitted("a"); + expect(store.savedHere("a")).toBe(true); +}); + +test("only a card that left the open list as decided folds away", () => { + 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([]); +}); + +test("the confirmation names who decided", () => { + expect(decidedBy("ana", "ana")).toBe("You"); + expect(decidedBy("ben", "ana")).toBe("@ben"); + expect(decidedBy(undefined, "ana")).toBeUndefined(); + expect(decidedBy("system", "ana")).toBeUndefined(); +}); diff --git a/packages/editor/src/save-bar.tsx b/packages/editor/src/save-bar.tsx new file mode 100644 index 000000000..bf7b49874 --- /dev/null +++ b/packages/editor/src/save-bar.tsx @@ -0,0 +1,197 @@ +/** + * Save for the open decision cards this person answered, 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. + * Cards report themselves only once this person has chosen an answer on them, + * so a collaborator's draft is never counted, cleared or saved from here. + */ + +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>(); + #submitted = new Set(); + + 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(); + }; + + /** This client asked the server to save the card. */ + submitted = (card: string): void => { + this.#submitted.add(card); + }; + + /** Whether this client saved the card, so its decision deserves a confirmation. */ + savedHere = (card: string): boolean => this.#submitted.has(card); + + 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; + /** Cards whose last save failed. */ + failed: string[]; + failedAnswers: number; + /** Cards with some questions still unanswered. */ + incomplete: string[]; +}; + +export function summarize(cards: Iterable<[string, PendingAnswers]>): SaveBarSummary { + let summary: SaveBarSummary = { + answered: 0, + saveable: 0, + submitting: false, + failed: [], + failedAnswers: 0, + incomplete: [], + }; + for (let [id, card] of cards) { + summary.answered += card.answered; + if (card.submitting) { + summary.submitting = true; + continue; + } + if (card.error) { + summary.failed.push(id); + summary.failedAnswers += card.answered; + } + if (card.complete) summary.saveable++; + else summary.incomplete.push(id); + } + return summary; +} + +export function unsavedLabel(answered: number): string { + return answered === 1 ? "1 unsaved answer" : `${answered} unsaved answers`; +} + +export function failedLabel(answered: number): string { + return answered === 1 ? "Couldn’t save 1 answer" : `Couldn’t save ${answered} answers`; +} + +export function incompleteLabel(cards: number): string { + return cards === 1 ? "1 decision needs another answer" : `${cards} decisions need more answers`; +} + +/** Cards showing an error that was not already reported. */ +export function newlyFailed( + reported: ReadonlySet, + cards: ReadonlyMap, +): string[] { + return [...cards] + .filter(([id, card]) => !reported.has(id) && !card.submitting && !!card.error) + .map(([id]) => id); +} + +export function SaveBar( + { onFailed, onFocusLeave, onIncomplete, store }: { + /** Bring a card whose save failed into view, so its explanation is seen. */ + onFailed: (card: string) => void; + onFocusLeave?: () => void; + /** Take the reader to a card that still needs an answer. */ + onIncomplete: (card: string) => void; + store: PendingAnswersStore; + }, +) { + let cards = useSyncExternalStore(store.subscribe, store.snapshot, store.snapshot); + let summary = summarize(cards); + let open = summary.answered > 0 || summary.submitting; + let bar = useRef(null); + // Errors already brought into view. A reply can land before this ever + // renders the card as saving, so a new error is what counts. + let reported = useRef>(new Set()); + // The bar leaves with the words it arrived with rather than emptying first. + let shown = useRef(summary); + if (open) shown.current = summary; + let { answered, failed, failedAnswers, incomplete, saveable, submitting } = shown.current; + let waiting = saveable === 0 && incomplete.length > 0 && !submitting; + + useEffect(() => { + let failedNow = newlyFailed(reported.current, cards); + reported.current = new Set( + [...cards].filter(([, card]) => card.error && !card.submitting).map(([id]) => id), + ); + if (failedNow[0]) onFailed(failedNow[0]); + }, [cards, onFailed]); + + useEffect(() => { + if (open || !bar.current?.contains(document.activeElement)) return; + onFocusLeave?.(); + }, [open, onFocusLeave]); + + return ( +
+ {failed.length > 0 + ? ( + + {failedLabel(failedAnswers)} + + ) + : ( + + {waiting ? incompleteLabel(incomplete.length) : unsavedLabel(answered)} + + )} + + {waiting + ? ( + + ) + : ( + + )} +
+ ); +} diff --git a/packages/editor/src/styles.css b/packages/editor/src/styles.css index 4c96d5a79..98838b83c 100644 --- a/packages/editor/src/styles.css +++ b/packages/editor/src/styles.css @@ -1827,6 +1827,251 @@ 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: 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. */ +.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; +} + +.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); + 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); +} + +/* 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; + 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-question { + display: -webkit-box; + min-width: 0; + flex: 1; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + line-clamp: 2; + color: var(--color-text-primary); +} + +.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/touched.test.ts b/packages/editor/src/touched.test.ts new file mode 100644 index 000000000..19c9127d3 --- /dev/null +++ b/packages/editor/src/touched.test.ts @@ -0,0 +1,35 @@ +import { expect, test } from "bun:test"; + +import { forget, remember, selectionOf, selectionsFor, stillMine, withChange } from "./touched"; + +import type { Question } from "@chopin/protocol"; + +const EMPTY: Question.DraftAnswer = { + mode: "choices", + choice: null, + options: { a: false, b: false }, + custom: "", +}; + +test("a card stays this person's only while the draft holds what they chose", () => { + let mine = selectionOf(withChange(EMPTY, { mode: "choices", choice: "a" })); + remember("card", "q", mine); + let drafts = { q: { ...EMPTY, choice: "a" } }; + expect(stillMine(selectionsFor("card"), drafts)).toBe(true); + // A collaborator chose something else after them. + expect(stillMine(selectionsFor("card"), { q: { ...EMPTY, choice: "b" } })).toBe(false); + // A draft that has not synced yet is not theirs either. + expect(stillMine(selectionsFor("card"), {})).toBe(false); + forget("card"); + expect(selectionsFor("card")).toBeUndefined(); + expect(stillMine(undefined, drafts)).toBe(false); +}); + +test("multiple choices compare as a set, and a choice leaves custom mode", () => { + let picked = withChange(EMPTY, { mode: "choices", options: { b: true, a: true } }); + expect(selectionOf(picked)).toBe(selectionOf({ ...EMPTY, options: { a: true, b: true } })); + expect(withChange({ ...EMPTY, mode: "custom", custom: "x" }, { choice: "a" }).mode).toBe( + "choices", + ); + expect(selectionOf({ ...EMPTY, mode: "custom", custom: " x " })).toBe("t:x"); +}); diff --git a/packages/editor/src/touched.ts b/packages/editor/src/touched.ts new file mode 100644 index 000000000..97c1cc24a --- /dev/null +++ b/packages/editor/src/touched.ts @@ -0,0 +1,109 @@ +import { useSyncExternalStore } from "react"; + +import type { Question } from "@chopin/protocol"; + +/** + * The answers this person chose on each decision card, 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. + * What is kept is the selection itself, not a flag: a card counts as this + * person's only while the shared draft still says what they chose, so a + * collaborator's later change is never saved under their name from the bar. + * Card ids are unique across documents. Storage can be unavailable, so it is + * an optional mirror of the in-memory map rather than the source of truth. + */ +const KEY = "chopin:decisions:selections"; + +type Draft = Question.DraftAnswer; +export type Selections = Readonly>; + +/** One question's selection as comparable text. */ +export function selectionOf(draft: Draft | undefined): string { + if (!draft) return ""; + if (draft.mode === "custom") return `t:${draft.custom.trim()}`; + let options = Object.keys(draft.options).filter(option => draft.options[option]).sort(); + return `c:${draft.choice ?? ""}|o:${options.join(",")}`; +} + +/** The draft as it will be once a local change lands, as the controller applies it. */ +export function withChange(draft: Draft | undefined, change: Partial): Draft { + let base: Draft = draft ?? { mode: "choices", choice: null, options: {}, custom: "" }; + let choosing = change.choice !== undefined || change.options !== undefined; + return { + ...base, + ...change, + mode: choosing ? "choices" : change.mode ?? base.mode, + options: { ...base.options, ...change.options }, + }; +} + +/** Whether the shared draft still holds exactly what this person chose. */ +export function stillMine( + selections: Selections | undefined, + drafts: Readonly>, +): boolean { + if (!selections) return false; + let questions = Object.keys(selections); + return questions.length > 0 + && questions.every(question => selections[question] === selectionOf(drafts[question])); +} + +function load(): Map { + try { + let stored = JSON.parse(sessionStorage.getItem(KEY) ?? "{}") as unknown; + if (!stored || typeof stored !== "object" || Array.isArray(stored)) return new Map(); + return new Map( + Object.entries(stored as Record).flatMap(([card, value]) => + value && typeof value === "object" && !Array.isArray(value) + ? [[card, value as Selections] as const] + : [] + ), + ); + } catch { + return new Map(); + } +} + +let cards: Map | undefined; +let listeners = new Set<() => void>(); + +function current(): Map { + cards ??= load(); + return cards; +} + +function publish() { + try { + sessionStorage.setItem(KEY, JSON.stringify(Object.fromEntries(current()))); + } catch { + // Remembered for this page only. + } + for (let listener of listeners) listener(); +} + +export function selectionsFor(card: string): Selections | undefined { + return current().get(card); +} + +/** This person chose an answer to one question on the card. */ +export function remember(card: string, question: string, selection: string): void { + let previous = current().get(card); + if (previous?.[question] === selection) return; + current().set(card, { ...previous, [question]: selection }); + publish(); +} + +/** Nothing of this person's is left to save on the card. */ +export function forget(card: string): void { + if (current().delete(card)) publish(); +} + +function subscribe(listener: () => void) { + listeners.add(listener); + return () => listeners.delete(listener); +} + +export function useSelections(card: string): Selections | undefined { + return useSyncExternalStore(subscribe, () => selectionsFor(card), () => undefined); +} 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..083f2481c 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,9 @@ 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 { forget, remember, selectionOf, stillMine, useSelections, withChange } from "../touched"; import { useRelations } from "../questionnaires"; import { widgets$ } from "../widget-options"; import { useTransitionPresence } from "../transition-presence"; @@ -316,6 +319,10 @@ function Undecided( let answerable = connected && !!state.definition; let editable = canEdit && answerable; let previous = previousAnswers(value); + let pending = useContext(PendingAnswersContext); + // Counted in the save bar only while the shared draft still says what this person chose. + let selections = useSelections(value.id); + let touched = stillMine(selections, state.drafts); return ( { + pending?.submitted(value.id); + state.submit(suggestion); + } + : undefined} renderPeople={people => ( forget(value.id) : undefined} + onLocalChange={pending + ? (question, change) => + remember(value.id, question, selectionOf(withChange(state.drafts[question], change))) + : undefined} + onPending={pending && (answers => pending.set(value.id, answers))} {...pointing} /> @@ -393,7 +413,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 +616,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 +652,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 + * short pointer so the card is not shown, and answered, twice. The icon says + * what it is, leaving the width to the question. + */ +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..3dd2cc4be 100644 --- a/packages/question/src/react/question-view.tsx +++ b/packages/question/src/react/question-view.tsx @@ -43,6 +43,20 @@ 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; + /** 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; +}; + export type QuestionViewProps = { definition: Definition; drafts: Drafts; @@ -101,6 +115,22 @@ 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; + /** + * 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; + /** False when this person cleared their answers here. */ + onTouchedChange?: (touched: boolean) => void; + /** This person chose an answer here; the host can keep what they chose. */ + onLocalChange?: (question: string, change: Partial) => void; }; export function currentQuestion( @@ -314,7 +344,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 +744,11 @@ export function QuestionView(props: QuestionViewProps) { onQuestionFocus, onQuestionSelect, renderStep, + deferSave = false, + onPending, + touched: hostTouched, + onTouchedChange, + onLocalChange, } = props; let [suggestionEdits, dispatchSuggestionEdit] = useReducer(reduceSuggestionEditState, { @@ -727,6 +764,14 @@ 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 [ownTouched, setOwnTouched] = useState(false); + let touched = hostTouched ?? ownTouched; + let setTouched = (value: boolean) => { + setOwnTouched(value); + if (!value) onTouchedChange?.(false); + }; let markComposerEdit = () => { dispatchSuggestionEdit({ type: "composer-edited" }); @@ -773,6 +818,54 @@ 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, drafts[question.id])).length; + let complete = answeredCount === definition.questions.length; + 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(() => { + let report = reporting.current; + if (!report) return; + if (!pendingEnabled || (answeredCount === 0 && !submitting)) { + report(undefined); + return; + } + report({ + answered: answeredCount, + complete, + submitting, + 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; + onChange?.( + question.id, + question.multiple + ? { + mode: "choices", + options: Object.fromEntries( + Object.keys(draft.options).map(option => [option, false]), + ), + } + : { mode: "choices", choice: null }, + ); + } + }, + }); + }, [answeredCount, complete, error, pendingEnabled, submitting]); + useEffect(() => () => reporting.current?.(undefined), []); + if (status === "discarded") { return (
@@ -855,6 +948,10 @@ 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 saveHere = () => onSubmit?.(); let discard = onDiscard ?? onCancel; let previousAnswer = previousFor(priorAnswers, current.id); let step = (offset: number, from?: "primary") => { @@ -942,6 +1039,8 @@ export function QuestionView(props: QuestionViewProps) { name={`${base}-${current.id}`} onChange={change => { markHumanEdit(); + setTouched(true); + onLocalChange?.(current.id, change); onChange?.(current.id, change); }} suggestedOptionId={projection.suggestion?.optionId} @@ -1050,9 +1149,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 +1167,7 @@ export function QuestionView(props: QuestionViewProps) { Next )} - {(onSubmit || showActions) && (!multiple || last) && ( + {(onSubmit || showActions) && (!multiple || last) && !deferSave && ( )} + {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 && ( + + )} )} diff --git a/packages/visuals/theme.css b/packages/visuals/theme.css index 49335321b..b60baae66 100644 --- a/packages/visuals/theme.css +++ b/packages/visuals/theme.css @@ -349,6 +349,10 @@ --icon-swap-blur: 2px; --badge-pop-dur: 180ms; --badge-close-dur: 180ms; + /* Phone decisions: the save bar rises from the bottom edge; a decided card folds away. */ + --save-bar-open-dur: 320ms; + --save-bar-close-dur: 180ms; + --decision-closure-dur: 320ms; --acc-expand: 250ms; --acc-collapse: 200ms; --toast-open: 280ms; diff --git a/scripts/design-contract/exceptions/dynamic-editor.json b/scripts/design-contract/exceptions/dynamic-editor.json index 73e12f20b..87d6e63cc 100644 --- a/scripts/design-contract/exceptions/dynamic-editor.json +++ b/scripts/design-contract/exceptions/dynamic-editor.json @@ -361,7 +361,7 @@ 2 ] ], - "sourceHash": "c73c56e66e1a75de4f82609d9347fa876effe2c499b58af96a921f566ccb0127" + "sourceHash": "a0feb156f5b197fb4cc5a06f849503d2b6f7436848439e86b2dcfeb140ab4382" }, { "file": "packages/editor/src/collaboration.tsx", @@ -403,7 +403,7 @@ 1 ] ], - "sourceHash": "49ccb021fb60c45cdbe234241549e468ba556277d74d824145ca8707ff2bc908" + "sourceHash": "cfa5c13295aeb09e48012ee5e809a81a5218bbcdf6219306aed7002a54dd5932" }, { "file": "packages/editor/src/widgets/code-view.tsx", @@ -466,7 +466,7 @@ 1 ] ], - "sourceHash": "49ccb021fb60c45cdbe234241549e468ba556277d74d824145ca8707ff2bc908" + "sourceHash": "cfa5c13295aeb09e48012ee5e809a81a5218bbcdf6219306aed7002a54dd5932" }, { "file": "packages/editor/src/resolved-layer.tsx", diff --git a/scripts/design-contract/exceptions/dynamic-packages.json b/scripts/design-contract/exceptions/dynamic-packages.json index 2a80d350f..c066d2248 100644 --- a/scripts/design-contract/exceptions/dynamic-packages.json +++ b/scripts/design-contract/exceptions/dynamic-packages.json @@ -32,7 +32,7 @@ 1 ] ], - "sourceHash": "f8782df8726829978d4c4da7a3ab75c25bef7f0c5ebb1d8f387b636013ea0f44" + "sourceHash": "3f93ab499db8fcae05e3f0b6b69d770b1e03ad7c1f47af4e8ef04ce302058271" }, { "file": "packages/question/src/react/resolved-actions.tsx",