Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/web/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
31 changes: 11 additions & 20 deletions apps/web/src/child-provenance.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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<Research.Provenance>();
let [briefOpen, setBriefOpen] = useState(false);
let briefId = useId();
let brief = useUnfold();
let setBriefOpen = brief.setOpen;
useEffect(() => {
let controller = new AbortController();
setProvenance(undefined);
Expand All @@ -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 = () => {
Expand Down Expand Up @@ -71,23 +72,13 @@ export function ChildProvenance({ channelId, parent }: { channelId: string; pare
</span>
</span>
</span>
<button
aria-controls={briefId}
aria-expanded={briefOpen}
className="child-provenance-toggle btn btn-sm btn-ghost"
onClick={() => setBriefOpen(open => !open)}
type="button"
>
<UnfoldToggle className="child-provenance-toggle btn btn-sm btn-ghost" unfold={brief}>
Brief
<ChevronIcon
aria-hidden="true"
className={briefOpen ? "rotate-90" : undefined}
/>
</button>
</UnfoldToggle>
</p>
<blockquote className="child-provenance-brief" hidden={!briefOpen} id={briefId}>
{provenance.brief}
</blockquote>
<UnfoldPanel unfold={brief}>
<blockquote className="child-provenance-brief">{provenance.brief}</blockquote>
</UnfoldPanel>
</div>
);
}
66 changes: 66 additions & 0 deletions apps/web/src/experiments/card.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
/<h3[^>]*><button[^>]*aria-expanded="false"[^>]*><span[^>]*>Compare startup approaches<\/span>/,
);
expect(markup).toMatch(/<p role="status"[^>]*>completed/);
expect(markup).toMatch(/<div[^>]*data-slot="unfold"[^>]*inert=""/);
expect(markup).toMatch(/<button[^>]*aria-expanded="false"[^>]*>Report<(?:svg)/);
expect(markup).not.toContain("<details");
});

test("an investigation that still needs work starts unfolded", () => {
let markup = render(investigation("requested"));

expect(markup).toMatch(/<h3[^>]*><button[^>]*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");
});
Loading
Loading