feat(frontend/onboarding): keep the brain dump ideas changing while recording, and let a click pause them

- The starter cards swap every 3.5 s (7 s under reduced motion), hopping
  across the page, with a soft blur-and-scale change; the two "For example"
  cards now take turns too, from five quotes per role instead of two.
- The cards and the live "Otto might ask" prompt keep changing while the user
  records (the prompt every 15 s), for inspiration mid-thought. Nothing that
  changes on its own is announced, so it cannot talk into the take.
- WCAG 2.2.2 is met without a visible control: a click on any starter card
  or on the live question pauses every changing idea, and another click
  resumes them; a focus-only toggle is the keyboard route.

Co-Authored-By: Claude Opus 5.5 (Claude Code) <[email protected]>
This commit is contained in:
Toran Bruce RichardsandClaude Opus 5.5 committed 2026-10-27 10:19:26 +00:00
1 parent d6abdc63d6
commit c89929254a
13 files changed
+345 -116

No files matched your search

@@ -4,6 +4,7 @@ import { Text } from "@/components/atoms/Text/Text";
import { cn } from "@/lib/utils";
import { AmbientPrompts } from "./components/AmbientPrompts";
import { FailureState } from "./components/FailureState";
import { IdeasPauseToggle } from "./components/IdeasPauseToggle";
import { InsufficientState } from "./components/InsufficientState";
import { MicButton, OrbScreen } from "./components/MicButton";
import { ModeSwitchLink } from "./components/ModeSwitchLink";
@@ -58,7 +59,17 @@ export function BrainDumpStep() {
{/* Wide enough that the starter ideas clear the typing composer. */}
<div className="relative flex w-full max-w-screen-2xl flex-col items-center justify-center self-stretch">
<AmbientPrompts role={dump.role} screen={dump.screen} />
<IdeasPauseToggle
screen={dump.screen}
isPaused={dump.prompt.isPaused}
onToggle={dump.prompt.togglePause}
/>
<AmbientPrompts
role={dump.role}
screen={dump.screen}
isPaused={dump.prompt.isPaused}
onTogglePause={dump.prompt.togglePause}
/>
<RevealGroup
className={cn(
"relative z-10 flex w-full flex-col items-center gap-6 px-4",
@@ -73,6 +84,7 @@ export function BrainDumpStep() {
insufficientMode={dump.insufficientMode}
elapsedSeconds={dump.elapsedSeconds}
question={dump.prompt.question.text}
onTogglePause={dump.prompt.togglePause}
/>
{orbScreen && (
@@ -94,6 +106,7 @@ export function BrainDumpStep() {
question={dump.prompt.question.text}
announcement={dump.prompt.announcement}
onAnotherQuestion={showAnotherQuestion}
onTogglePause={dump.prompt.togglePause}
isMicBlocked={dump.isMicBlocked}
isMicUnavailable={dump.isMicUnavailable}
micPermission={dump.micPermission}
@@ -105,6 +118,7 @@ export function BrainDumpStep() {
question={dump.prompt.question.text}
onAnother={showAnotherQuestion}
announcement={dump.prompt.announcement}
onTogglePause={dump.prompt.togglePause}
/>
<RecordingControls
onStop={dump.handleStop}
@@ -19,14 +19,14 @@ const ROLE_IDS = [
];
describe("brain dump prompt sets", () => {
it.each(ROLE_IDS)("has eight questions and two examples for %s", (role) => {
it.each(ROLE_IDS)("has eight questions and five examples for %s", (role) => {
const set = promptSetForRole(role);
expect(set).toBe(
BRAIN_DUMP_PROMPT_SETS[role as keyof typeof BRAIN_DUMP_PROMPT_SETS],
);
expect(set.questions).toHaveLength(8);
expect(set.examples).toHaveLength(2);
expect(set.examples).toHaveLength(5);
for (const question of set.questions) {
expect(question.label).not.toBe("");
expect(question.text).toMatch(/\?$/);
@@ -86,24 +86,49 @@ describe("usePromptRotation", () => {
expect(result.current.index).toBe(6);
});
it("only moves on a click while recording", async () => {
it("keeps moving every 15 seconds while recording, unannounced", async () => {
const { result } = renderRotation({
role: "Operations",
screen: "recording",
});
await wait(120_000);
await wait(14_999);
expect(result.current.index).toBe(4);
expect(trackBrainDump).not.toHaveBeenCalled();
act(() => result.current.advance("click"));
await wait(1);
expect(result.current.index).toBe(5);
expect(result.current.announcement).toBe("");
expect(trackBrainDump).toHaveBeenCalledWith("brain_dump_prompt_advanced", {
via: "click",
via: "auto",
screen: "recording",
prompt_index: 5,
prompt_set: "Operations",
});
act(() => result.current.advance("click"));
expect(result.current.index).toBe(6);
});
it("holds still while paused and picks up again when resumed", async () => {
const { result } = renderRotation({
role: "Operations",
screen: "recording",
});
act(() => result.current.togglePause());
expect(result.current.isPaused).toBe(true);
await wait(120_000);
expect(result.current.index).toBe(4);
expect(vi.getTimerCount()).toBe(0);
// "Another question" still works while paused.
act(() => result.current.advance("click"));
expect(result.current.index).toBe(5);
act(() => result.current.togglePause());
expect(result.current.isPaused).toBe(false);
await wait(15_000);
expect(result.current.index).toBe(6);
});
it("announces only a question the user asked for", async () => {
@@ -3,36 +3,38 @@
import { Text } from "@/components/atoms/Text/Text";
import { cn } from "@/lib/utils";
import { AnimatePresence, motion } from "framer-motion";
import type { AmbientContent } from "./useAmbientPrompts";
interface Props {
position: string;
label: string;
text: string;
isQuote: boolean;
content: AmbientContent;
isHidden: boolean;
// Null where the card should hold still: reduced motion, or a screen too
// narrow to show the layer at all.
// Null where the card should hold still: paused, reduced motion, or a
// screen too narrow to show the layer at all.
drift: { durationSecs: number; delaySecs: number } | null;
reduceMotion: boolean;
onClick?: () => void;
}
const EASE = [0.16, 1, 0.3, 1] as const;
export function AmbientCard({
position,
label,
text,
isQuote,
content,
isHidden,
drift,
reduceMotion,
onClick,
}: Props) {
return (
<motion.div
onClick={onClick}
className={cn(
"absolute w-[196px] rounded-xl bg-surface-hover px-3 py-2.5 transition-opacity duration-500",
position,
isHidden && "opacity-0",
isHidden ? "opacity-0" : "pointer-events-auto cursor-pointer",
)}
animate={drift ? { y: [0, -6, 0] } : undefined}
animate={drift ? { y: [0, -6, 0] } : { y: 0 }}
transition={
drift
? {
@@ -41,24 +43,32 @@ export function AmbientCard({
repeat: Infinity,
ease: "easeInOut",
}
: undefined
: { duration: 0.6, ease: EASE }
}
>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={text}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: reduceMotion ? 0 : 0.4 }}
className={cn(isQuote && "italic")}
key={content.text}
initial={
reduceMotion
? { opacity: 0 }
: { opacity: 0, scale: 0.96, filter: "blur(6px)" }
}
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
exit={
reduceMotion
? { opacity: 0 }
: { opacity: 0, scale: 0.96, filter: "blur(6px)" }
}
transition={{ duration: reduceMotion ? 0 : 0.45, ease: EASE }}
className={cn(content.isQuote && "italic")}
>
<Text
variant="small-semibold"
as="span"
className="block text-caption-sm uppercase tracking-eyebrow text-accent-strong"
>
{label}
{content.label}
</Text>
<Text
variant="body"
@@ -66,7 +76,7 @@ export function AmbientCard({
tone="secondary"
className="block text-body-sm"
>
{text}
{content.text}
</Text>
</motion.div>
</AnimatePresence>
@@ -4,56 +4,86 @@ import { cn } from "@/lib/utils";
import type { ScreenState } from "../useBrainDumpStep";
import { useIsWideLayout } from "../useIsWideLayout";
import { AmbientCard } from "./AmbientCard";
import { useAmbientPrompts } from "./useAmbientPrompts";
import { type AmbientSlot, useAmbientPrompts } from "./useAmbientPrompts";
interface Props {
role: string;
screen: ScreenState;
isPaused: boolean;
onTogglePause: () => void;
}
type Card = { position: string; driftSecs: number; delaySecs: number } & (
| { question: number; besideComposer?: boolean }
| { example: number }
);
interface Card {
position: string;
driftSecs: number;
delaySecs: number;
slot: AmbientSlot;
besideComposer?: boolean;
}
// Left column then right. The middle pair sits beside the composer, so it
// steps out while the user types.
const CARDS: Card[] = [
{ position: "left-[3%] top-[5%]", driftSecs: 9, delaySecs: 0, question: 0 },
{
position: "left-[3%] top-[5%]",
driftSecs: 9,
delaySecs: 0,
slot: "question",
},
{
position: "left-[7%] top-[36%]",
driftSecs: 11,
delaySecs: 1.5,
question: 1,
slot: "question",
besideComposer: true,
},
{ position: "left-[4%] top-[66%]", driftSecs: 13, delaySecs: 3, example: 0 },
{ position: "right-[3%] top-[7%]", driftSecs: 11, delaySecs: 2, question: 2 },
{
position: "left-[4%] top-[66%]",
driftSecs: 13,
delaySecs: 3,
slot: "example",
},
{
position: "right-[3%] top-[7%]",
driftSecs: 11,
delaySecs: 2,
slot: "question",
},
{
position: "right-[6%] top-[38%]",
driftSecs: 13,
delaySecs: 0.5,
question: 3,
slot: "question",
besideComposer: true,
},
{
position: "right-[4%] top-[68%]",
driftSecs: 9,
delaySecs: 2.5,
example: 1,
slot: "example",
},
];
const SLOTS = CARDS.map((card) => card.slot);
const HIDDEN_ON: ScreenState[] = ["recovery", "failed", "insufficient"];
// Starter ideas around the column on wide screens (see useIsWideLayout). Text, not controls: no
// border, shadow, hover or focus, and hidden from assistive tech, so that
// nobody mistakes them for buttons. The live prompt is the accessible one.
export function AmbientPrompts({ role, screen }: Props) {
// Starter ideas around the column on wide screens (see useIsWideLayout). Text,
// not controls: no border, shadow, hover or focus, and hidden from assistive
// tech; the live prompt is the accessible one. They keep changing while the
// user talks, for inspiration mid-thought, and a click on any card pauses or
// resumes them all (the keyboard route is the focus-only toggle beside the
// layer, so WCAG 2.2.2 holds without a visible control).
export function AmbientPrompts({
role,
screen,
isPaused,
onTogglePause,
}: Props) {
const isShown = useIsWideLayout() && !HIDDEN_ON.includes(screen);
const ambient = useAmbientPrompts({
role,
isRotating: isShown && screen !== "recording",
slots: SLOTS,
isRotating: isShown && !isPaused,
});
if (HIDDEN_ON.includes(screen)) return null;
const isTyping = screen === "typing";
@@ -62,33 +92,27 @@ export function AmbientPrompts({ role, screen }: Props) {
<div
aria-hidden="true"
data-testid="ambient-prompts"
data-paused={isPaused}
className={cn(
"pointer-events-none absolute inset-0 z-0 hidden select-none transition-opacity duration-500 xl:block",
(isTyping || screen === "recording") && "opacity-50",
)}
>
{CARDS.map((card) => {
const isQuote = "example" in card;
{CARDS.map((card, index) => {
const isHidden = isTyping && Boolean(card.besideComposer);
return (
<AmbientCard
key={card.position}
position={card.position}
label={
isQuote ? "For example" : ambient.questions[card.question].label
}
text={
isQuote
? `“${ambient.examples[card.example]}”`
: ambient.questions[card.question].text
}
isQuote={isQuote}
isHidden={isTyping && "besideComposer" in card}
content={ambient.cards[index]}
isHidden={isHidden}
drift={
isShown && !ambient.reduceMotion
isShown && !isPaused && !ambient.reduceMotion
? { durationSecs: card.driftSecs, delaySecs: card.delaySecs }
: null
}
reduceMotion={ambient.reduceMotion}
onClick={isHidden ? undefined : onTogglePause}
/>
);
})}
@@ -0,0 +1,30 @@
"use client";
import { Button } from "@/components/atoms/Button/Button";
import type { ScreenState } from "../useBrainDumpStep";
interface Props {
screen: ScreenState;
isPaused: boolean;
onToggle: () => void;
}
const ROTATING_ON: ScreenState[] = ["rest", "typing", "recording"];
// The keyboard route to pausing the ideas. A mouse or touch user clicks an
// idea instead, so this stays out of sight until it takes focus.
export function IdeasPauseToggle({ screen, isPaused, onToggle }: Props) {
if (!ROTATING_ON.includes(screen)) return null;
return (
<Button
type="button"
variant="secondary"
size="xs"
aria-pressed={isPaused}
onClick={onToggle}
className="sr-only focus-visible:not-sr-only focus-visible:absolute focus-visible:left-1/2 focus-visible:top-0 focus-visible:z-20 focus-visible:-translate-x-1/2"
>
{isPaused ? "Resume changing ideas" : "Pause changing ideas"}
</Button>
);
}
@@ -14,6 +14,9 @@ interface Props {
announcement?: string;
align?: "left" | "center";
compact?: boolean;
// A click on the question pauses or resumes every idea that changes on its
// own; there is no visible control for it, on purpose.
onTogglePause?: () => void;
className?: string;
}
@@ -23,6 +26,7 @@ export function OttoMightAsk({
announcement = "",
align = "center",
compact = false,
onTogglePause,
className,
}: Props) {
const isLeft = align === "left";
@@ -57,7 +61,8 @@ export function OttoMightAsk({
<Text
variant={compact ? "body" : "large"}
tone={compact ? "secondary" : undefined}
className="text-pretty"
className={cn("text-pretty", onTogglePause && "cursor-pointer")}
onClick={onTogglePause}
>
{question}
</Text>
@@ -22,6 +22,7 @@ interface Props {
insufficientMode: "voice" | "typed";
elapsedSeconds: number;
question: string;
onTogglePause: () => void;
}
export function StepHeader({
@@ -30,6 +31,7 @@ export function StepHeader({
insufficientMode,
elapsedSeconds,
question,
onTogglePause,
}: Props) {
const isRecording = screen === "recording";
@@ -61,7 +63,12 @@ export function StepHeader({
{/* On wide screens the ambient cards carry these questions, and
they are hidden from assistive tech, so this one stays in the
accessibility tree. */}
<OttoMightAsk question={question} compact className="xl:sr-only" />
<OttoMightAsk
question={question}
compact
onTogglePause={onTogglePause}
className="xl:sr-only"
/>
</RevealItem>
)}
{screen === "typing" && (
@@ -13,6 +13,7 @@ interface Props {
onSubmit: () => void;
isTooLong: boolean;
question: string;
onTogglePause: () => void;
announcement: string;
onAnotherQuestion: () => void;
isMicBlocked: boolean;
@@ -27,6 +28,7 @@ export function TypingComposer({
onSubmit,
isTooLong,
question,
onTogglePause,
announcement,
onAnotherQuestion,
isMicBlocked,
@@ -50,6 +52,7 @@ export function TypingComposer({
<OttoMightAsk
question={question}
onAnother={onAnotherQuestion}
onTogglePause={onTogglePause}
announcement={announcement}
align="left"
/>
@@ -1,4 +1,4 @@
import { act, render, screen } from "@testing-library/react";
import { act, fireEvent, render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -26,9 +26,29 @@ vi.mock("framer-motion", async (importOriginal) => ({
}));
import { BRAIN_DUMP_PROMPT_SETS } from "../../prompts";
import type { ScreenState } from "../../useBrainDumpStep";
import { AmbientPrompts } from "../AmbientPrompts";
const OPERATIONS = BRAIN_DUMP_PROMPT_SETS.Operations;
const onTogglePause = vi.fn();
function renderLayer(role: string, screenState: ScreenState, isPaused = false) {
return render(
<AmbientPrompts
role={role}
screen={screenState}
isPaused={isPaused}
onTogglePause={onTogglePause}
/>,
);
}
function shownExamples() {
return OPERATIONS.examples
.map((example, index) => ({ index, text: `“${example}”` }))
.filter(({ text }) => screen.queryByText(text) !== null)
.map(({ index }) => index);
}
function layer() {
return screen.getByTestId("ambient-prompts");
@@ -45,6 +65,7 @@ beforeEach(() => {
vi.useFakeTimers();
motionPreference.reduced = false;
device.isWide = true;
onTogglePause.mockReset();
});
afterEach(() => {
@@ -52,64 +73,75 @@ afterEach(() => {
});
describe("AmbientPrompts", () => {
it("is hidden from assistive tech and has nothing to focus or click", () => {
render(<AmbientPrompts role="Operations" screen="rest" />);
it("is hidden from assistive tech and has nothing to focus", () => {
renderLayer("Operations", "rest");
expect(layer().getAttribute("aria-hidden")).toBe("true");
expect(layer().className).toContain("pointer-events-none");
expect(
layer().querySelectorAll("button, a, input, textarea, [tabindex]"),
).toHaveLength(0);
});
it("shows four of the role's questions and both examples as quotes", () => {
render(<AmbientPrompts role="Operations" screen="rest" />);
it("pauses or resumes on a click on any card", () => {
renderLayer("Operations", "rest");
fireEvent.click(screen.getByText(OPERATIONS.questions[0].text));
fireEvent.click(screen.getByText(`“${OPERATIONS.examples[0]}”`));
expect(onTogglePause).toHaveBeenCalledTimes(2);
});
it("shows four of the role's questions and two examples as quotes", () => {
renderLayer("Operations", "rest");
expect(shownQuestions()).toEqual([0, 1, 2, 3]);
expect(screen.getAllByText("For example")).toHaveLength(2);
for (const example of OPERATIONS.examples) {
expect(screen.getByText(`“${example}”`)).toBeDefined();
}
expect(shownExamples()).toEqual([0, 1]);
});
it("swaps one question card every 7 seconds, in turn", async () => {
render(<AmbientPrompts role="Operations" screen="rest" />);
it("swaps one card every 3.5 seconds, hopping across the page", async () => {
renderLayer("Operations", "rest");
await act(async () => {
vi.advanceTimersByTime(6_999);
vi.advanceTimersByTime(3_499);
});
expect(shownQuestions()).toEqual([0, 1, 2, 3]);
// Card 3 (right, top) first, then card 1 (left, middle)...
await act(async () => {
vi.advanceTimersByTime(1);
});
expect(shownQuestions()).toEqual([1, 2, 3, 4]);
expect(shownQuestions()).toEqual([0, 1, 3, 4]);
await act(async () => {
vi.advanceTimersByTime(7_000);
vi.advanceTimersByTime(3_500);
});
expect(shownQuestions()).toEqual([2, 3, 4, 5]);
// The quotes stay put.
expect(screen.getAllByText("For example")).toHaveLength(2);
expect(shownQuestions()).toEqual([0, 3, 4, 5]);
expect(shownExamples()).toEqual([0, 1]);
// ...then a quote (right, bottom), so the examples take turns too.
await act(async () => {
vi.advanceTimersByTime(3_500);
});
expect(shownExamples()).toEqual([0, 2]);
});
it("rotates half as often when motion is reduced", async () => {
motionPreference.reduced = true;
render(<AmbientPrompts role="Operations" screen="rest" />);
renderLayer("Operations", "rest");
await act(async () => {
vi.advanceTimersByTime(7_000);
vi.advanceTimersByTime(3_500);
});
expect(shownQuestions()).toEqual([0, 1, 2, 3]);
await act(async () => {
vi.advanceTimersByTime(7_000);
vi.advanceTimersByTime(3_500);
});
expect(shownQuestions()).toEqual([1, 2, 3, 4]);
expect(shownQuestions()).toEqual([0, 1, 3, 4]);
});
it("uses the Other set for a role it does not know", () => {
render(<AmbientPrompts role="Dentist" screen="rest" />);
renderLayer("Dentist", "rest");
expect(
screen.getByText(BRAIN_DUMP_PROMPT_SETS.Other.questions[0].text),
@@ -119,26 +151,36 @@ describe("AmbientPrompts", () => {
it.each(["typing", "recording"] as const)(
"steps back while %s",
(screenState) => {
render(<AmbientPrompts role="Operations" screen={screenState} />);
renderLayer("Operations", screenState);
expect(layer().className).toContain("opacity-50");
},
);
it("holds its questions still while recording", async () => {
render(<AmbientPrompts role="Operations" screen="recording" />);
const before = shownQuestions();
it("keeps changing while recording", async () => {
renderLayer("Operations", "recording");
await act(async () => {
vi.advanceTimersByTime(3_500);
});
expect(shownQuestions()).toEqual([0, 1, 3, 4]);
});
it("holds still while paused", async () => {
renderLayer("Operations", "recording", true);
await act(async () => {
vi.advanceTimersByTime(60_000);
});
expect(shownQuestions()).toEqual(before);
expect(shownQuestions()).toEqual([0, 1, 2, 3]);
expect(layer().getAttribute("data-paused")).toBe("true");
});
it("runs no timer where the layer does not fit", () => {
device.isWide = false;
render(<AmbientPrompts role="Operations" screen="rest" />);
renderLayer("Operations", "rest");
expect(vi.getTimerCount()).toBe(0);
});
@@ -146,7 +188,7 @@ describe("AmbientPrompts", () => {
it.each(["recovery", "failed", "insufficient"] as const)(
"is gone on the %s screen",
(screenState) => {
render(<AmbientPrompts role="Operations" screen={screenState} />);
renderLayer("Operations", screenState);
expect(screen.queryByTestId("ambient-prompts")).toBeNull();
},
@@ -2,19 +2,27 @@ import { useReducedMotion } from "framer-motion";
import { useEffect, useState } from "react";
import { BRAIN_DUMP_PROMPT_SETS, promptSetNameForRole } from "../prompts";
const SWAP_MS = 7_000;
const REDUCED_MOTION_SWAP_MS = 14_000;
// Four cards carry questions; the two quotes never change.
const QUESTION_CARDS = 4;
const SWAP_MS = 3_500;
const REDUCED_MOTION_SWAP_MS = 7_000;
export type AmbientSlot = "question" | "example";
interface Args {
role: string;
// Off while recording, so nothing changes under someone mid-answer, and
// wherever the layer is not on screen.
// What each card carries, in the order the cards are laid out.
slots: AmbientSlot[];
// Off wherever the layer is not on screen, and while the user has paused
// the cards.
isRotating: boolean;
}
export function useAmbientPrompts({ role, isRotating }: Args) {
export interface AmbientContent {
label: string;
text: string;
isQuote: boolean;
}
export function useAmbientPrompts({ role, slots, isRotating }: Args) {
const promptSet = promptSetNameForRole(role);
const reduceMotion = useReducedMotion() === true;
const [rotation, setRotation] = useState({ promptSet, swaps: 0 });
@@ -36,21 +44,47 @@ export function useAmbientPrompts({ role, isRotating }: Args) {
}, [promptSet, reduceMotion, isRotating]);
const { questions, examples } = BRAIN_DUMP_PROMPT_SETS[promptSet];
return {
questions: Array.from(
{ length: QUESTION_CARDS },
(_, card) => questions[questionOnCard(card, swaps, questions.length)],
),
examples,
reduceMotion,
};
const dealt = dealCards(slots, swaps, {
question: questions.length,
example: examples.length,
});
const cards: AmbientContent[] = dealt.map((index, card) => {
if (slots[card] === "example")
return {
label: "For example",
text: `“${examples[index]}”`,
isQuote: true,
};
return { ...questions[index], isQuote: false };
});
return { cards, reduceMotion };
}
// The cards take turns: swap n lands on card n % 4 and brings in the next
// question after the four dealt at the start.
function questionOnCard(card: number, swaps: number, total: number) {
if (swaps <= card) return card;
const lastSwap =
card + QUESTION_CARDS * Math.floor((swaps - 1 - card) / QUESTION_CARDS);
return (QUESTION_CARDS + lastSwap) % total;
// Which card the next swap lands on, for the six-card layout: it hops
// across the page rather than walking down one side, so each change pulls
// the eye somewhere new.
const SWAP_ORDER = [3, 1, 5, 0, 4, 2];
function swapOrder(cardCount: number) {
if (cardCount === SWAP_ORDER.length) return SWAP_ORDER;
return Array.from({ length: cardCount }, (_, card) => card);
}
// Deals the opening hand (questions and quotes in order), then replays every
// swap: the swapped card takes the next item of its own kind nobody has seen
// yet, wrapping round once the set runs out.
export function dealCards(
slots: AmbientSlot[],
swaps: number,
sizes: Record<AmbientSlot, number>,
) {
const next: Record<AmbientSlot, number> = { question: 0, example: 0 };
const shown = slots.map((slot) => next[slot]++ % sizes[slot]);
const order = swapOrder(slots.length);
for (let swap = 0; swap < swaps; swap++) {
const card = order[swap % order.length];
const slot = slots[card];
shown[card] = next[slot]++ % sizes[slot];
}
return shown;
}
@@ -29,6 +29,9 @@ export const BRAIN_DUMP_PROMPT_SETS = {
[
"So, um, I run a little bakery, and honestly the ordering side is killing me.",
"Three of us, we sell to gyms, and I am the whole sales team and the whole marketing team.",
"Okay, so, we are a small agency, and every new client means the same proposal, the same onboarding emails, all of it by hand.",
"I sell online, um, mostly on Shopify, and I never get round to the newsletter or the follow-up emails.",
"Honestly I am the bottleneck. Everything goes through my inbox and I am drowning a bit.",
],
),
Operations: promptSet(
@@ -45,6 +48,9 @@ export const BRAIN_DUMP_PROMPT_SETS = {
[
"Okay so I run ops for a small clinic, and half my day is confirming appointments and chasing suppliers.",
"We do events. Every week it is the same checklist, the same emails, and the same spreadsheet.",
"Right, so every Monday I pull numbers from three tools into one spreadsheet, and it takes the whole morning.",
"I manage a warehouse team and, um, half my job is answering where is my order emails.",
"We onboard a new client every week and it is the same twenty steps, done by hand, every time.",
],
),
"Sales/BD": promptSet(
@@ -61,6 +67,9 @@ export const BRAIN_DUMP_PROMPT_SETS = {
[
"Okay where do I start. I am a recruiter, and I spend half my day just chasing people for replies.",
"I sell to dental practices and I never have a clean list to call.",
"So I do partnerships, and finding the right person to email at each company takes me forever.",
"Um, after every call I mean to write the follow-up and update the CRM, and I just do not.",
"I am a solo consultant, so I am the sales team, and the pipeline lives in my head and a sticky note.",
],
),
Marketing: promptSet(
@@ -77,6 +86,9 @@ export const BRAIN_DUMP_PROMPT_SETS = {
[
"I do marketing for a two-person skincare brand and the Instagram backlog haunts me.",
"We make a scheduling tool for tutors and I am the whole marketing team.",
"So we publish one blog post a month and we should be doing four, and I never have time to research them.",
"Um, I run the newsletter, the socials and the ads, and the reporting at the end of the month kills me.",
"We are launching in spring and, honestly, I do not even have a content calendar yet.",
],
),
"Product/PM": promptSet(
@@ -93,6 +105,9 @@ export const BRAIN_DUMP_PROMPT_SETS = {
[
"I am a PM at a small SaaS and I spend my mornings turning support tickets into a feedback doc.",
"We have one product, four customer types, and I am the only person reading the reviews.",
"Okay so every sprint I write the same kind of update for three different audiences.",
"Um, I want to know what competitors ship each week, but I only find out when a customer tells me.",
"We are pre-launch, and I keep a list of user interview notes that nobody, including me, has read twice.",
],
),
Engineering: promptSet(
@@ -109,6 +124,9 @@ export const BRAIN_DUMP_PROMPT_SETS = {
[
"I lead a team of five and I lose Fridays to status updates and release notes.",
"Solo developer, two products, and nobody doing the marketing or the support inbox.",
"So I spend half of Monday answering the same questions from support about how a feature works.",
"Um, I keep meaning to write the docs, and the changelog, and the onboarding guide, and none of it happens.",
"We are an agency of three devs and the client updates and invoices eat our Fridays.",
],
),
"HR/People": promptSet(
@@ -125,6 +143,9 @@ export const BRAIN_DUMP_PROMPT_SETS = {
[
"I run people ops for a 40-person startup and scheduling interviews eats my afternoons.",
"Two of us in HR, and the job posts, the screening and the onboarding docs all fall to me.",
"So, um, every new hire needs the same twelve emails and three docs, and I write them from scratch.",
"I screen something like a hundred applications a week and most of it is reading the same CVs.",
"Honestly the reminders are the worst part: reviews, check-ins, training, I chase all of it.",
],
),
Other: promptSet(
@@ -141,6 +162,9 @@ export const BRAIN_DUMP_PROMPT_SETS = {
[
"Right, so the idea is a dog-walking app, but for people who work shifts. Hear me out.",
"I teach piano, I have thirty students, and the admin is the part I hate.",
"Um, I am a wedding photographer, and the emails, the contracts and the reminders take longer than the shoots.",
"So I run a small charity, we are all volunteers, and nobody has time for the grant applications.",
"Okay where do I start. I am a landlord with six flats and the tenant messages never stop.",
],
),
} satisfies Record<string, BrainDumpPromptSet>;
@@ -18,11 +18,13 @@ const FIRST_INDEX = 4;
type PromptScreen = "rest" | "typing" | "recording";
type AdvanceVia = "auto" | "click";
// How long each question stays. While recording it only moves on a click: a
// question changing mid-answer pulls the speaker off their thought.
const AUTO_ADVANCE_MS: Partial<Record<PromptScreen, number>> = {
// How long each question stays. It keeps moving while recording, so a
// speaker who runs dry has something new to pick up; it is never announced,
// so it cannot talk into the take, and the user can pause it (togglePause).
const AUTO_ADVANCE_MS: Record<PromptScreen, number> = {
rest: 12_000,
typing: 20_000,
recording: 15_000,
};
interface Args {
@@ -35,6 +37,9 @@ export function usePromptRotation({ role, screen }: Args) {
const hasAmbientCards = useIsWideLayout();
const reduceMotion = useReducedMotion() === true;
const [rotation, setRotation] = useState<Rotation>(firstRotation(promptSet));
// One switch for every idea that changes on its own: the live prompt and
// the ambient cards (WCAG 2.2.2).
const [isPaused, setIsPaused] = useState(false);
const current =
rotation.promptSet === promptSet ? rotation : firstRotation(promptSet);
const { index } = current;
@@ -49,25 +54,31 @@ export function usePromptRotation({ role, screen }: Args) {
// Restarted on every advance, so a click buys the new question its full
// time on screen.
useEffect(() => {
const interval = shownOn ? AUTO_ADVANCE_MS[shownOn] : undefined;
if (!shownOn || !interval || reduceMotion) return;
if (!shownOn || reduceMotion || isPaused) return;
const interval = AUTO_ADVANCE_MS[shownOn];
const timer = setInterval(() => {
if (document.visibilityState !== "visible") return;
setRotation(advanceFrom({ promptSet, index, screen: shownOn }, "auto"));
}, interval);
return () => clearInterval(timer);
}, [shownOn, reduceMotion, promptSet, index]);
}, [shownOn, reduceMotion, isPaused, promptSet, index]);
function advance(via: AdvanceVia) {
if (!shownOn) return;
setRotation(advanceFrom({ promptSet, index, screen: shownOn }, via));
}
function togglePause() {
setIsPaused((paused) => !paused);
}
return {
question,
index,
promptSet,
advance,
isPaused,
togglePause,
// Only a question the user asked for is read out: one announced on
// every timer tick would talk over a screen reader, and into the take.
announcement: