From 5a363ef97f8bd101b729da18715fe14e362ccdad Mon Sep 17 00:00:00 2001
From: Harsh Kumar
Date: Fri, 28 Aug 2026 08:57:55 -0400
Subject: [PATCH 1/3] feat: add synthetic exhibition gallery preview
---
src/exhibition/ExhibitionApp.tsx | 112 ++
src/exhibition/components/BlackoutText.tsx | 34 +
src/exhibition/components/ChatHistory.tsx | 56 +
.../components/ExhibitionGallery.tsx | 255 ++++
.../components/ExhibitionHeader.tsx | 29 +
src/exhibition/components/PoemDetail.tsx | 196 +++
src/exhibition/components/ProcessTimeline.tsx | 155 +++
src/exhibition/components/StarMark.tsx | 16 +
src/exhibition/data/preview.ts | 196 +++
src/exhibition/exhibition.css | 1155 +++++++++++++++++
src/exhibition/types.ts | 79 ++
src/exhibition/utils.ts | 201 +++
src/main.tsx | 18 +-
13 files changed, 2495 insertions(+), 7 deletions(-)
create mode 100644 src/exhibition/ExhibitionApp.tsx
create mode 100644 src/exhibition/components/BlackoutText.tsx
create mode 100644 src/exhibition/components/ChatHistory.tsx
create mode 100644 src/exhibition/components/ExhibitionGallery.tsx
create mode 100644 src/exhibition/components/ExhibitionHeader.tsx
create mode 100644 src/exhibition/components/PoemDetail.tsx
create mode 100644 src/exhibition/components/ProcessTimeline.tsx
create mode 100644 src/exhibition/components/StarMark.tsx
create mode 100644 src/exhibition/data/preview.ts
create mode 100644 src/exhibition/exhibition.css
create mode 100644 src/exhibition/types.ts
create mode 100644 src/exhibition/utils.ts
diff --git a/src/exhibition/ExhibitionApp.tsx b/src/exhibition/ExhibitionApp.tsx
new file mode 100644
index 0000000..64a207c
--- /dev/null
+++ b/src/exhibition/ExhibitionApp.tsx
@@ -0,0 +1,112 @@
+import { useEffect, useState } from "react";
+import {
+ BrowserRouter,
+ Route,
+ Routes,
+ useLocation,
+} from "react-router-dom";
+import ExhibitionGallery from "./components/ExhibitionGallery";
+import PoemDetailRoute from "./components/PoemDetail";
+import type { ExhibitionDataset } from "./types";
+import "./exhibition.css";
+
+const DEFAULT_STUDY_ID = "6a8cbdb524cc2e2b32049b00";
+const STUDY_ID =
+ import.meta.env.VITE_EXHIBITION_STUDY_ID?.trim() || DEFAULT_STUDY_ID;
+const USE_LIVE_DATA =
+ import.meta.env.VITE_EXHIBITION_USE_LIVE_DATA?.trim().toLowerCase() === "true";
+
+function ScrollToTop() {
+ const { pathname } = useLocation();
+ useEffect(() => {
+ window.scrollTo(0, 0);
+ }, [pathname]);
+ return null;
+}
+
+function ExhibitionRoutes({ dataset }: { dataset: ExhibitionDataset }) {
+ return (
+ <>
+
+
+ } />
+ }
+ />
+
+ >
+ );
+}
+
+export default function ExhibitionApp() {
+ const [dataset, setDataset] = useState(null);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ const previousTitle = document.title;
+ document.title = "The Blackout Room";
+ return () => {
+ document.title = previousTitle;
+ };
+ }, []);
+
+ useEffect(() => {
+ const controller = new AbortController();
+ const load = async () => {
+ try {
+ if (!USE_LIVE_DATA) {
+ const { previewDataset } = await import("./data/preview");
+ setDataset(previewDataset);
+ return;
+ }
+ const response = await fetch(
+ `/api/firebase/exhibition?studyId=${encodeURIComponent(STUDY_ID)}`,
+ { signal: controller.signal },
+ );
+ if (!response.ok) throw new Error(`Data request failed (${response.status})`);
+ const payload = (await response.json()) as ExhibitionDataset;
+ setDataset(payload);
+ } catch (loadError) {
+ if (controller.signal.aborted) return;
+ if (import.meta.env.DEV) {
+ const { previewDataset } = await import("./data/preview");
+ setDataset(previewDataset);
+ return;
+ }
+ setError(
+ loadError instanceof Error
+ ? loadError.message
+ : "The exhibition data could not be loaded.",
+ );
+ }
+ };
+ void load();
+ return () => controller.abort();
+ }, []);
+
+ if (error) {
+ return (
+
+ ✦
+ The exhibition is between states.
+ {error}
+
+ );
+ }
+
+ if (!dataset) {
+ return (
+
+ ✦
+ Preparing the room…
+
+ );
+ }
+
+ return (
+
+
+
+ );
+}
diff --git a/src/exhibition/components/BlackoutText.tsx b/src/exhibition/components/BlackoutText.tsx
new file mode 100644
index 0000000..5677a85
--- /dev/null
+++ b/src/exhibition/components/BlackoutText.tsx
@@ -0,0 +1,34 @@
+interface BlackoutTextProps {
+ passage: string;
+ visibleIndexes: number[];
+ activeWordIndex?: number;
+ compact?: boolean;
+}
+
+export default function BlackoutText({
+ passage,
+ visibleIndexes,
+ activeWordIndex,
+ compact = false,
+}: BlackoutTextProps) {
+ const visible = new Set(visibleIndexes);
+ const words = passage.split(" ");
+
+ return (
+
+ {words.map((word, index) => {
+ const isVisible = visible.has(index);
+ const isActive = activeWordIndex === index;
+ return (
+
+
+ {word}
+ {" "}
+
+ );
+ })}
+
+ );
+}
diff --git a/src/exhibition/components/ChatHistory.tsx b/src/exhibition/components/ChatHistory.tsx
new file mode 100644
index 0000000..6003254
--- /dev/null
+++ b/src/exhibition/components/ChatHistory.tsx
@@ -0,0 +1,56 @@
+import type { TimelineEvent } from "../types";
+
+interface ChatHistoryProps {
+ events: TimelineEvent[];
+ eventIndex: number;
+ showAll: boolean;
+}
+
+export default function ChatHistory({
+ events,
+ eventIndex,
+ showAll,
+}: ChatHistoryProps) {
+ const visibleEvents = (showAll ? events : events.slice(0, eventIndex + 1)).filter(
+ (event) => event.message,
+ );
+ const displayMessage = (content: string) =>
+ content.replace(/\*\*(.*?)\*\*/gs, "$1").replace(/_(.*?)_/gs, "$1");
+
+ return (
+
+
+
Conversation
+
Shown in playback order
+
+
+ {visibleEvents.length > 0 ? (
+ visibleEvents.map((event, index) => {
+ const previousStage = visibleEvents[index - 1]?.stage;
+ return (
+
+ {event.stage !== previousStage ? (
+
+ {event.stage === "SPARK" ? "Brainstorm" : "Writing"}
+
+ ) : null}
+
+
+ {event.kind === "user-message" ? "Participant" : "Assistant"}
+
+ {displayMessage(event.message?.content ?? "")}
+
+
+ );
+ })
+ ) : (
+
The conversation has not begun at this point.
+ )}
+
+
+ );
+}
diff --git a/src/exhibition/components/ExhibitionGallery.tsx b/src/exhibition/components/ExhibitionGallery.tsx
new file mode 100644
index 0000000..0994d6e
--- /dev/null
+++ b/src/exhibition/components/ExhibitionGallery.tsx
@@ -0,0 +1,255 @@
+import { useMemo, useState } from "react";
+import { useNavigate } from "react-router-dom";
+import type { ArtistCondition } from "../../types";
+import type { ExhibitionDataset, ExhibitionParticipant } from "../types";
+import { average, formatDuration, getPoemTitle } from "../utils";
+import BlackoutText from "./BlackoutText";
+import ExhibitionHeader from "./ExhibitionHeader";
+import StarMark from "./StarMark";
+
+type ConditionFilter = "ALL" | ArtistCondition;
+const PAGE_SIZE = 12;
+
+const median = (values: number[]) => {
+ if (values.length === 0) return null;
+ const ordered = [...values].sort((a, b) => a - b);
+ const midpoint = Math.floor(ordered.length / 2);
+ return ordered.length % 2
+ ? ordered[midpoint]
+ : (ordered[midpoint - 1] + ordered[midpoint]) / 2;
+};
+
+interface PoemPreviewProps {
+ participant: ExhibitionParticipant;
+ featured?: boolean;
+ index: number;
+ onOpen: () => void;
+}
+
+function PoemPreview({
+ participant,
+ featured = false,
+ index,
+ onOpen,
+}: PoemPreviewProps) {
+ const totalMs = participant.poem.taskTiming?.totalDurationMs;
+ const selectionCount = participant.poem.text.length;
+
+ return (
+
+ Poem {String(index + 1).padStart(2, "0")}
+
+
+
+ Source:
+ {participant.poem.passage.author}, {participant.poem.passage.title}
+
+
+ {selectionCount} selections ·{" "}
+ {formatDuration(totalMs)} ·{" "}
+ {participant.condition === "LLM" ? "With AI" : "Without AI"}
+
+
+
+ );
+}
+
+export default function ExhibitionGallery({ dataset }: { dataset: ExhibitionDataset }) {
+ const navigate = useNavigate();
+ const [condition, setCondition] = useState("ALL");
+ const [passageId, setPassageId] = useState("ALL");
+ const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
+
+ const passages = useMemo(() => {
+ const byId = new Map();
+ dataset.participants.forEach(({ poem }) => {
+ byId.set(poem.passageId, {
+ id: poem.passageId,
+ title: poem.passage.title,
+ author: poem.passage.author,
+ });
+ });
+ return [...byId.values()];
+ }, [dataset.participants]);
+
+ const filtered = useMemo(
+ () =>
+ dataset.participants.filter(
+ (participant) =>
+ (condition === "ALL" || participant.condition === condition) &&
+ (passageId === "ALL" || participant.poem.passageId === passageId),
+ ),
+ [condition, dataset.participants, passageId],
+ );
+ const visibleParticipants = filtered.slice(0, visibleCount);
+ const remainingCount = filtered.length - visibleParticipants.length;
+
+ const llmCount = dataset.participants.filter(
+ (participant) => participant.condition === "LLM",
+ ).length;
+ const noAiCount = dataset.participants.length - llmCount;
+ const durations = dataset.participants
+ .map(({ poem }) => poem.taskTiming?.totalDurationMs)
+ .filter((value): value is number => typeof value === "number");
+ const selectionCounts = dataset.participants.map(({ poem }) => poem.text.length);
+ const editCounts = dataset.participants.map(({ poem }) => poem.editHistory.length);
+ const expressiveRealization = average(
+ dataset.participants.map(({ outcomes }) => outcomes.expressive_realization),
+ );
+ const creativitySupport = average(
+ dataset.participants.map(({ outcomes }) => outcomes.csi_able_to_be_creative),
+ );
+ const roomMetrics = [
+ ["Median making time", formatDuration(median(durations))],
+ ["Source passages", String(passages.length)],
+ [
+ "Words kept",
+ selectionCounts.length
+ ? `${Math.min(...selectionCounts)}–${Math.max(...selectionCounts)}`
+ : "—",
+ ],
+ ["Median edit events", String(median(editCounts) ?? "—")],
+ [
+ "Expressive realization",
+ expressiveRealization === null ? "—" : `${expressiveRealization.toFixed(1)}/7`,
+ ],
+ [
+ "Felt able to create",
+ creativitySupport === null ? "—" : `${creativitySupport.toFixed(1)}/10`,
+ ],
+ ];
+
+ return (
+
+
+
+
+
+
An evolving archive
+
+ Blackout poems, their source passages, and the choices that brought them into view.
+
+
+ {dataset.participants.length} poems in view · {noAiCount} without AI · {llmCount} with AI
+ {dataset.isPreview ? Preview data : null}
+
+
+
+
+ {([
+ ["ALL", "All"],
+ ["NO_AI", "Without AI"],
+ ["LLM", "With AI"],
+ ] as const).map(([value, label]) => (
+
+ ))}
+
+
+
+
+
+
+
+
A reading of the room
+
Descriptive overview · updates as new work enters the archive
+
+
+ {roomMetrics.map(([label, value]) => (
+
+
- {label}
+ - {value}
+
+ ))}
+
+
+
+ {filtered.length > 0 ? (
+ <>
+
+ {visibleParticipants.map((participant, index) => (
+ navigate(`/exhibition/${participant.id}`)}
+ participant={participant}
+ />
+ ))}
+
+
+
+ Showing {visibleParticipants.length} of {filtered.length} poems
+
+ {remainingCount > 0 ? (
+
+ ) : null}
+
+ >
+ ) : (
+
+
+ No poems in this part of the room.
+
+
+ )}
+
+
+
+ );
+}
diff --git a/src/exhibition/components/ExhibitionHeader.tsx b/src/exhibition/components/ExhibitionHeader.tsx
new file mode 100644
index 0000000..5f89db4
--- /dev/null
+++ b/src/exhibition/components/ExhibitionHeader.tsx
@@ -0,0 +1,29 @@
+import { Link } from "react-router-dom";
+import StarMark from "./StarMark";
+
+interface ExhibitionHeaderProps {
+ detail?: boolean;
+}
+
+export default function ExhibitionHeader({ detail = false }: ExhibitionHeaderProps) {
+ return (
+
+
+ The Blackout Room
+
+
+
+
+ );
+}
diff --git a/src/exhibition/components/PoemDetail.tsx b/src/exhibition/components/PoemDetail.tsx
new file mode 100644
index 0000000..caa0dc9
--- /dev/null
+++ b/src/exhibition/components/PoemDetail.tsx
@@ -0,0 +1,196 @@
+import { useMemo, useState } from "react";
+import { Navigate, useNavigate, useParams } from "react-router-dom";
+import type { ExhibitionDataset, ExhibitionParticipant } from "../types";
+import {
+ average,
+ buildTimeline,
+ getPoemTitle,
+ replaySelections,
+} from "../utils";
+import BlackoutText from "./BlackoutText";
+import ChatHistory from "./ChatHistory";
+import ExhibitionHeader from "./ExhibitionHeader";
+import ProcessTimeline from "./ProcessTimeline";
+import StarMark from "./StarMark";
+
+const asScore = (value: unknown, maximum: number) =>
+ typeof value === "number" ? `${value}/${maximum}` : "—";
+
+const emotionText = (value: unknown) => {
+ if (!value || typeof value !== "object") return "—";
+ const emotion = value as { emotion?: string; intensity?: number };
+ return emotion.emotion
+ ? `${emotion.emotion}${typeof emotion.intensity === "number" ? ` · ${emotion.intensity}/5` : ""}`
+ : "—";
+};
+
+function MakerAccount({ participant }: { participant: ExhibitionParticipant }) {
+ const outcomes = participant.outcomes;
+ const ownership = average([
+ outcomes.ownership_own_work,
+ outcomes.ownership_responsibility,
+ outcomes.ownership_personal_connection,
+ outcomes.ownership_emotional_connection,
+ ]);
+ const completedRequests = (participant.poem.llmUsage?.requests ?? []).filter(
+ (request) => request.status === "COMPLETED",
+ );
+ const participantMessages = [
+ ...(participant.poem.sparkConversation ?? []),
+ ...(participant.poem.writeConversation ?? []),
+ ].filter((message) => message.role === "user");
+ const exchangeCount = completedRequests.length || participantMessages.length;
+ const stages = new Set(completedRequests.map((request) => request.stage));
+
+ return (
+
+
+
Expressive realization
+ {asScore(outcomes.expressive_realization, 7)}
+
+
+
Ownership
+ {ownership === null ? "—" : `${ownership.toFixed(1)}/7`}
+
+
+
Creative control
+ {asScore(outcomes.creative_control, 5)}
+
+
+
Mental effort
+ {asScore(outcomes.mental_effort, 5)}
+
+
+ {participant.condition === "LLM" ? (
+
+
AI process
+
+ {exchangeCount} {exchangeCount === 1 ? "exchange" : "exchanges"}
+ {stages.size > 0
+ ? ` · used in ${[...stages]
+ .map((stage) => (stage === "SPARK" ? "brainstorming" : "writing"))
+ .join(" and ")}`
+ : " · conversation recorded alongside the poem"}
+
+
+ ) : null}
+
+ );
+}
+
+interface PoemDetailProps {
+ dataset: ExhibitionDataset;
+ participant: ExhibitionParticipant;
+}
+
+function PoemDetail({ dataset, participant }: PoemDetailProps) {
+ const navigate = useNavigate();
+ const participantIndex = dataset.participants.findIndex(
+ (item) => item.id === participant.id,
+ );
+ const events = useMemo(() => buildTimeline(participant), [participant]);
+ const [eventIndex, setEventIndex] = useState(() => Math.max(0, events.length - 1));
+ const [isPlaying, setIsPlaying] = useState(false);
+ const [processMode, setProcessMode] = useState(false);
+ const currentEvent = events[eventIndex];
+ const visibleIndexes = processMode
+ ? replaySelections(participant, events, eventIndex)
+ : participant.poem.text;
+ const hasChat = participant.condition === "LLM";
+
+ const goToParticipant = (index: number) => {
+ const target = dataset.participants[index];
+ if (target) navigate(`/exhibition/${target.id}`);
+ };
+
+ return (
+
+
+
+
+
+
+
+
Poem {String(participantIndex + 1).padStart(2, "0")} of {String(dataset.participants.length).padStart(2, "0")}
+
+
+
{getPoemTitle(participant)}
+
+ after {participant.poem.passage.author}, {participant.poem.passage.title}
+
+
+ {participant.condition === "LLM" ? "Created with AI" : "Created without AI"}
+
+
+
+
+ {hasChat ? (
+
+ ) : null}
+
+
+
+ {
+ setIsPlaying(false);
+ setProcessMode((value) => !value);
+ }}
+ processMode={processMode}
+ totalDurationMs={participant.poem.taskTiming?.totalDurationMs}
+ />
+
+
+
+ );
+}
+
+export default function PoemDetailRoute({ dataset }: { dataset: ExhibitionDataset }) {
+ const { participantId } = useParams();
+ const participant = dataset.participants.find((item) => item.id === participantId);
+ if (!participant) return ;
+ return ;
+}
diff --git a/src/exhibition/components/ProcessTimeline.tsx b/src/exhibition/components/ProcessTimeline.tsx
new file mode 100644
index 0000000..47ffee5
--- /dev/null
+++ b/src/exhibition/components/ProcessTimeline.tsx
@@ -0,0 +1,155 @@
+import { useEffect } from "react";
+import type { TimelineEvent } from "../types";
+import { formatDuration } from "../utils";
+
+interface ProcessTimelineProps {
+ eventIndex: number;
+ events: TimelineEvent[];
+ isPlaying: boolean;
+ onEventIndexChange: (index: number) => void;
+ onPlayingChange: (playing: boolean) => void;
+ onProcessMode: () => void;
+ processMode: boolean;
+ totalDurationMs?: number | null;
+}
+
+const tickSymbol = (event: TimelineEvent) => {
+ if (event.kind === "add") return "+";
+ if (event.kind === "remove") return "×";
+ if (event.kind === "undo") return "↶";
+ if (event.kind === "redo") return "↷";
+ if (event.kind === "user-message") return "u";
+ if (event.kind === "assistant-message") return "a";
+ if (event.kind === "chat-open") return "○";
+ return "│";
+};
+
+export default function ProcessTimeline({
+ eventIndex,
+ events,
+ isPlaying,
+ onEventIndexChange,
+ onPlayingChange,
+ onProcessMode,
+ processMode,
+ totalDurationMs,
+}: ProcessTimelineProps) {
+ const maxIndex = Math.max(0, events.length - 1);
+ const currentEvent = events[eventIndex];
+ const eventDuration = events.at(-1)?.atMs ?? 0;
+ const durationMs = Math.max(totalDurationMs ?? 0, eventDuration, 1);
+
+ useEffect(() => {
+ if (!isPlaying || events.length === 0) return;
+ const timer = window.setInterval(() => {
+ onEventIndexChange(eventIndex >= maxIndex ? 0 : eventIndex + 1);
+ }, 900);
+ return () => window.clearInterval(timer);
+ }, [eventIndex, events.length, isPlaying, maxIndex, onEventIndexChange]);
+
+ return (
+
+
+
+
How the poem emerged
+
+ {currentEvent
+ ? `${formatDuration(currentEvent.atMs)} · ${currentEvent.label}`
+ : "No recorded events"}
+
+
+
+ + Add
+ × Remove
+ ↶ Undo
+ u/a Chat
+
+
+
+ /
+
+
+
+
+
+
+
+
+
+ {formatDuration(currentEvent?.atMs ?? 0)} / {formatDuration(durationMs)}
+
+
+
+ {events.map((event, index) => (
+
+ {tickSymbol(event)}
+
+ ))}
+
+
{
+ onPlayingChange(false);
+ onEventIndexChange(Number(event.target.value));
+ }}
+ step={1}
+ type="range"
+ value={Math.min(eventIndex, maxIndex)}
+ />
+
+ 0:00
+ {formatDuration(durationMs)}
+
+
+
+
+ );
+}
diff --git a/src/exhibition/components/StarMark.tsx b/src/exhibition/components/StarMark.tsx
new file mode 100644
index 0000000..c7961e2
--- /dev/null
+++ b/src/exhibition/components/StarMark.tsx
@@ -0,0 +1,16 @@
+interface StarMarkProps {
+ className?: string;
+}
+
+export default function StarMark({ className = "" }: StarMarkProps) {
+ return (
+
+ );
+}
diff --git a/src/exhibition/data/preview.ts b/src/exhibition/data/preview.ts
new file mode 100644
index 0000000..3affe9e
--- /dev/null
+++ b/src/exhibition/data/preview.ts
@@ -0,0 +1,196 @@
+import { Passages } from "../../consts/passages";
+import type {
+ LegacyChatOpening,
+ Message,
+ PoemSnapshot,
+ TaskTiming,
+} from "../../types";
+import type {
+ ExhibitionDataset,
+ ExhibitionParticipant,
+} from "../types";
+
+const BASE_TIME = new Date("2026-08-24T22:14:00.000Z").getTime();
+
+const selections = [
+ [1, 4, 6, 11, 18, 26, 38, 54, 67],
+ [2, 8, 14, 21, 29, 41, 56, 73, 91, 108],
+ [0, 5, 12, 19, 35, 48, 60, 75, 86],
+ [3, 9, 17, 31, 44, 59, 77, 96],
+ [1, 13, 25, 39, 52, 68, 84, 105],
+ [4, 16, 28, 43, 57, 71, 89, 112],
+ [2, 10, 23, 36, 51, 66, 82, 101],
+];
+
+const makeTiming = (start: number, totalMs: number): TaskTiming => {
+ const sparkStart = new Date(start);
+ const writeStart = new Date(start + 95_000);
+ const completedAt = new Date(start + totalMs);
+ return {
+ startedAt: sparkStart,
+ completedAt,
+ totalDurationMs: totalMs,
+ phases: {
+ spark: {
+ startedAt: sparkStart,
+ completedAt: writeStart,
+ durationMs: 95_000,
+ },
+ write: {
+ startedAt: writeStart,
+ completedAt,
+ durationMs: totalMs - 95_000,
+ },
+ },
+ };
+};
+
+const makeHistory = (
+ indexes: number[],
+ writeStart: number,
+): PoemSnapshot[] => {
+ const events: PoemSnapshot[] = indexes.map((index, eventIndex) => ({
+ action: "ADD",
+ index,
+ timestamp: new Date(writeStart + eventIndex * 17_000),
+ source: "DIRECT",
+ }));
+ const revisedIndex = indexes[2];
+ if (revisedIndex !== undefined) {
+ events.splice(4, 0, {
+ action: "REMOVE",
+ index: revisedIndex,
+ timestamp: new Date(writeStart + 58_000),
+ source: "DIRECT",
+ });
+ events.splice(6, 0, {
+ action: "ADD",
+ index: revisedIndex,
+ timestamp: new Date(writeStart + 82_000),
+ source: "UNDO",
+ });
+ }
+ return events;
+};
+
+const makeLlmHistory = (start: number) => {
+ const sparkConversation: Message[] = [
+ {
+ id: "assistant-opening",
+ role: "assistant",
+ content: "What image or feeling in the passage keeps pulling your attention?",
+ timestamp: new Date(start + 18_000),
+ stage: "SPARK",
+ kind: "STAGE_OPENING",
+ },
+ {
+ id: "participant-spark",
+ role: "user",
+ content: "I like the tension between the city and the possible future.",
+ timestamp: new Date(start + 35_000),
+ stage: "SPARK",
+ kind: "USER_MESSAGE",
+ },
+ {
+ id: "assistant-spark",
+ role: "assistant",
+ content: "You could follow the future-facing words, or make the city interrupt that optimism. Which tension feels truer?",
+ timestamp: new Date(start + 48_000),
+ stage: "SPARK",
+ kind: "LLM_RESPONSE",
+ },
+ ];
+ const writeConversation: Message[] = [
+ {
+ id: "participant-write",
+ role: "user",
+ content: "Help me find a short ending that still feels hopeful.",
+ timestamp: new Date(start + 142_000),
+ stage: "WRITE",
+ kind: "USER_MESSAGE",
+ },
+ {
+ id: "assistant-write",
+ role: "assistant",
+ content: "Try ending on **possible future** for openness, or **golden tissue** for a more fragile kind of hope.",
+ timestamp: new Date(start + 151_000),
+ stage: "WRITE",
+ kind: "LLM_RESPONSE",
+ },
+ ];
+ const chatOpenings: LegacyChatOpening[] = [
+ { stage: "SPARK", timestamp: new Date(start + 15_000) },
+ { stage: "WRITE", timestamp: new Date(start + 135_000) },
+ ];
+ return { sparkConversation, writeConversation, chatOpenings };
+};
+
+const makeParticipant = (index: number): ExhibitionParticipant => {
+ const passage = Passages[index % Passages.length];
+ const selected = selections[index] ?? [];
+ const start = BASE_TIME + index * 1_800_000;
+ const totalMs = [718_000, 1_031_000, 794_000, 527_000, 1_094_000, 455_000, 639_000][index] ?? 600_000;
+ const timing = makeTiming(start, totalMs);
+ const writeStart = new Date(timing.phases.write?.startedAt ?? start).getTime();
+ const isLlm = index === 2;
+ const llm = isLlm
+ ? makeLlmHistory(start)
+ : { sparkConversation: [], writeConversation: [], chatOpenings: [] };
+ const finalPoem = selected
+ .map((wordIndex) => passage.text.split(" ")[wordIndex])
+ .filter(Boolean)
+ .join(" ");
+
+ return {
+ id: `poem-${String(index + 1).padStart(2, "0")}`,
+ condition: isLlm ? "LLM" : "NO_AI",
+ assignment: { passageId: passage.id, strategy: "PREVIEW" },
+ completedAt: new Date(start + totalMs).toISOString(),
+ poem: {
+ passageId: passage.id,
+ passage,
+ text: selected,
+ finalPoem,
+ editHistory: makeHistory(selected, writeStart),
+ sparkConversation: llm.sparkConversation,
+ writeConversation: llm.writeConversation,
+ taskTiming: timing,
+ llmUsage: {
+ chatOpenings: llm.chatOpenings,
+ requests: [],
+ },
+ derivedMetrics: {
+ selectedWordCount: selected.length,
+ totalEditingActivity: selected.length + 2,
+ totalTaskTimeMs: totalMs,
+ llmTurnCount: isLlm ? 2 : 0,
+ },
+ },
+ outcomes: {
+ final_intended_meaning:
+ index % 2 === 0
+ ? "A hopeful reminder that the future is assembled from small acts of attention."
+ : "The poem holds a quiet tension between change and the wish to remain known.",
+ felt_emotion: { emotion: index % 2 === 0 ? "Joy" : "Sadness", intensity: 4 },
+ intended_emotion: { emotion: index % 2 === 0 ? "Hope" : "Longing", intensity: 4 },
+ expressive_realization: 6,
+ ownership_own_work: isLlm ? 5 : 7,
+ ownership_responsibility: 7,
+ ownership_personal_connection: 6,
+ ownership_emotional_connection: 6,
+ creative_control: isLlm ? 4 : 5,
+ creative_intentionality: 5,
+ mental_effort: 4,
+ llm_contribution_attribution: isLlm
+ ? "I was creating the poem and AI was assisting me."
+ : undefined,
+ },
+ };
+};
+
+export const previewDataset: ExhibitionDataset = {
+ studyId: "6a8cbdb524cc2e2b32049b00",
+ generatedAt: new Date(BASE_TIME).toISOString(),
+ isPreview: true,
+ participants: Array.from({ length: 7 }, (_, index) => makeParticipant(index)),
+};
diff --git a/src/exhibition/exhibition.css b/src/exhibition/exhibition.css
new file mode 100644
index 0000000..a7b7ec0
--- /dev/null
+++ b/src/exhibition/exhibition.css
@@ -0,0 +1,1155 @@
+:root {
+ --ex-ink: #2f2f2f;
+ --ex-mid: #606060;
+ --ex-muted: #909090;
+ --ex-line: #b3b3b3;
+ --ex-soft-line: #ececec;
+ --ex-paper: #ffffff;
+ --ex-wash: #f7f7f7;
+ --ex-serif: Georgia, "Times New Roman", Times, serif;
+ --ex-sans: Arial, Helvetica, sans-serif;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body:has(.ex-shell),
+body:has(.ex-status-page) {
+ margin: 0;
+ min-width: 320px;
+ background: var(--ex-paper);
+ color: var(--ex-ink);
+}
+
+button,
+select,
+a {
+ -webkit-tap-highlight-color: transparent;
+}
+
+.ex-shell {
+ min-height: 100vh;
+ background: var(--ex-paper);
+ color: var(--ex-ink);
+ font-family: var(--ex-sans);
+}
+
+.ex-shell button,
+.ex-shell select {
+ color: inherit;
+ font: inherit;
+}
+
+.ex-shell button:focus-visible,
+.ex-shell a:focus-visible,
+.ex-shell select:focus-visible,
+.ex-shell input:focus-visible {
+ outline: 2px solid var(--ex-ink);
+ outline-offset: 4px;
+}
+
+.ex-header {
+ align-items: center;
+ border-bottom: 1px solid var(--ex-line);
+ display: flex;
+ height: 84px;
+ justify-content: space-between;
+ padding: 0 42px;
+}
+
+.ex-wordmark {
+ align-items: center;
+ color: var(--ex-ink);
+ display: inline-flex;
+ font-size: 17px;
+ gap: 13px;
+ letter-spacing: 0.32em;
+ text-decoration: none;
+ text-transform: uppercase;
+}
+
+.ex-wordmark__star {
+ height: 32px;
+ width: 28px;
+}
+
+.ex-header__nav {
+ align-items: center;
+ display: flex;
+ gap: 34px;
+}
+
+.ex-header__nav a {
+ border-bottom: 1px solid transparent;
+ color: var(--ex-ink);
+ font-size: 12px;
+ letter-spacing: 0.14em;
+ padding: 7px 0;
+ text-decoration: none;
+ text-transform: uppercase;
+}
+
+.ex-header__nav a:hover,
+.ex-header__nav a.is-active {
+ border-color: var(--ex-ink);
+}
+
+.ex-gallery {
+ padding: 30px 42px 42px;
+}
+
+.ex-gallery__intro {
+ align-items: end;
+ border-bottom: 1px solid var(--ex-line);
+ display: grid;
+ gap: 40px;
+ grid-template-columns: minmax(0, 1fr) auto;
+ padding-bottom: 24px;
+}
+
+.ex-gallery__intro h1 {
+ font-family: var(--ex-serif);
+ font-size: clamp(52px, 5.1vw, 78px);
+ font-weight: 400;
+ letter-spacing: -0.045em;
+ line-height: 0.98;
+ margin: 0 0 9px;
+}
+
+.ex-gallery__dek {
+ font-family: var(--ex-serif);
+ font-size: 23px;
+ line-height: 1.3;
+ margin: 0;
+}
+
+.ex-gallery__cohort {
+ align-items: center;
+ display: flex;
+ flex-wrap: wrap;
+ font-size: 12px;
+ gap: 14px;
+ letter-spacing: 0.08em;
+ margin: 17px 0 0;
+}
+
+.ex-preview-flag {
+ border-left: 1px solid var(--ex-line);
+ color: var(--ex-muted);
+ padding-left: 14px;
+ text-transform: uppercase;
+}
+
+.ex-gallery__filters {
+ align-items: flex-end;
+ display: flex;
+ flex-direction: column;
+ gap: 22px;
+ min-width: 430px;
+}
+
+.ex-filter-links {
+ display: flex;
+ gap: 35px;
+}
+
+.ex-filter-links button,
+.ex-mode-links button {
+ background: transparent;
+ border: 0;
+ border-bottom: 1px solid transparent;
+ cursor: pointer;
+ font-size: 11px;
+ letter-spacing: 0.13em;
+ padding: 6px 0;
+ text-transform: uppercase;
+}
+
+.ex-filter-links button:hover,
+.ex-filter-links button.is-active,
+.ex-mode-links button:hover,
+.ex-mode-links button.is-active {
+ border-color: var(--ex-ink);
+}
+
+.ex-passage-filter {
+ align-items: center;
+ display: flex;
+ gap: 16px;
+}
+
+.ex-passage-filter > span {
+ color: var(--ex-muted);
+ font-size: 10px;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+}
+
+.ex-passage-filter select {
+ appearance: none;
+ background: transparent
+ url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath d='m5 7 5 5 5-5' fill='none' stroke='%232f2f2f' stroke-width='1.2'/%3E%3C/svg%3E")
+ no-repeat right 2px center / 15px;
+ border: 0;
+ border-bottom: 1px solid var(--ex-ink);
+ border-radius: 0;
+ cursor: pointer;
+ font-family: var(--ex-serif);
+ font-size: 14px;
+ max-width: 285px;
+ padding: 5px 25px 5px 0;
+}
+
+.ex-room-reading {
+ border-bottom: 1px solid var(--ex-line);
+ display: grid;
+ gap: 36px;
+ grid-template-columns: minmax(180px, 0.7fr) minmax(0, 2.3fr);
+ padding: 25px 0 27px;
+}
+
+.ex-room-reading__heading h2 {
+ font-family: var(--ex-serif);
+ font-size: 22px;
+ font-weight: 400;
+ margin: 0 0 8px;
+}
+
+.ex-room-reading__heading p {
+ color: var(--ex-muted);
+ font-family: var(--ex-serif);
+ font-size: 11px;
+ line-height: 1.4;
+ margin: 0;
+ max-width: 245px;
+}
+
+.ex-room-reading dl {
+ display: grid;
+ gap: 20px;
+ grid-template-columns: repeat(6, minmax(0, 1fr));
+ margin: 0;
+}
+
+.ex-room-reading dl > div {
+ border-left: 1px solid var(--ex-soft-line);
+ padding-left: 14px;
+}
+
+.ex-room-reading dt {
+ color: var(--ex-muted);
+ font-size: 9px;
+ letter-spacing: 0.09em;
+ line-height: 1.35;
+ min-height: 27px;
+ text-transform: uppercase;
+}
+
+.ex-room-reading dd {
+ font-family: var(--ex-serif);
+ font-size: 25px;
+ margin: 7px 0 0;
+}
+
+.ex-gallery__grid {
+ display: grid;
+ gap: 28px 34px;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ padding-top: 25px;
+}
+
+.ex-gallery__more {
+ align-items: center;
+ border-top: 1px solid var(--ex-line);
+ display: flex;
+ justify-content: space-between;
+ margin-top: 34px;
+ padding: 20px 0 2px;
+}
+
+.ex-gallery__more p {
+ color: var(--ex-muted);
+ font-size: 10px;
+ letter-spacing: 0.1em;
+ margin: 0;
+ text-transform: uppercase;
+}
+
+.ex-gallery__more button {
+ background: transparent;
+ border: 0;
+ border-bottom: 1px solid var(--ex-ink);
+ cursor: pointer;
+ font-size: 10px;
+ letter-spacing: 0.13em;
+ padding: 6px 0;
+ text-transform: uppercase;
+}
+
+.ex-preview {
+ min-width: 0;
+}
+
+.ex-preview--featured {
+ grid-column: span 2;
+}
+
+.ex-preview__index {
+ font-size: 11px;
+ letter-spacing: 0.12em;
+ margin: 0 0 8px;
+ text-transform: uppercase;
+}
+
+.ex-preview__frame {
+ align-items: flex-start;
+ background: var(--ex-paper);
+ border: 1px solid var(--ex-line);
+ border-radius: 0;
+ cursor: pointer;
+ display: flex;
+ height: 234px;
+ overflow: hidden;
+ padding: 23px;
+ position: relative;
+ text-align: left;
+ transition: border-color 180ms ease;
+ width: 100%;
+}
+
+.ex-preview--featured .ex-preview__frame {
+ height: 288px;
+ padding: 29px;
+}
+
+.ex-preview__frame:hover {
+ border-color: var(--ex-ink);
+}
+
+.ex-preview__open {
+ background: var(--ex-paper);
+ bottom: 18px;
+ font-size: 10px;
+ letter-spacing: 0.12em;
+ opacity: 0;
+ padding: 5px 0 5px 10px;
+ position: absolute;
+ right: 20px;
+ text-transform: uppercase;
+ transform: translateX(-5px);
+ transition: opacity 180ms ease, transform 180ms ease;
+}
+
+.ex-preview__frame:hover .ex-preview__open,
+.ex-preview__frame:focus-visible .ex-preview__open {
+ opacity: 1;
+ transform: translateX(0);
+}
+
+.ex-preview__meta {
+ font-family: var(--ex-serif);
+ font-size: 12px;
+ line-height: 1.35;
+ padding-top: 8px;
+}
+
+.ex-preview__meta p {
+ margin: 0 0 4px;
+}
+
+.ex-preview__meta p:last-child {
+ color: var(--ex-mid);
+ font-family: var(--ex-sans);
+ font-size: 10px;
+ letter-spacing: 0.04em;
+}
+
+.ex-blackout {
+ align-content: flex-start;
+ display: block;
+ font-family: var(--ex-serif);
+ font-size: 19px;
+ line-height: 1.78;
+ max-width: 100%;
+ text-align: left;
+}
+
+.ex-blackout--compact {
+ font-size: 14px;
+ line-height: 1.74;
+}
+
+.ex-preview--featured .ex-blackout {
+ font-size: 18px;
+ line-height: 1.82;
+}
+
+.ex-blackout__word {
+ box-decoration-break: clone;
+ -webkit-box-decoration-break: clone;
+ display: inline;
+ transition: background-color 180ms ease, color 180ms ease, outline-color 180ms ease;
+}
+
+.ex-blackout__word.is-covered {
+ background: var(--ex-ink);
+ box-shadow: 0.04em 0 0 var(--ex-ink), -0.04em 0 0 var(--ex-ink);
+ color: transparent;
+}
+
+.ex-blackout__word.is-visible {
+ background: var(--ex-paper);
+ color: var(--ex-ink);
+}
+
+.ex-blackout__word.is-active {
+ outline: 1px solid var(--ex-ink);
+ outline-offset: 3px;
+}
+
+.ex-gallery__empty {
+ align-items: center;
+ display: flex;
+ flex-direction: column;
+ min-height: 490px;
+ justify-content: center;
+ text-align: center;
+}
+
+.ex-gallery__empty svg {
+ height: 48px;
+ margin-bottom: 25px;
+ width: 42px;
+}
+
+.ex-gallery__empty h2 {
+ font-family: var(--ex-serif);
+ font-size: 31px;
+ font-weight: 400;
+ margin: 0 0 22px;
+}
+
+.ex-gallery__empty button {
+ background: transparent;
+ border: 1px solid var(--ex-ink);
+ border-radius: 0;
+ cursor: pointer;
+ font-size: 10px;
+ letter-spacing: 0.14em;
+ padding: 13px 22px;
+ text-transform: uppercase;
+}
+
+.ex-footer {
+ align-items: center;
+ border-top: 1px solid var(--ex-line);
+ display: flex;
+ gap: 18px;
+ min-height: 70px;
+ padding: 16px 42px;
+}
+
+.ex-footer p {
+ font-family: var(--ex-serif);
+ font-size: 12px;
+ margin: 0;
+}
+
+.ex-footer p span {
+ font-family: var(--ex-sans);
+ font-size: 10px;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+}
+
+.ex-footer__star {
+ height: 28px;
+ width: 24px;
+}
+
+.ex-detail {
+ min-height: calc(100vh - 154px);
+}
+
+.ex-detail__poem {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 300px;
+ min-height: 590px;
+}
+
+.ex-detail__poem.has-chat {
+ grid-template-columns: minmax(0, 1fr) 318px 290px;
+}
+
+.ex-poem-panel {
+ min-width: 0;
+ overflow: auto;
+ padding: 34px 44px 44px;
+}
+
+.ex-poem-nav {
+ align-items: center;
+ display: flex;
+ gap: 18px;
+ margin-bottom: 22px;
+}
+
+.ex-poem-nav button,
+.ex-step {
+ background: transparent;
+ border: 0;
+ cursor: pointer;
+ font-family: var(--ex-serif);
+ font-size: 24px;
+ line-height: 1;
+ padding: 3px;
+}
+
+.ex-poem-nav button:disabled,
+.ex-step:disabled {
+ color: var(--ex-line);
+ cursor: default;
+}
+
+.ex-poem-nav p {
+ font-size: 11px;
+ letter-spacing: 0.13em;
+ margin: 0;
+ text-transform: uppercase;
+}
+
+.ex-poem-panel h1 {
+ font-family: var(--ex-serif);
+ font-size: clamp(36px, 3.6vw, 58px);
+ font-weight: 400;
+ letter-spacing: -0.035em;
+ line-height: 0.98;
+ margin: 0 0 12px;
+ max-width: 940px;
+ text-transform: uppercase;
+}
+
+.ex-poem-panel__source {
+ font-family: var(--ex-serif);
+ font-size: 18px;
+ margin: 0 0 14px;
+}
+
+.ex-poem-panel__condition,
+.ex-label {
+ font-size: 10px;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+}
+
+.ex-poem-panel__condition {
+ margin: 0 0 29px;
+}
+
+.ex-poem-panel > .ex-blackout {
+ font-size: clamp(17px, 1.5vw, 22px);
+ line-height: 1.72;
+ max-width: 1000px;
+}
+
+.ex-account {
+ border-left: 1px solid var(--ex-line);
+ padding: 45px 34px 34px;
+}
+
+.ex-account h2,
+.ex-chat h2,
+.ex-timeline h2 {
+ font-family: var(--ex-serif);
+ font-size: 27px;
+ font-weight: 400;
+ line-height: 1.15;
+ margin: 0;
+}
+
+.ex-account__meaning {
+ border-bottom: 1px solid var(--ex-line);
+ font-family: var(--ex-serif);
+ font-size: 15px;
+ line-height: 1.48;
+ margin-top: 28px;
+ padding-bottom: 26px;
+}
+
+.ex-account__meaning p {
+ margin: 0 0 10px;
+}
+
+.ex-account dl {
+ margin: 0;
+}
+
+.ex-account dl > div {
+ align-items: baseline;
+ border-bottom: 1px solid var(--ex-line);
+ display: flex;
+ gap: 12px;
+ justify-content: space-between;
+ padding: 21px 0;
+}
+
+.ex-account dt {
+ font-size: 9px;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+}
+
+.ex-account dd {
+ flex: 0 0 auto;
+ font-family: var(--ex-serif);
+ font-size: 15px;
+ margin: 0;
+ text-align: right;
+}
+
+.ex-account__ai {
+ font-family: var(--ex-serif);
+ font-size: 14px;
+ line-height: 1.45;
+ padding-top: 24px;
+}
+
+.ex-account__ai p {
+ margin: 0 0 8px;
+}
+
+.ex-chat {
+ border-left: 1px solid var(--ex-line);
+ display: flex;
+ flex-direction: column;
+ max-height: 590px;
+ min-width: 0;
+ padding: 44px 0 0;
+}
+
+.ex-chat__heading {
+ align-items: baseline;
+ display: flex;
+ justify-content: space-between;
+ padding: 0 25px 22px;
+}
+
+.ex-chat__heading h2 {
+ font-size: 24px;
+}
+
+.ex-chat__heading > p {
+ color: var(--ex-muted);
+ font-size: 9px;
+ letter-spacing: 0.05em;
+ margin: 0;
+}
+
+.ex-chat__scroll {
+ border-top: 1px solid var(--ex-soft-line);
+ flex: 1;
+ min-height: 0;
+ overflow-y: auto;
+ padding: 0 25px 28px;
+}
+
+.ex-chat__stage {
+ border-bottom: 1px solid var(--ex-soft-line);
+ color: var(--ex-muted);
+ font-size: 9px;
+ letter-spacing: 0.14em;
+ margin: 20px 0 0;
+ padding-bottom: 8px;
+ text-transform: uppercase;
+}
+
+.ex-chat__message {
+ border-bottom: 1px solid var(--ex-soft-line);
+ font-family: var(--ex-serif);
+ font-size: 13px;
+ line-height: 1.45;
+ padding: 17px 0;
+}
+
+.ex-chat__message.is-participant {
+ padding-left: 24px;
+}
+
+.ex-chat__message p {
+ margin: 0;
+ white-space: pre-wrap;
+}
+
+.ex-chat__role {
+ color: var(--ex-muted);
+ font-family: var(--ex-sans);
+ font-size: 8px;
+ letter-spacing: 0.12em;
+ margin-bottom: 6px !important;
+ text-transform: uppercase;
+}
+
+.ex-chat__waiting {
+ color: var(--ex-muted);
+ font-family: var(--ex-serif);
+ font-size: 14px;
+ font-style: italic;
+ line-height: 1.5;
+ margin: 30px 0;
+}
+
+.ex-timeline {
+ border-top: 1px solid var(--ex-line);
+ min-height: 240px;
+ padding: 26px 42px 30px;
+}
+
+.ex-timeline__topline {
+ align-items: start;
+ display: grid;
+ gap: 28px;
+ grid-template-columns: minmax(270px, 1fr) auto auto;
+}
+
+.ex-timeline h2 {
+ font-size: 25px;
+ text-transform: uppercase;
+}
+
+.ex-timeline__event {
+ font-family: var(--ex-serif);
+ font-size: 14px;
+ margin: 7px 0 0;
+}
+
+.ex-timeline__legend {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 19px;
+ padding-top: 6px;
+}
+
+.ex-timeline__legend span {
+ font-size: 9px;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.ex-timeline__legend b {
+ font-family: var(--ex-serif);
+ font-size: 14px;
+ font-weight: 400;
+ margin-right: 4px;
+}
+
+.ex-mode-links {
+ align-items: center;
+ display: flex;
+ font-size: 10px;
+ gap: 7px;
+}
+
+.ex-timeline__controls {
+ align-items: center;
+ display: grid;
+ gap: 14px;
+ grid-template-columns: 50px 26px 26px 102px minmax(0, 1fr);
+ margin-top: 28px;
+}
+
+.ex-play {
+ align-items: center;
+ background: var(--ex-ink);
+ border: 1px solid var(--ex-ink);
+ border-radius: 0;
+ color: var(--ex-paper) !important;
+ cursor: pointer;
+ display: flex;
+ font-size: 18px !important;
+ height: 48px;
+ justify-content: center;
+ padding: 0 0 0 2px;
+ width: 48px;
+}
+
+.ex-play:disabled {
+ opacity: 0.35;
+}
+
+.ex-timeline__time {
+ font-family: var(--ex-serif);
+ font-size: 15px;
+ margin: 0;
+}
+
+.ex-timeline__time span {
+ color: var(--ex-muted);
+}
+
+.ex-scrubber-wrap {
+ min-width: 0;
+ padding-top: 20px;
+ position: relative;
+}
+
+.ex-scrubber-wrap input[type="range"] {
+ appearance: none;
+ background: linear-gradient(var(--ex-ink), var(--ex-ink)) center / 100% 1px no-repeat;
+ cursor: pointer;
+ height: 20px;
+ margin: 0;
+ width: 100%;
+}
+
+.ex-scrubber-wrap input[type="range"]::-webkit-slider-thumb {
+ appearance: none;
+ background: var(--ex-paper);
+ border: 1px solid var(--ex-ink);
+ border-radius: 0;
+ height: 15px;
+ width: 9px;
+}
+
+.ex-scrubber-wrap input[type="range"]::-moz-range-thumb {
+ background: var(--ex-paper);
+ border: 1px solid var(--ex-ink);
+ border-radius: 0;
+ height: 15px;
+ width: 9px;
+}
+
+.ex-ticks {
+ height: 26px;
+ left: 0;
+ position: absolute;
+ right: 0;
+ top: 0;
+}
+
+.ex-tick {
+ color: var(--ex-ink);
+ font-family: var(--ex-serif);
+ font-size: 13px;
+ position: absolute;
+ text-align: center;
+ transform: translateX(-50%);
+ width: 15px;
+}
+
+.ex-tick--assistant-message,
+.ex-tick--user-message,
+.ex-tick--chat-open {
+ color: var(--ex-muted);
+ font-family: var(--ex-sans);
+ font-size: 9px;
+ text-transform: uppercase;
+}
+
+.ex-tick.is-current {
+ font-weight: 700;
+ transform: translateX(-50%) scale(1.25);
+}
+
+.ex-scrubber-scale {
+ color: var(--ex-muted);
+ display: flex;
+ font-family: var(--ex-serif);
+ font-size: 10px;
+ justify-content: space-between;
+ margin-top: 3px;
+}
+
+.ex-footer--detail {
+ justify-content: flex-start;
+}
+
+.ex-status-page {
+ align-items: center;
+ background: var(--ex-paper);
+ color: var(--ex-ink);
+ display: flex;
+ flex-direction: column;
+ font-family: var(--ex-serif);
+ justify-content: center;
+ min-height: 100vh;
+ padding: 40px;
+ text-align: center;
+}
+
+.ex-status-page h1 {
+ font-size: 42px;
+ font-weight: 400;
+ margin: 0 0 16px;
+}
+
+.ex-status-page p {
+ margin: 0 0 16px;
+}
+
+.ex-status-page__mark {
+ font-family: var(--ex-sans) !important;
+ font-size: 50px;
+}
+
+.ex-status-page__mark--turning {
+ animation: ex-turn 2.4s linear infinite;
+}
+
+@keyframes ex-turn {
+ to { transform: rotate(360deg); }
+}
+
+@media (min-width: 1151px) {
+ .ex-detail__poem {
+ grid-template-rows: minmax(0, 1fr);
+ height: 620px;
+ min-height: 620px;
+ }
+
+ .ex-poem-panel,
+ .ex-chat,
+ .ex-account {
+ min-height: 0;
+ }
+
+ .ex-account {
+ overflow-y: auto;
+ }
+
+ .ex-chat {
+ max-height: none;
+ }
+}
+
+@media (max-width: 1150px) {
+ .ex-room-reading dl {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ }
+
+ .ex-detail__poem.has-chat {
+ grid-template-columns: minmax(0, 1fr) 300px;
+ }
+
+ .ex-detail__poem.has-chat .ex-account {
+ border-top: 1px solid var(--ex-line);
+ grid-column: 1 / -1;
+ }
+
+ .ex-detail__poem.has-chat .ex-account dl {
+ display: grid;
+ grid-template-columns: repeat(5, minmax(0, 1fr));
+ }
+
+ .ex-detail__poem.has-chat .ex-account dl > div {
+ border-right: 1px solid var(--ex-soft-line);
+ display: block;
+ padding-right: 18px;
+ }
+
+ .ex-detail__poem.has-chat .ex-account dd {
+ margin-top: 8px;
+ text-align: left;
+ }
+}
+
+@media (max-width: 900px) {
+ .ex-header {
+ height: 74px;
+ padding: 0 24px;
+ }
+
+ .ex-wordmark {
+ font-size: 13px;
+ letter-spacing: 0.22em;
+ }
+
+ .ex-header__nav a:not(:first-child) {
+ display: none;
+ }
+
+ .ex-gallery {
+ padding: 27px 24px 34px;
+ }
+
+ .ex-gallery__intro {
+ align-items: start;
+ grid-template-columns: 1fr;
+ }
+
+ .ex-gallery__filters {
+ align-items: flex-start;
+ min-width: 0;
+ width: 100%;
+ }
+
+ .ex-room-reading {
+ grid-template-columns: 1fr;
+ }
+
+ .ex-room-reading__heading p {
+ max-width: none;
+ }
+
+ .ex-gallery__grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .ex-preview--featured {
+ grid-column: 1 / -1;
+ }
+
+ .ex-detail__poem,
+ .ex-detail__poem.has-chat {
+ grid-template-columns: 1fr;
+ }
+
+ .ex-chat,
+ .ex-account {
+ border-left: 0;
+ border-top: 1px solid var(--ex-line);
+ max-height: none;
+ }
+
+ .ex-detail__poem.has-chat .ex-account {
+ grid-column: auto;
+ }
+
+ .ex-detail__poem.has-chat .ex-account dl {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .ex-timeline__topline {
+ grid-template-columns: 1fr auto;
+ }
+
+ .ex-timeline__legend {
+ grid-column: 1 / -1;
+ grid-row: 2;
+ }
+
+ .ex-timeline__controls {
+ grid-template-columns: 48px 24px 24px 1fr;
+ }
+
+ .ex-scrubber-wrap {
+ grid-column: 1 / -1;
+ }
+}
+
+@media (max-width: 620px) {
+ .ex-header__nav {
+ gap: 0;
+ }
+
+ .ex-header__nav a {
+ font-size: 9px;
+ }
+
+ .ex-wordmark span {
+ max-width: 170px;
+ }
+
+ .ex-gallery__intro h1 {
+ font-size: 49px;
+ }
+
+ .ex-gallery__dek {
+ font-size: 19px;
+ }
+
+ .ex-filter-links {
+ gap: 21px;
+ }
+
+ .ex-passage-filter {
+ align-items: flex-start;
+ flex-direction: column;
+ gap: 6px;
+ width: 100%;
+ }
+
+ .ex-passage-filter select {
+ max-width: none;
+ width: 100%;
+ }
+
+ .ex-room-reading dl {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .ex-room-reading dd {
+ font-size: 22px;
+ }
+
+ .ex-gallery__grid {
+ display: block;
+ }
+
+ .ex-gallery__more {
+ align-items: flex-start;
+ gap: 16px;
+ }
+
+ .ex-preview {
+ margin-bottom: 31px;
+ }
+
+ .ex-preview__frame,
+ .ex-preview--featured .ex-preview__frame {
+ height: 250px;
+ padding: 22px;
+ }
+
+ .ex-poem-panel {
+ padding: 28px 23px 38px;
+ }
+
+ .ex-poem-panel h1 {
+ font-size: 38px;
+ }
+
+ .ex-account,
+ .ex-chat {
+ padding-top: 32px;
+ }
+
+ .ex-account {
+ padding-left: 23px;
+ padding-right: 23px;
+ }
+
+ .ex-detail__poem.has-chat .ex-account dl {
+ display: block;
+ }
+
+ .ex-timeline {
+ padding: 25px 23px 30px;
+ }
+
+ .ex-timeline__topline {
+ display: block;
+ }
+
+ .ex-mode-links,
+ .ex-timeline__legend {
+ margin-top: 18px;
+ }
+
+ .ex-footer {
+ padding-left: 24px;
+ padding-right: 24px;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .ex-blackout__word,
+ .ex-preview__frame,
+ .ex-preview__open {
+ transition: none;
+ }
+
+ .ex-status-page__mark--turning {
+ animation: none;
+ }
+}
diff --git a/src/exhibition/types.ts b/src/exhibition/types.ts
new file mode 100644
index 0000000..84d0cdf
--- /dev/null
+++ b/src/exhibition/types.ts
@@ -0,0 +1,79 @@
+import type {
+ ArtistCondition,
+ Message,
+ Passage,
+ PoemSnapshot,
+ Stage,
+ TaskTiming,
+} from "../types";
+
+export type ExhibitionOutcomes = Record;
+
+export interface ExhibitionPoem {
+ passageId: string;
+ passage: Passage;
+ text: number[];
+ finalPoem: string;
+ editHistory: PoemSnapshot[];
+ sparkConversation: Message[];
+ writeConversation: Message[];
+ taskTiming: TaskTiming;
+ llmUsage: {
+ chatOpenings?: Array<{
+ stage: Stage;
+ timestamp: Date | string;
+ }>;
+ chatAvailability?: Array<{
+ stage: Stage;
+ availableAt: Date | string;
+ }>;
+ requests: Array<{
+ stage: Stage;
+ status: "STARTED" | "COMPLETED" | "FAILED";
+ requestedAt?: Date | string;
+ completedAt?: Date | string;
+ failedAt?: Date | string;
+ }>;
+ };
+ derivedMetrics: Record;
+}
+
+export interface ExhibitionParticipant {
+ id: string;
+ condition: ArtistCondition;
+ assignment?: {
+ passageId?: string;
+ strategy?: string;
+ } | null;
+ completedAt?: string | null;
+ poem: ExhibitionPoem;
+ outcomes: ExhibitionOutcomes;
+}
+
+export interface ExhibitionDataset {
+ studyId: string;
+ generatedAt: string;
+ participants: ExhibitionParticipant[];
+ isPreview?: boolean;
+}
+
+export type TimelineEventKind =
+ | "phase"
+ | "add"
+ | "remove"
+ | "undo"
+ | "redo"
+ | "chat-open"
+ | "user-message"
+ | "assistant-message";
+
+export interface TimelineEvent {
+ id: string;
+ atMs: number;
+ timestamp: string;
+ kind: TimelineEventKind;
+ stage: "SPARK" | "WRITE";
+ label: string;
+ wordIndex?: number;
+ message?: Message;
+}
diff --git a/src/exhibition/utils.ts b/src/exhibition/utils.ts
new file mode 100644
index 0000000..d759def
--- /dev/null
+++ b/src/exhibition/utils.ts
@@ -0,0 +1,201 @@
+import type { Message, PoemSnapshot } from "../types";
+import type {
+ ExhibitionParticipant,
+ TimelineEvent,
+ TimelineEventKind,
+} from "./types";
+
+const toMs = (value: unknown): number | null => {
+ if (value instanceof Date) return value.getTime();
+ if (typeof value === "string" || typeof value === "number") {
+ const parsed = new Date(value).getTime();
+ return Number.isFinite(parsed) ? parsed : null;
+ }
+ if (value && typeof value === "object") {
+ const candidate = value as { seconds?: number; _seconds?: number };
+ const seconds = candidate.seconds ?? candidate._seconds;
+ if (typeof seconds === "number") return seconds * 1000;
+ }
+ return null;
+};
+
+export const formatDuration = (durationMs: number | null | undefined) => {
+ if (durationMs === null || durationMs === undefined || durationMs < 0) return "—";
+ const totalSeconds = Math.round(durationMs / 1000);
+ const minutes = Math.floor(totalSeconds / 60);
+ const seconds = totalSeconds % 60;
+ return `${minutes}:${String(seconds).padStart(2, "0")}`;
+};
+
+export const getPoemTitle = (participant: ExhibitionParticipant) => {
+ const selected = participant.poem.finalPoem.trim().split(/\s+/).filter(Boolean);
+ if (selected.length === 0) return "Untitled blackout";
+ return selected.slice(0, 7).join(" ").replace(/[.,;:!?]+$/, "");
+};
+
+const snapshotKind = (snapshot: PoemSnapshot): TimelineEventKind => {
+ if (snapshot.source === "UNDO") return "undo";
+ if (snapshot.source === "REDO") return "redo";
+ return snapshot.action === "ADD" ? "add" : "remove";
+};
+
+const messageLabel = (message: Message) =>
+ message.role === "user" ? "Participant asked the assistant" : "Assistant replied";
+
+interface PendingEvent {
+ id: string;
+ timestampMs: number;
+ kind: TimelineEventKind;
+ stage: "SPARK" | "WRITE";
+ label: string;
+ wordIndex?: number;
+ message?: Message;
+}
+
+export const buildTimeline = (
+ participant: ExhibitionParticipant,
+): TimelineEvent[] => {
+ const { poem } = participant;
+ const writeStart = toMs(poem.taskTiming?.phases?.write?.startedAt);
+ const sparkStart = toMs(poem.taskTiming?.phases?.spark?.startedAt);
+ const taskStart = toMs(poem.taskTiming?.startedAt);
+ const pending: PendingEvent[] = [];
+
+ if (sparkStart) {
+ pending.push({
+ id: "phase-spark",
+ timestampMs: sparkStart,
+ kind: "phase",
+ stage: "SPARK",
+ label: "Brainstorm began",
+ });
+ }
+ if (writeStart) {
+ pending.push({
+ id: "phase-write",
+ timestampMs: writeStart,
+ kind: "phase",
+ stage: "WRITE",
+ label: "Writing began",
+ });
+ }
+
+ poem.editHistory.forEach((snapshot, index) => {
+ const timestampMs = toMs(snapshot.timestamp) ?? (writeStart ?? taskStart ?? 0) + index;
+ const word = poem.passage.text.split(" ")[snapshot.index] ?? `word ${snapshot.index + 1}`;
+ const kind = snapshotKind(snapshot);
+ pending.push({
+ id: `edit-${index}`,
+ timestampMs,
+ kind,
+ stage: "WRITE",
+ label:
+ kind === "undo"
+ ? `Undid change to “${word}”`
+ : kind === "redo"
+ ? `Redid change to “${word}”`
+ : `${kind === "add" ? "Added" : "Removed"} “${word}”`,
+ wordIndex: snapshot.index,
+ });
+ });
+
+ const addMessages = (
+ messages: Message[],
+ stage: "SPARK" | "WRITE",
+ prefix: string,
+ ) => {
+ const seenMessages = new Set(
+ pending
+ .filter((event) => event.message)
+ .map((event) =>
+ event.message?.id ||
+ `${event.message?.role}|${toMs(event.message?.timestamp)}|${event.message?.content}`,
+ ),
+ );
+ messages.forEach((message, index) => {
+ const messageStage =
+ "stage" in message && message.stage === "SPARK"
+ ? "SPARK"
+ : "stage" in message && message.stage === "WRITE"
+ ? "WRITE"
+ : stage;
+ const messageKey =
+ message.id || `${message.role}|${toMs(message.timestamp)}|${message.content}`;
+ if (seenMessages.has(messageKey)) return;
+ seenMessages.add(messageKey);
+ const fallbackStart = messageStage === "SPARK" ? sparkStart : writeStart;
+ pending.push({
+ id: `${prefix}-${message.id || index}`,
+ timestampMs: toMs(message.timestamp) ?? (fallbackStart ?? taskStart ?? 0) + index,
+ kind: message.role === "user" ? "user-message" : "assistant-message",
+ stage: messageStage,
+ label: messageLabel(message),
+ message,
+ });
+ });
+ };
+
+ addMessages(poem.sparkConversation ?? [], "SPARK", "spark-message");
+ addMessages(poem.writeConversation ?? [], "WRITE", "write-message");
+
+ (poem.llmUsage?.chatOpenings ?? []).forEach((opening, index) => {
+ pending.push({
+ id: `chat-open-${index}`,
+ timestampMs:
+ toMs(opening.timestamp) ??
+ (opening.stage === "SPARK" ? sparkStart : writeStart) ??
+ taskStart ??
+ index,
+ kind: "chat-open",
+ stage: opening.stage === "SPARK" ? "SPARK" : "WRITE",
+ label: `Opened the assistant during ${opening.stage === "SPARK" ? "brainstorming" : "writing"}`,
+ });
+ });
+
+ (poem.llmUsage?.chatAvailability ?? []).forEach((availability, index) => {
+ pending.push({
+ id: `chat-available-${index}`,
+ timestampMs:
+ toMs(availability.availableAt) ??
+ (availability.stage === "SPARK" ? sparkStart : writeStart) ??
+ taskStart ??
+ index,
+ kind: "chat-open",
+ stage: availability.stage === "SPARK" ? "SPARK" : "WRITE",
+ label: `Assistant became available during ${availability.stage === "SPARK" ? "brainstorming" : "writing"}`,
+ });
+ });
+
+ pending.sort((a, b) => a.timestampMs - b.timestampMs);
+ const origin = taskStart ?? pending[0]?.timestampMs ?? 0;
+
+ return pending.map((event) => ({
+ ...event,
+ atMs: Math.max(0, event.timestampMs - origin),
+ timestamp: new Date(event.timestampMs).toISOString(),
+ }));
+};
+
+export const replaySelections = (
+ participant: ExhibitionParticipant,
+ events: TimelineEvent[],
+ eventIndex: number,
+) => {
+ const selected = new Set();
+ events.slice(0, eventIndex + 1).forEach((event) => {
+ if (event.wordIndex === undefined) return;
+ if (event.kind === "add" || event.kind === "redo") {
+ selected.add(event.wordIndex);
+ } else if (event.kind === "remove" || event.kind === "undo") {
+ selected.delete(event.wordIndex);
+ }
+ });
+ if (events.length === 0) return participant.poem.text;
+ return [...selected];
+};
+
+export const average = (values: unknown[]) => {
+ const numeric = values.filter((value): value is number => typeof value === "number");
+ if (numeric.length === 0) return null;
+ return numeric.reduce((sum, value) => sum + value, 0) / numeric.length;
+};
diff --git a/src/main.tsx b/src/main.tsx
index 7e88a8a..fc73a80 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -1,12 +1,16 @@
-import { StrictMode } from 'react'
+import { lazy, StrictMode, Suspense } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
-import App from './App.tsx'
-createRoot(document.getElementById('root')!).render(
-
-
-
- ,
+const isExhibition = window.location.pathname.startsWith('/exhibition')
+const Application = lazy(() =>
+ isExhibition ? import('./exhibition/ExhibitionApp.tsx') : import('./App.tsx'),
+)
+createRoot(document.getElementById('root')!).render(
+
+
+
+
+ ,
)
From de8d3d7ee6c39f4f7c13868d03197c48392e0e1e Mon Sep 17 00:00:00 2001
From: Harsh Kumar
Date: Fri, 28 Aug 2026 09:03:47 -0400
Subject: [PATCH 2/3] feat: serve de-identified exhibition data
---
server/api/routes/firebaseAPI.ts | 222 +++++++++++++++++++++++++++++++
src/exhibition/ExhibitionApp.tsx | 15 +--
2 files changed, 226 insertions(+), 11 deletions(-)
diff --git a/server/api/routes/firebaseAPI.ts b/server/api/routes/firebaseAPI.ts
index 913bb0c..55d17d0 100644
--- a/server/api/routes/firebaseAPI.ts
+++ b/server/api/routes/firebaseAPI.ts
@@ -1,5 +1,6 @@
import express from "express";
import { db, FieldValue } from "../firebase/firebase";
+import type { DocumentReference } from "firebase-admin/firestore";
const router = express.Router();
@@ -8,6 +9,227 @@ const ARTIST_SURVEY_COLLECTION = "artistSurvey";
const POEM_COLLECTION = "poem";
const INCOMPLETE_SESSION_COLLECTION = "artistIncompleteSession";
const ASSIGNMENT_COLLECTION = "artistAssignment";
+const DEFAULT_EXHIBITION_STUDY_ID = "6a8cbdb524cc2e2b32049b00";
+
+const PUBLIC_OUTCOME_KEYS = new Set([
+ "felt_emotion",
+ "final_intended_meaning",
+ "intended_emotion",
+ "expressive_realization",
+ "csi_able_to_be_creative",
+ "csi_tools_allowed_expression",
+ "ownership_own_work",
+ "ownership_responsibility",
+ "ownership_personal_connection",
+ "ownership_emotional_connection",
+ "creative_control",
+ "creative_intentionality",
+ "mental_effort",
+ "llm_contribution_attribution",
+ "would_repeat_activity",
+ "ai_attitude",
+]);
+
+const asRecord = (value: unknown): Record =>
+ value && typeof value === "object" ? (value as Record) : {};
+
+const toPublicJson = (value: unknown): unknown => {
+ if (value === null || value === undefined) return value;
+ if (Array.isArray(value)) return value.map(toPublicJson);
+ if (value instanceof Date) return value.toISOString();
+ if (typeof value !== "object") return value;
+
+ const candidate = value as {
+ toDate?: () => Date;
+ path?: string;
+ };
+ if (typeof candidate.toDate === "function") {
+ return candidate.toDate().toISOString();
+ }
+ if (candidate.path) return undefined;
+
+ return Object.fromEntries(
+ Object.entries(value as Record)
+ .map(([key, item]) => [key, toPublicJson(item)] as const)
+ .filter(([, item]) => item !== undefined),
+ );
+};
+
+const filterPublicOutcomes = (answers: Record = {}) =>
+ Object.fromEntries(
+ Object.entries(answers).filter(([key]) => PUBLIC_OUTCOME_KEYS.has(key)),
+ );
+
+const isDocumentReference = (value: unknown): value is DocumentReference =>
+ Boolean(
+ value &&
+ typeof value === "object" &&
+ "get" in value &&
+ typeof (value as { get?: unknown }).get === "function",
+ );
+
+const publicConversation = (
+ value: unknown,
+ defaultStage: "SPARK" | "WRITE",
+) => {
+ if (!Array.isArray(value)) return [];
+ return value
+ .map((item, index) => {
+ const message = asRecord(item);
+ if (message.role !== "user" && message.role !== "assistant") return null;
+ if (typeof message.content !== "string") return null;
+ return {
+ id: `${defaultStage.toLowerCase()}-${index + 1}`,
+ role: message.role,
+ content: message.content,
+ timestamp: message.timestamp ?? null,
+ stage:
+ message.stage === "SPARK" || message.stage === "WRITE"
+ ? message.stage
+ : defaultStage,
+ kind: typeof message.kind === "string" ? message.kind : null,
+ };
+ })
+ .filter((message) => message !== null);
+};
+
+const publicLlmUsage = (value: unknown) => {
+ const usage = asRecord(value);
+ const chatOpenings = Array.isArray(usage.chatOpenings)
+ ? usage.chatOpenings.map((item) => {
+ const opening = asRecord(item);
+ return { stage: opening.stage, timestamp: opening.timestamp };
+ })
+ : [];
+ const chatAvailability = Array.isArray(usage.chatAvailability)
+ ? usage.chatAvailability.map((item) => {
+ const availability = asRecord(item);
+ return {
+ stage: availability.stage,
+ availableAt: availability.availableAt,
+ };
+ })
+ : [];
+ const requests = Array.isArray(usage.requests)
+ ? usage.requests.map((item) => {
+ const request = asRecord(item);
+ return {
+ stage: request.stage,
+ status: request.status,
+ requestedAt: request.requestedAt,
+ completedAt: request.completedAt,
+ failedAt: request.failedAt,
+ };
+ })
+ : [];
+ return { chatOpenings, chatAvailability, requests };
+};
+
+const publicTimestampMs = (value: unknown) => {
+ if (value instanceof Date) return value.getTime();
+ const candidate = asRecord(value) as { toDate?: () => Date };
+ if (typeof candidate.toDate === "function") return candidate.toDate().getTime();
+ const parsed = new Date(String(value ?? "")).getTime();
+ return Number.isFinite(parsed) ? parsed : 0;
+};
+
+router.get("/exhibition", async (req, res) => {
+ try {
+ const studyId = String(req.query.studyId ?? "");
+ const allowedStudyIds = new Set(
+ (process.env.EXHIBITION_STUDY_IDS ?? DEFAULT_EXHIBITION_STUDY_ID)
+ .split(",")
+ .map((id) => id.trim())
+ .filter(Boolean),
+ );
+
+ if (!studyId || !allowedStudyIds.has(studyId)) {
+ return res.status(404).json({ error: "Exhibition not found" });
+ }
+
+ const artistSnapshot = await db
+ .collection(ARTIST_COLLECTION)
+ .where("prolific.studyId", "==", studyId)
+ .get();
+
+ const hydrated = await Promise.all(
+ artistSnapshot.docs.map(async (artistDoc) => {
+ const artist = artistDoc.data();
+ const poemRef = artist.poem;
+ const surveyRef = artist.surveyResponse;
+
+ if (!isDocumentReference(poemRef) || !isDocumentReference(surveyRef)) {
+ return null;
+ }
+
+ const [poemSnapshot, surveySnapshot] = await Promise.all([
+ poemRef.get(),
+ surveyRef.get(),
+ ]);
+ if (!poemSnapshot.exists || !surveySnapshot.exists) return null;
+
+ const poem = poemSnapshot.data() ?? {};
+ const survey = surveySnapshot.data() ?? {};
+ const postSurveyAnswers = filterPublicOutcomes(
+ (survey.postSurveyAnswers as Record) ?? {},
+ );
+ const completionValue = Array.isArray(artist.timestamps)
+ ? artist.timestamps[artist.timestamps.length - 1]
+ : poem.taskTiming?.completedAt;
+
+ const sparkConversation = publicConversation(
+ poem.sparkConversation,
+ "SPARK",
+ );
+ const writeConversation = publicConversation(
+ poem.writeConversation,
+ "WRITE",
+ ).filter((message) => message?.stage !== "SPARK");
+
+ return {
+ condition: artist.condition,
+ completedAt: completionValue ?? null,
+ poem: {
+ passageId: poem.passageId,
+ passage: poem.passage,
+ text: poem.text ?? poem.selectedWordIndexes ?? [],
+ finalPoem: poem.finalPoem ?? "",
+ editHistory: poem.editHistory ?? poem.snapshot ?? [],
+ sparkConversation,
+ writeConversation,
+ taskTiming: poem.taskTiming ?? {},
+ llmUsage: publicLlmUsage(poem.llmUsage),
+ derivedMetrics: poem.derivedMetrics ?? {},
+ },
+ outcomes: postSurveyAnswers,
+ };
+ }),
+ );
+
+ const participants = hydrated
+ .filter((participant) => participant !== null)
+ .sort(
+ (a, b) =>
+ publicTimestampMs(a.completedAt) - publicTimestampMs(b.completedAt),
+ )
+ .map((participant, index) => ({
+ id: `poem-${String(index + 1).padStart(2, "0")}`,
+ ...participant,
+ }));
+
+ res.set("Cache-Control", "public, max-age=0, s-maxage=60");
+ res.json(
+ toPublicJson({
+ studyId,
+ generatedAt: new Date(),
+ participants,
+ }),
+ );
+ } catch (error) {
+ console.error(error);
+ res.status(500).json({ error: "Failed to load exhibition" });
+ }
+});
router.post("/artist-assignment", async (req, res) => {
try {
diff --git a/src/exhibition/ExhibitionApp.tsx b/src/exhibition/ExhibitionApp.tsx
index 64a207c..353a202 100644
--- a/src/exhibition/ExhibitionApp.tsx
+++ b/src/exhibition/ExhibitionApp.tsx
@@ -13,8 +13,9 @@ import "./exhibition.css";
const DEFAULT_STUDY_ID = "6a8cbdb524cc2e2b32049b00";
const STUDY_ID =
import.meta.env.VITE_EXHIBITION_STUDY_ID?.trim() || DEFAULT_STUDY_ID;
-const USE_LIVE_DATA =
- import.meta.env.VITE_EXHIBITION_USE_LIVE_DATA?.trim().toLowerCase() === "true";
+const DATA_URL =
+ import.meta.env.VITE_EXHIBITION_DATA_URL?.trim() ||
+ `/api/firebase/exhibition?studyId=${encodeURIComponent(STUDY_ID)}`;
function ScrollToTop() {
const { pathname } = useLocation();
@@ -55,15 +56,7 @@ export default function ExhibitionApp() {
const controller = new AbortController();
const load = async () => {
try {
- if (!USE_LIVE_DATA) {
- const { previewDataset } = await import("./data/preview");
- setDataset(previewDataset);
- return;
- }
- const response = await fetch(
- `/api/firebase/exhibition?studyId=${encodeURIComponent(STUDY_ID)}`,
- { signal: controller.signal },
- );
+ const response = await fetch(DATA_URL, { signal: controller.signal });
if (!response.ok) throw new Error(`Data request failed (${response.status})`);
const payload = (await response.json()) as ExhibitionDataset;
setDataset(payload);
From 599c7f574a4ea31a640caae4296169c0d7c17da6 Mon Sep 17 00:00:00 2001
From: Harsh Kumar
Date: Fri, 28 Aug 2026 09:10:13 -0400
Subject: [PATCH 3/3] refactor: require actual exhibition data
---
src/exhibition/ExhibitionApp.tsx | 5 -
.../components/ExhibitionGallery.tsx | 1 -
src/exhibition/data/preview.ts | 196 ------------------
src/exhibition/exhibition.css | 7 -
src/exhibition/types.ts | 1 -
5 files changed, 210 deletions(-)
delete mode 100644 src/exhibition/data/preview.ts
diff --git a/src/exhibition/ExhibitionApp.tsx b/src/exhibition/ExhibitionApp.tsx
index 353a202..a63fb5f 100644
--- a/src/exhibition/ExhibitionApp.tsx
+++ b/src/exhibition/ExhibitionApp.tsx
@@ -62,11 +62,6 @@ export default function ExhibitionApp() {
setDataset(payload);
} catch (loadError) {
if (controller.signal.aborted) return;
- if (import.meta.env.DEV) {
- const { previewDataset } = await import("./data/preview");
- setDataset(previewDataset);
- return;
- }
setError(
loadError instanceof Error
? loadError.message
diff --git a/src/exhibition/components/ExhibitionGallery.tsx b/src/exhibition/components/ExhibitionGallery.tsx
index 0994d6e..030ff44 100644
--- a/src/exhibition/components/ExhibitionGallery.tsx
+++ b/src/exhibition/components/ExhibitionGallery.tsx
@@ -143,7 +143,6 @@ export default function ExhibitionGallery({ dataset }: { dataset: ExhibitionData
{dataset.participants.length} poems in view · {noAiCount} without AI · {llmCount} with AI
- {dataset.isPreview ? Preview data : null}
diff --git a/src/exhibition/data/preview.ts b/src/exhibition/data/preview.ts
deleted file mode 100644
index 3affe9e..0000000
--- a/src/exhibition/data/preview.ts
+++ /dev/null
@@ -1,196 +0,0 @@
-import { Passages } from "../../consts/passages";
-import type {
- LegacyChatOpening,
- Message,
- PoemSnapshot,
- TaskTiming,
-} from "../../types";
-import type {
- ExhibitionDataset,
- ExhibitionParticipant,
-} from "../types";
-
-const BASE_TIME = new Date("2026-08-24T22:14:00.000Z").getTime();
-
-const selections = [
- [1, 4, 6, 11, 18, 26, 38, 54, 67],
- [2, 8, 14, 21, 29, 41, 56, 73, 91, 108],
- [0, 5, 12, 19, 35, 48, 60, 75, 86],
- [3, 9, 17, 31, 44, 59, 77, 96],
- [1, 13, 25, 39, 52, 68, 84, 105],
- [4, 16, 28, 43, 57, 71, 89, 112],
- [2, 10, 23, 36, 51, 66, 82, 101],
-];
-
-const makeTiming = (start: number, totalMs: number): TaskTiming => {
- const sparkStart = new Date(start);
- const writeStart = new Date(start + 95_000);
- const completedAt = new Date(start + totalMs);
- return {
- startedAt: sparkStart,
- completedAt,
- totalDurationMs: totalMs,
- phases: {
- spark: {
- startedAt: sparkStart,
- completedAt: writeStart,
- durationMs: 95_000,
- },
- write: {
- startedAt: writeStart,
- completedAt,
- durationMs: totalMs - 95_000,
- },
- },
- };
-};
-
-const makeHistory = (
- indexes: number[],
- writeStart: number,
-): PoemSnapshot[] => {
- const events: PoemSnapshot[] = indexes.map((index, eventIndex) => ({
- action: "ADD",
- index,
- timestamp: new Date(writeStart + eventIndex * 17_000),
- source: "DIRECT",
- }));
- const revisedIndex = indexes[2];
- if (revisedIndex !== undefined) {
- events.splice(4, 0, {
- action: "REMOVE",
- index: revisedIndex,
- timestamp: new Date(writeStart + 58_000),
- source: "DIRECT",
- });
- events.splice(6, 0, {
- action: "ADD",
- index: revisedIndex,
- timestamp: new Date(writeStart + 82_000),
- source: "UNDO",
- });
- }
- return events;
-};
-
-const makeLlmHistory = (start: number) => {
- const sparkConversation: Message[] = [
- {
- id: "assistant-opening",
- role: "assistant",
- content: "What image or feeling in the passage keeps pulling your attention?",
- timestamp: new Date(start + 18_000),
- stage: "SPARK",
- kind: "STAGE_OPENING",
- },
- {
- id: "participant-spark",
- role: "user",
- content: "I like the tension between the city and the possible future.",
- timestamp: new Date(start + 35_000),
- stage: "SPARK",
- kind: "USER_MESSAGE",
- },
- {
- id: "assistant-spark",
- role: "assistant",
- content: "You could follow the future-facing words, or make the city interrupt that optimism. Which tension feels truer?",
- timestamp: new Date(start + 48_000),
- stage: "SPARK",
- kind: "LLM_RESPONSE",
- },
- ];
- const writeConversation: Message[] = [
- {
- id: "participant-write",
- role: "user",
- content: "Help me find a short ending that still feels hopeful.",
- timestamp: new Date(start + 142_000),
- stage: "WRITE",
- kind: "USER_MESSAGE",
- },
- {
- id: "assistant-write",
- role: "assistant",
- content: "Try ending on **possible future** for openness, or **golden tissue** for a more fragile kind of hope.",
- timestamp: new Date(start + 151_000),
- stage: "WRITE",
- kind: "LLM_RESPONSE",
- },
- ];
- const chatOpenings: LegacyChatOpening[] = [
- { stage: "SPARK", timestamp: new Date(start + 15_000) },
- { stage: "WRITE", timestamp: new Date(start + 135_000) },
- ];
- return { sparkConversation, writeConversation, chatOpenings };
-};
-
-const makeParticipant = (index: number): ExhibitionParticipant => {
- const passage = Passages[index % Passages.length];
- const selected = selections[index] ?? [];
- const start = BASE_TIME + index * 1_800_000;
- const totalMs = [718_000, 1_031_000, 794_000, 527_000, 1_094_000, 455_000, 639_000][index] ?? 600_000;
- const timing = makeTiming(start, totalMs);
- const writeStart = new Date(timing.phases.write?.startedAt ?? start).getTime();
- const isLlm = index === 2;
- const llm = isLlm
- ? makeLlmHistory(start)
- : { sparkConversation: [], writeConversation: [], chatOpenings: [] };
- const finalPoem = selected
- .map((wordIndex) => passage.text.split(" ")[wordIndex])
- .filter(Boolean)
- .join(" ");
-
- return {
- id: `poem-${String(index + 1).padStart(2, "0")}`,
- condition: isLlm ? "LLM" : "NO_AI",
- assignment: { passageId: passage.id, strategy: "PREVIEW" },
- completedAt: new Date(start + totalMs).toISOString(),
- poem: {
- passageId: passage.id,
- passage,
- text: selected,
- finalPoem,
- editHistory: makeHistory(selected, writeStart),
- sparkConversation: llm.sparkConversation,
- writeConversation: llm.writeConversation,
- taskTiming: timing,
- llmUsage: {
- chatOpenings: llm.chatOpenings,
- requests: [],
- },
- derivedMetrics: {
- selectedWordCount: selected.length,
- totalEditingActivity: selected.length + 2,
- totalTaskTimeMs: totalMs,
- llmTurnCount: isLlm ? 2 : 0,
- },
- },
- outcomes: {
- final_intended_meaning:
- index % 2 === 0
- ? "A hopeful reminder that the future is assembled from small acts of attention."
- : "The poem holds a quiet tension between change and the wish to remain known.",
- felt_emotion: { emotion: index % 2 === 0 ? "Joy" : "Sadness", intensity: 4 },
- intended_emotion: { emotion: index % 2 === 0 ? "Hope" : "Longing", intensity: 4 },
- expressive_realization: 6,
- ownership_own_work: isLlm ? 5 : 7,
- ownership_responsibility: 7,
- ownership_personal_connection: 6,
- ownership_emotional_connection: 6,
- creative_control: isLlm ? 4 : 5,
- creative_intentionality: 5,
- mental_effort: 4,
- llm_contribution_attribution: isLlm
- ? "I was creating the poem and AI was assisting me."
- : undefined,
- },
- };
-};
-
-export const previewDataset: ExhibitionDataset = {
- studyId: "6a8cbdb524cc2e2b32049b00",
- generatedAt: new Date(BASE_TIME).toISOString(),
- isPreview: true,
- participants: Array.from({ length: 7 }, (_, index) => makeParticipant(index)),
-};
diff --git a/src/exhibition/exhibition.css b/src/exhibition/exhibition.css
index a7b7ec0..cfaa17b 100644
--- a/src/exhibition/exhibition.css
+++ b/src/exhibition/exhibition.css
@@ -134,13 +134,6 @@ a {
margin: 17px 0 0;
}
-.ex-preview-flag {
- border-left: 1px solid var(--ex-line);
- color: var(--ex-muted);
- padding-left: 14px;
- text-transform: uppercase;
-}
-
.ex-gallery__filters {
align-items: flex-end;
display: flex;
diff --git a/src/exhibition/types.ts b/src/exhibition/types.ts
index 84d0cdf..4ce156f 100644
--- a/src/exhibition/types.ts
+++ b/src/exhibition/types.ts
@@ -54,7 +54,6 @@ export interface ExhibitionDataset {
studyId: string;
generatedAt: string;
participants: ExhibitionParticipant[];
- isPreview?: boolean;
}
export type TimelineEventKind =