From 712ec3f7fced355fb4ecfed1305aeeddbac71e7a Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Sun, 11 Oct 2026 10:44:04 +0100 Subject: [PATCH 01/10] Add a shared Unfold disclosure with an interruptible grid-row fold Co-Authored-By: Claude Opus 5.5 --- apps/web/DESIGN.md | 1 + packages/visuals/package.json | 4 + packages/visuals/src/index.ts | 8 ++ packages/visuals/src/styles.css | 1 + packages/visuals/src/ui/unfold.css | 88 +++++++++++++++++ packages/visuals/src/ui/unfold.test.tsx | 113 ++++++++++++++++++++++ packages/visuals/src/ui/unfold.tsx | 121 ++++++++++++++++++++++++ 7 files changed, 336 insertions(+) create mode 100644 packages/visuals/src/ui/unfold.css create mode 100644 packages/visuals/src/ui/unfold.test.tsx create mode 100644 packages/visuals/src/ui/unfold.tsx diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index 4f8e420a7..3bdf9d391 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. +- **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/packages/visuals/package.json b/packages/visuals/package.json index 7e2b13f3b..1e3445900 100644 --- a/packages/visuals/package.json +++ b/packages/visuals/package.json @@ -15,6 +15,10 @@ "types": "./src/ui/inline-notice.tsx", "default": "./src/ui/inline-notice.tsx" }, + "./unfold": { + "types": "./src/ui/unfold.tsx", + "default": "./src/ui/unfold.tsx" + }, "./styles.css": "./src/styles.css", "./theme.css": "./theme.css" }, diff --git a/packages/visuals/src/index.ts b/packages/visuals/src/index.ts index eb89c9474..d74271d55 100644 --- a/packages/visuals/src/index.ts +++ b/packages/visuals/src/index.ts @@ -35,3 +35,11 @@ export type { TableRowProps, TableVariant, } from "./ui/table"; +export { UnfoldPanel, UnfoldSection, UnfoldToggle, useUnfold } from "./ui/unfold"; +export type { + Unfold, + UnfoldPanelProps, + UnfoldSectionProps, + UnfoldToggleProps, + UnfoldTriggerProps, +} from "./ui/unfold"; diff --git a/packages/visuals/src/styles.css b/packages/visuals/src/styles.css index 468b55e66..b3b2c3ad1 100644 --- a/packages/visuals/src/styles.css +++ b/packages/visuals/src/styles.css @@ -9,3 +9,4 @@ @import "./ui/select.css"; @import "./ui/sparkline.css"; @import "./ui/table.css"; +@import "./ui/unfold.css"; diff --git a/packages/visuals/src/ui/unfold.css b/packages/visuals/src/ui/unfold.css new file mode 100644 index 000000000..9023e0fb4 --- /dev/null +++ b/packages/visuals/src/ui/unfold.css @@ -0,0 +1,88 @@ +/* Two speeds, both tokens: plain accordions, and card previews at the linger pace. */ +.cv-unfold, +.cv-unfold-toggle { + --cv-unfold-duration: var(--acc-expand); +} + +[data-unfold-speed="card"] { + --cv-unfold-duration: var(--duration-linger); +} + +/* Height folds through the grid row; content fades and tips open from its top edge. */ +.cv-unfold { + display: grid; + grid-template-rows: 0fr; + transition: grid-template-rows var(--cv-unfold-duration) var(--ease-out); +} + +.cv-unfold[data-open] { + grid-template-rows: 1fr; +} + +.cv-unfold-clip { + min-height: 0; + overflow: hidden; + perspective: 50rem; +} + +/* Visibility flips only after the fold, so closed content leaves find-in-page and focus. */ +.cv-unfold-content { + opacity: 0; + transform: rotateX(-40deg); + transform-origin: top center; + visibility: hidden; + transition: + opacity var(--cv-unfold-duration) var(--ease-out), + transform var(--cv-unfold-duration) var(--ease-out), + visibility 0s var(--cv-unfold-duration); +} + +.cv-unfold[data-open] > .cv-unfold-clip > .cv-unfold-content { + opacity: 1; + transform: none; + visibility: visible; + transition-delay: 0s; +} + +:where(.cv-unfold-toggle) { + display: inline-flex; + align-items: center; + gap: calc(var(--spacing) * 1.5); + cursor: pointer; + text-align: start; +} + +.cv-unfold-chevron { + flex: none; + color: var(--color-text-tertiary); + transition: transform var(--cv-unfold-duration) var(--ease-out); +} + +.cv-unfold-toggle[aria-expanded="true"] > .cv-unfold-chevron { + transform: rotate(90deg); +} + +.cv-unfold-section-toggle { + font-size: var(--text-sm); + line-height: var(--text-sm--line-height); + color: var(--color-text-secondary); +} + +.cv-unfold-section-body { + padding-block-start: calc(var(--spacing) * 2); +} + +@media (prefers-reduced-motion: reduce) { + .cv-unfold { + transition: none; + } + + .cv-unfold-content { + transform: none; + transition: opacity var(--duration-base) var(--ease-out), visibility 0s var(--duration-base); + } + + .cv-unfold-chevron { + transition: none; + } +} diff --git a/packages/visuals/src/ui/unfold.test.tsx b/packages/visuals/src/ui/unfold.test.tsx new file mode 100644 index 000000000..d8285f391 --- /dev/null +++ b/packages/visuals/src/ui/unfold.test.tsx @@ -0,0 +1,113 @@ +import { expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { UnfoldPanel, UnfoldSection, UnfoldToggle, useUnfold } from "@chopin/visuals/unfold"; + +const CSS = readFileSync(join(import.meta.dir, "unfold.css"), "utf8"); + +function Harness({ defaultOpen, speed }: { defaultOpen?: boolean; speed?: "card" | "accordion" }) { + let unfold = useUnfold(defaultOpen, speed); + return ( + <> + + Preview + + + Source + + + ); +} + +function tagWithId(markup: string, id: string): string { + let escaped = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return markup.match(new RegExp(`]*id="${escaped}"[^>]*>`))?.[0] ?? ""; +} + +test("a closed unfold names its panel and keeps the content mounted but inert", () => { + let markup = renderToStaticMarkup(); + let id = markup.match(/aria-controls="([^"]+)"/)?.[1]; + + expect(id).toBeTruthy(); + expect(markup).toContain('aria-expanded="false"'); + expect(markup).toContain('type="button"'); + expect(markup).toContain('aria-label="Preview report"'); + expect(markup).toMatch(/]*class="cv-unfold-toggle host"/); + let panel = tagWithId(markup, id!); + expect(panel).toContain('data-slot="unfold"'); + expect(panel).toContain('class="cv-unfold panel"'); + expect(panel).toContain('inert=""'); + expect(panel).toContain('aria-hidden="true"'); + expect(panel).not.toContain("data-open"); + expect(markup).toContain('
'); + expect(markup).toContain('href="https://example.com/source"'); +}); + +test("an open unfold exposes its panel", () => { + let markup = renderToStaticMarkup(); + let id = markup.match(/aria-controls="([^"]+)"/)?.[1]; + let panel = tagWithId(markup, id!); + + expect(markup).toContain('aria-expanded="true"'); + expect(panel).toContain('data-open=""'); + expect(panel).not.toContain("inert"); + expect(panel).not.toContain("aria-hidden"); +}); + +test("the toggle carries one decorative chevron after its label", () => { + let markup = renderToStaticMarkup(); + + let chevron = markup.match(/Preview(]*>)/)?.[1] ?? ""; + expect(chevron).toContain('class="cv-unfold-chevron"'); + expect(chevron).toContain('aria-hidden="true"'); +}); + +test("a section pairs a labelled toggle with its panel", () => { + let markup = renderToStaticMarkup( + +

Body

+
, + ); + + expect(markup).toContain('data-slot="unfold-section"'); + expect(markup).toMatch(/]*aria-expanded="false"[^>]*>Report

Body

'); +}); + +test("toggle and panel carry the same speed; accordion is the default", () => { + let plain = renderToStaticMarkup(); + let card = renderToStaticMarkup(); + + expect(plain.match(/data-unfold-speed="accordion"/g)).toHaveLength(2); + expect(card.match(/data-unfold-speed="card"/g)).toHaveLength(2); +}); + +test("speeds map to tokens: accordion 250ms, card previews 600ms", () => { + expect(CSS).toMatch( + /\.cv-unfold,\s*\.cv-unfold-toggle\s*{\s*--cv-unfold-duration:\s*var\(--acc-expand\);/, + ); + expect(CSS).toMatch( + /\[data-unfold-speed="card"\]\s*{\s*--cv-unfold-duration:\s*var\(--duration-linger\);/, + ); +}); + +test("the fold uses jig row D motion and reverses through CSS transitions", () => { + expect(CSS).toMatch( + /\.cv-unfold\s*{[^}]*grid-template-rows:\s*0fr;[^}]*transition:\s*grid-template-rows var\(--cv-unfold-duration\) var\(--ease-out\)/s, + ); + expect(CSS).toMatch(/\.cv-unfold\[data-open\]\s*{[^}]*grid-template-rows:\s*1fr/s); + expect(CSS).toMatch(/\.cv-unfold-content\s*{[^}]*transform:\s*rotateX\(-40deg\)/s); + expect(CSS).toMatch(/\.cv-unfold-content\s*{[^}]*visibility 0s var\(--cv-unfold-duration\)/s); +}); + +test("reduced motion snaps the fold and keeps a short crossfade", () => { + let reduced = CSS.match(/@media \(prefers-reduced-motion: reduce\)\s*{([\s\S]*)}\s*$/)?.[1] ?? ""; + + expect(reduced).toMatch(/\.cv-unfold\s*{\s*transition:\s*none;/); + expect(reduced).toMatch( + /\.cv-unfold-content\s*{[^}]*transform:\s*none;[^}]*opacity var\(--duration-base\)/s, + ); + expect(reduced).toMatch(/\.cv-unfold-chevron\s*{\s*transition:\s*none;/); +}); diff --git a/packages/visuals/src/ui/unfold.tsx b/packages/visuals/src/ui/unfold.tsx new file mode 100644 index 000000000..aded3287e --- /dev/null +++ b/packages/visuals/src/ui/unfold.tsx @@ -0,0 +1,121 @@ +import { useCallback, useId, useRef, useState } from "react"; +import { ChevronIcon } from "@chopin/icons"; + +import type { ComponentProps, ReactNode, RefObject } from "react"; + +export type UnfoldTriggerProps = { + "aria-controls": string; + "aria-expanded": boolean; + onClick: () => void; + ref: RefObject; + type: "button"; +}; + +export type UnfoldSpeed = "card" | "accordion"; + +export type Unfold = { + id: string; + open: boolean; + speed: UnfoldSpeed; + panelRef: RefObject; + setOpen: (open: boolean, options?: { focusTrigger?: boolean }) => void; + toggle: () => void; + trigger: UnfoldTriggerProps; +}; + +/** `card` previews fold at the linger pace; plain accordions use the accordion token. */ +export function useUnfold(defaultOpen = false, speed: UnfoldSpeed = "accordion"): Unfold { + let id = useId(); + let [open, setState] = useState(defaultOpen); + let triggerRef = useRef(null); + let panelRef = useRef(null); + let setOpen = useCallback((next: boolean, options: { focusTrigger?: boolean } = {}) => { + let panel = panelRef.current; + // A closing panel turns inert; focus left inside it would fall to the page. + if (!next && (options.focusTrigger || panel?.contains(panel.ownerDocument.activeElement))) { + triggerRef.current?.focus({ preventScroll: true }); + } + setState(next); + }, []); + let toggle = useCallback(() => setOpen(!open), [open, setOpen]); + return { + id, + open, + panelRef, + setOpen, + speed, + toggle, + trigger: { + "aria-controls": id, + "aria-expanded": open, + onClick: toggle, + ref: triggerRef, + type: "button", + }, + }; +} + +function classes(...names: (string | undefined)[]): string { + return names.filter(Boolean).join(" "); +} + +export type UnfoldToggleProps = + & Omit, "aria-controls" | "aria-expanded" | "onClick" | "ref" | "type"> + & { unfold: Unfold }; + +export function UnfoldToggle({ children, className, unfold, ...props }: UnfoldToggleProps) { + return ( + + ); +} + +export type UnfoldPanelProps = Omit, "id" | "ref"> & { unfold: Unfold }; + +/** Stays mounted so a fold can reverse from any frame; closed content is inert. */ +export function UnfoldPanel({ children, className, unfold, ...props }: UnfoldPanelProps) { + return ( +
+
+
{children}
+
+
+ ); +} + +export type UnfoldSectionProps = { + children: ReactNode; + className?: string; + defaultOpen?: boolean; + label: ReactNode; +}; + +export function UnfoldSection({ children, className, defaultOpen, label }: UnfoldSectionProps) { + let unfold = useUnfold(defaultOpen); + return ( +
+ {label} + +
{children}
+
+
+ ); +} From 2e563565f9fbbfdd7b95404c8492d8b547ec9d02 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Sun, 11 Oct 2026 10:46:17 +0100 Subject: [PATCH 02/10] Let a ready research card unfold a preview of its brief and sources Co-Authored-By: Claude Sonnet 5.5 --- packages/editor/src/styles.css | 47 ++++++++++++++ packages/editor/src/widgets/research.test.tsx | 62 ++++++++++++++++++- packages/editor/src/widgets/research.tsx | 57 +++++++++++++++++ 3 files changed, 165 insertions(+), 1 deletion(-) diff --git a/packages/editor/src/styles.css b/packages/editor/src/styles.css index 90fd5363e..7aa12163f 100644 --- a/packages/editor/src/styles.css +++ b/packages/editor/src/styles.css @@ -77,6 +77,53 @@ background: transparent; } +/* The preview sits above the whole-card open button; only its links take the pointer. */ +.plan-research-preview-toggle { + position: relative; + z-index: 2; +} + +.plan-research-card .plan-research-preview { + position: relative; + z-index: 2; + margin-block-start: -0.5rem; + pointer-events: none; +} + +.plan-research-preview a { + pointer-events: auto; + color: var(--color-brand); + text-decoration: underline; + text-decoration-color: color-mix(in oklch, var(--color-brand) 30%, transparent); + text-underline-offset: 0.15em; +} + +.plan-research-preview-body { + display: grid; + gap: 0.5rem; + padding-block-start: 1rem; + font-size: var(--text-sm); + line-height: var(--text-sm--line-height); + color: var(--color-text-secondary); +} + +.plan-research-preview-body p, +.plan-research-preview-sources { + margin: 0; +} + +.plan-research-preview-question { + display: grid; + gap: 0.125rem; +} + +.plan-research-preview-sources { + display: grid; + gap: 0.25rem; + padding: 0; + list-style: none; +} + /* Composer, progress and result share the document's prose column. */ .plan-research-composer, .plan-research-tracked { diff --git a/packages/editor/src/widgets/research.test.tsx b/packages/editor/src/widgets/research.test.tsx index ed9ff9378..e9b21aa6f 100644 --- a/packages/editor/src/widgets/research.test.tsx +++ b/packages/editor/src/widgets/research.test.tsx @@ -259,7 +259,16 @@ describe("research card", () => { expect(markup).toMatch( /]*class="[^"]*\brelative\b[^"]*"[^>]*data-research-ready=""/, ); - expect(markup).not.toContain("https://example.com/releases"); + expect(markup).toContain('aria-label="Preview Rollout evidence"'); + expect(markup).toContain('data-tooltip="Preview"'); + let panel = markup.match(/aria-controls="([^"]+)"/)?.[1]; + expect(panel).toBeTruthy(); + let escaped = panel!.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + let tag = markup.match(new RegExp(`]*id="${escaped}"[^>]*>`))?.[0] ?? ""; + expect(tag).toContain("plan-research-preview"); + expect(tag).toContain('inert=""'); + expect(markup).toContain('href="https://example.com/releases"'); + expect(markup).toContain(REQUEST_BASE.question); expect(markup).not.toContain("Remove research reference"); expect(markup).not.toContain("Retry research"); expect(markup).not.toContain("Cancel research"); @@ -290,8 +299,59 @@ describe("research card", () => { expect(zero).toContain("No sources"); expect(plural).toContain("3 sources"); }); + + it("offers no preview before the report is ready", async () => { + let { card: Card } = await components(); + if (!Card) return; + let markup = renderToStaticMarkup(createElement(Card, { request: BASE, onCancel() {} })); + + expect(markup).not.toContain("Preview"); + expect(markup).not.toContain("plan-research-preview"); + }); }); +describe("research preview", () => { + it("links only http and https sources", async () => { + let { safeSourceHref } = await import("./research"); + + expect(safeSourceHref("https://example.com/a")).toBe("https://example.com/a"); + expect(safeSourceHref("http://example.com")).toBe("http://example.com/"); + expect(safeSourceHref("javascript:alert(1)")).toBeUndefined(); + expect(safeSourceHref("data:text/html,hello")).toBeUndefined(); + expect(safeSourceHref("not a url")).toBeUndefined(); + }); + + it("shows the question, five sources, and how many more there are", async () => { + let { ResearchPreview } = await import("./research"); + let sources = Array.from({ length: 7 }, (_, index) => ({ + title: `Source ${index + 1}`, + url: index === 1 ? "javascript:alert(1)" : `https://example.com/${index + 1}`, + })); + let markup = renderToStaticMarkup( + createElement(ResearchPreview, { question: "Which evidence?", sources }), + ); + + expect(markup).toContain("Asked"); + expect(markup).toContain("Which evidence?"); + expect(markup).toContain('aria-label="Research sources"'); + expect(markup.match(/
  • /g)).toHaveLength(5); + expect(markup).toContain(''); + expect(markup).toContain("
  • Source 2
  • "); + expect(markup).not.toContain("javascript:"); + expect(markup).not.toContain("Source 6"); + expect(markup).toContain("2 more sources"); + }); + + it("omits the source list when the report cites none", async () => { + let { ResearchPreview } = await import("./research"); + let markup = renderToStaticMarkup( + createElement(ResearchPreview, { question: "Which evidence?", sources: [] }), + ); + + expect(markup).not.toContain("Research sources"); + expect(markup).not.toContain("more source"); + }); +}); describe("active research deletion", () => { it("blocks deletion while publication is still in progress", async () => { let state = mutableStore({ ...BASE, stage: "publishing" }); diff --git a/packages/editor/src/widgets/research.tsx b/packages/editor/src/widgets/research.tsx index a5c056fb8..a31255893 100644 --- a/packages/editor/src/widgets/research.tsx +++ b/packages/editor/src/widgets/research.tsx @@ -27,6 +27,7 @@ import { Fold, Swap, useLast } from "./research-motion"; import { widgets$ } from "../widget-options"; import { $isResearchNode } from "@chopin/dialect"; import { CloseIcon, SparkleIcon, WarningIcon } from "@chopin/icons"; +import { UnfoldPanel, UnfoldToggle, useUnfold } from "@chopin/visuals/unfold"; import type { FormEvent, MouseEvent, ReactNode } from "react"; import type { Research } from "@chopin/protocol"; @@ -295,6 +296,48 @@ function plural(count: number, noun: string): string { return `${count} ${noun}${count === 1 ? "" : "s"}`; } +const PREVIEW_SOURCES = 5; + +export function safeSourceHref(url: string): string | undefined { + try { + let parsed = new URL(url); + return parsed.protocol === "https:" || parsed.protocol === "http:" ? parsed.href : undefined; + } catch { + return undefined; + } +} + +/** What a reader sees before opening the report: what was asked and where it looked. */ +export function ResearchPreview( + { question, sources }: { question: string; sources: readonly Research.Source[] }, +) { + let shown = sources.slice(0, PREVIEW_SOURCES); + let more = sources.length - shown.length; + return ( + + ); +} + export function ResearchCard( { actionError, @@ -333,6 +376,7 @@ export function ResearchCard( card?.focus({ preventScroll: true }); }; let shownSummary = useLast(ready?.summary); + let preview = useUnfold(false, "card"); let shownFound = useLast(found > 0 ? found : undefined); let attention: ReactNode = shownMessages !== undefined && ( + {ready && ( + + )}
    {ready ? ready.title : } @@ -389,6 +441,11 @@ export function ResearchCard( {shownSummary !== undefined &&

    {shownSummary}

    }
    + {ready && ( + + + + )} {attention}
    From ebbc01848420d542f0230dc4a565689255f26650 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Sun, 11 Oct 2026 10:58:00 +0100 Subject: [PATCH 03/10] Cover research preview keyboard, focus, interruption and reduced motion Keep the editor caret when pressing the toggle or a source link, let the unfold crossfade survive the editor's reduced-motion reset, clamp the question, and key duplicate source URLs uniquely. Co-Authored-By: Claude Sonnet 5.5 --- e2e/unfold-previews.e2e.ts | 262 ++++++++++++++++++ packages/editor/src/styles.css | 9 +- packages/editor/src/widgets/research.test.tsx | 1 + packages/editor/src/widgets/research.tsx | 12 +- 4 files changed, 280 insertions(+), 4 deletions(-) create mode 100644 e2e/unfold-previews.e2e.ts diff --git a/e2e/unfold-previews.e2e.ts b/e2e/unfold-previews.e2e.ts new file mode 100644 index 000000000..dc882ad2e --- /dev/null +++ b/e2e/unfold-previews.e2e.ts @@ -0,0 +1,262 @@ +// e2e/unfold-previews.e2e.ts +import { seedChildChannel } from "./database"; +import { + markResearchPublished, + seedCompletedResearchWorkspace, +} from "./research-recovery-database"; +import { expect, test } from "./room"; +import { researchReportSource } from "../apps/server/src/research/publication"; + +import type { SeedState } from "../apps/server/src/testing/plan"; +import type { Page } from "@playwright/test"; + +const QUESTION = "Compare the evidence for a stable research document."; +const SOURCE = { title: "Primary public source", url: "https://example.com/source" }; + +function port(baseURL: string): number { + return Number(new URL(baseURL).port); +} + +async function readyResearch( + baseURL: string, + room: string, + seed: (source: string, state?: SeedState) => Promise, +): Promise { + let databasePort = port(baseURL); + let title = `Preview research ${room.slice(0, 8)}`; + let workspaceId = `workspace-${crypto.randomUUID()}`; + let report = { + title, + summary: "A complete report grounded in the discovered sources.", + findings: [{ text: "The source supports the report.", sourceUrls: [SOURCE.url] }], + caveats: ["The evidence has a limited scope."], + }; + let child = await seedChildChannel( + databasePort, + room, + crypto.randomUUID(), + title, + researchReportSource(report, [SOURCE]), + ); + await seed(`# Parent document\n\n\n`); + await seedCompletedResearchWorkspace(databasePort, room, { + workspaceId, + question: QUESTION, + report: { + title, + summary: report.summary, + finding: report.findings[0]!.text, + caveat: report.caveats[0]!, + source: SOURCE, + }, + }); + await markResearchPublished(databasePort, room, workspaceId, child.id); + return title; +} + +async function preview(page: Page, title: string) { + let card = page.getByRole("article", { name: "Research" }).filter({ hasText: title }); + await expect(card).toHaveAttribute("data-stage", "ready"); + let toggle = card.getByRole("button", { name: `Preview ${title}`, exact: true }); + let id = await toggle.getAttribute("aria-controls"); + expect(id).toBeTruthy(); + return { card, panel: `[id="${id}"]`, toggle }; +} + +/** Clicks the panel's trigger on the given frames and records the panel height every frame. */ +function sampleFold( + page: Page, + panel: string, + clicks: number[], + frames: number, +): Promise { + return page.evaluate(({ clicks, frames, panel }) => + new Promise(resolve => { + let element = document.querySelector(panel)!; + let trigger = document.querySelector( + `[aria-controls="${CSS.escape(element.id)}"]`, + )!; + let heights: number[] = []; + let frame = () => { + if (clicks.includes(heights.length)) trigger.click(); + heights.push(element.getBoundingClientRect().height); + if (heights.length < frames) requestAnimationFrame(frame); + else resolve(heights); + }; + requestAnimationFrame(frame); + }), { clicks, frames, panel }); +} + +test("a research preview unfolds by keyboard without opening the report", async ({ baseURL, join, room, seed }) => { + let title = await readyResearch(baseURL!, room, seed); + let page = await join("ana"); + let { card, toggle } = await preview(page, title); + let child = page.getByRole("region", { name: `Child document: ${title}` }); + let open = card.getByRole("button", { name: `Open ${title}`, exact: true }); + let source = card.getByRole("link", { name: SOURCE.title, exact: true }); + + await expect(toggle).toHaveAttribute("aria-expanded", "false"); + await expect(source).toBeHidden(); + await toggle.focus(); + await page.keyboard.press("Enter"); + await expect(toggle).toHaveAttribute("aria-expanded", "true"); + await expect(toggle).toBeFocused(); + await expect(source).toBeVisible(); + await expect(source).toHaveAttribute("href", SOURCE.url); + await expect(card.getByText(QUESTION, { exact: true })).toBeVisible(); + await expect(child).toHaveCount(0); + + await page.keyboard.press("Tab"); + await expect(source).toBeFocused(); + await page.keyboard.press("Shift+Tab"); + await expect(toggle).toBeFocused(); + await page.keyboard.press("Space"); + await expect(toggle).toHaveAttribute("aria-expanded", "false"); + await expect(source).toBeHidden(); + + // A folded preview takes no tab stop; the next one is the card's own open action. + await page.keyboard.press("Tab"); + await expect(open).toBeFocused(); + await page.keyboard.press("Enter"); + await expect(child).toBeVisible(); +}); + +test("pressing preview text still opens the report fully", async ({ baseURL, join, room, seed }) => { + let title = await readyResearch(baseURL!, room, seed); + let page = await join("ana"); + let { card, toggle } = await preview(page, title); + + await toggle.click(); + await expect(page.getByRole("region", { name: `Child document: ${title}` })).toHaveCount(0); + let question = card.getByText(QUESTION, { exact: true }); + await expect(question).toBeVisible(); + // Wait out the unfold so the click lands where the text finally rests. + let rest = ""; + await expect.poll(async () => { + let now = JSON.stringify(await question.boundingBox()); + let steady = now === rest; + rest = now; + return steady; + }).toBe(true); + let box = await question.boundingBox(); + await page.mouse.click(box!.x + 4, box!.y + box!.height / 2); + await expect(page.getByRole("region", { name: `Child document: ${title}` })).toBeVisible(); +}); + +test("a preview folds continuously and reverses from its current frame", async ({ baseURL, join, room, seed }) => { + let title = await readyResearch(baseURL!, room, seed); + let page = await join("ana"); + await page.emulateMedia({ reducedMotion: "no-preference" }); + let { panel, toggle } = await preview(page, title); + + let opening = await sampleFold(page, panel, [0], 50); + let full = opening.at(-1)!; + expect(opening[0]).toBeLessThan(1); + expect(full).toBeGreaterThan(20); + expect(opening.filter(height => height > 1 && height < full - 1).length) + .toBeGreaterThanOrEqual(5); + for (let index = 1; index < opening.length; index++) { + expect(opening[index]!).toBeGreaterThanOrEqual(opening[index - 1]! - 0.5); + } + + await toggle.click(); + await expect(toggle).toHaveAttribute("aria-expanded", "false"); + await expect.poll(() => + page.locator(panel).evaluate(element => element.getBoundingClientRect().height) + ).toBeLessThan(1); + + // Open, then close again twelve frames in: the fold turns back where it is. + let interrupted = await sampleFold(page, panel, [0, 12], 60); + let peak = Math.max(...interrupted); + let turn = interrupted.indexOf(peak); + expect(peak).toBeGreaterThan(1); + expect(peak).toBeLessThan(full - 1); + expect(interrupted[turn + 1]!).toBeGreaterThan(peak * 0.6); + for (let index = turn + 1; index < interrupted.length; index++) { + expect(interrupted[index]!).toBeLessThanOrEqual(interrupted[index - 1]! + 0.5); + } + expect(interrupted.at(-1)!).toBeLessThan(1); + await expect(toggle).toHaveAttribute("aria-expanded", "false"); +}); + +test("reduced motion snaps the fold and keeps the crossfade", async ({ baseURL, join, room, seed }) => { + let title = await readyResearch(baseURL!, room, seed); + let page = await join("ana"); + let { panel, toggle } = await preview(page, title); + + await page.emulateMedia({ reducedMotion: "no-preference" }); + await toggle.click(); + await expect.poll(async () => { + let samples = await sampleFold(page, panel, [], 2); + return samples[1]! - samples[0]!; + }).toBe(0); + let full = (await sampleFold(page, panel, [], 1))[0]!; + await toggle.click(); + await expect.poll(async () => (await sampleFold(page, panel, [], 1))[0]!).toBeLessThan(1); + + await page.emulateMedia({ reducedMotion: "reduce" }); + let snapped = await sampleFold(page, panel, [0], 3); + expect(snapped[1]!).toBeGreaterThan(full - 1); + let timing = await page.locator(panel).evaluate(element => ({ + content: getComputedStyle(element.querySelector(".cv-unfold-content")!).transitionDuration, + fold: getComputedStyle(element).transitionDuration, + transform: getComputedStyle(element.querySelector(".cv-unfold-content")!).transform, + })); + expect(timing.fold).toBe("0s"); + expect(timing.content.split(", ")[0]).toBe("0.2s"); + expect(timing.transform).toBe("none"); + await expect(toggle).toHaveAttribute("aria-expanded", "true"); +}); + +test("folding while focus is inside the panel returns focus to the toggle", async ({ baseURL, join, room, seed }) => { + let title = await readyResearch(baseURL!, room, seed); + let page = await join("ana"); + let { card, panel, toggle } = await preview(page, title); + let source = card.getByRole("link", { name: SOURCE.title, exact: true }); + + await toggle.click(); + await expect(source).toBeVisible(); + await source.focus(); + await expect(source).toBeFocused(); + // Fold without moving focus first, as a pointer elsewhere or a script would. + await page.locator(`[aria-controls="${await toggle.getAttribute("aria-controls")}"]`) + .evaluate(element => (element as HTMLElement).click()); + await expect(toggle).toHaveAttribute("aria-expanded", "false"); + await expect(toggle).toBeFocused(); + await expect(page.locator(panel)).toHaveAttribute("inert", ""); + await expect(source).toBeHidden(); +}); + +test("using the preview leaves the editor selection alone", async ({ baseURL, join, room, seed }) => { + let title = await readyResearch(baseURL!, room, seed); + let page = await join("ana"); + let { card, toggle } = await preview(page, title); + let snapshot = () => + page.evaluate(() => { + let selection = window.getSelection()!; + return { + anchor: selection.anchorNode?.textContent ?? null, + anchorOffset: selection.anchorOffset, + collapsed: selection.isCollapsed, + focusOffset: selection.focusOffset, + }; + }); + + await page.getByText("Parent document", { exact: true }).click(); + await page.keyboard.press("End"); + let before = await snapshot(); + expect(before.anchor).toContain("Parent document"); + + await toggle.click(); + await expect(toggle).toHaveAttribute("aria-expanded", "true"); + expect(await snapshot()).toEqual(before); + + let popup = page.waitForEvent("popup"); + await card.getByRole("link", { name: SOURCE.title, exact: true }).click(); + await (await popup).close(); + expect(await snapshot()).toEqual(before); + + await toggle.click(); + await expect(toggle).toHaveAttribute("aria-expanded", "false"); + expect(await snapshot()).toEqual(before); +}); diff --git a/packages/editor/src/styles.css b/packages/editor/src/styles.css index 7aa12163f..d9d14ef28 100644 --- a/packages/editor/src/styles.css +++ b/packages/editor/src/styles.css @@ -117,6 +117,13 @@ gap: 0.125rem; } +.plan-research-preview-question > :last-child { + display: -webkit-box; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; +} + .plan-research-preview-sources { display: grid; gap: 0.25rem; @@ -3502,7 +3509,7 @@ body:has(.plan-comment-sheet-popup[data-swiping]) } @media (prefers-reduced-motion: reduce) { - .plan * { + .plan *:not(.cv-unfold-content) { transition: none !important; animation: none !important; } diff --git a/packages/editor/src/widgets/research.test.tsx b/packages/editor/src/widgets/research.test.tsx index e9b21aa6f..e2ffdd6cc 100644 --- a/packages/editor/src/widgets/research.test.tsx +++ b/packages/editor/src/widgets/research.test.tsx @@ -352,6 +352,7 @@ describe("research preview", () => { expect(markup).not.toContain("more source"); }); }); + describe("active research deletion", () => { it("blocks deletion while publication is still in progress", async () => { let state = mutableStore({ ...BASE, stage: "publishing" }); diff --git a/packages/editor/src/widgets/research.tsx b/packages/editor/src/widgets/research.tsx index a31255893..1ab9f689b 100644 --- a/packages/editor/src/widgets/research.tsx +++ b/packages/editor/src/widgets/research.tsx @@ -320,11 +320,15 @@ export function ResearchPreview( {question}

    {shown.length > 0 && ( -
      - {shown.map(source => { +
        event.preventDefault()} + > + {shown.map((source, index) => { let href = safeSourceHref(source.url); return ( -
      • +
      • {href ? {source.title} : source.title} @@ -431,6 +435,8 @@ export function ResearchCard( aria-label={`Preview ${ready.title}`} className="plan-research-preview-toggle btn btn-icon btn-ghost" data-tooltip="Preview" + // Pressing must not pull focus, and with it the caret, out of the editor. + onMouseDown={event => event.preventDefault()} unfold={preview} /> )} From d551dc407b893a6d3451abb436779db4006b094e Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:01:21 +0100 Subject: [PATCH 04/10] Fold finished investigations to their brief with the shared Unfold Co-Authored-By: Claude Opus 5.5 --- apps/web/src/experiments/card.test.tsx | 50 +++++ apps/web/src/experiments/card.tsx | 241 +++++++++++++------------ e2e/experiments.e2e.ts | 20 +- 3 files changed, 185 insertions(+), 126 deletions(-) diff --git a/apps/web/src/experiments/card.test.tsx b/apps/web/src/experiments/card.test.tsx index 5c0c69a09..00c969fe0 100644 --- a/apps/web/src/experiments/card.test.tsx +++ b/apps/web/src/experiments/card.test.tsx @@ -64,3 +64,53 @@ test("a build lock preserves shared result selections while blocking document mu })); expect(peer).not.toContain("Cancel investigation"); }); + +function investigation(state: PublishedInvestigation["state"]): PublishedInvestigation { + return { + id: `investigation-${state}`, + documentId: "document", + revision: 1, + brief: "Compare startup approaches", + requester: "ana", + state, + generation: 1, + expiresAt: 0, + createdAt: 0, + updatedAt: 0, + progress: "", + result: performance, + views: Object.fromEntries(performance.views.map(view => [view.key, initialState(view)])), + decisions: [], + }; +} + +function render(item: PublishedInvestigation): string { + let store = new ExperimentStore("document"); + store.values.set(item.id, item); + return renderToStaticMarkup(createElement(InvestigationCard, { + store, + summary: { ...item, decisionCount: 0 }, + userId: "ana", + canEdit: true, + locked: false, + })); +} + +test("a completed investigation folds to its brief and status", () => { + let markup = render(investigation("completed")); + + expect(markup).toMatch( + /]*>]*aria-expanded="false"[^>]*>]*>Compare startup approaches<\/span>/, + ); + expect(markup).toMatch(/

        ]*>completed/); + expect(markup).toMatch(/]*data-slot="unfold"[^>]*inert=""/); + expect(markup).toMatch(/]*aria-expanded="false"[^>]*>Report { + let markup = render(investigation("requested")); + + expect(markup).toMatch(/]*>]*aria-expanded="true"/); + expect(markup).toContain("Run on my workspace"); +}); diff --git a/apps/web/src/experiments/card.tsx b/apps/web/src/experiments/card.tsx index f1be9a9df..06b9f8bfc 100644 --- a/apps/web/src/experiments/card.tsx +++ b/apps/web/src/experiments/card.tsx @@ -4,6 +4,7 @@ import { StaticPlanEditor } from "@chopin/editor/static"; import type { ExperimentStore } from "./store"; import { EvidenceActions } from "./evidence"; import type { InvestigationSummary } from "@chopin/experiment/records"; +import { UnfoldPanel, UnfoldSection, UnfoldToggle, useUnfold } from "@chopin/visuals/unfold"; export function InvestigationCard({ store, summary, userId, canEdit, locked }: { store: ExperimentStore; @@ -19,6 +20,8 @@ export function InvestigationCard({ store, summary, userId, canEdit, locked }: { let [loading, setLoading] = useState(!store.get(summary.id)); let item = store.get(summary.id); let owned = store.connections.filter(connection => connection.owner === userId); + // Work that still needs a person opens; a finished result folds to its brief. + let body = useUnfold(summary.state !== "completed"); let load = async () => { setLoading(true); await store.load(summary.id); @@ -45,7 +48,11 @@ export function InvestigationCard({ store, summary, userId, canEdit, locked }: { aria-label={summary.brief} className="flex min-w-0 flex-col gap-3 rounded-md border border-edge p-4" > -

        {summary.brief}

        +

        + + {summary.brief} + +

        {summary.state} {summary.progress && `· ${summary.progress}`}

        @@ -61,128 +68,128 @@ export function InvestigationCard({ store, summary, userId, canEdit, locked }: { )} {item && ( - <> - {canEdit && item.state === "requested" && ( -
        - {owned.length === 0 && ( -

        - Connect a local workspace to run this investigation. -

        - )} - setConnectionId(event.target.value)} + > + + {owned.map(value => ( + + ))} + + +
        + )} + {canEdit && ["queued", "running", "publishing"].includes(item.state) + && item.input?.authorizer === userId && ( + + )} + {canEdit && ["failed", "cancelled", "interrupted"].includes(item.state) && ( -
    - )} - {canEdit && ["queued", "running", "publishing"].includes(item.state) - && item.input?.authorizer === userId && ( - - )} - {canEdit && ["failed", "cancelled", "interrupted"].includes(item.state) && ( - - )} - {item.result && ( - <> -
    - Report - -
    - {item.result.views.map(view => ( -
    - - void act(async () => { - try { - await store.action(item.id, "state", { - view: view.key, - patch: { - mutationId: crypto.randomUUID(), - expected: { [field]: item.views[view.key].fields[field].revision }, - set: { [field]: values }, - }, - }); - } finally { - await store.load(item.id); - } - })} - /> - -
    - - Download JSON - - - Download CSV - + )} + {item.result && ( + <> + + + + {item.result.views.map(view => ( +
    + + void act(async () => { + try { + await store.action(item.id, "state", { + view: view.key, + patch: { + mutationId: crypto.randomUUID(), + expected: { [field]: item.views[view.key].fields[field].revision }, + set: { [field]: values }, + }, + }); + } finally { + await store.load(item.id); + } + })} + /> + +
    -
    - ))} -

    {item.result.provenance.environment}

    - {item.result.provenance.limitations.map((value, index) => ( -

    {value}

    - ))} - {item.result.evidence.map(value => ( -
    - {value.title} -
    {value.text}
    -
    - ))} - - )} - + ))} +

    {item.result.provenance.environment}

    + {item.result.provenance.limitations.map((value, index) => ( +

    {value}

    + ))} + {item.result.evidence.map(value => ( + +
    {value.text}
    +
    + ))} + + )} +
    + )} ); diff --git a/e2e/experiments.e2e.ts b/e2e/experiments.e2e.ts index aced5a806..bbed6b6b2 100644 --- a/e2e/experiments.e2e.ts +++ b/e2e/experiments.e2e.ts @@ -119,15 +119,17 @@ test("owner pairs a workspace and publishes evidence that survives disconnect", await tool("disconnect_workspace", {}); await page.reload(); - await expect( - page.getByRole("article", { name: "Compare startup approaches", exact: true }).getByRole( - "table", - { - name: "Median startup time", - exact: true, - }, - ), - ).toContainText("410"); + let completed = page.getByRole("article", { name: "Compare startup approaches", exact: true }); + let completedToggle = completed.getByRole("button", { + name: "Compare startup approaches", + exact: true, + }); + await expect(completedToggle).toHaveAttribute("aria-expanded", "false"); + await expect(completed.getByRole("table", { name: "Median startup time", exact: true })) + .toBeHidden(); + await completedToggle.click(); + await expect(completed.getByRole("table", { name: "Median startup time", exact: true })) + .toContainText("410"); }); test("an incoming proposal appears inline and recovers from a failed detail load", async ({ join, seed, room }) => { From b4ab64e8e0dda9ac1946fb477191780b3ccd3aa3 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:02:36 +0100 Subject: [PATCH 05/10] Fold the investigation card at the card pace Co-Authored-By: Claude Opus 5.5 --- apps/web/src/experiments/card.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/experiments/card.tsx b/apps/web/src/experiments/card.tsx index 06b9f8bfc..7623b9e3a 100644 --- a/apps/web/src/experiments/card.tsx +++ b/apps/web/src/experiments/card.tsx @@ -21,7 +21,7 @@ export function InvestigationCard({ store, summary, userId, canEdit, locked }: { let item = store.get(summary.id); let owned = store.connections.filter(connection => connection.owner === userId); // Work that still needs a person opens; a finished result folds to its brief. - let body = useUnfold(summary.state !== "completed"); + let body = useUnfold(summary.state !== "completed", "card"); let load = async () => { setLoading(true); await store.load(summary.id); From 47e916b59cf0883a6ca3e7e8296b706623fc5117 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:05:01 +0100 Subject: [PATCH 06/10] Move investigation proposals and saved evidence onto Unfold Render the investigation card toggle only once its panel has loaded. Co-Authored-By: Claude Sonnet 5.5 --- apps/web/src/experiments/card.test.tsx | 16 ++++++ apps/web/src/experiments/card.tsx | 10 ++-- apps/web/src/experiments/evidence.tsx | 6 +-- apps/web/src/experiments/inline.tsx | 71 +++++++++++++------------- e2e/connector.e2e.ts | 11 ++-- e2e/experiments.e2e.ts | 21 ++++++-- 6 files changed, 88 insertions(+), 47 deletions(-) diff --git a/apps/web/src/experiments/card.test.tsx b/apps/web/src/experiments/card.test.tsx index 00c969fe0..5ad9fc04d 100644 --- a/apps/web/src/experiments/card.test.tsx +++ b/apps/web/src/experiments/card.test.tsx @@ -114,3 +114,19 @@ test("an investigation that still needs work starts unfolded", () => { expect(markup).toMatch(/]*>]*aria-expanded="true"/); expect(markup).toContain("Run on my workspace"); }); + +test("the fold toggle only exists once its panel has loaded", () => { + let store = new ExperimentStore("document"); + let item = investigation("completed"); + let markup = renderToStaticMarkup(createElement(InvestigationCard, { + store, + summary: { ...item, decisionCount: 0 }, + userId: "ana", + canEdit: true, + locked: false, + })); + + expect(markup).toContain("Compare startup approaches"); + expect(markup).not.toContain("aria-controls"); + expect(markup).not.toContain("aria-expanded"); +}); diff --git a/apps/web/src/experiments/card.tsx b/apps/web/src/experiments/card.tsx index 7623b9e3a..ef4c5b064 100644 --- a/apps/web/src/experiments/card.tsx +++ b/apps/web/src/experiments/card.tsx @@ -49,9 +49,13 @@ export function InvestigationCard({ store, summary, userId, canEdit, locked }: { className="flex min-w-0 flex-col gap-3 rounded-md border border-edge p-4" >

    - - {summary.brief} - + {item + ? ( + + {summary.brief} + + ) + : {summary.brief}}

    {summary.state} {summary.progress && `· ${summary.progress}`} diff --git a/apps/web/src/experiments/evidence.tsx b/apps/web/src/experiments/evidence.tsx index c4ff083eb..a2efc0feb 100644 --- a/apps/web/src/experiments/evidence.tsx +++ b/apps/web/src/experiments/evidence.tsx @@ -3,6 +3,7 @@ import { ExperimentView } from "@chopin/experiment/react"; import type { PublishedInvestigation } from "@chopin/experiment/records"; import type { View } from "@chopin/experiment"; import type { ExperimentStore } from "./store"; +import { UnfoldSection } from "@chopin/visuals/unfold"; import { RequestIdentity } from "./request-id"; export function EvidenceActions({ store, item, view, disabled }: { @@ -131,10 +132,9 @@ export function EvidenceDecisions( Recorded by {decision.by} · Fixed evidence

    {view && item.result && ( -
    - View saved evidence + -
    + )} {canEdit && (