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.

+ +
+ )} +
+
+ +

Lost in Translation — Human expression in the age of AI.

+
+
+ ); +} 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 ( + + ); +} + +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} + /> +
+
+ +

The Blackout Room is a research exhibition of creative process.

+
+
+ ); +} + +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)} +

+
+ + { + 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 =