diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index 3bdf9d391..2e2a381b9 100644 --- a/apps/web/DESIGN.md +++ b/apps/web/DESIGN.md @@ -173,6 +173,7 @@ Rectangular controls use a restrained four-step radius scale: small for grips an - **Document:** The authored page is white, readable, and wider only where content requires it. Headings, lists, tables, code, and callouts retain document semantics inside the editor. - **Document images:** Click opens a full-screen preview with zoom and close controls. Hover or keyboard focus reveals metadata editing and corner resize handles. Resizing preserves the image ratio, shares the chosen width with collaborators, and remains bounded by the document or column on narrow screens. - **Decision decks:** Adjacent decisions stack as a deck. The top card is live; up to two cards behind it show their header edges and are inert. Previous and next controls, Left and Right arrows, and a count of the top card's place cycle the deck with a short shuffle; answered decisions settle to the back. Reduced motion reorders without animation. Deck order is each reader's own. + On desktop, Decisions shows open decisions as the same deck when two or more wait, with resolved decisions listed below; phones keep the single-pane list and save bar. A blank document whose Planner asks decisions opens Decisions once per visit, unless the reader is editing the document. - **Unfold:** One disclosure for accordions and card previews. A button with `aria-expanded` and `aria-controls` folds a mounted panel open through its grid row while the content fades and tips down from its top edge, with the shared ease-out: card previews take the linger duration, plain accordions the accordion duration. A second press reverses from the current frame. Closed content is inert and hidden once the fold ends. Reduced motion snaps the height and keeps a short crossfade. A card preview never replaces the card's own open action. ## Do's and Don'ts diff --git a/apps/web/src/document-editing.ts b/apps/web/src/document-editing.ts new file mode 100644 index 000000000..7342f7981 --- /dev/null +++ b/apps/web/src/document-editing.ts @@ -0,0 +1,28 @@ +import { useEffect, useState } from "react"; + +/** Whether focus is in this room's document editor, so routing can leave the reader alone. */ +export function useDocumentEditing(room: string): boolean { + let [editing, setEditing] = useState(false); + useEffect(() => { + // An open child's editor sits inside the parent's workspace but is not this document. + let selector = `[data-workspace-room="${CSS.escape(room)}"] [data-document-view="plan"] ` + + `[contenteditable="true"]:not(.workspace-child-slot *)`; + let inEditor = (target: EventTarget | null) => + target instanceof Element && !!target.closest(selector); + let focus = (event: FocusEvent) => setEditing(inEditor(event.target)); + // During focusout activeElement is not yet the new target; relatedTarget is. + let blur = (event: FocusEvent) => { + // The window losing focus keeps the editor's focus for when it returns. + if (event.relatedTarget === null && !document.hasFocus()) return; + setEditing(inEditor(event.relatedTarget)); + }; + setEditing(inEditor(document.activeElement)); + document.addEventListener("focusin", focus); + document.addEventListener("focusout", blur); + return () => { + document.removeEventListener("focusin", focus); + document.removeEventListener("focusout", blur); + }; + }, [room]); + return editing; +} diff --git a/apps/web/src/room-workspace.tsx b/apps/web/src/room-workspace.tsx index 443621ac7..5b90cd1f9 100644 --- a/apps/web/src/room-workspace.tsx +++ b/apps/web/src/room-workspace.tsx @@ -33,6 +33,7 @@ import { Chat } from "./chat/chat"; import { ChildProvenance } from "./child-provenance"; import { shouldShowResearchActionError, useResearchOfferLinks } from "./chat/research-offer"; import type { ResearchDraftController } from "./chat/research-draft-controller"; +import { useDocumentEditing } from "./document-editing"; import { advanceConversationAnnouncement } from "./conversation-plan/announcements"; import type { ConversationAnnouncementSummary } from "./conversation-plan/announcements"; import { evidenceRows, hasEvidence } from "./conversation-plan/evidence"; @@ -467,13 +468,13 @@ export function RoomWorkspace( let [researchErrors, setResearchErrors] = useState>({}); let [researchExecution, setResearchExecution] = useState(false); let researchDrafts = useMemo(() => new Map(), [room]); - let [reveal, setReveal] = useState<{ widget: string; token: number }>(); + let [reveal, setReveal] = useState<{ widget: string; token: number; resume?: boolean }>(); let [planScrollTop, setPlanScrollTop] = useState(0); let entries = useQuestionnaires(questions); let cardMetadata = useSyncExternalStore(cardMeta.subscribe, cardMeta.snapshot, cardMeta.snapshot); let unanswered = countUnanswered(entries, cardMetadata); let hasPlanProse = useHasPlanContent(questions); - let hasPlanContent = documentHasPlanningContent(hasPlanProse, entries); + let hasPlanContent = documentHasPlanningContent(hasPlanProse); let [decisionView, setDecisionView] = useState(() => { let stored = localStorage.getItem("chopin:view:document"); return { @@ -481,9 +482,10 @@ export function RoomWorkspace( preferred: initialDocumentView(profile, stored), }; }); + let typing = useDocumentEditing(room); let view: WorkspaceDocumentView = showImplementation && profile.implementation ? "build" - : visibleDecisionView(decisionView, hasPlanContent, unanswered); + : visibleDecisionView(decisionView, hasPlanContent, unanswered, typing); let attention = useDecisionAttention(unanswered); let latestCanEdit = useRef(canEdit); let latestCanManage = useRef(canManage); @@ -543,10 +545,28 @@ export function RoomWorkspace( } }, [onDocumentChanged, room, userId]); + // Focus alone never routes: only new decisions or new prose re-evaluate the opening. useEffect(() => { - setDecisionView(state => advanceDecisionView(state, hasPlanContent, unanswered)); + let next = advanceDecisionView(decisionView, hasPlanContent, unanswered, typing); + if (next === decisionView) return; + setDecisionView(state => (state === decisionView ? next : state)); + // oxlint-disable-next-line react-hooks/exhaustive-deps -- typing and decisionView are read, not triggers }, [hasPlanContent, unanswered]); + // Announce the route only once it has applied: a tab choice in the same + // batch makes the routing updater above a no-op. + let routedPhase = useRef(decisionView.phase); + useEffect(() => { + let before = routedPhase.current; + routedPhase.current = decisionView.phase; + if (before !== "initial" || decisionView.phase !== "forced") return; + setAnnouncement(current => ({ + text: `Showing Decisions: ${unanswered} to answer`, + sequence: current.sequence + 1, + })); + // oxlint-disable-next-line react-hooks/exhaustive-deps -- the count is read when routing applies + }, [decisionView.phase]); + useEffect(() => { if (!taskLink || !hasPlanContent) return; setTaskLink(false); @@ -612,7 +632,7 @@ export function RoomWorkspace( } if (next === "decisions" && revealFirst) { let first = firstOpenDecision(entries, cardMetadata); - setReveal({ widget: first?.id ?? "", token: Date.now() }); + setReveal({ widget: first?.id ?? "", token: Date.now(), resume: true }); } }; diff --git a/e2e/decision-deck-fixtures.ts b/e2e/decision-deck-fixtures.ts index 078a183fd..d3b8474db 100644 --- a/e2e/decision-deck-fixtures.ts +++ b/e2e/decision-deck-fixtures.ts @@ -1,6 +1,8 @@ import { storedQuestion } from "../apps/server/src/testing/plan"; +import { expect } from "./room"; import type { SeedState } from "../apps/server/src/testing/plan"; +import type { Page } from "@playwright/test"; /** The digest of a paragraph reading exactly `Anchored paragraph.` */ export const ANCHORED_DIGEST = @@ -86,3 +88,21 @@ ${choices.map(choice => `