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/apps/web/src/child-provenance.tsx b/apps/web/src/child-provenance.tsx index ae363d172..9252e6c26 100644 --- a/apps/web/src/child-provenance.tsx +++ b/apps/web/src/child-provenance.tsx @@ -1,5 +1,6 @@ -import { useEffect, useId, useState } from "react"; -import { ChevronIcon, SparkleIcon } from "@chopin/icons"; +import { useEffect, useState } from "react"; +import { SparkleIcon } from "@chopin/icons"; +import { UnfoldPanel, UnfoldToggle, useUnfold } from "@chopin/visuals/unfold"; import { researchProvenance } from "./research-requests"; @@ -13,8 +14,8 @@ function plural(count: number, noun: string): string { /** A quiet line above a research child that names its parent, brief, and sources. */ export function ChildProvenance({ channelId, parent }: { channelId: string; parent: ChildParent }) { let [provenance, setProvenance] = useState(); - let [briefOpen, setBriefOpen] = useState(false); - let briefId = useId(); + let brief = useUnfold(); + let setBriefOpen = brief.setOpen; useEffect(() => { let controller = new AbortController(); setProvenance(undefined); @@ -26,7 +27,7 @@ export function ChildProvenance({ channelId, parent }: { channelId: string; pare // Provenance is context, not content; the document stays usable without it. .catch(() => {}); return () => controller.abort(); - }, [channelId]); + }, [channelId, setBriefOpen]); if (!provenance || provenance.parentChannelId !== parent.id) return null; let requestId = provenance.requestId; let returnToCard = () => { @@ -71,23 +72,13 @@ export function ChildProvenance({ channelId, parent }: { channelId: string; pare - +

- + +
{provenance.brief}
+
); } diff --git a/apps/web/src/experiments/card.test.tsx b/apps/web/src/experiments/card.test.tsx index 5c0c69a09..c38073738 100644 --- a/apps/web/src/experiments/card.test.tsx +++ b/apps/web/src/experiments/card.test.tsx @@ -64,3 +64,69 @@ 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<(?:svg)/); + expect(markup).not.toContain(" { + let markup = render(investigation("requested")); + + 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 f1be9a9df..ef4c5b064 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", "card"); let load = async () => { setLoading(true); await store.load(summary.id); @@ -45,7 +48,15 @@ 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}

+

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

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

@@ -61,128 +72,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/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 && (