mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-11-10 04:41:59 -10:00
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:
1 parent
d6abdc63d6
commit
c89929254a
13 files changed
+345
-116
No files matched your search
+15
-1
@@ -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}
|
||||
|
||||
+2
-2
@@ -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(/\?$/);
|
||||
|
||||
+30
-5
@@ -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 () => {
|
||||
|
||||
+29
-19
@@ -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>
|
||||
|
||||
+53
-29
@@ -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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
+30
@@ -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>
|
||||
);
|
||||
}
|
||||
+6
-1
@@ -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>
|
||||
|
||||
+8
-1
@@ -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" && (
|
||||
|
||||
+3
@@ -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"
|
||||
/>
|
||||
|
||||
+72
-30
@@ -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();
|
||||
},
|
||||
|
||||
+56
-22
@@ -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;
|
||||
}
|
||||
+24
@@ -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>;
|
||||
|
||||
+17
-6
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user