diff --git a/README.md b/README.md
index 335d47b7a..dd2fc8ab3 100644
--- a/README.md
+++ b/README.md
@@ -52,7 +52,7 @@ self-contained under `src/features/` and surfaced through dedicated routes:
- **Insights — Knowledge Gaps** — AI-detected missing documentation per component, with severity (high / medium / low), component owners, and refresh tracking.
- **Insights — FAQ** — AI-generated clusters of frequently asked questions and the documents that answer them.
- **Settings & Profile** — Central configuration hub with tabs for user profile management (avatar, display name, password update), appearance (light/dark/system theme), chat preferences, moments toggles, and access tokens (GitHub PAT / Jira credential management for authorized roles).
-- **Moments & Easter Eggs** — Gamified celebrations (confetti, achievement moments, sound effects) and interactive easter eggs (`dino`, `game2048`, `space-invaders`).
+- **Moments & Easter Eggs** — Gamified celebrations (confetti, achievement moments, sound effects) and interactive easter eggs (the dino waiting-game, `2048` behind the dashboard header icon, Space Invaders behind the 404 page's rocket).
---
@@ -70,10 +70,9 @@ src/
│ ├── connectors/ # Connector + source allow/deny management
│ ├── dashboard/ # Dashboard hero & NextStepWidget
│ ├── data-ingestion/ # Sources, ingestion runs, artifact tables
-│ ├── dino/ # Dino easter-egg mini game
-│ ├── easter-eggs/ # Easter-egg trigger hooks & modals
+│ ├── easter-eggs/ # Easter eggs: egg shell + game registry, the
+│ │ # effect bus, phrases and the dino waiting-game
│ ├── faq/ # AI FAQ clusters (insights)
-│ ├── game2048/ # 2048 easter-egg mini game
│ ├── knowledge-base/ # Artifact browsing + streamed summaries + file upload
│ ├── knowledge-gaps/ # AI-detected documentation gaps (insights)
│ ├── moments/ # Celebrations, confetti, rocket animations
@@ -81,7 +80,6 @@ src/
│ ├── profile/ # Profile form components
│ ├── projects/ # Multi-project switching & global ProjectContext
│ ├── settings/ # User settings tabs, themes & credentials
-│ ├── space-invaders/ # Space Invaders easter-egg mini game
│ └── team-management/ # Team overview, member detail, Skill Wizard
├── pages/ # Route-level views (one per user-facing flow)
├── router/ # React Router v7 config + AuthGuard
diff --git a/public/easter-eggs/2048.html b/public/easter-eggs/2048.html
index 1fe538f85..e97ba57aa 100644
--- a/public/easter-eggs/2048.html
+++ b/public/easter-eggs/2048.html
@@ -1000,6 +1000,13 @@
const mapped = keyMap[event.key];
if (!modifiers) {
+ if (event.key === "Escape") {
+ try {
+ window.parent.postMessage({ type: "EGG_EXIT" }, window.location.origin);
+ } catch (_) {}
+ return;
+ }
+
if (mapped !== undefined) {
event.preventDefault();
// Ignore auto-repeat key events so holding an arrow doesn't
diff --git a/src/App.tsx b/src/App.tsx
index 68f5cf5c0..1b8587466 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -18,6 +18,7 @@ import { SelectionActions } from "./features/board/selection/SelectionActions";
import { CardMarksProvider } from "./features/board/marks/CardMarksProvider";
import { useAuth } from "./context/useAuth";
import { AuroraBackground } from "./components/layout/AuroraBackground";
+import { EggEffectsLayer } from "./features/easter-eggs/components/EggEffectsLayer";
import { MyKnowledgeGapsProvider } from "./features/knowledge-gaps/MyKnowledgeGapsProvider";
import { KnowledgeGapOwnerAnnouncement } from "./features/knowledge-gaps/components/KnowledgeGapOwnerAnnouncement";
import { useScrollRestoration } from "./hooks/useScrollRestoration";
@@ -139,6 +140,12 @@ function AppContent() {
sits on top of the login screen, and off unless turned on in
Settings (see AppearanceSection). */}
{signedIn && showRocketPet && }
+
+ {/* Whole-window egg effects (barrel roll, matrix rain), rendered
+ once for the whole app. Any chat surface fires them through the
+ bus (playEggEffect); this is where they actually draw. Not gated
+ on signedIn: a fired effect must always have its renderer. */}
+
);
diff --git a/src/components/layout/PageHeader.tsx b/src/components/layout/PageHeader.tsx
index 8335d8d02..647ae5c80 100644
--- a/src/components/layout/PageHeader.tsx
+++ b/src/components/layout/PageHeader.tsx
@@ -4,7 +4,7 @@ import type { LucideIcon } from "lucide-react";
type PageHeaderProps = {
icon: LucideIcon;
title: string;
- subtitle: string;
+ subtitle?: string;
actions?: ReactNode;
className?: string;
/**
@@ -13,20 +13,32 @@ type PageHeaderProps = {
* where the subtitle is secondary. `undefined` keeps the subtitle always visible.
*/
hideSubtitleBelow?: "sm" | "md" | "lg";
+ /** Optional click handler for the header icon (e.g., for easter eggs). */
+ onIconClick?: () => void;
/**
- * Optional click handler for the header icon (e.g., for easter eggs).
+ * Marks the icon as hiding an easter egg: it grows slightly on hover and
+ * plays a one-shot glow pulse on mount. Purely decorative, so it is not
+ * reflected in the accessible name; users who prefer reduced motion get
+ * neither effect (see the page-header-icon rules in styles/index.css).
*/
- onIconClick?: () => void;
+ eggHint?: boolean;
+ /**
+ * Optional popover or speech bubble anchored directly to the header icon
+ * (e.g. for easter-egg hints or status badges).
+ */
+ iconPopover?: ReactNode;
};
export function PageHeader({
icon: Icon,
title,
- subtitle,
+ subtitle = "",
actions,
className = "",
hideSubtitleBelow,
onIconClick,
+ eggHint = false,
+ iconPopover,
}: PageHeaderProps) {
return (
@@ -34,15 +46,23 @@ export function PageHeader({
{onIconClick ? (
-
-
-
+
+
+
+
+ {iconPopover}
+
) : (
-
+
+
+ {iconPopover}
+
)}
@@ -50,13 +70,15 @@ export function PageHeader({
-
- {subtitle}
-
+ {subtitle ? (
+
+ {subtitle}
+
+ ) : null}
{actions ? (
diff --git a/src/components/layout/SidebarLogo.tsx b/src/components/layout/SidebarLogo.tsx
index 811b17a3a..5497b1975 100644
--- a/src/components/layout/SidebarLogo.tsx
+++ b/src/components/layout/SidebarLogo.tsx
@@ -1,8 +1,59 @@
-import { motion, useReducedMotion, type Variants } from "framer-motion";
+import { useState } from "react";
+import { motion, useReducedMotion, type TargetAndTransition, type Variants } from "framer-motion";
+import { useRepeatClicks } from "../../features/easter-eggs/hooks/useRepeatClicks";
+import { hoverSpringToken, logoHopSpringToken } from "../../styles/tokens";
const BADGE_SIZE = 44;
const MARK_SIZE = 28;
+/**
+ * How far the badge falls before it hits the floor. Chosen to clear the
+ * header row visually (parents are overflow-visible) so the drop reads as
+ * "fell past its shelf, bounced, hopped back" without portals or measured
+ * rects — a fixed translateY keeps the effect layout-independent.
+ */
+const DROP_DISTANCE = 140;
+
+/**
+ * The gravity choreography, one entry per phase. Each phase animates the
+ * whole badge; `onAnimationComplete` walks idle → falling → impact →
+ * bouncing → hopping → idle, so every beat lands exactly when the previous
+ * one finishes instead of relying on hand-tuned delays.
+ */
+const DROP_PHASES: Record
, TargetAndTransition> = {
+ // Gravity accelerates: easeIn, with a slight forward tumble.
+ falling: { y: DROP_DISTANCE, rotate: 6, transition: { duration: 0.45, ease: "easeIn" } },
+ // Sideways squash on impact — the classic cartoon beat.
+ impact: {
+ scaleX: 1.14,
+ scaleY: 0.8,
+ rotate: 0,
+ transition: { duration: 0.09, ease: "easeOut" },
+ },
+ // Two diminishing bounces; each landing re-squashes a little less.
+ bouncing: {
+ y: [DROP_DISTANCE, 94, DROP_DISTANCE, 119, DROP_DISTANCE],
+ scaleX: [1.14, 1.02, 1.1, 1.03, 1.1],
+ scaleY: [0.8, 1, 0.87, 1, 0.93],
+ transition: {
+ duration: 0.62,
+ times: [0, 0.26, 0.5, 0.74, 1],
+ ease: ["easeOut", "easeIn", "easeOut", "easeIn"],
+ },
+ },
+ // Spring back to the shelf with a small overshoot, rotation settling.
+ hopping: {
+ y: 0,
+ scaleX: 1,
+ scaleY: 1,
+ rotate: 0,
+ transition: logoHopSpringToken,
+ },
+};
+
+/** The gravity sequence phases: idle at rest, then falling → impact → bouncing → hopping. */
+type DropPhase = "idle" | "falling" | "impact" | "bouncing" | "hopping";
+
/**
* Where the exhaust meets the body, in viewBox units. The flame scales from
* here so the plume grows away from the engine instead of stretching from its
@@ -29,13 +80,31 @@ const NOZZLE_ORIGIN = { transformOrigin: "7px 17px", transformBox: "view-box" }
export function SidebarLogo({ className = "" }: { className?: string }) {
const prefersReducedMotion = useReducedMotion();
+ // The gravity easter egg: five clicks drop the badge off its shelf.
+ // Reduced motion skips the animation entirely (pure motion has no honest
+ // static equivalent) — the counter still consumes, nothing plays.
+ const [dropPhase, setDropPhase] = useState("idle");
+ const handleLogoClick = useRepeatClicks(5, () => {
+ if (!prefersReducedMotion && dropPhase === "idle") setDropPhase("falling");
+ });
+
+ /** Walks the choreography forward as each beat's animation completes. */
+ const advanceDrop = () => {
+ setDropPhase((current) => {
+ if (current === "falling") return "impact";
+ if (current === "impact") return "bouncing";
+ if (current === "bouncing") return "hopping";
+ return "idle";
+ });
+ };
+
const badgeVariants: Variants = prefersReducedMotion
? { rest: {}, lift: {} }
: {
rest: { scale: 1 },
lift: {
scale: 1.08,
- transition: { type: "spring", stiffness: 400, damping: 16 },
+ transition: hoverSpringToken,
},
};
@@ -51,7 +120,7 @@ export function SidebarLogo({ className = "" }: { className?: string }) {
lift: {
x: 1.5,
y: -1.5,
- transition: { type: "spring", stiffness: 420, damping: 14 },
+ transition: hoverSpringToken,
},
};
@@ -94,11 +163,29 @@ export function SidebarLogo({ className = "" }: { className?: string }) {
return (
{
if (!isOpen) return;
+ /**
+ * Escape closes the drawer only when nothing inside it has already claimed
+ * the key. Inner consumers (e.g. the dino mini-game, which exits on Escape)
+ * call `preventDefault()`; without this check one keypress would close both
+ * the inner widget and the whole drawer.
+ */
function handleKeyDown(event: KeyboardEvent) {
+ if (event.key === "Escape" && event.defaultPrevented) return;
+
if (event.key === "Escape" && closeOnEscape) {
onClose();
return;
diff --git a/src/context/ChatContext.ts b/src/context/ChatContext.ts
index f03e47823..d1c34dba0 100644
--- a/src/context/ChatContext.ts
+++ b/src/context/ChatContext.ts
@@ -54,6 +54,14 @@ export type ChatContextValue = {
*/
streamingChatId: string | null;
+ /**
+ * How the most recent turn ended, per chat. `null` while a turn is running
+ * or before the first one. Exists so waiting UI (the dino easter egg) can
+ * say "Stopped" / "Reply failed" instead of claiming a reply is ready when
+ * the user pressed Stop or the stream errored.
+ */
+ lastTurnOutcome: ChatTurnOutcome | null;
+
selectedCitation: SelectedCitation | null;
setSelectedCitation: (value: SelectedCitation | null) => void;
@@ -170,4 +178,13 @@ export type ChatContextValue = {
deleteChat: (chatId: string) => Promise;
};
+/**
+ * Outcome of a finished chat turn, keyed by the chat it belongs to so a
+ * background chat's result never leaks into the one on screen.
+ */
+export type ChatTurnOutcome = {
+ chatId: string;
+ kind: "done" | "stopped" | "failed";
+};
+
export const ChatContext = createContext(undefined);
diff --git a/src/context/ChatProvider.tsx b/src/context/ChatProvider.tsx
index df63f2a2f..8cdf647ef 100644
--- a/src/context/ChatProvider.tsx
+++ b/src/context/ChatProvider.tsx
@@ -11,7 +11,7 @@ import { useAuth } from "./useAuth";
import { useToastApi } from "./useToast";
import { useProjectContext } from "../features/projects/useProjectContext";
import { ChatContext } from "./ChatContext";
-import type { ChatContextValue, SelectedCitation } from "./ChatContext";
+import type { ChatContextValue, ChatTurnOutcome, SelectedCitation } from "./ChatContext";
import type {
Chat,
ChatMessage,
@@ -85,6 +85,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
const [messagesByChat, setMessagesByChat] = useState({});
const [isThinking, setIsThinking] = useState(false);
+ const [lastTurnOutcome, setLastTurnOutcome] = useState(null);
const [isStreaming, setIsStreaming] = useState(false);
const [streamingMessageId, setStreamingMessageId] = useState(null);
@@ -173,6 +174,9 @@ export function ChatProvider({ children }: { children: ReactNode }) {
// first token of the post-tool answer glues onto pre-tool preamble text
// ("Let me search…Searching knowledge base…") inside the same bubble.
const sawToolUseRef = useRef(false);
+ // Set to true on the first content token from onToken. Keeps isThinking true
+ // during any reasoning/thoughts streaming phase so that the waiting game stays active.
+ const replyStartedRef = useRef(false);
// Monotonic id per `sendMessage` call. Each handler captures the streamId
// it was created for and no-ops if it doesn't match the current value —
@@ -317,6 +321,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
clearStreamTimeout();
streamingStartedRef.current = false;
sawToolUseRef.current = false;
+ replyStartedRef.current = false;
streamIdRef.current += 1;
latestLoadRef.current = null;
if (draftRef.current && draftRef.current.rafId !== null) {
@@ -525,6 +530,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
}));
setIsThinking(true);
+ setLastTurnOutcome(null);
// A follow-up sent mid-stream would otherwise inherit the previous
// stream's flags — a caret on a message that no longer receives tokens
// and the previous turn's tool label under the dots.
@@ -534,6 +540,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
commitStreamingChat(currentChatId);
streamingStartedRef.current = false;
sawToolUseRef.current = false;
+ replyStartedRef.current = false;
// Initialize the rAF-batched draft so token/reasoning/citation events
// append to a mutable buffer instead of triggering a state update each.
@@ -571,6 +578,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
"The answer timed out",
"The assistant stopped responding for five minutes.",
);
+ setLastTurnOutcome({ chatId: currentChatId, kind: "failed" });
}, STREAM_TIMEOUT_MS);
};
armStreamTimeout();
@@ -592,6 +600,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
if (!isCurrentStream()) return;
streamingStartedRef.current = false;
sawToolUseRef.current = false;
+ replyStartedRef.current = false;
abortControllerRef.current = null;
setIsStreaming(false);
setIsThinking(false);
@@ -648,7 +657,8 @@ export function ChatProvider({ children }: { children: ReactNode }) {
if (!streamingStartedRef.current) {
streamingStartedRef.current = true;
setIsStreaming(true);
- setIsThinking(false);
+ // Keep isThinking true while thoughts/reasoning stream,
+ // so the Dino waiting game remains armed until the actual reply arrives.
setStreamingMessageId(assistantId);
}
@@ -666,9 +676,12 @@ export function ChatProvider({ children }: { children: ReactNode }) {
if (!streamingStartedRef.current) {
streamingStartedRef.current = true;
setIsStreaming(true);
- setIsThinking(false);
setStreamingMessageId(assistantId);
}
+ if (!replyStartedRef.current) {
+ replyStartedRef.current = true;
+ setIsThinking(false);
+ }
const draft = draftRef.current;
if (draft) {
@@ -743,6 +756,14 @@ export function ChatProvider({ children }: { children: ReactNode }) {
),
}));
+ // The timeout path aborts and then lands here — keep its
+ // "failed" verdict instead of overwriting it with "done".
+ setLastTurnOutcome((prev) =>
+ prev?.chatId === currentChatId && prev.kind === "failed"
+ ? prev
+ : { chatId: currentChatId, kind: "done" },
+ );
+
void refreshChats();
// The answer is complete, so the next queued message may go.
@@ -760,6 +781,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
if (!isCurrentStream()) return;
reportFailure("The answer failed", err);
+ setLastTurnOutcome({ chatId: currentChatId, kind: "failed" });
// A failed turn still frees the queue: the next message may well
// be the retry, and holding it hostage to a failure the user has
// already been told about helps nobody.
@@ -781,6 +803,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
if (!isCurrentStream()) return;
reportFailure("The answer failed", "Unexpected error during streaming.");
+ setLastTurnOutcome({ chatId: currentChatId, kind: "failed" });
drainRef.current(currentChatId);
}
},
@@ -814,10 +837,12 @@ export function ChatProvider({ children }: { children: ReactNode }) {
flushDraft();
const stopped = draftRef.current;
draftRef.current = null;
+ if (stopped) setLastTurnOutcome({ chatId: stopped.chatId, kind: "stopped" });
abortControllerRef.current?.abort();
abortControllerRef.current = null;
streamingStartedRef.current = false;
sawToolUseRef.current = false;
+ replyStartedRef.current = false;
setIsStreaming(false);
setIsThinking(false);
setStreamingMessageId(null);
@@ -1017,6 +1042,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
streamingMessageId,
thinkingState,
streamingChatId,
+ lastTurnOutcome,
selectedCitation,
setSelectedCitation,
newRequest,
diff --git a/src/features/buddy/components/BuddyComposer.tsx b/src/features/buddy/components/BuddyComposer.tsx
index 6875a604c..8568d68d8 100644
--- a/src/features/buddy/components/BuddyComposer.tsx
+++ b/src/features/buddy/components/BuddyComposer.tsx
@@ -7,7 +7,12 @@ import { useAutoResize } from "../../../components/ui/useAutoResize";
type BuddyComposerProps = {
draft: string;
setDraft: (value: string) => void;
- handleSubmit: (event: React.FormEvent) => void;
+ /**
+ * Submits the box. Returns whether a turn was actually started: `false` when the caller
+ * swallowed the submission (an easter-egg phrase, an empty draft), which is what keeps the
+ * caret here instead of handing it off on a send that never happened — see `submit`.
+ */
+ handleSubmit: (event: React.FormEvent) => boolean;
/** Composer placeholder — "Type your answer…" while the buddy is intaking. */
placeholder?: string;
/** Drops the keyboard hint under the box, for the dock where the room is better spent. */
@@ -20,6 +25,23 @@ type BuddyComposerProps = {
* the dock would otherwise be typed in front of.
*/
focusOnMount?: boolean;
+ /**
+ * Whether the buddy is still writing (thinking dots or streaming text).
+ *
+ * Used for one thing: the caret comes back to this box when the answer
+ * finishes, undoing the deliberate blur a send performs (see `submit`).
+ * The chat page runs the same two-way dance — see `ChatPage`'s focus
+ * effect — and for the same reason: Space starts the dino waiting-game
+ * only while nothing is focused, so a box that keeps the caret after a
+ * send would quietly make that egg unreachable on this surface.
+ */
+ busy?: boolean;
+ /**
+ * Whether the dino waiting-game is open. The caret is held back while it is: the game
+ * ignores keys aimed at text fields, so a refocus mid-run (the reply arriving) would kill
+ * the dino, deaden Escape and type Spaces in here. Closing the game hands the caret back.
+ */
+ gameActive?: boolean;
};
/**
@@ -42,9 +64,17 @@ export function BuddyComposer({
placeholder = "Ask your buddy anything...",
compact = false,
focusOnMount = false,
+ busy = false,
+ gameActive = false,
}: BuddyComposerProps) {
const fieldRef = useRef(null);
+ // Set when *this* composer gave up the caret on a send, so the refocus
+ // below never reaches for focus it never had: the dock mounts on every
+ // page, and a composer that grabbed the caret on its own would steal it
+ // from whatever the hire was actually typing in.
+ const handedOffCaretRef = useRef(false);
+
// One line at rest, six at most — past that it scrolls rather than eating the thread.
useAutoResize({ ref: fieldRef, value: draft, minRows: 1, maxRows: 6 });
@@ -58,6 +88,42 @@ export function BuddyComposer({
field.setSelectionRange(field.value.length, field.value.length);
}, [focusOnMount]);
+ // The mirror of the blur in `submit`: once the buddy has stopped writing, the caret comes
+ // back so a follow-up question does not need a click first. Skipped when somebody else holds
+ // focus, so this never pulls the caret out of a field the hire moved to in the meantime.
+ useEffect(() => {
+ if (busy || gameActive || !handedOffCaretRef.current) return;
+ handedOffCaretRef.current = false;
+ const field = fieldRef.current;
+ if (!field) return;
+ const active = document.activeElement;
+ if (active && active !== document.body) return;
+ field.focus();
+ }, [busy, gameActive]);
+
+ /**
+ * Sends, then hands the caret back to the page.
+ *
+ * Space opens the dino waiting-game while the buddy works, and the trigger refuses to fire
+ * while a text field holds focus — otherwise it would eat the space bar. The chat page solves
+ * this by blurring its composer on submit; this box said nothing about focus, so the game was
+ * only reachable after clicking away from it. Pressing Escape or clicking the thread still
+ * works too: this only makes the documented gesture (just press Space) true here.
+ *
+ * Only a submission that started a turn counts. An egg phrase is swallowed by the caller —
+ * there is no turn to play a game under, and the caret belongs in the box the hire is still
+ * typing in, not handed away and left to come back on its own (which it never would: the
+ * refocus below hangs off `busy` flipping, and nothing ever became busy).
+ */
+ const submit = (event: React.FormEvent) => {
+ if (!handleSubmit(event)) return;
+ const field = fieldRef.current;
+ if (field && document.activeElement === field) {
+ handedOffCaretRef.current = true;
+ field.blur();
+ }
+ };
+
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Enter" || event.shiftKey) return;
// Enter also *commits* an IME candidate — a compose-key 'ü', or any CJK input. Sending
@@ -76,7 +142,7 @@ export function BuddyComposer({
return (
diff --git a/src/features/buddy/components/BuddyDock.tsx b/src/features/buddy/components/BuddyDock.tsx
index d47230bac..e7d1dca7d 100644
--- a/src/features/buddy/components/BuddyDock.tsx
+++ b/src/features/buddy/components/BuddyDock.tsx
@@ -76,6 +76,14 @@ type BuddyDockProps = Pick<
openError?: string | null;
/** Tries the read again, from the banner that reports the failure. */
onRetryOpen?: () => void;
+ /** Whether the dino waiting-game is open while the buddy thinks (see `BuddyThread`). */
+ dinoGameActive?: boolean;
+ /**
+ * Called when the player leaves the dino waiting-game. Named `onDinoGameExit` to match
+ * BuddyThread, which is what the dock forwards it to — one name across the dock → thread
+ * boundary so callers pass it once and forget.
+ */
+ onDinoGameExit?: () => void;
/** Whether the hire has put the suggestion row away for this session. */
suggestionsHidden?: boolean;
/** Puts it away. Held by the widget so it survives closing and reopening the dock. */
@@ -131,6 +139,8 @@ export function BuddyDock({
confirmAction,
dismissAction,
suggestions,
+ dinoGameActive = false,
+ onDinoGameExit,
startFreshVisit,
isGreeting,
isDeciding,
@@ -323,6 +333,7 @@ export function BuddyDock({
compact
messages={messages}
isThinking={isThinking}
+ isStreaming={isStreaming}
activeTool={activeTool}
lastMessageFooter={lastMessageFooter}
confirmAction={confirmAction}
@@ -334,6 +345,8 @@ export function BuddyDock({
}
openError={openError}
onRetryOpen={onRetryOpen}
+ dinoGameActive={dinoGameActive}
+ onDinoGameExit={onDinoGameExit}
onStartFreshVisit={() => void startFreshVisit()}
/>
@@ -382,6 +395,8 @@ export function BuddyDock({
handleSubmit={handleSubmit}
compact
focusOnMount
+ busy={isBusy}
+ gameActive={dinoGameActive}
/>
diff --git a/src/features/buddy/components/BuddyMessage.tsx b/src/features/buddy/components/BuddyMessage.tsx
index 488348bf3..2739e9366 100644
--- a/src/features/buddy/components/BuddyMessage.tsx
+++ b/src/features/buddy/components/BuddyMessage.tsx
@@ -2,6 +2,11 @@ import type { ReactNode } from "react";
import { motion, useReducedMotion } from "framer-motion";
import { AlertCircle, UserRound } from "lucide-react";
import { SleepyBot } from "../../chatbot/components/SleepyBot";
+import { BotGlyph } from "../../chatbot/components/BotGlyph";
+import { DinoGame } from "../../chatbot/components/DinoGame.tsx";
+import { dinoCompletionProps } from "../../chatbot/dinoOutcome.ts";
+import type { DinoTurnOutcome } from "../../chatbot/dinoOutcome.ts";
+import { centralSpringToken } from "../../../styles/tokens.ts";
import { UserAvatar } from "../../../components/common/UserAvatar";
import { useAuth } from "../../../context/useAuth";
@@ -218,12 +223,78 @@ function SpeakerAvatar({ speaker, isStreaming }: { speaker: BuddySpeaker; isStre
export function BuddyTypingMessage({
label,
showName = false,
+ gameActive = false,
+ replyReady = false,
+ turnOutcome = null,
+ onGameExit,
}: {
label?: string;
showName?: boolean;
+ /** True when the dino waiting-game is open instead of the dots. */
+ gameActive?: boolean;
+ /**
+ * True when the turn has finished while the game is open: forwarded to the
+ * game as its completion badge and used to stop showing the dots — a reply
+ * that has arrived is not being typed anymore.
+ */
+ replyReady?: boolean;
+ /**
+ * How the finished turn ended — a failed reply must not be announced as "Reply ready".
+ * Only read once `replyReady` is true.
+ */
+ turnOutcome?: DinoTurnOutcome;
+ /** Called when the player leaves the dino game (Escape / exit button). */
+ onGameExit?: () => void;
}) {
const prefersReducedMotion = useReducedMotion();
+ // The unlocked dino waiting-game replaces the dots while the buddy works —
+ // the same deal the AI chat's ThinkingIndicator offers. The dots stay
+ // underneath as the status row while the buddy is still working, and the
+ // label keeps explaining what the buddy is doing behind the game. Once the
+ // turn has finished (replyReady) the dots stop: nobody is typing anymore,
+ // and the game's own completion badge takes over as the status line.
+ if (gameActive && onGameExit) {
+ return (
+
+ {/* The game is deliberately outside any live region: its score changes many times a
+ second and would flood a screen reader. This one concise status says what the buddy
+ is doing; the game announces its own completion and game-over lines. */}
+
+ {replyReady ? "" : (label ?? "Buddy is thinking…")}
+
+
+
+
+
+
+
+
+
+ {!replyReady && (
+
+
+
+
+
+
+ {label && {label} }
+
+ )}
+
+
+ );
+ }
+
return (
void;
+ /**
+ * Whether the dino waiting-game is open while the buddy thinks (unlocked
+ * users only; Space opens it — see useSpaceOpensDino). Both surfaces pass
+ * it so dock and page offer the same deal.
+ */
+ dinoGameActive?: boolean;
+ /** Called when the player leaves the dino waiting-game. */
+ onDinoGameExit?: () => void;
/**
* Clears the conversation above the visit divider and opens a clean one.
*
@@ -90,6 +104,7 @@ type BuddyThreadProps = {
export function BuddyThread({
messages,
isThinking,
+ isStreaming = false,
activeTool,
confirmAction,
dismissAction,
@@ -101,6 +116,8 @@ export function BuddyThread({
renderReplyAction,
openError,
onRetryOpen,
+ dinoGameActive = false,
+ onDinoGameExit,
onStartFreshVisit,
freshVisitShortcut,
}: BuddyThreadProps) {
@@ -217,10 +234,16 @@ export function BuddyThread({
);
})}
- {isThinking && (
+ {(isThinking || dinoGameActive) && (
)}
diff --git a/src/features/buddy/components/BuddyWidget.tsx b/src/features/buddy/components/BuddyWidget.tsx
index 9dce202e4..bd794db83 100644
--- a/src/features/buddy/components/BuddyWidget.tsx
+++ b/src/features/buddy/components/BuddyWidget.tsx
@@ -72,6 +72,9 @@ export function BuddyWidget() {
confirmAction,
dismissAction,
suggestions,
+ dinoGameActive,
+ closeDinoGame,
+ registerDinoSurface,
openError,
retryOpen,
closeDock,
@@ -261,6 +264,14 @@ export function BuddyWidget() {
};
}, [handoff, closeDock]);
+ // The dock is a surface the dino game may live in only while it is actually on screen:
+ // minimised, or hidden behind `/buddy`, a Space press must not open a game nobody can see.
+ const dockVisible = isOpen && !(pathname === BUDDY_PAGE && handoff === "idle");
+ useEffect(() => {
+ if (!dockVisible) return;
+ return registerDinoSurface();
+ }, [dockVisible, registerDinoSurface]);
+
// Normally the widget takes itself off `/buddy` — the launcher would offer the page you are
// reading, and the dock would put a second composer over the first. During the hand-off it
// has to stay: it *is* the transition, and unmounting it the instant the route changes is
@@ -305,6 +316,8 @@ export function BuddyWidget() {
confirmAction={confirmAction}
dismissAction={dismissAction}
suggestions={suggestions}
+ dinoGameActive={dinoGameActive}
+ onDinoGameExit={closeDinoGame}
startFreshVisit={startFreshVisit}
isGreeting={isGreeting}
isDeciding={isDeciding}
@@ -316,7 +329,8 @@ export function BuddyWidget() {
suggestionsHidden={suggestionsHidden}
onHideSuggestions={() => setSuggestionsHidden(true)}
// Hire conversation ↔ team conversations, in the header beside the title. The
- // switcher carries the restore audit with it (see `BuddyModeSwitcher`).
+ // switcher only *offers* the switch; the restore audit lives in the session
+ // (`useBuddyConversation` / `BuddyProvider`).
headerControl={
(null);
+
+ // Dino waiting-game: unlocked users may press Space while the buddy thinks
+ // to play the runner until the answer arrives — the same deal the AI chat
+ // offers. Closing is handled inside the hook: on exit, when the turn ends,
+ // or when the cogwheel unlock flag flips off.
+ const dinoUnlocked = useDinoUnlocked();
+ // How many surfaces currently show this thread (the open dock, the mounted `/buddy` page).
+ // The session lives app-wide in BuddyProvider, so arming on `isThinking` alone let Space open
+ // a game inside a minimised dock nobody could see — and with `keepActiveUntilExit` that
+ // invisible game held the shared slot for good. Surfaces register via `useDinoSurface`.
+ const [dinoSurfaceCount, setDinoSurfaceCount] = useState(0);
+ const dinoSurfaceVisible = dinoSurfaceCount > 0;
+ const [dinoGameActive, closeDinoGame] = useSpaceOpensDino(
+ isThinking && dinoSurfaceVisible,
+ dinoUnlocked,
+ {
+ // Parity with the chat and the drawer: the game outlives the turn it was armed for
+ // and stays open until the player leaves it — the reply's arrival only flips its
+ // completion badge. Without this the first token unmounted the game mid-run.
+ keepActiveUntilExit: true,
+ },
+ );
+
+ // The last visible surface went away (dock minimised, page left): the game it hosted is gone
+ // from screen, so it must not keep running — or keep the shared slot — behind it.
+ useEffect(() => {
+ if (!dinoSurfaceVisible) closeDinoGame();
+ }, [dinoSurfaceVisible, closeDinoGame]);
+
+ /**
+ * Declares that a surface showing this thread is on screen. Returns the matching release;
+ * meant to be called from an effect (see `useDinoSurface`).
+ */
+ const registerDinoSurface = useCallback(() => {
+ setDinoSurfaceCount((count) => count + 1);
+ return () => setDinoSurfaceCount((count) => count - 1);
+ }, []);
+
// The one suggested next step the opening greeting invites, until the hire acts or asks.
const [openerAction, setOpenerAction] = useState(null);
// True while a surface is opening the conversation, so it can show a loading state rather
@@ -121,8 +162,15 @@ export function useBuddyConversation(
const { profile } = useAuth();
const userId = profile?.id ?? null;
const userIdRef = useRef(null);
+ // Declared before the effect that resets it: the target binding must not outlive the user whose
+ // thread created it.
+ const teamTargetRef = useRef(null);
useEffect(() => {
userIdRef.current = userId;
+ // A signed-in-user change drops the old user's project binding here, not just the visible
+ // mode: a stale target would make the adopt effect compare the next user's selection against
+ // it and overwrite their restored preference with a bogus exit.
+ teamTargetRef.current = null;
// Re-read on every subject change: a logout or an account switch must not inherit the
// previous user's preference. Nothing persisted while there is no user to own it.
// Deferred to a microtask so the setState never runs synchronously in the effect body.
@@ -137,7 +185,6 @@ export function useBuddyConversation(
* target while a switch is pending. `null` means team mode is on but has not adopted a
* project yet (a restored preference waiting for the list to vouch).
*/
- const teamTargetRef = useRef(null);
const setTeamMode = useCallback((value: boolean) => {
setIsTeamMode(value);
@@ -426,8 +473,17 @@ export function useBuddyConversation(
// second open. The backend replays the greeting it has just written rather than composing
// another, so the hire would read the identical words twice.
// `pendingDecisionsRef` is read alongside the state: a decision and this click can land
- // in one frame, before the "deciding" state has re-rendered.
- if (greetingRef.current || isDeciding || pendingDecisionsRef.current > 0) return;
+ // in one frame, before the "deciding" state has re-rendered. A live reply is also a
+ // reason to refuse: clearing mid-stream would silently drop the tokens, citations and
+ // proposed actions still on their way.
+ if (
+ greetingRef.current ||
+ isDeciding ||
+ isThinking ||
+ isStreaming ||
+ pendingDecisionsRef.current > 0
+ )
+ return;
greetingRef.current = true;
setMessages([]);
@@ -443,7 +499,7 @@ export function useBuddyConversation(
greetingRef.current = false;
setIsOpening(false);
}
- }, [greet, isDeciding]);
+ }, [greet, isDeciding, isThinking, isStreaming]);
/**
* Marks the turn a reply was streaming into as failed, so the thread says so.
@@ -825,9 +881,21 @@ export function useBuddyConversation(
// The latch is per conversation: releasing it is what lets `ensureOpened` read and greet
// the one being switched to, exactly as it did the first time.
loadedRef.current = false;
+ // A game left open from the previous conversation would keep claiming "Reply ready" for a
+ // thread that has just been cleared.
+ closeDinoGame();
void ensureOpened();
- }, [teamProjectId, isThinking, isStreaming, isOpening, isGreeting, isDeciding, ensureOpened]);
+ }, [
+ teamProjectId,
+ isThinking,
+ isStreaming,
+ isOpening,
+ isGreeting,
+ isDeciding,
+ ensureOpened,
+ closeDinoGame,
+ ]);
/**
* Points the buddy at a managed project (`null` returns to the hire's own onboarding). The
@@ -866,17 +934,33 @@ export function useBuddyConversation(
[isThinking, isStreaming, isOpening, isGreeting, isDeciding, selection, setTeamMode],
);
+ /**
+ * Handles a composer submission: an egg phrase plays its effect and is swallowed, anything
+ * else is sent. Returns whether a turn was started — `false` means the submission went
+ * nowhere, which the composer uses to decide whether the caret should be handed off.
+ */
const handleSubmit = useCallback(
(event: React.FormEvent) => {
event.preventDefault();
+ // Easter-egg phrases are intercepted before anything is sent: the
+ // effect plays app-wide (EggEffectsLayer) and the message is swallowed
+ // silently — no reply, no request. Same contract as the AI chat.
+ const eggEffect = matchEggPhrase(draft);
+ if (eggEffect) {
+ setDraft("");
+ playEggEffect(eggEffect);
+ return false;
+ }
+
const text = draft;
- if (!text.trim()) return;
+ if (!text.trim()) return false;
setDraft("");
void sendMessage(text);
+ return true;
},
- [draft, sendMessage],
+ [draft, sendMessage, setDraft],
);
return {
@@ -906,6 +990,11 @@ export function useBuddyConversation(
confirmAction,
dismissAction,
+ dinoGameActive,
+ closeDinoGame,
+ dinoUnlocked,
+ registerDinoSurface,
+
ensureOpened,
retryOpen,
startFreshVisit,
diff --git a/src/features/chatbot/components/DinoGame.tsx b/src/features/chatbot/components/DinoGame.tsx
index 30e5548d2..06f693d4d 100644
--- a/src/features/chatbot/components/DinoGame.tsx
+++ b/src/features/chatbot/components/DinoGame.tsx
@@ -1,10 +1,44 @@
import { useCallback, useEffect, useRef, useState } from "react";
+import { Button } from "../../../components/ui/Button.tsx";
+import { isTypingTarget } from "../../easter-eggs/lib/keyTargets.ts";
+
+/**
+ * How the work the player is waiting on ended. Drives the badge colour *and*
+ * its wording, so the outcome never rests on colour alone (WCAG 1.4.1):
+ * `success` — the reply/sync/path is there; `neutral` — the user stopped it;
+ * `danger` — it failed.
+ */
+export type DinoCompletionTone = "success" | "neutral" | "danger";
+
+const TONE_DOT: Record = {
+ success: "bg-app-success-solid",
+ neutral: "bg-app-text-muted",
+ danger: "bg-app-danger-solid",
+};
type DinoGameProps = {
/**
* Called when the player leaves the game (Escape or the exit button).
*/
onExit: () => void;
+ /**
+ * True when the assistant reply has arrived while the game is open.
+ */
+ replyReady?: boolean;
+ /**
+ * Optional custom completion label shown when `replyReady` is true (defaults to "Reply ready").
+ */
+ completionLabel?: string;
+ /**
+ * How the awaited work ended (default `success`). A stopped or failed turn must not
+ * claim "Reply ready" — the host passes the matching label and tone instead.
+ */
+ completionTone?: DinoCompletionTone;
+ /**
+ * Label of the game-over button that leaves the game once `replyReady` is true.
+ * Defaults to "View Reply", or to `completionLabel` when one is given.
+ */
+ continueLabel?: string;
};
type Phase = "intro" | "play" | "over";
@@ -160,7 +194,13 @@ function roundedRect(
* The whole game runs on a canvas driven by requestAnimationFrame; React state
* is only used for the surrounding chrome (game-over overlay, score badge).
*/
-export function DinoGame({ onExit }: DinoGameProps) {
+export function DinoGame({
+ onExit,
+ replyReady = false,
+ completionLabel,
+ completionTone = "success",
+ continueLabel,
+}: DinoGameProps) {
const containerRef = useRef(null);
const canvasRef = useRef(null);
@@ -181,7 +221,7 @@ export function DinoGame({ onExit }: DinoGameProps) {
scoreF: 0,
lastMilestone: 0,
distanceSinceSpawn: 0,
- nextGap: 320,
+ nextGap: 380,
groundOffset: 0,
dustTimer: 0,
landTimer: 0,
@@ -199,20 +239,26 @@ export function DinoGame({ onExit }: DinoGameProps) {
const resetWorld = useCallback(() => {
const w = worldRef.current;
-
w.phase = "intro";
- w.player = { x: 56, y: -40, vy: 0, size: 34, onGround: false }; // start above, drop in
+ w.player = { x: 56, y: -40, vy: 0, size: 34, onGround: false };
w.obstacles = [];
+ w.clouds = [];
w.particles = [];
w.toasts = [];
w.speed = START_SPEED;
w.scoreF = 0;
w.lastMilestone = 0;
w.distanceSinceSpawn = 0;
- w.nextGap = 320;
+ w.nextGap = 380;
+ w.groundOffset = 0;
+ w.dustTimer = 0;
w.landTimer = 0;
w.shakeTimer = 0;
+ w.time = 0;
+ w.jumpHeld = false;
w.jumpBuffer = 0;
+ w.duckHeld = false;
+
setScore(0);
setNewHighScore(false);
setStatus("intro");
@@ -223,6 +269,10 @@ export function DinoGame({ onExit }: DinoGameProps) {
w.jumpHeld = true;
if (w.phase === "over") {
+ if (replyReady) {
+ onExit();
+ return;
+ }
resetWorld();
return;
}
@@ -235,7 +285,7 @@ export function DinoGame({ onExit }: DinoGameProps) {
w.jumpBuffer = JUMP_BUFFER_TIME;
}
}
- }, [resetWorld]);
+ }, [onExit, replyReady, resetWorld]);
const releaseJump = useCallback(() => {
const w = worldRef.current;
@@ -251,12 +301,12 @@ export function DinoGame({ onExit }: DinoGameProps) {
// Keyboard + pointer controls.
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
- if (e.key === "Escape") {
- e.preventDefault();
- onExit();
- return;
- }
-
+ // The game listens on the window, and it now outlives the turn it was armed
+ // for — exactly the window in which the user starts typing the next message
+ // (the composer regains focus the moment the turn ends). While the event
+ // target is a text field, every key below is a character or caret move:
+ // leave it unprevented and unhandled.
+ if (isTypingTarget(e.target)) return;
if (e.code === "Space" || e.key === "ArrowUp" || e.key === "w") {
e.preventDefault();
if (!e.repeat) pressJump();
@@ -270,6 +320,7 @@ export function DinoGame({ onExit }: DinoGameProps) {
};
const onKeyUp = (e: KeyboardEvent) => {
+ if (isTypingTarget(e.target)) return;
if (e.code === "Space" || e.key === "ArrowUp" || e.key === "w") {
releaseJump();
return;
@@ -280,9 +331,26 @@ export function DinoGame({ onExit }: DinoGameProps) {
}
};
+ // Escape is handled in the *capture* phase on the window, i.e. before any
+ // listener on the document or an element sees it. The game is often hosted inside
+ // a surface that closes on Escape itself (SidePanel listens on the document, which
+ // fires before a bubbling window listener). One Escape must close only the
+ // innermost thing — the game — so it is claimed here: preventDefault for hosts
+ // that check `defaultPrevented`, stopPropagation for the ones that do not. It is
+ // honoured even from a text field: Escape types nothing, and leaving the game from
+ // wherever focus happens to be is what the "Esc ✕" label promises.
+ const onEscape = (e: KeyboardEvent) => {
+ if (e.key !== "Escape" || e.defaultPrevented) return;
+ e.preventDefault();
+ e.stopPropagation();
+ onExit();
+ };
+
+ window.addEventListener("keydown", onEscape, true);
window.addEventListener("keydown", onKeyDown);
window.addEventListener("keyup", onKeyUp);
return () => {
+ window.removeEventListener("keydown", onEscape, true);
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("keyup", onKeyUp);
};
@@ -780,7 +848,21 @@ export function DinoGame({ onExit }: DinoGameProps) {
className="relative w-full overflow-hidden rounded-2xl border border-app-border bg-app-surface-muted"
role="application"
aria-label="Mini dino game — space jumps (hold for higher), arrow down ducks, escape exits"
+ data-testid="dino-game"
>
+ {/*
+ The one live region of the game. Hosts must NOT wrap the game in their own
+ role="status": the score below changes ~12×/s and would be read out endlessly.
+ Only the outcome of the awaited work and the final score are announced.
+ */}
+
+ {replyReady
+ ? (completionLabel ?? "Reply ready")
+ : status === "over"
+ ? `Game over. Score ${score}.`
+ : ""}
+
+
-
+
HI {String(highScore).padStart(5, "0")}
@@ -800,13 +882,30 @@ export function DinoGame({ onExit }: DinoGameProps) {
-
+ ) : undefined
+ }
>
- Esc ✕
-
+ {replyReady ? (
+
+ {`${completionLabel ?? "Reply ready"} · Esc ✕`}
+
+ ) : (
+ "Esc ✕"
+ )}
+
{/* Controls hint */}
@@ -824,8 +923,8 @@ export function DinoGame({ onExit }: DinoGameProps) {
GAME OVER
{newHighScore ? (
-
-
🏆
+
+ 🏆
New High Score! {score}
) : (
@@ -834,20 +933,23 @@ export function DinoGame({ onExit }: DinoGameProps) {
)}
-
- Play Again (Space)
-
-
+ {replyReady ? (
+
+ {`${continueLabel ?? completionLabel ?? "View Reply"} (Space)`}
+
+ ) : (
+
+ Play Again (Space)
+
+ )}
+
Exit (Esc)
-
+
)}
diff --git a/src/features/chatbot/components/ThinkingIndicator.tsx b/src/features/chatbot/components/ThinkingIndicator.tsx
index 59f9d90fb..34d487df1 100644
--- a/src/features/chatbot/components/ThinkingIndicator.tsx
+++ b/src/features/chatbot/components/ThinkingIndicator.tsx
@@ -1,14 +1,24 @@
import { BotGlyph } from "./BotGlyph";
import { DinoGame } from "./DinoGame";
import { THINKING_LABELS, type ThinkingState } from "../constants";
+import { dinoCompletionProps, type DinoTurnOutcome } from "../dinoOutcome";
type ThinkingIndicatorProps = {
- /** True while the assistant is working (before the first token arrives). */
+ /** True while the assistant is working (before the first reply token arrives). */
isThinking: boolean;
/** Whether the dino easter-egg game is active (shown instead of the dots). */
gameActive: boolean;
/** The tool the backend currently reports it's running, if any. */
thinkingState: string | null;
+ /** Whether thoughts/reasoning are actively present (suppresses duplicate dots). */
+ hasReasoning?: boolean;
+ /** True if the turn finished (reply, stop or failure) while the game is still active. */
+ replyReady?: boolean;
+ /**
+ * How the finished turn ended, so the game says "Stopped" / "Reply failed"
+ * instead of "Reply ready" after a Stop or stream error.
+ */
+ turnOutcome?: DinoTurnOutcome;
/** Called when the user exits the dino game. */
onGameExit: () => void;
};
@@ -23,9 +33,21 @@ export function ThinkingIndicator({
isThinking,
gameActive,
thinkingState,
+ hasReasoning = false,
+ replyReady = false,
+ turnOutcome = null,
onGameExit,
}: ThinkingIndicatorProps) {
- if (!isThinking) return null;
+ // If neither thinking nor game active, nothing to render.
+ if (!isThinking && !gameActive) return null;
+
+ // When the dino game is closed, streamed reasoning leaves the ReasoningPanel as the
+ // visible carrier of the thinking state, so the bouncing dots are suppressed as
+ // redundant. Residual gap, accepted: a tool label set *after* the reasoning (e.g.
+ // "Searching knowledge base…") has no surface while the panel is open — the panel
+ // does not render thinkingState, and these dots stay hidden. With the game open the
+ // label shows here next to the game.
+ if (hasReasoning && !gameActive) return null;
// Resolve the status label; unknown tool names fall back to no label.
const state = thinkingState as ThinkingState | null;
@@ -47,27 +69,33 @@ export function ThinkingIndicator({
);
if (gameActive) {
+ // The game (score ticks ~12x/s) must not sit inside a live region, or a
+ // screen reader re-reads it constantly. One concise, always-mounted status
+ // carries the working state; DinoGame announces its own outcome.
return (
-
+
-
+
+ {isThinking ? (label ?? "Thinking…") : ""}
+
-
-
-
-
+
+ {isThinking && (
+
- {label &&
{label} }
-
+ >
+
+
+
+ {label &&
{label} }
+
+ )}
);
diff --git a/src/features/chatbot/dinoOutcome.ts b/src/features/chatbot/dinoOutcome.ts
new file mode 100644
index 000000000..7301f8f5c
--- /dev/null
+++ b/src/features/chatbot/dinoOutcome.ts
@@ -0,0 +1,47 @@
+/**
+ * How a waited-for assistant turn ended. `null` while it is still running.
+ */
+export type DinoTurnOutcome = "done" | "stopped" | "failed" | null;
+
+/** The subset of DinoGame props that describe the finished turn. */
+export type DinoCompletionProps = {
+ replyReady: boolean;
+ completionLabel?: string;
+ completionTone?: "success" | "neutral" | "danger";
+ continueLabel?: string;
+};
+
+/**
+ * Maps a finished turn onto the dino game's completion badge.
+ *
+ * Why: the game used to say "Reply ready" whenever the busy flags dropped, so a
+ * Stop or a failed stream was announced as a successful reply. The label (not
+ * just the badge colour) carries the outcome so it also reads correctly for
+ * colour-blind and screen-reader users.
+ *
+ * @param finished True once the turn is no longer thinking/streaming.
+ * @param outcome How it ended; `null` (unknown) falls back to "Reply ready".
+ */
+export function dinoCompletionProps(
+ finished: boolean,
+ outcome: DinoTurnOutcome,
+): DinoCompletionProps {
+ if (!finished) return { replyReady: false };
+ if (outcome === "stopped") {
+ return {
+ replyReady: true,
+ completionLabel: "Stopped",
+ completionTone: "neutral",
+ continueLabel: "Back to chat",
+ };
+ }
+ if (outcome === "failed") {
+ return {
+ replyReady: true,
+ completionLabel: "Reply failed",
+ completionTone: "danger",
+ continueLabel: "Back to chat",
+ };
+ }
+ return { replyReady: true };
+}
diff --git a/src/features/chatbot/hooks/useChat.ts b/src/features/chatbot/hooks/useChat.ts
index ade20b756..02cb44d07 100644
--- a/src/features/chatbot/hooks/useChat.ts
+++ b/src/features/chatbot/hooks/useChat.ts
@@ -125,6 +125,7 @@ export function useChat() {
streamingMessageId,
thinkingState,
streamingChatId,
+ lastTurnOutcome,
selectedCitation,
setSelectedCitation,
newRequest,
@@ -492,6 +493,9 @@ export function useChat() {
streamingMessageId: isActiveChatStreaming ? streamingMessageId : null,
+ // Only this chat's verdict; another chat's Stop must not relabel this one.
+ turnOutcome: lastTurnOutcome && lastTurnOutcome.chatId === chatId ? lastTurnOutcome.kind : null,
+
selectedCitation,
setSelectedCitation,
diff --git a/src/features/data-ingestion/components/SourceDetailsPanel.tsx b/src/features/data-ingestion/components/SourceDetailsPanel.tsx
index cdef3a5a3..8d050c3b3 100644
--- a/src/features/data-ingestion/components/SourceDetailsPanel.tsx
+++ b/src/features/data-ingestion/components/SourceDetailsPanel.tsx
@@ -41,6 +41,8 @@ import {
} from "./GithubRepositorySyncSettings.tsx";
import { SourceStatusChip } from "./SourceStatusChip.tsx";
import { SourceTypeBadge } from "./SourceTypeBadge.tsx";
+import { DinoGame } from "../../chatbot/components/DinoGame.tsx";
+import { useDinoUnlocked, useSpaceOpensDino } from "../../easter-eggs/hooks/useDinoWaitingGame.ts";
type SourceDetailsPanelProps = {
source: DataSource;
@@ -117,6 +119,12 @@ export function SourceDetailsPanel({
const isConfluence = source.sourceSystem === "CONFLUENCE";
const isUpdating = updateState === "loading";
const isRefreshing = refreshState === "loading";
+ const isSyncing = source.statusView.state === "syncing" || isUpdating;
+ const syncFailed = source.statusView.state === "attention";
+ const dinoUnlocked = useDinoUnlocked();
+ const [dinoActive, closeDino] = useSpaceOpensDino(isSyncing, dinoUnlocked, {
+ keepActiveUntilExit: true,
+ });
// Update is available for a GitHub repo (needs owner/name), a Jira instance
// (needs its URL), or a Confluence space (needs its ID).
const canUpdate =
@@ -388,16 +396,45 @@ export function SourceDetailsPanel({
}
>
- {source.statusView.state === "syncing" && (
+ {isSyncing && (
-
- {/* The sentence beside it already says what is happening, so the
- glyph stays silent rather than announcing a second time. */}
-
- {source.statusView.label === "Indexing"
- ? "Indexing artifacts into the knowledge base…"
- : "Syncing the latest changes…"}
-
+
+
+ {/* The sentence beside it already says what is happening, so the
+ glyph stays silent rather than announcing a second time. */}
+
+ {source.statusView.label === "Indexing"
+ ? "Indexing artifacts into the knowledge base…"
+ : "Syncing the latest changes…"}
+
+ {dinoUnlocked && !dinoActive && (
+
+ Press{" "}
+
+ Space
+ {" "}
+ to pass the time 🦖
+
+ )}
+
+
+ )}
+
+ {dinoActive && (
+
+
)}
diff --git a/src/features/dino/components/DinoGameModal.tsx b/src/features/dino/components/DinoGameModal.tsx
deleted file mode 100644
index 220f3ab42..000000000
--- a/src/features/dino/components/DinoGameModal.tsx
+++ /dev/null
@@ -1,68 +0,0 @@
-import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
-import { useScrollLock } from "../../../components/ui/useScrollLock";
-import { getModalDialogVariants, modalBackdropVariants } from "../../../styles/tokens";
-import { DinoGame } from "../../chatbot/components/DinoGame";
-
-/**
- * Props for {@link DinoGameModal}.
- */
-interface DinoGameModalProps {
- /** When true, the modal is visible and the dino runner is mounted. */
- open: boolean;
- /**
- * Called when the user requests to close (overlay click, or via the
- * DinoGame's own Esc / exit button — DinoGame calls `onExit` on Escape
- * and on its in-game "Esc ✕" button, which we route here).
- */
- onClose: () => void;
-}
-
-/**
- * Dashboard easter-egg wrapper that mounts the existing {@link DinoGame}
- * runner inside a Framer Motion modal.
- *
- * Unlike {@link Game2048Modal}, this wrapper has no header bar and no
- * modal-level Escape listener: {@link DinoGame} already renders its own
- * score / "Esc ✕" overlay on top of the canvas and calls `onExit` on
- * Escape, so adding a second header or Esc handler would duplicate chrome
- * and double-fire on Esc. Bypasses the `dinoUnlocked` localStorage gate —
- * the dashboard chord is a true easter egg, always available.
- */
-export function DinoGameModal({ open, onClose }: DinoGameModalProps) {
- // Not a `Modal`: the game owns the keyboard, and Modal's focus trap
- // would fight it for the arrow keys. The one thing every overlay needs
- // regardless is the page behind it holding still.
- useScrollLock(open);
-
- const prefersReducedMotion = useReducedMotion();
- const dialogVariants = getModalDialogVariants(Boolean(prefersReducedMotion));
-
- return (
-
- {open && (
-
- e.stopPropagation()}
- >
-
-
-
- )}
-
- );
-}
diff --git a/src/features/dino/hooks/useDinoShortcut.ts b/src/features/dino/hooks/useDinoShortcut.ts
deleted file mode 100644
index 1a3c6ea38..000000000
--- a/src/features/dino/hooks/useDinoShortcut.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-import { useEffect } from "react";
-
-/**
- * Listen for the Ctrl+Shift+1 keyboard chord and fire `onTrigger`.
- *
- * The "1" hints at the dino runner (the first easter egg in the app — it
- * was the original hidden game before 2048 was added). Mirrors the
- * Game2048Shortcut pattern: ignores the chord when the user is typing in
- * a textarea/input/contentEditable so we don't hijack regular editing.
- *
- * @param onTrigger called once per chord press (auto-repeat suppressed)
- */
-export function useDinoShortcut(onTrigger: () => void): void {
- useEffect(() => {
- const isTypingTarget = (el: Element | null) =>
- el instanceof HTMLElement &&
- (el.tagName === "TEXTAREA" || el.tagName === "INPUT" || el.isContentEditable);
-
- const onKeyDown = (e: KeyboardEvent) => {
- // Use e.code (physical key) instead of e.key (produced character):
- // Shift+1 produces "!" on both US QWERTY and German QWERTZ, so
- // e.key === "1" only matches when Shift is NOT pressed — which
- // defeats the whole chord. e.code === "Digit1" is layout-stable.
- if (!(e.ctrlKey && e.shiftKey && e.code === "Digit1")) return;
- if (isTypingTarget(document.activeElement)) return;
- if (e.repeat) return;
-
- e.preventDefault();
- onTrigger();
- };
-
- window.addEventListener("keydown", onKeyDown);
- return () => window.removeEventListener("keydown", onKeyDown);
- }, [onTrigger]);
-}
diff --git a/src/features/easter-eggs/components/ConfettiBurst.tsx b/src/features/easter-eggs/components/ConfettiBurst.tsx
new file mode 100644
index 000000000..7152739b3
--- /dev/null
+++ b/src/features/easter-eggs/components/ConfettiBurst.tsx
@@ -0,0 +1,282 @@
+import { useEffect, useRef } from "react";
+import { useReducedMotion } from "framer-motion";
+import { clearEggEffect } from "../eggEffectBus.ts";
+import { ReducedMotionEffectChip } from "./ReducedMotionEffectChip.tsx";
+
+/**
+ * Confetti particle: position, velocity, spin and look. Plain mutable data —
+ * the rAF loop owns it, React never reads it.
+ */
+type Particle = {
+ x: number;
+ y: number;
+ vx: number;
+ vy: number;
+ /** Rotation angle and per-particle spin speed (rad/s). */
+ rot: number;
+ vr: number;
+ w: number;
+ h: number;
+ color: string;
+ shape: "rect" | "circle";
+ /**
+ * Phase of the paper-flutter oscillation. Advances faster while falling,
+ * which is what turns ballistic arcs into tumbling paper.
+ */
+ wobblePhase: number;
+ wobbleSpeed: number;
+ age: number;
+};
+
+/** How long a particle lives at most, in seconds. */
+const MAX_AGE_S = 3.5;
+/** Gravity, px/s² — tuned so the arc peaks around mid-screen. */
+const GRAVITY = 900;
+/** Per-frame velocity damping (applied once per second worth of time). */
+const DRAG = 0.99;
+/** Particles fired from each of the two bottom cannons. */
+const PARTICLES_PER_CANNON = 75;
+/** Launch window: both cannons finish spawning within this many ms. */
+const SPAWN_WINDOW_MS = 150;
+
+const CANNON_ANGLES = {
+ left: -Math.PI / 2 + Math.PI / 6, // up-and-inward from bottom-left
+ right: -Math.PI / 2 - Math.PI / 6,
+} as const;
+
+/**
+ * ConfettiBurst
+ *
+ * A full-screen canvas confetti celebration, triggered as a chat easter
+ * egg (type "party" / "party time" / "let's party" / 🎉 into any chat
+ * composer — see eggPhrases.ts). Two cannons at the bottom corners fire
+ * ~150 paper particles up-and-inward; they tumble under gravity with air
+ * drag and paper flutter until they leave the screen or expire (~3.5s),
+ * then the canvas fades out and clears its bus effect.
+ *
+ * Implementation notes:
+ * - Same contract as MatrixRain: all mutable state lives outside React,
+ * one delta-time-scaled rAF loop (identical motion on 60/144 Hz), DPR-
+ * aware canvas, cleanup cancels the frame and the resize listener.
+ * - Colors are read ONCE from the app's CSS custom properties at spawn
+ * time, so the confetti follows the active light/dark theme and any
+ * future palette change without this file ever hardcoding hex values.
+ * - Re-fire while running (the bus's `seq`) simply remounts this
+ * component via EggEffectsLayer's key, respawning the world.
+ * - Reduced motion renders no particles at all — see ReducedMotionEffectChip.
+ * - The repo has two other components called `ConfettiBurst`
+ * (`moments/` and `onboarding/`): those radiate outward from a point and
+ * are drawn with framer-motion elements, which is fine for a burst that
+ * belongs to one card or modal. This one is the app-wide `party` effect,
+ * so it takes the whole viewport, runs 150 particles on one canvas, and
+ * ends itself through the egg bus — a different job, not a copy.
+ */
+export function ConfettiBurst() {
+ const canvasRef = useRef(null);
+ const prefersReducedMotion = useReducedMotion();
+
+ // The fade-out's own timer, held so unmounting can cancel it. It fires
+ // into the bus rather than into this component, so a burst that was
+ // remounted (or replaced by another effect) would otherwise have the old
+ // instance's timer clear the *new* effect a few hundred ms in.
+ const fadeTimeoutRef = useRef | null>(null);
+
+ useEffect(() => {
+ if (prefersReducedMotion) return;
+ // The reduced-motion chip owns its own timer; this effect
+ // only runs the canvas. `prefersReducedMotion` is listed so the
+ // linter sees the guard — flipping it remounts nothing because the
+ // layer keys ConfettiBurst by seq, and a flip mid-burst simply ends
+ // the burst, which is the correct outcome anyway.
+
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+
+ const ctx = canvas.getContext("2d");
+ if (!ctx) {
+ // No 2D context (very old browsers, some test environments): the
+ // effect cannot render, so end it immediately instead of leaving a
+ // stuck entry on the bus.
+ clearEggEffect();
+ return;
+ }
+
+ const dpr = Math.min(window.devicePixelRatio || 1, 2);
+ let isFadingOut = false;
+
+ const palette = readPalette();
+
+ const resize = () => {
+ canvas.width = Math.floor(window.innerWidth * dpr);
+ canvas.height = Math.floor(window.innerHeight * dpr);
+ canvas.style.width = `${window.innerWidth}px`;
+ canvas.style.height = `${window.innerHeight}px`;
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+ };
+ resize();
+ window.addEventListener("resize", resize);
+
+ const world: Particle[] = [];
+
+ const spawnCannon = (side: "left" | "right") => {
+ const baseX = side === "left" ? 24 : window.innerWidth - 24;
+ const baseAngle = CANNON_ANGLES[side];
+ for (let i = 0; i < PARTICLES_PER_CANNON; i++) {
+ // ±12° jitter around the cannon's base aim.
+ const angle = baseAngle + (Math.random() - 0.5) * (Math.PI / 7.5);
+ const speed = 520 + Math.random() * 380;
+ world.push({
+ x: baseX,
+ y: window.innerHeight - 12,
+ vx: Math.cos(angle) * speed,
+ vy: Math.sin(angle) * speed,
+ rot: Math.random() * Math.PI * 2,
+ vr: (Math.random() - 0.5) * 14,
+ w: 6 + Math.random() * 5,
+ h: 9 + Math.random() * 6,
+ color: palette[Math.floor(Math.random() * palette.length)],
+ shape: Math.random() < 0.75 ? "rect" : "circle",
+ wobblePhase: Math.random() * Math.PI * 2,
+ wobbleSpeed: 4 + Math.random() * 5,
+ age: 0,
+ });
+ }
+ };
+
+ // Both cannons go off within the first 150ms rather than one instant,
+ // so the burst reads as two pops instead of one wall of paper.
+ const leftTimer = setTimeout(() => spawnCannon("left"), 0);
+ const rightTimer = setTimeout(() => spawnCannon("right"), SPAWN_WINDOW_MS);
+
+ let lastTime = performance.now();
+ // The cannons spawn over the first 150ms, so the world is empty for
+ // the first frames — wait for the first spawn before starting to
+ // judge "everything has died" (otherwise the burst would fade out
+ // before it began).
+ let started = false;
+ let rafId = 0;
+
+ const draw = (now: number): void => {
+ if (!started && world.length > 0) started = true;
+ const dt = Math.min((now - lastTime) / 1000, 0.05);
+ lastTime = now;
+
+ const w = window.innerWidth;
+ const h = window.innerHeight;
+
+ if (!isFadingOut && world.length > 0) {
+ ctx.clearRect(0, 0, w, h);
+ for (let i = world.length - 1; i >= 0; i--) {
+ const p = world[i];
+ p.age += dt;
+ p.vy += GRAVITY * dt;
+ const dragFactor = Math.pow(DRAG, dt * 60);
+ p.vx *= dragFactor;
+ p.vy *= dragFactor;
+ p.wobblePhase += p.wobbleSpeed * dt;
+ p.x += p.vx * dt + Math.sin(p.wobblePhase) * 30 * dt;
+ p.y += p.vy * dt;
+ p.rot += p.vr * dt;
+
+ if (p.age > MAX_AGE_S || p.y > h + 40 || p.x < -60 || p.x > w + 60) {
+ world.splice(i, 1);
+ continue;
+ }
+
+ // Tumbling paper: squash the rect along its rotation so it
+ // flashes between full face and edge-on, like real confetti.
+ ctx.save();
+ ctx.translate(p.x, p.y);
+ ctx.rotate(p.rot);
+ ctx.fillStyle = p.color;
+ if (p.shape === "rect") {
+ const flutterScale = 0.35 + 0.65 * Math.abs(Math.sin(p.wobblePhase));
+ ctx.fillRect((-p.w * flutterScale) / 2, -p.h / 2, p.w * flutterScale, p.h);
+ } else {
+ ctx.beginPath();
+ ctx.arc(0, 0, p.w / 2, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ ctx.restore();
+ }
+ }
+
+ if (world.length === 0 && started) {
+ if (!isFadingOut) {
+ // Every particle has died (or was never spawned); fade the
+ // canvas out over ~250ms before handing control back to the bus.
+ isFadingOut = true;
+ const el = canvasRef.current;
+ if (!el) {
+ clearEggEffect();
+ return;
+ }
+ el.style.transition = "opacity 250ms ease-out";
+ el.style.opacity = "0";
+ fadeTimeoutRef.current = setTimeout(() => clearEggEffect(), 280);
+ return; // stop scheduling; fade runs on its own
+ }
+ return;
+ }
+
+ rafId = requestAnimationFrame(draw);
+ };
+
+ rafId = requestAnimationFrame(draw);
+
+ return () => {
+ cancelAnimationFrame(rafId);
+ clearTimeout(leftTimer);
+ clearTimeout(rightTimer);
+ if (fadeTimeoutRef.current !== null) clearTimeout(fadeTimeoutRef.current);
+ window.removeEventListener("resize", resize);
+ };
+ }, [prefersReducedMotion]);
+
+ // Reduced motion: particle animation IS the effect, so there is no
+ // honest way to keep it — show a static celebratory chip instead, so
+ // typing "party" never disappears into a void.
+ if (prefersReducedMotion) return ;
+
+ return (
+
+ );
+}
+
+/**
+ * Reads the confetti colors from the app's CSS custom properties.
+ *
+ * Uses the extended badge hues (brand, purple, pink, orange, amber) —
+ * festive but still the app's own palette. The amber is the solid warning
+ * token rather than the highlight yellow: that one is a pale text-marker
+ * wash (30% alpha in dark mode) and vanishes against the page.
+ */
+function readPalette(): string[] {
+ if (typeof window === "undefined" || typeof document === "undefined") {
+ return FALLBACK_PALETTE;
+ }
+ const styles = getComputedStyle(document.documentElement);
+ const candidates = [
+ "--color-app-brand",
+ "--color-app-purple-text",
+ "--color-app-pink-text",
+ "--color-app-orange-text",
+ "--color-app-warning-solid",
+ ];
+ const resolved = candidates.map((name) => styles.getPropertyValue(name).trim()).filter(Boolean);
+ return resolved.length >= 3 ? resolved : FALLBACK_PALETTE;
+}
+
+/**
+ * Used only when the tokens above cannot be resolved — in practice jsdom,
+ * which does not load the app stylesheet, or a stylesheet that failed to
+ * load. The only raw hex values in this effect, and deliberately so: a
+ * canvas `fillStyle` needs a concrete color, and without one the burst would
+ * draw nothing at all. Roughly the light theme's brand/amber/green/blue/
+ * purple, so a fallback burst still looks like this app.
+ */
+const FALLBACK_PALETTE: string[] = ["#e11d48", "#f59e0b", "#10b981", "#3b82f6", "#8b5cf6"];
diff --git a/src/features/easter-eggs/components/EggEffectsLayer.tsx b/src/features/easter-eggs/components/EggEffectsLayer.tsx
new file mode 100644
index 000000000..c3792c5ad
--- /dev/null
+++ b/src/features/easter-eggs/components/EggEffectsLayer.tsx
@@ -0,0 +1,81 @@
+import { useEffect } from "react";
+import { useReducedMotion } from "framer-motion";
+import { clearEggEffect, useActiveEggEffect } from "../eggEffectBus.ts";
+import { MatrixRain } from "./MatrixRain.tsx";
+import { ConfettiBurst } from "./ConfettiBurst.tsx";
+import { ReducedMotionEffectChip } from "./ReducedMotionEffectChip.tsx";
+
+/** How long the barrel roll spins before the page settles again (ms). */
+const BARREL_ROLL_MS = 2000;
+
+/**
+ * The single app-level renderer for whole-window easter-egg effects.
+ *
+ * Mounted once (in App), it turns whatever {@link playEggEffect} fired —
+ * from the AI chat, the buddy chat, anywhere — into the actual visuals:
+ * the barrel-roll body class with a timed cleanup, and the MatrixRain
+ * canvas overlay which cleans up after itself. Centralizing this is what
+ * lets any chat trigger an effect without each surface owning DOM side
+ * effects; two layers would double-apply the body class, so surfaces must
+ * call the bus instead of rendering effects themselves.
+ *
+ * Effects that are pure motion never move for users who asked for less of
+ * it. The barrel roll plays nothing (see its branch below); confetti and the
+ * matrix rain fall back to a short static {@link ReducedMotionEffectChip},
+ * so the phrase the user typed is still acknowledged.
+ */
+export function EggEffectsLayer() {
+ const effect = useActiveEggEffect();
+ const prefersReducedMotion = useReducedMotion();
+
+ useEffect(() => {
+ if (effect?.id !== "barrel-roll") return;
+ // The spin of the whole app is the entire effect and has no honest still
+ // frame, so somebody who prefers reduced motion gets nothing rather than
+ // a rotation — the same call the sidebar logo's drop makes ("the counter
+ // still consumes, nothing plays"). The state is cleared immediately so
+ // the bus is not left holding an effect that is not being drawn.
+ if (prefersReducedMotion) {
+ clearEggEffect();
+ return;
+ }
+ // `seq` is a dependency, not just the id: firing the roll again while it
+ // is running must give it its full time again instead of letting the
+ // first trigger's timer cut the second roll short. The class itself is
+ // idempotent — cleanup drops it, the effect forces a style recalc and
+ // puts it back, so the engine actually restarts the animation instead of
+ // leaving the already-finished roll on the element.
+ void document.body.offsetWidth;
+ document.body.classList.add("barrel-roll-active");
+ const timeout = setTimeout(() => {
+ if (document.body.classList.contains("barrel-roll-active")) {
+ document.body.classList.remove("barrel-roll-active");
+ clearEggEffect();
+ }
+ }, BARREL_ROLL_MS);
+ return () => {
+ clearTimeout(timeout);
+ document.body.classList.remove("barrel-roll-active");
+ };
+ }, [effect?.id, effect?.seq, prefersReducedMotion]);
+
+ if (!effect) return null;
+
+ // Keyed by seq so re-triggering while running replays the burst.
+ if (effect.id === "party") return ;
+
+ if (effect.id === "matrix") {
+ // The falling glyphs are the whole effect, so under reduced motion the
+ // canvas never mounts — same still chip the confetti uses. Keyed by seq
+ // so a re-fire restarts its timer too.
+ if (prefersReducedMotion) {
+ return ;
+ }
+ // Same key contract: the rain ends itself on a timer from its own mount,
+ // so without a remount a second "matrix" would just cut the first short.
+ return ;
+ }
+
+ // The barrel roll needs no DOM of its own — the body class is the effect.
+ return null;
+}
diff --git a/src/features/easter-eggs/components/EggErrorBoundary.tsx b/src/features/easter-eggs/components/EggErrorBoundary.tsx
new file mode 100644
index 000000000..c3e9c916a
--- /dev/null
+++ b/src/features/easter-eggs/components/EggErrorBoundary.tsx
@@ -0,0 +1,111 @@
+import { Component, useEffect, useRef } from "react";
+import type { ErrorInfo, ReactNode } from "react";
+import { X } from "lucide-react";
+import { Button } from "../../../components/ui/Button.tsx";
+import { EmptyState } from "../../../components/ui/EmptyState.tsx";
+
+type EggErrorBoundaryProps = {
+ /** The egg's human-readable name, used in the failure message. */
+ label: string;
+ /** Closes the modal from the failure state. */
+ onClose: () => void;
+ /**
+ * Whether the failure state should offer its own close affordances (a
+ * button and Escape). False when the shell already renders both — the
+ * iframe game's header bar and window Escape listener — so a press is
+ * never handled twice.
+ */
+ ownsClose: boolean;
+ children: ReactNode;
+};
+
+type EggErrorBoundaryState = { failed: boolean };
+
+/**
+ * Catches a game that fails to load or render inside {@link EggModalShell}.
+ *
+ * Every game arrives as a lazy chunk, and a chunk can fail — a stale deploy
+ * whose hashed file is gone, or the network dropping mid-fetch. Without a
+ * boundary the rejected `lazy()` throws up to the app root and unmounts the
+ * whole app for what is an easter egg; with it the modal says what went wrong
+ * and can still be closed.
+ *
+ * A class because React still exposes error boundaries only as class
+ * components. It is remounted with the modal on every open, so a later open
+ * gets a fresh attempt at rendering.
+ */
+export class EggErrorBoundary extends Component {
+ override state: EggErrorBoundaryState = { failed: false };
+
+ static getDerivedStateFromError(): EggErrorBoundaryState {
+ return { failed: true };
+ }
+
+ override componentDidCatch(error: unknown, info: ErrorInfo): void {
+ // Deliberately logged: a failing chunk is a deploy/network problem the
+ // user cannot see, and the console is where somebody debugging it looks.
+ console.error("Easter egg failed to load", error, info.componentStack);
+ }
+
+ override render(): ReactNode {
+ if (!this.state.failed) return this.props.children;
+ return (
+
+ );
+ }
+}
+
+/**
+ * The failure state itself. A function component so it can hold the Escape
+ * listener as an effect: canvas games normally own that key, and a game that
+ * never mounted leaves nothing else listening.
+ */
+function EggLoadError({
+ label,
+ onClose,
+ ownsClose,
+}: {
+ label: string;
+ onClose: () => void;
+ ownsClose: boolean;
+}) {
+ const onCloseRef = useRef(onClose);
+ useEffect(() => {
+ onCloseRef.current = onClose;
+ }, [onClose]);
+
+ useEffect(() => {
+ if (!ownsClose) return;
+ const onKeyDown = (e: KeyboardEvent) => {
+ if (e.key === "Escape") onCloseRef.current();
+ };
+ window.addEventListener("keydown", onKeyDown);
+ return () => window.removeEventListener("keydown", onKeyDown);
+ }, [ownsClose]);
+
+ return (
+
+ }
+ >
+ Close
+
+ ) : undefined
+ }
+ >
+ The game didn't arrive — check your connection or reload the page, then try again.
+
+
+ );
+}
diff --git a/src/features/easter-eggs/components/EggModalShell.tsx b/src/features/easter-eggs/components/EggModalShell.tsx
new file mode 100644
index 000000000..921bfc863
--- /dev/null
+++ b/src/features/easter-eggs/components/EggModalShell.tsx
@@ -0,0 +1,262 @@
+import { Suspense, useEffect, useLayoutEffect, useRef, useState } from "react";
+import { X } from "lucide-react";
+import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
+import { Button } from "../../../components/ui/Button.tsx";
+import { useScrollLock } from "../../../components/ui/useScrollLock.ts";
+import { getModalDialogVariants, modalBackdropVariants } from "../../../styles/tokens.ts";
+import { EGG_REGISTRY } from "../registry.ts";
+import type { EggId } from "../registry.ts";
+import { EggErrorBoundary } from "./EggErrorBoundary.tsx";
+
+/** Everything Tab can land on inside the dialog, in document order. */
+const FOCUSABLE_SELECTOR =
+ 'a[href], button:not([disabled]), iframe, [tabindex]:not([tabindex="-1"])';
+
+function getFocusables(container: HTMLElement): HTMLElement[] {
+ return [...container.querySelectorAll(FOCUSABLE_SELECTOR)];
+}
+
+type EggModalShellProps = {
+ /** Which registered egg to show. Unknown ids render nothing. */
+ eggId: EggId;
+ /** When true, the modal is visible and the lazily-loaded game mounts. */
+ open: boolean;
+ /** Called when the user requests to close (Esc, exit button, overlay click). */
+ onClose: () => void;
+};
+
+/**
+ * The one modal wrapper behind every modal easter egg — replaces the old
+ * per-game DinoGameModal / SpaceInvadersModal / Game2048Modal trio, whose
+ * backdrop, spring animation and scroll lock were three copies of the
+ * same file with a different aria-label. The game arrives through
+ * {@link EGG_REGISTRY} as a lazy chunk: none of the game code loads until
+ * an egg is actually opened.
+ *
+ * Two shapes fall out of the registry:
+ *
+ * - Canvas games (Space Invaders) own their keyboard and already draw
+ * their score / "Esc ✕" chrome on top of the canvas and call their
+ * `onExit` prop on Escape — so this shell adds no header and no Escape
+ * listener of its own (a second handler would double-fire).
+ *
+ * - The iframe game (2048) is a vanilla-JS page with no React props, so
+ * the shell renders a titled header bar with a close button and listens
+ * for Escape on the parent window; the frame's own same-origin listener
+ * (see {@link Game2048Frame}) covers presses inside the iframe — the two
+ * never double-fire because keydowns in a focused iframe don't bubble out.
+ *
+ * Not the shared `Modal`: games own the keyboard, and Modal's focus trap
+ * would fight them for the arrow keys. The focus contract this shell does
+ * implement keeps hands off the keys the games use: focus lands on the
+ * dialog itself when it opens (so a screen reader announces it and the
+ * games' window-level keys work regardless of focus) and returns to
+ * whoever held it before when the dialog closes (if that element is still
+ * in the document); focus cannot leave the dialog while it is open — Tab in
+ * the parent chrome wraps, and focus that escapes any other way (tabbing
+ * past the last control *inside* the 2048 iframe, whose keydowns this
+ * window never sees) is pulled straight back. The canvas games never use
+ * Tab, so neither mechanism fights them.
+ * The one thing every overlay needs regardless — the page behind it
+ * holding still — comes from `useScrollLock`.
+ *
+ * A game whose chunk fails to load (stale deploy, offline) is caught by
+ * {@link EggErrorBoundary}: the modal explains and stays closable instead of
+ * the error unmounting the whole app.
+ */
+export function EggModalShell({ eggId, open, onClose }: EggModalShellProps) {
+ const egg = EGG_REGISTRY[eggId];
+
+ useScrollLock(open);
+ const prefersReducedMotion = useReducedMotion();
+ const dialogVariants = getModalDialogVariants(Boolean(prefersReducedMotion));
+
+ // Focus in on open, back to the opener on close, and never anywhere else
+ // in between. The dialog carries `tabIndex={-1}` so it is programmatically
+ // focusable without joining the page's Tab order.
+ //
+ // A *layout* effect on purpose: it runs before any child's passive effect,
+ // so the opener is recorded before a game (e.g. the 2048 frame focusing
+ // itself) can move focus — a plain effect here would, on a cached-chunk
+ // reopen, record the game as the opener. Restoring happens in the cleanup,
+ // i.e. the moment `open` flips false, and only onto an opener that is
+ // still in the document: focusing a removed node is a silent no-op that
+ // would strand keyboard users on , so better not to pretend.
+ //
+ // The focusin guard is the half of the trap the Tab handler below cannot
+ // provide: a Tab press inside the 2048 iframe is dispatched to the frame's
+ // own document, so tabbing past the frame's last control moves focus onto
+ // the page behind the overlay without this window ever seeing the key.
+ // Focus landing outside the dialog is pulled back; where it landed tells
+ // the direction — before the dialog in document order means the user went
+ // backwards (wrap to the last focusable), anything else forwards (wrap to
+ // the first). It lives in this same effect so the cleanup can remove it
+ // *before* restoring focus; otherwise the restore itself — the dialog is
+ // still in the DOM during its exit animation — would be pulled back in.
+ const dialogRef = useRef(null);
+ useLayoutEffect(() => {
+ if (!open) return;
+ const active = document.activeElement;
+ const opener = active instanceof HTMLElement && active !== document.body ? active : null;
+
+ const onFocusIn = (e: FocusEvent) => {
+ const dialog = dialogRef.current;
+ const target = e.target;
+ if (!dialog || !(target instanceof Node) || dialog.contains(target)) return;
+ const focusables = getFocusables(dialog);
+ const wentBackwards = Boolean(
+ target.compareDocumentPosition(dialog) & Node.DOCUMENT_POSITION_FOLLOWING,
+ );
+ const next = wentBackwards ? focusables[focusables.length - 1] : focusables[0];
+ (next ?? dialog).focus();
+ };
+
+ dialogRef.current?.focus();
+ document.addEventListener("focusin", onFocusIn);
+ return () => {
+ document.removeEventListener("focusin", onFocusIn);
+ if (opener?.isConnected) opener.focus();
+ };
+ }, [open]);
+
+ // Tab wraps inside the dialog for presses the parent window sees (the
+ // header close button, the dialog itself). Deliberately Tab-only: canvas
+ // games bind arrows/w/s/space on the window. Presses inside the 2048
+ // iframe never reach this handler — the focusin guard above covers those.
+ // The focusable list includes the iframe itself, so one Tab from the
+ // header close button walks into the frame.
+ useEffect(() => {
+ if (!open) return;
+ const onKeyDown = (e: KeyboardEvent) => {
+ if (e.key !== "Tab") return;
+ const dialog = dialogRef.current;
+ if (!dialog || !(e.target instanceof Node) || !dialog.contains(e.target)) return;
+
+ const focusables = getFocusables(dialog);
+ if (focusables.length === 0) {
+ e.preventDefault();
+ dialog.focus();
+ return;
+ }
+ const first = focusables[0];
+ const last = focusables[focusables.length - 1];
+ const active = document.activeElement;
+ if (e.shiftKey && (active === first || active === dialog)) {
+ e.preventDefault();
+ last.focus();
+ } else if (!e.shiftKey && active === last) {
+ e.preventDefault();
+ first.focus();
+ }
+ };
+ window.addEventListener("keydown", onKeyDown);
+ return () => window.removeEventListener("keydown", onKeyDown);
+ }, [open]);
+
+ // Close on Escape while focus is outside the iframe (header bar, close
+ // button, or before the frame has loaded). Canvas games handle Escape
+ // themselves via `onExit` — but only from the moment they mount, and the
+ // chunk behind them arrives asynchronously: until then nothing else can
+ // close the modal from the keyboard, so the shell takes the key for
+ // exactly that window (`gameLoading` is raised by the fallback below,
+ // which is mounted only while the chunk is still on the wire). The ref
+ // keeps the latest callback without re-subscribing; calling it inside the
+ // handler (not during render) stays clear of the set-state-in-effect rule.
+ const onCloseRef = useRef(onClose);
+ useEffect(() => {
+ onCloseRef.current = onClose;
+ }, [onClose]);
+
+ const [gameLoading, setGameLoading] = useState(false);
+ const shellOwnsEscape = egg?.kind === "iframe" || gameLoading;
+
+ useEffect(() => {
+ if (!open || !egg || !shellOwnsEscape) return;
+ const onKeyDown = (e: KeyboardEvent) => {
+ if (e.key === "Escape") onCloseRef.current();
+ };
+ window.addEventListener("keydown", onKeyDown);
+ return () => window.removeEventListener("keydown", onKeyDown);
+ }, [open, egg, shellOwnsEscape]);
+
+ if (!egg) return null;
+
+ const Game = egg.component;
+
+ return (
+
+ {open && (
+
+ e.stopPropagation()}
+ >
+ {/* Header bar for the iframe game (canvas games draw their own
+ chrome). Outside the boundary and Suspense so the close
+ button is there while the chunk loads and if it fails. */}
+ {egg.kind === "iframe" && (
+
+
{egg.label}
+
+
+
+
+ )}
+ {/* All registry components are lazy: Suspense covers the fetch,
+ the boundary covers a fetch that fails. The boundary only
+ brings its own close controls where the header above does
+ not already provide them. */}
+
+ }>
+
+
+
+
+
+ )}
+
+ );
+}
+
+/**
+ * The Suspense fallback: an empty box exactly the size the games reserve.
+ *
+ * Its only other job is telling the shell that the chunk is still loading —
+ * it is mounted precisely while `lazy()` is unresolved, which is the window
+ * where Escape is still the shell's key to handle (see above). It reports
+ * through the setter itself: a fresh closure per render would re-run this
+ * component's effect and loop.
+ */
+function EggLoadingFallback({ onLoadingChange }: { onLoadingChange: (loading: boolean) => void }) {
+ useEffect(() => {
+ onLoadingChange(true);
+ return () => onLoadingChange(false);
+ }, [onLoadingChange]);
+
+ return
;
+}
diff --git a/src/features/easter-eggs/components/Game2048Frame.tsx b/src/features/easter-eggs/components/Game2048Frame.tsx
new file mode 100644
index 000000000..7a039ad3d
--- /dev/null
+++ b/src/features/easter-eggs/components/Game2048Frame.tsx
@@ -0,0 +1,94 @@
+/**
+ * Renders the self-contained vanilla-JS 2048 page (public/easter-eggs/
+ * 2048.html) inside an iframe so it fits the registry's shared
+ * `{ onExit }` game shape.
+ *
+ * Keyboard wiring is split by who can hear the press: focusing the frame
+ * after load makes arrow keys drive the board immediately, without a click
+ * first; Escape pressed in the parent chrome is the shell's own window
+ * listener; Escape pressed *inside* the frame is reported back by the page
+ * itself as an EGG_EXIT message. Nothing here listens for keys, so one
+ * press is never counted twice.
+ */
+import { useEffect, useRef } from "react";
+
+type Game2048FrameProps = {
+ /**
+ * Called when the player leaves the game: Escape in the parent chrome
+ * (the shell) or the frame's own EGG_EXIT report. Mainly the callback
+ * the registry's shared game shape requires.
+ */
+ onExit: () => void;
+};
+
+function isEggExitMessage(data: unknown): data is { type: "EGG_EXIT" } {
+ return (
+ typeof data === "object" && data !== null && (data as { type?: unknown }).type === "EGG_EXIT"
+ );
+}
+
+const GAME_URL = "/easter-eggs/2048.html";
+
+export function Game2048Frame({ onExit }: Game2048FrameProps) {
+ const iframeRef = useRef(null);
+ const onExitRef = useRef(onExit);
+
+ useEffect(() => {
+ onExitRef.current = onExit;
+ }, [onExit]);
+
+ useEffect(() => {
+ const handleMessage = (event: MessageEvent) => {
+ // Same origin *and* this very frame: the game page is served by this
+ // app, so a message from anywhere else — another origin, or another
+ // same-origin frame or window — is somebody else closing a modal that
+ // is not its business.
+ if (event.origin !== window.location.origin) return;
+ if (event.source !== iframeRef.current?.contentWindow) return;
+ if (isEggExitMessage(event.data)) {
+ onExitRef.current();
+ }
+ };
+ window.addEventListener("message", handleMessage);
+ return () => window.removeEventListener("message", handleMessage);
+ }, []);
+
+ useEffect(() => {
+ const iframe = iframeRef.current;
+ if (!iframe) return;
+
+ // Put the caret in the frame once it has loaded, so arrow keys drive the
+ // board immediately rather than the first press scrolling the page behind
+ // the modal. Escape needs nothing here: the frame's own page reports it
+ // through the message above, and presses that land outside the frame are
+ // the shell's window listener. One mechanism per press, so none of them
+ // is counted twice.
+ const focusFrame = () => {
+ try {
+ iframe.contentWindow?.focus();
+ } catch {
+ // Cross-origin frame: nothing to focus into; the message path stands.
+ }
+ };
+
+ // Only the `load` event, never an early `readyState` check: a freshly
+ // inserted iframe still holds its initial about:blank document, whose
+ // readyState is already "complete". Focusing that would steal focus
+ // before the game exists — and before the shell has recorded who opened
+ // it, which is how the frame used to end up as the "opener" focus was
+ // restored to on close.
+ iframe.addEventListener("load", focusFrame);
+
+ return () => iframe.removeEventListener("load", focusFrame);
+ }, []);
+
+ return (
+
+ );
+}
diff --git a/src/features/easter-eggs/components/MatrixRain.tsx b/src/features/easter-eggs/components/MatrixRain.tsx
index 485d041a9..040a9f78b 100644
--- a/src/features/easter-eggs/components/MatrixRain.tsx
+++ b/src/features/easter-eggs/components/MatrixRain.tsx
@@ -9,16 +9,18 @@ type MatrixRainProps = {
* MatrixRain
*
* A full-screen "Matrix digital rain" canvas effect, triggered as a chat
- * easter egg (type "matrix" / "the matrix" / "do matrix" in the chat
- * composer — see ChatPage). Renders a fixed, pointer-events-none canvas
+ * easter egg (type "matrix" / "the matrix" / "do matrix" in the chat or
+ * buddy composer — matched by `matchEggPhrase`, drawn by
+ * {@link EggEffectsLayer}). Renders a fixed, pointer-events-none canvas
* above the app for ~6s, then auto-dismisses. Press Escape to dismiss early.
*
* Implementation notes:
- * - `onClose` is read through a ref so this effect has `[]` deps. ChatPage
- * passes an inline arrow (`() => setIsMatrixActive(false)`) whose identity
- * changes on every render; depending on it directly would tear down and
- * restart the canvas + timers on each ChatPage re-render (e.g. while a
- * streamed message arrives), making the rain flicker.
+ * - `onClose` is read through a ref so this effect keeps `[]` deps. The
+ * callback is stable today (the layer hands over the bus's own
+ * `clearEggEffect`), but an inline arrow from a caller would tear down and
+ * restart the canvas + timers on every re-render — which is what the old
+ * ChatPage version did while a streamed message arrived, making the rain
+ * flicker.
* - The canvas is DPR-scaled for crisp rendering and recomputes its column
* count on resize.
* - The palette is intentionally hardcoded black+green: this is a
diff --git a/src/features/easter-eggs/components/ReducedMotionEffectChip.tsx b/src/features/easter-eggs/components/ReducedMotionEffectChip.tsx
new file mode 100644
index 000000000..2b799c2d5
--- /dev/null
+++ b/src/features/easter-eggs/components/ReducedMotionEffectChip.tsx
@@ -0,0 +1,55 @@
+import { useEffect, useState } from "react";
+import { clearEggEffect } from "../eggEffectBus.ts";
+
+/** How long the reduced-motion chip stays before the effect clears (ms). */
+export const REDUCED_MOTION_CHIP_MS = 1500;
+
+/**
+ * Delay before the live region receives its text (ms). Screen readers only
+ * announce *changes* to a live region they already know about; a region that
+ * is inserted together with its text is frequently skipped.
+ */
+const ANNOUNCE_DELAY_MS = 100;
+
+type ReducedMotionEffectChipProps = {
+ /** What the chip shows and what gets announced, e.g. "🎉 Party!". */
+ text: string;
+};
+
+/**
+ * The still stand-in for a whole-window effect whose motion *is* the effect
+ * (confetti, matrix rain), shown to users who prefer reduced motion.
+ *
+ * Dropping the effect silently would make the typed phrase disappear into a
+ * void; a short static chip acknowledges it without anything moving. It then
+ * clears its bus effect on a timer, just as the animated versions end
+ * themselves. The visible chip is `aria-hidden` and the announcement goes
+ * through a separate status region that mounts empty and is filled a moment
+ * later, so the text is actually read out rather than inserted unnoticed.
+ */
+export function ReducedMotionEffectChip({ text }: ReducedMotionEffectChipProps) {
+ const [announced, setAnnounced] = useState("");
+
+ useEffect(() => {
+ const announce = setTimeout(() => setAnnounced(text), ANNOUNCE_DELAY_MS);
+ const clear = setTimeout(() => clearEggEffect(), REDUCED_MOTION_CHIP_MS);
+ return () => {
+ clearTimeout(announce);
+ clearTimeout(clear);
+ };
+ }, [text]);
+
+ return (
+ <>
+
+ {text}
+
+
+ {announced}
+
+ >
+ );
+}
diff --git a/src/features/easter-eggs/components/SpaceInvaders.tsx b/src/features/easter-eggs/components/SpaceInvaders.tsx
index fb86c9431..1a2b52166 100644
--- a/src/features/easter-eggs/components/SpaceInvaders.tsx
+++ b/src/features/easter-eggs/components/SpaceInvaders.tsx
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { centralSpringToken } from "../../../styles/tokens";
+import { isTypingTarget } from "../lib/keyTargets.ts";
type SpaceInvadersProps = {
/**
@@ -267,9 +268,10 @@ function spawnPlayerExplosion(w: World, x: number, y: number): void {
/**
* SpaceInvaders
*
- * A canvas Space Invaders clone, hidden as an easter egg on the 404 page
- * and reachable from the dashboard via the Ctrl+Shift+3 chord (see
- * {@link SpaceInvadersModal}).
+ * A canvas Space Invaders clone, hidden as an easter egg behind the 404
+ * page's rocket teaser and opened through the shared egg shell
+ * (see {@link EggModalShell}, registry id `space-invaders`). The game owns
+ * its own keyboard — Escape and the exit button call `onExit`.
*
* Architecture mirrors {@link DinoGame}: all mutable game state lives in a
* single `useRef` so the `requestAnimationFrame` loop never restarts
@@ -328,11 +330,10 @@ export function SpaceInvaders({ onExit }: SpaceInvadersProps) {
// --- Keyboard input (Escape, Space, Arrows / A-D) ---
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
- if (e.key === "Escape") {
- e.preventDefault();
- onExit();
- return;
- }
+ // Shortcuts (Ctrl/Cmd+A, Alt+D, …) and typing belong to the browser and
+ // the focused field, not the game — same guards as DinoGame.
+ if (e.ctrlKey || e.metaKey || e.altKey) return;
+ if (isTypingTarget(e.target)) return;
if (e.code === "Space") {
e.preventDefault();
@@ -366,9 +367,21 @@ export function SpaceInvaders({ onExit }: SpaceInvadersProps) {
}
};
+ // Escape in the capture phase, as in DinoGame: one press must close only
+ // the game, before any surrounding surface that also closes on Escape
+ // sees it. Honoured from anywhere, including a text field.
+ const onEscape = (e: KeyboardEvent) => {
+ if (e.key !== "Escape" || e.defaultPrevented) return;
+ e.preventDefault();
+ e.stopPropagation();
+ onExit();
+ };
+
+ window.addEventListener("keydown", onEscape, true);
window.addEventListener("keydown", onKeyDown);
window.addEventListener("keyup", onKeyUp);
return () => {
+ window.removeEventListener("keydown", onEscape, true);
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("keyup", onKeyUp);
};
diff --git a/src/features/easter-eggs/eggEffectBus.ts b/src/features/easter-eggs/eggEffectBus.ts
new file mode 100644
index 000000000..126763df5
--- /dev/null
+++ b/src/features/easter-eggs/eggEffectBus.ts
@@ -0,0 +1,61 @@
+import { useSyncExternalStore } from "react";
+
+/**
+ * Which whole-window easter-egg effects exist. These are not modals — they
+ * take over (or decorate) the entire screen, so any surface must be able to
+ * fire them, and exactly one app-level layer renders them.
+ */
+export type EggEffectId = "barrel-roll" | "matrix" | "party";
+
+type ActiveEffect = {
+ id: EggEffectId;
+ /**
+ * Increments every time an effect fires, even the same one twice in a row.
+ * The rendering layer keys off it so re-triggering replays the effect
+ * instead of being swallowed by an equal-state bail-out.
+ */
+ seq: number;
+};
+
+let active: ActiveEffect | null = null;
+let nextSeq = 1;
+const listeners = new Set<() => void>();
+
+function emit(): void {
+ for (const listener of listeners) listener();
+}
+
+/**
+ * Fires a whole-window easter-egg effect. Safe to call from anywhere —
+ * chat submit, buddy submit, a future trigger — because the rendering
+ * lives once, in {@link EggEffectsLayer}, not at the call site.
+ */
+export function playEggEffect(id: EggEffectId): void {
+ active = { id, seq: nextSeq++ };
+ emit();
+}
+
+/** Ends the running effect (called by the layer's own timers / Escape). */
+export function clearEggEffect(): void {
+ if (!active) return;
+ active = null;
+ emit();
+}
+
+function subscribe(listener: () => void): () => void {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+}
+
+function getSnapshot(): ActiveEffect | null {
+ return active;
+}
+
+/**
+ * Subscribes a component to the currently active effect. `null` when
+ * nothing is playing. Pair with {@link EggEffectsLayer} rather than
+ * rendering effects yourself: two layers would double-apply the body class.
+ */
+export function useActiveEggEffect(): ActiveEffect | null {
+ return useSyncExternalStore(subscribe, getSnapshot);
+}
diff --git a/src/features/easter-eggs/hooks/useDinoWaitingGame.ts b/src/features/easter-eggs/hooks/useDinoWaitingGame.ts
new file mode 100644
index 000000000..bcd05ee05
--- /dev/null
+++ b/src/features/easter-eggs/hooks/useDinoWaitingGame.ts
@@ -0,0 +1,160 @@
+import { useCallback, useEffect, useState } from "react";
+import { isInteractiveTarget } from "../lib/keyTargets.ts";
+
+/**
+ * Reads the unlock flag, treating unavailable storage as "locked". Storage
+ * access throws in some privacy modes and sandboxed frames; an easter egg
+ * must never be the reason a chat page fails to render.
+ */
+function readDinoUnlocked(): boolean {
+ try {
+ return localStorage.getItem("dinoUnlocked") === "true";
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Reads the persisted dino unlock flag (`localStorage["dinoUnlocked"]`)
+ * and keeps it live: reacts to the `dinoUnlockChanged` window event that
+ * `useDinoEasterEgg` dispatches after a triple-click toggle, and to the
+ * browser's cross-tab `storage` event.
+ */
+export function useDinoUnlocked(): boolean {
+ const [isUnlocked, setIsUnlocked] = useState(readDinoUnlocked);
+
+ useEffect(() => {
+ const sync = () => setIsUnlocked(readDinoUnlocked());
+ window.addEventListener("dinoUnlockChanged", sync);
+ window.addEventListener("storage", sync);
+ return () => {
+ window.removeEventListener("dinoUnlockChanged", sync);
+ window.removeEventListener("storage", sync);
+ };
+ }, []);
+
+ return isUnlocked;
+}
+
+/**
+ * Which host currently has the one waiting-game open, if any.
+ *
+ * The AI chat, the onboarding generation step and the buddy conversation
+ * are each armed while their own turn runs and each listen on the window,
+ * so without a shared slot a single Space press with two surfaces busy
+ * opened two games at once. The first host to claim it plays; the others
+ * stay shut. A module-level variable rather than context because the hosts
+ * are a page, a floating dock and a wizard step, with no common component
+ * above them to own the state.
+ */
+let gameHost: symbol | null = null;
+
+/** Frees the shared slot, but only for the host that still holds it. */
+function releaseGameSlot(host: symbol): void {
+ if (gameHost === host) gameHost = null;
+}
+
+export type SpaceOpensDinoOptions = {
+ /**
+ * When true, if `armed` transitions from true to false while a game is actively
+ * being played, the game remains active until the player exits (via Esc, exit button,
+ * or game over). New games cannot be opened while `armed` is false.
+ */
+ keepActiveUntilExit?: boolean;
+};
+
+/**
+ * The Space-to-play trigger shared by every dino waiting-game host
+ * (AI chat, onboarding generation, buddy chat): while `armed` is true and
+ * the game is not already open, pressing Space opens it — unless the user
+ * is typing in a field or has a control focused that Space activates (see
+ * {@link isInteractiveTarget}), in which case Space keeps its meaning. A
+ * modified, auto-repeated or already-handled press is never the trigger.
+ *
+ * Returns whether the game should be shown, plus a way to close it early.
+ * The game belongs to the wait it was opened under, so it closes by itself
+ * the moment `armed` flips off — unless `keepActiveUntilExit` is set, in which
+ * case an ongoing run is allowed to finish.
+ */
+export function useSpaceOpensDino(
+ armed: boolean,
+ isUnlocked: boolean,
+ options?: SpaceOpensDinoOptions,
+): [boolean, () => void] {
+ const keepActiveUntilExit = options?.keepActiveUntilExit ?? false;
+ const [gameActive, setGameActive] = useState(false);
+
+ // Stable per-instance identity for the shared slot. Initial state rather
+ // than a ref so it exists on the first render without reading a ref during
+ // it (`react-hooks/refs` forbids that, and rightly: a handler registered in
+ // the first effect would capture a ref that is still null).
+ const [host] = useState(() => Symbol("dino-waiting-game"));
+
+ useEffect(() => {
+ if (!armed || !isUnlocked || gameActive) return;
+
+ const onKeyDown = (e: KeyboardEvent) => {
+ if (e.code !== "Space") return;
+ if (e.repeat || e.defaultPrevented) return;
+ if (e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) return;
+ if (isInteractiveTarget(document.activeElement) || isInteractiveTarget(e.target)) return;
+ if (gameHost !== null) return;
+ e.preventDefault();
+ gameHost = host;
+ setGameActive(true);
+ };
+
+ window.addEventListener("keydown", onKeyDown);
+ return () => window.removeEventListener("keydown", onKeyDown);
+ }, [armed, isUnlocked, gameActive, host]);
+
+ // A host that goes away mid-game (navigating off the page) must not take
+ // the slot with it, or every other surface loses the trigger for good.
+ useEffect(() => () => releaseGameSlot(host), [host]);
+
+ // The slot follows `gameActive` after commit, never during render: the
+ // closes below run in the render phase, and a render React discards
+ // (concurrent retry, StrictMode double render) must not free a slot the
+ // committed game still holds — another surface could claim it and open a
+ // second game. `close` releases eagerly as well; that runs in an event
+ // handler, where mutating module state is fine.
+ useEffect(() => {
+ if (!gameActive) releaseGameSlot(host);
+ }, [gameActive, host]);
+
+ // Locking mid-game (triple-click toggle elsewhere, another tab) closes
+ // it. Uses React's documented "adjust state when a value changes"
+ // pattern instead of an effect, mirroring ChatPage — see
+ // https://react.dev/learn/you-might-not-need-an-effect
+ const [prevUnlocked, setPrevUnlocked] = useState(isUnlocked);
+ if (prevUnlocked !== isUnlocked) {
+ setPrevUnlocked(isUnlocked);
+ if (!isUnlocked) {
+ setGameActive(false);
+ }
+ }
+
+ // The other half of "this game belongs to this wait": when the wait ends,
+ // the game is over. Closing here is not a nicety — the game's DOM belongs
+ // to the host's waiting state, so it disappears on its own when the answer
+ // arrives, and without this the shared slot would stay claimed by a game
+ // nobody can see (the onboarding generation step has no close of its own,
+ // which is how a finished generation used to eat the trigger for the rest
+ // of the visit). Same render-phase pattern as the lock above; the slot
+ // itself is freed by the `gameActive` effect once the close commits.
+ // When `keepActiveUntilExit` is true, an active run is preserved until the
+ // user exits or finishes their run.
+ const [prevArmed, setPrevArmed] = useState(armed);
+ if (prevArmed !== armed) {
+ setPrevArmed(armed);
+ if (!armed && (!keepActiveUntilExit || !gameActive)) {
+ setGameActive(false);
+ }
+ }
+
+ const close = useCallback(() => {
+ releaseGameSlot(host);
+ setGameActive(false);
+ }, [host]);
+ return [gameActive, close];
+}
diff --git a/src/features/easter-eggs/hooks/useRepeatClicks.ts b/src/features/easter-eggs/hooks/useRepeatClicks.ts
new file mode 100644
index 000000000..569022c10
--- /dev/null
+++ b/src/features/easter-eggs/hooks/useRepeatClicks.ts
@@ -0,0 +1,60 @@
+import { useCallback, useEffect, useRef } from "react";
+
+/** Time budget per required click when no explicit window is given (ms). */
+const MS_PER_CLICK = 1000;
+
+/**
+ * Returns a click handler that fires `onReached` once every
+ * `requiredClicks` consecutive calls, resetting the count in between.
+ *
+ * The whole gesture has to fit in `windowMs`, measured from its *first*
+ * click: once that much time has passed since the click that started the
+ * count, the next click starts a new one. (Measuring the gap between two
+ * neighbouring clicks instead would let a slow drip of clicks, each within
+ * the window of the last, add up forever.) Without the window, the
+ * counter is an accumulator that never resets, and stray clicks on the
+ * dashboard's header icon spread over a working day would open the 2048
+ * modal out of nowhere.
+ *
+ * The default allows one second per required click (3s for a triple click,
+ * 5s for the logo's five) on purpose: this is a deliberate gesture, and a
+ * window tight enough to demand double-click tempo would read as broken to
+ * somebody counting their clicks. Platform precedents for hidden gesture
+ * unlocks (Android's 7x build number) have no timer at all; the guard here
+ * only has to rule out accumulation across a session.
+ *
+ * Generalizes the Settings cogwheel's triple-click unlock so the next
+ * hidden trigger reuses one counter instead of copying ref plumbing.
+ * The count deliberately lives in a ref rather than state so the side
+ * effect runs exactly once even when React double-invokes updaters in
+ * StrictMode dev — mirroring the `useDinoEasterEgg` implementation this
+ * was extracted from. `onReached` is read through a ref too, so callers
+ * can pass an inline closure without re-arming the handler.
+ */
+export function useRepeatClicks(
+ requiredClicks: number,
+ onReached: () => void,
+ windowMs: number = requiredClicks * MS_PER_CLICK,
+): () => void {
+ const countRef = useRef(0);
+ const firstClickRef = useRef(0);
+ const onReachedRef = useRef(onReached);
+
+ useEffect(() => {
+ onReachedRef.current = onReached;
+ });
+
+ return useCallback(() => {
+ const now = Date.now();
+ if (countRef.current === 0 || now - firstClickRef.current > windowMs) {
+ countRef.current = 0;
+ firstClickRef.current = now;
+ }
+
+ countRef.current += 1;
+ if (countRef.current < requiredClicks) return;
+
+ countRef.current = 0;
+ onReachedRef.current();
+ }, [requiredClicks, windowMs]);
+}
diff --git a/src/features/easter-eggs/lib/eggPhrases.ts b/src/features/easter-eggs/lib/eggPhrases.ts
new file mode 100644
index 000000000..ec1d60df9
--- /dev/null
+++ b/src/features/easter-eggs/lib/eggPhrases.ts
@@ -0,0 +1,35 @@
+/**
+ * Recognized chat phrases → effect ids. Kept verbatim from ChatPage's old
+ * inline matching so existing behaviour (and muscle memory) is unchanged:
+ * {@link matchEggPhrase} trims/lowercases before comparing, and folds curly
+ * apostrophes to straight ones — macOS/iOS smart punctuation turns "let's"
+ * into "let’s" as it is typed.
+ *
+ * "do barrel" exists because users abbreviate mid-thought; "matrix" alone
+ * matches because asking the AI about the movie was nobody's actual intent.
+ */
+import type { EggEffectId } from "../eggEffectBus.ts";
+const EGG_PHRASES: ReadonlyArray = [
+ [["do a barrel roll", "do barrel roll", "do barrel"], "barrel-roll"],
+ [["the matrix", "do matrix", "matrix"], "matrix"],
+ [["party", "party time", "let's party", "🎉"], "party"],
+];
+
+/** The whole-window easter eggs a chat surface can trigger by phrase. */
+export type { EggEffectId } from "../eggEffectBus";
+
+/**
+ * Returns the easter-egg effect a submitted chat message should trigger,
+ * or null when the text is a normal message.
+ */
+export function matchEggPhrase(text: string): EggEffectId | null {
+ const normalized = text
+ .trim()
+ .toLowerCase()
+ .replace(/[\u2018\u2019]/g, "'");
+ if (!normalized) return null;
+ for (const [phrases, effect] of EGG_PHRASES) {
+ if (phrases.includes(normalized)) return effect;
+ }
+ return null;
+}
diff --git a/src/features/easter-eggs/lib/keyTargets.ts b/src/features/easter-eggs/lib/keyTargets.ts
new file mode 100644
index 000000000..c3f2600fa
--- /dev/null
+++ b/src/features/easter-eggs/lib/keyTargets.ts
@@ -0,0 +1,53 @@
+/**
+ * Key-target guards shared by the easter-egg games and the Space-to-play
+ * trigger. Generic keyboard helpers, so they live here rather than in any one
+ * game's hook.
+ */
+
+/** Shared guard: true while the event landed in a text field that owns the keys. */
+export const isTypingTarget = (el: EventTarget | null): boolean =>
+ el instanceof HTMLElement &&
+ (el.tagName === "TEXTAREA" || el.tagName === "INPUT" || el.isContentEditable === true);
+
+/**
+ * Controls on which Space already has a meaning of its own: activating a
+ * button, toggling a checkbox or switch, opening a select or a disclosure.
+ */
+const SPACE_ACTIVATED_SELECTOR = [
+ "button",
+ "select",
+ "summary",
+ "a[href]",
+ '[role="button"]',
+ '[role="checkbox"]',
+ '[role="switch"]',
+ '[role="combobox"]',
+ '[role="textbox"]',
+ '[role="menuitem"]',
+ '[role="menuitemcheckbox"]',
+ '[role="menuitemradio"]',
+ '[role="option"]',
+ '[role="radio"]',
+ '[role="tab"]',
+].join(", ");
+
+/**
+ * True when Space pressed on `el` belongs to the element rather than to a
+ * global shortcut: any text field (see {@link isTypingTarget}) plus every
+ * control Space activates.
+ *
+ * Separate from `isTypingTarget` on purpose — the games themselves use that
+ * one to decide whether a key is typing, and a focused button is not typing.
+ * Only the Space-to-play trigger needs the wider set, because swallowing
+ * Space on a focused "Send" button would open a game instead of pressing it.
+ *
+ * A disabled control is the exception: Space on it does nothing, and a
+ * busy button (`ui/Button` with `loading`) is disabled while it may still
+ * hold focus, so treating it as the owner of Space would keep the game
+ * shut for exactly the wait it exists for.
+ */
+export const isInteractiveTarget = (el: EventTarget | null): boolean =>
+ isTypingTarget(el) ||
+ (el instanceof Element &&
+ el.matches(SPACE_ACTIVATED_SELECTOR) &&
+ !el.matches(':disabled, [aria-disabled="true"]'));
diff --git a/src/features/easter-eggs/registry.ts b/src/features/easter-eggs/registry.ts
new file mode 100644
index 000000000..c6fee39cd
--- /dev/null
+++ b/src/features/easter-eggs/registry.ts
@@ -0,0 +1,45 @@
+import type { ComponentType, LazyExoticComponent } from "react";
+import { lazy } from "react";
+
+/**
+ * Every modal easter egg the app knows about, in one registry.
+ *
+ * A "game egg" is `{ id, label, kind, component }`. The component is the
+ * game itself and is loaded lazily so none of the game code ships in the
+ * main bundle — the chunk is fetched the first time the egg is actually
+ * opened. Adding a future game means one entry here plus one component
+ * file; every trigger, page and test keeps addressing eggs by id.
+ */
+
+export type EggId = "game-2048" | "space-invaders";
+
+type EggDefinition = {
+ /** Human-readable name for aria labels and visible chrome. */
+ label: string;
+ /**
+ * "canvas": React component owning its keyboard, draws its own score /
+ * exit chrome and calls `onExit`. "iframe": self-contained page loaded
+ * in an iframe; `EggModalShell` adds a header bar and Escape handling.
+ */
+ kind: "canvas" | "iframe";
+ component: LazyExoticComponent void }>>;
+};
+
+export const EGG_REGISTRY: Record = {
+ "game-2048": {
+ label: "2048",
+ kind: "iframe",
+ // Thin wrapper so the vanilla-JS page satisfies the shared game shape.
+ // The page's own path lives in that wrapper, not here.
+ component: lazy(() =>
+ import("./components/Game2048Frame").then((m) => ({ default: m.Game2048Frame })),
+ ),
+ },
+ "space-invaders": {
+ label: "Space Invaders game",
+ kind: "canvas",
+ component: lazy(() =>
+ import("./components/SpaceInvaders").then((m) => ({ default: m.SpaceInvaders })),
+ ),
+ },
+};
diff --git a/src/features/game2048/components/Game2048Modal.tsx b/src/features/game2048/components/Game2048Modal.tsx
deleted file mode 100644
index 02517e2f2..000000000
--- a/src/features/game2048/components/Game2048Modal.tsx
+++ /dev/null
@@ -1,128 +0,0 @@
-import { X } from "lucide-react";
-import { useEffect, useRef } from "react";
-import { useScrollLock } from "../../../components/ui/useScrollLock";
-import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
-import { getModalDialogVariants, modalBackdropVariants } from "../../../styles/tokens";
-
-/**
- * Props for {@link Game2048Modal}.
- */
-interface Game2048ModalProps {
- /** When true, the modal is visible and the iframe is mounted. */
- open: boolean;
- /** Called when the user requests to close (Esc, overlay click, X button). */
- onClose: () => void;
-}
-
-/**
- * A modal wrapper that loads the self-contained 2048 game from
- * `/easter-eggs/2048.html` in an iframe.
- *
- * The game itself is vanilla JS — this component only provides the
- * surrounding chrome (backdrop, panel, close button, animations).
- * Theme/dark-mode is handled inside the iframe; the wrapper uses
- * SPRINTSTART's semantic tokens so the chrome blends in.
- *
- * Keyboard behaviour: the iframe is focused on load so arrow keys work
- * immediately (no click required). Escape closes the modal whether it's
- * pressed while the iframe has focus (listener attached inside the
- * iframe document — same-origin) or while focus is outside the iframe
- * (listener on the parent window). The two listeners don't double-fire
- * because keydown events inside a focused iframe don't bubble to the
- * parent document.
- */
-export function Game2048Modal({ open, onClose }: Game2048ModalProps) {
- // Not a `Modal`: the game owns the keyboard, and Modal's focus trap
- // would fight it for the arrow keys. The one thing every overlay needs
- // regardless is the page behind it holding still.
- useScrollLock(open);
-
- const prefersReducedMotion = useReducedMotion();
- const dialogVariants = getModalDialogVariants(Boolean(prefersReducedMotion));
-
- const iframeRef = useRef(null);
-
- // Close on Escape when focus is outside the iframe (header, close
- // button, or before the iframe has loaded).
- useEffect(() => {
- if (!open) return;
- const onKeyDown = (e: KeyboardEvent) => {
- if (e.key === "Escape") onClose();
- };
- window.addEventListener("keydown", onKeyDown);
- return () => window.removeEventListener("keydown", onKeyDown);
- }, [open, onClose]);
-
- // Once the iframe has loaded: focus it so arrow keys work without a
- // click, and attach an Esc listener inside the iframe document (key
- // events inside a focused iframe don't bubble out to the parent, so
- // the window listener above can't see them).
- const handleIframeLoad = () => {
- const iframe = iframeRef.current;
- if (!iframe) return;
- const doc = iframe.contentDocument;
- const win = iframe.contentWindow;
- if (!doc || !win) return;
-
- win.focus();
- const onKeyDown = (e: KeyboardEvent) => {
- if (e.key === "Escape") onClose();
- };
- doc.addEventListener("keydown", onKeyDown);
- // The iframe is unmounted on close (AnimatePresence exit), which
- // discards its contentDocument and the listener with it.
- };
-
- return (
-
- {open && (
-
- e.stopPropagation()}
- >
- {/* Header bar */}
-
-
- 2048
-
-
-
-
-
-
- {/* Iframe: the game lives in public/easter-eggs/2048.html */}
-
-
-
- )}
-
- );
-}
diff --git a/src/features/game2048/hooks/useGame2048Shortcut.ts b/src/features/game2048/hooks/useGame2048Shortcut.ts
deleted file mode 100644
index 8bd303b3c..000000000
--- a/src/features/game2048/hooks/useGame2048Shortcut.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-import { useEffect } from "react";
-
-/**
- * Listen for the Ctrl+Shift+2 keyboard chord and fire `onTrigger`.
- *
- * The "2" hints at 2048. Mirrors the DinoGame pattern in ChatPage: ignores
- * the chord when the user is typing in a textarea/input/contentEditable so
- * we don't hijack regular editing.
- *
- * @param onTrigger called once per chord press (auto-repeat suppressed)
- */
-export function useGame2048Shortcut(onTrigger: () => void): void {
- useEffect(() => {
- const isTypingTarget = (el: Element | null) =>
- el instanceof HTMLElement &&
- (el.tagName === "TEXTAREA" || el.tagName === "INPUT" || el.isContentEditable);
-
- const onKeyDown = (e: KeyboardEvent) => {
- // Use e.code (physical key) instead of e.key (produced character):
- // Shift+2 produces "@" on US QWERTY and '"' on German QWERTZ, so
- // e.key === "2" only matches when Shift is NOT pressed — which
- // defeats the whole chord. e.code === "Digit2" is layout-stable.
- if (!(e.ctrlKey && e.shiftKey && e.code === "Digit2")) return;
- if (isTypingTarget(document.activeElement)) return;
- if (e.repeat) return;
-
- e.preventDefault();
- onTrigger();
- };
-
- window.addEventListener("keydown", onKeyDown);
- return () => window.removeEventListener("keydown", onKeyDown);
- }, [onTrigger]);
-}
diff --git a/src/features/moments/MomentsProvider.tsx b/src/features/moments/MomentsProvider.tsx
index 5608686b4..27b9b710e 100644
--- a/src/features/moments/MomentsProvider.tsx
+++ b/src/features/moments/MomentsProvider.tsx
@@ -4,7 +4,6 @@ import { useReducedMotion } from "framer-motion";
import { useAuth } from "../../context/useAuth.ts";
import { dismissBootSplash, rememberBootGreeting } from "../../bootSplash.ts";
import { MomentsContext } from "./MomentsContext.ts";
-import { useMomentDevShortcuts } from "./useMomentDevShortcuts.ts";
import { LaunchSequence } from "./components/LaunchSequence.tsx";
import { MomentCelebration } from "./components/MomentCelebration.tsx";
import { MissionComplete } from "./components/MissionComplete.tsx";
@@ -161,15 +160,6 @@ export function MomentsProvider({ children }: { children: ReactNode }) {
// provider render would keep resetting them.
const endPathReveal = useCallback(() => setPathReveal(null), []);
- // TEMPORARY — remove this call together with the hook before merging to dev.
- useMomentDevShortcuts({
- celebrate,
- flyby,
- completeMission,
- revealPath,
- playLaunchSequence,
- });
-
const value = useMemo(
() => ({
celebrate,
diff --git a/src/features/moments/useMomentDevShortcuts.ts b/src/features/moments/useMomentDevShortcuts.ts
deleted file mode 100644
index 1c87aa943..000000000
--- a/src/features/moments/useMomentDevShortcuts.ts
+++ /dev/null
@@ -1,98 +0,0 @@
-// ============================================================================
-// TEMPORARY — REMOVE BEFORE MERGING TO dev
-//
-// A tuning aid, not a feature. Delete this file, its call in `MomentsProvider`,
-// and the shortcut table from any docs once the moments are signed off.
-// ============================================================================
-
-import { useEffect } from "react";
-import type { CelebrationInput } from "./types.ts";
-
-interface DevShortcutHandlers {
- celebrate: (input: CelebrationInput) => void;
- flyby: () => void;
- completeMission: () => void;
- revealPath: () => void;
- playLaunchSequence: () => void;
-}
-
-/** Physical key -> which moment it fires. */
-const CHORDS = ["Digit4", "Digit5", "Digit6", "Digit7", "Digit8"] as const;
-
-/**
- * Keyboard chords for triggering each moment on demand, in development only.
- *
- * These moments are gated behind real progress — the finale in particular only
- * fires once a user has cleared every phase of their onboarding path, which is
- * not something anyone can reasonably redo to check whether a 200ms delay feels
- * right. Without a way in, tuning them means guessing.
- *
- * | Chord | Moment |
- * | -------------- | -------------------------- |
- * | Ctrl+Shift+4 | step flyby |
- * | Ctrl+Shift+5 | phase cleared (with ring) |
- * | Ctrl+Shift+6 | onboarding finale |
- * | Ctrl+Shift+7 | login launch sequence |
- * | Ctrl+Shift+8 | new path launch |
- *
- * Digits continue the existing easter-egg chords (Ctrl+Shift+1/2/3 for the
- * dino, 2048 and Space Invaders) rather than starting a second scheme on
- * letters — and letter chords collide with browser and IDE bindings, which is
- * how a shortcut ends up looking broken when the handler is fine.
- *
- * Matched on `event.code` for the same reason the game shortcuts do: with Shift
- * held, `event.key` is the produced character, so Shift+4 is "$" on US QWERTY
- * and "4" nowhere useful. `Digit4` is layout-stable.
- *
- * The whole body compiles out of production builds — `import.meta.env.DEV` is a
- * literal at build time, so this ships nothing to real users.
- */
-export function useMomentDevShortcuts({
- celebrate,
- flyby,
- completeMission,
- revealPath,
- playLaunchSequence,
-}: DevShortcutHandlers) {
- useEffect(() => {
- if (!import.meta.env.DEV) return;
-
- const isTypingTarget = (element: Element | null) =>
- element instanceof HTMLElement &&
- (element.tagName === "TEXTAREA" || element.tagName === "INPUT" || element.isContentEditable);
-
- function handleKeyDown(event: KeyboardEvent) {
- if (!event.ctrlKey || !event.shiftKey || event.repeat) return;
- if (!CHORDS.includes(event.code as (typeof CHORDS)[number])) return;
- if (isTypingTarget(document.activeElement)) return;
-
- event.preventDefault();
-
- switch (event.code) {
- case "Digit4":
- flyby();
- break;
- case "Digit5":
- celebrate({
- tone: "milestone",
- title: "Phase cleared",
- message: "Dev preview — this is what clearing a mid-path phase looks like.",
- progress: { current: 2, total: 5 },
- });
- break;
- case "Digit6":
- completeMission();
- break;
- case "Digit7":
- playLaunchSequence();
- break;
- case "Digit8":
- revealPath();
- break;
- }
- }
-
- window.addEventListener("keydown", handleKeyDown);
- return () => window.removeEventListener("keydown", handleKeyDown);
- }, [celebrate, flyby, completeMission, revealPath, playLaunchSequence]);
-}
diff --git a/src/features/onboarding/components/journey/GenerationScreen.tsx b/src/features/onboarding/components/journey/GenerationScreen.tsx
index 6ff9a245c..12998a66d 100644
--- a/src/features/onboarding/components/journey/GenerationScreen.tsx
+++ b/src/features/onboarding/components/journey/GenerationScreen.tsx
@@ -2,8 +2,22 @@ import { CheckCircle2, CircleDashed, Loader2, Sparkles, TriangleAlert } from "lu
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { DinoGame } from "../../../chatbot/components/DinoGame";
+import { useDinoUnlocked, useSpaceOpensDino } from "../../../easter-eggs/hooks/useDinoWaitingGame";
import type { GenerationPhaseProgress } from "../../generation/OnboardingJourneyContext";
+/**
+ * The phases as they stand once the generation has finished.
+ *
+ * The screen can outlive the run (it stays up while the dino game is still open), and the stage
+ * events it was showing may never have reported their last phase as done -- so a finished path
+ * would otherwise sit under "Path ready" with a phase still spinning. Failed phases stay failed.
+ */
+function settle(phases: GenerationPhaseProgress[]): GenerationPhaseProgress[] {
+ return phases.map((phase) =>
+ phase.state === "working" || phase.state === "waiting" ? { ...phase, state: "done" } : phase,
+ );
+}
+
function elapsed(startedAt: number, now: number): string {
const seconds = Math.max(0, Math.floor((now - startedAt) / 1000));
const minutes = Math.floor(seconds / 60);
@@ -20,56 +34,51 @@ function elapsed(startedAt: number, now: number): string {
export function GenerationScreen({
phases,
startedAt,
+ isRunning,
+ isCompleted = false,
+ onGameActiveChange,
}: {
phases: GenerationPhaseProgress[];
startedAt: number;
+ /**
+ * The generation request is still in flight (`generation.status === "running"`). The only
+ * source of "still generating": the phases are built incrementally from stage events and can
+ * all read done/idle while the path is still being persisted or between two phases.
+ */
+ isRunning: boolean;
+ /** The generation finished with a path: every phase reads as done and the clock stops. */
+ isCompleted?: boolean;
+ /** Reports the dino game opening/closing, so the page can keep this screen up while it is played. */
+ onGameActiveChange?: (active: boolean) => void;
}) {
const [now, setNow] = useState(() => Date.now());
- const [gameActive, setGameActive] = useState(false);
- const [dinoUnlocked, setDinoUnlocked] = useState(
- () => typeof localStorage !== "undefined" && localStorage.getItem("dinoUnlocked") === "true",
- );
+ const dinoUnlocked = useDinoUnlocked();
- useEffect(() => {
- const timer = window.setInterval(() => setNow(Date.now()), 1000);
- return () => window.clearInterval(timer);
- }, []);
+ // From the run's status, never from the phases: they are presentational only, and reading them
+ // here let an open game claim "Path ready" (and the clock stop) before the run had finished.
+ const isGenerating = isRunning && !isCompleted;
+ const shownPhases = isCompleted ? settle(phases) : phases;
+
+ const [gameActive, closeGame] = useSpaceOpensDino(isGenerating, dinoUnlocked, {
+ keepActiveUntilExit: true,
+ });
- // Easter egg: Space starts the runner while the path is being generated.
useEffect(() => {
- const syncUnlock = () => {
- const unlocked = localStorage.getItem("dinoUnlocked") === "true";
- setDinoUnlocked(unlocked);
- if (!unlocked) setGameActive(false);
- };
- window.addEventListener("dinoUnlockChanged", syncUnlock);
- window.addEventListener("storage", syncUnlock);
- return () => {
- window.removeEventListener("dinoUnlockChanged", syncUnlock);
- window.removeEventListener("storage", syncUnlock);
- };
- }, []);
+ onGameActiveChange?.(gameActive);
+ }, [gameActive, onGameActiveChange]);
+ // The elapsed clock only runs while something is still being assembled; a finished run must not
+ // keep counting up behind the game.
useEffect(() => {
- if (gameActive || !dinoUnlocked) return;
- const onKeyDown = (event: KeyboardEvent) => {
- if (event.code !== "Space") return;
- const active = document.activeElement;
- if (
- active instanceof HTMLElement &&
- (active.tagName === "TEXTAREA" || active.tagName === "INPUT" || active.isContentEditable)
- ) {
- return;
- }
- event.preventDefault();
- setGameActive(true);
- };
- window.addEventListener("keydown", onKeyDown);
- return () => window.removeEventListener("keydown", onKeyDown);
- }, [gameActive, dinoUnlocked]);
+ if (!isGenerating) return;
+ const timer = window.setInterval(() => setNow(Date.now()), 1000);
+ return () => window.clearInterval(timer);
+ }, [isGenerating]);
- const done = phases.filter((phase) => phase.state === "done" || phase.state === "failed").length;
- const total = phases.length;
+ const done = shownPhases.filter(
+ (phase) => phase.state === "done" || phase.state === "failed",
+ ).length;
+ const total = shownPhases.length;
const percentage = total > 0 ? Math.round((done / total) * 100) : 0;
return (
@@ -107,7 +116,9 @@ export function GenerationScreen({
{total > 0 ? `${done} of ${total} phases assembled` : "Starting up…"}
- {elapsed(startedAt, now)}
+
+ {elapsed(startedAt, now)}
+
diff --git a/src/features/settings/components/DinoUnlockPopover.tsx b/src/features/settings/components/DinoUnlockPopover.tsx
new file mode 100644
index 000000000..d82e71716
--- /dev/null
+++ b/src/features/settings/components/DinoUnlockPopover.tsx
@@ -0,0 +1,99 @@
+import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
+import { getModalDialogVariants } from "../../../styles/tokens.ts";
+import type { DinoToggleKind } from "../hooks/useDinoEasterEgg.ts";
+
+type DinoUnlockPopoverProps = {
+ /** Result of the latest toggle from `useDinoEasterEgg`, or null when hidden. */
+ kind: DinoToggleKind | null;
+ /** Optional custom CSS classes for the visual bubble container. */
+ className?: string;
+};
+
+/**
+ * Plain-text announcement per toggle result. Kept separate from the visual
+ * copy (which splits the text around a `
` badge and adds an emoji) so
+ * screen readers hear one clean sentence instead of fragments and emoji names.
+ */
+const ANNOUNCEMENT: Record = {
+ unlocked: "shh... press Space whenever you're waiting",
+ locked: "you saw nothing...",
+};
+
+/**
+ * Anchored speech-bubble popover displayed directly below the Settings icon
+ * when the dino easter egg is toggled (triple-click).
+ *
+ * Accessibility: live regions are only announced when their *content* changes
+ * after they are already in the DOM, so the `role="status"` element is always
+ * mounted (visually hidden) and only its text flips with `kind`. The animated
+ * bubble is purely visual and `aria-hidden`, which also keeps the emoji and
+ * the split `` copy from being read twice.
+ *
+ * Copy is chosen from the explicit `kind` discriminant, never by matching
+ * message text, so rewording can't silently swap the lock/unlock rendering.
+ */
+export function DinoUnlockPopover({ kind, className = "" }: DinoUnlockPopoverProps) {
+ const prefersReducedMotion = useReducedMotion() ?? false;
+ const variants = getModalDialogVariants(prefersReducedMotion);
+
+ return (
+ <>
+
+ {kind ? ANNOUNCEMENT[kind] : ""}
+
+
+
+ {kind && (
+
+
+ {/* Upward caret arrow pointing to the cogwheel icon above */}
+
+
+ {kind === "unlocked" ? (
+
+
+ 🤫
+
+
+ shh... press
+
+ Space
+
+ {"whenever you're waiting"}
+
+
+ ) : (
+
+
+ 🫣
+
+
+ you saw nothing...
+
+
+ )}
+
+
+ )}
+
+ >
+ );
+}
diff --git a/src/features/settings/hooks/useDinoEasterEgg.ts b/src/features/settings/hooks/useDinoEasterEgg.ts
index 29c30b348..8ffabd614 100644
--- a/src/features/settings/hooks/useDinoEasterEgg.ts
+++ b/src/features/settings/hooks/useDinoEasterEgg.ts
@@ -1,55 +1,61 @@
import { useCallback, useEffect, useRef, useState } from "react";
+import { useRepeatClicks } from "../../easter-eggs/hooks/useRepeatClicks";
const TOAST_DURATION_MS = 3000;
const TOGGLE_DEBOUNCE_MS = 2000;
const UNLOCK_THRESHOLD = 3;
+/**
+ * Which way the last triple-click flipped the dino game. Exposed as an
+ * explicit discriminant so the popover picks its copy from state rather than
+ * substring-matching a message — copy edits can then never silently break the
+ * lock/unlock rendering.
+ */
+export type DinoToggleKind = "unlocked" | "locked";
+
type DinoEasterEgg = {
- isUnlocked: boolean;
- gameActive: boolean;
- toast: string | null;
+ /** Result of the most recent toggle while its notice is showing, or null when hidden. */
+ kind: DinoToggleKind | null;
+ /** Registers one cogwheel click; every third click toggles the unlock. */
handleIconClick: () => void;
- setGameActive: (active: boolean) => void;
};
/**
- * Dino game easter-egg: triple-clicking the settings cogwheel toggles a
- * hidden Space-to-play dinosaur game. The unlock state is persisted in
- * `localStorage` under `dinoUnlocked` and broadcast via a custom
- * `dinoUnlockChanged` window event so multiple components stay in sync.
+ * Dino game easter-egg trigger: triple-clicking the settings cogwheel
+ * toggles the hidden Space-to-play dinosaur game, persisted in
+ * `localStorage` under `dinoUnlocked`.
*
- * Extracted from `SettingsPage` so the page itself only orchestrates
- * settings sections. The click-counter state lives in a ref (not in a state
- * updater) so the side effects (localStorage write, event dispatch, toast)
- * run exactly once even when React double-invokes updaters in StrictMode dev.
+ * This hook only owns the *trigger* (click counting, persistence, toast).
+ * Whoever plays the game subscribes through {@link useDinoUnlocked} /
+ * {@link useSpaceOpensDino}, which react to the `dinoUnlockChanged`
+ * window event dispatched here — so unlocking in Settings instantly arms
+ * the chat, onboarding and buddy waiting-games without any direct wiring.
+ *
+ * The toggle is debounced (2s) so an accidental fourth+third click can't
+ * silently re-lock the game, and the unlock write is wrapped in try/catch
+ * because private-browsing modes can reject localStorage writes.
*/
export function useDinoEasterEgg(): DinoEasterEgg {
- const [isUnlocked, setIsUnlocked] = useState(
- () => localStorage.getItem("dinoUnlocked") === "true",
- );
- const [gameActive, setGameActive] = useState(false);
- const [toast, setToast] = useState(null);
-
- const clickCountRef = useRef(0);
- const lastToggleRef = useRef(0);
+ const [kind, setKind] = useState(null);
const toastTimerRef = useRef | null>(null);
+ const lastToggleRef = useRef(0);
- const showToast = useCallback((message: string) => {
- setToast(message);
+ // Clear any pending toast timer on unmount.
+ useEffect(() => {
+ return () => {
+ if (toastTimerRef.current) clearTimeout(toastTimerRef.current);
+ };
+ }, []);
+
+ const showToast = useCallback((nextKind: DinoToggleKind) => {
+ setKind(nextKind);
if (toastTimerRef.current) clearTimeout(toastTimerRef.current);
toastTimerRef.current = setTimeout(() => {
- setToast(null);
+ setKind(null);
}, TOAST_DURATION_MS);
}, []);
- const handleIconClick = useCallback(() => {
- const next = clickCountRef.current + 1;
- if (next < UNLOCK_THRESHOLD) {
- clickCountRef.current = next;
- return;
- }
-
- clickCountRef.current = 0;
+ const toggleUnlock = useCallback(() => {
if (Date.now() - lastToggleRef.current < TOGGLE_DEBOUNCE_MS) return;
lastToggleRef.current = Date.now();
@@ -61,67 +67,20 @@ export function useDinoEasterEgg(): DinoEasterEgg {
console.warn("Failed to persist dino unlock state", error);
}
- // Update our own state directly (batched with the click handler) rather
- // than round-tripping through a synchronous `dispatchEvent`, which can
- // land the listener's setState during a sibling's render cycle (React
- // 19: "Cannot update a component while rendering a different
- // component"). Notify external listeners (SideBar, DinoGame) on a
- // microtask so their setState is deferred past the current render.
- const nextUnlocked = nextValue === "true";
- setIsUnlocked(nextUnlocked);
- if (!nextUnlocked) setGameActive(false);
+ // Notify listeners (chat, onboarding, buddy) on a microtask so their
+ // setState is deferred past the current render — a synchronous
+ // dispatchEvent can land a listener's setState during a sibling's
+ // render cycle (React 19: "Cannot update a component while rendering
+ // a different component").
queueMicrotask(() => window.dispatchEvent(new Event("dinoUnlockChanged")));
- showToast(currentlyUnlocked ? "you saw nothing... 🫣" : "shh... 🤫 (press Space)");
+ showToast(currentlyUnlocked ? "locked" : "unlocked");
}, [showToast]);
- // Keep isUnlocked in sync with localStorage (other tabs, or our own event).
- useEffect(() => {
- const sync = () => {
- const unlocked = localStorage.getItem("dinoUnlocked") === "true";
- setIsUnlocked(unlocked);
- if (!unlocked) setGameActive(false);
- };
- window.addEventListener("dinoUnlockChanged", sync);
- window.addEventListener("storage", sync);
- return () => {
- window.removeEventListener("dinoUnlockChanged", sync);
- window.removeEventListener("storage", sync);
- };
- }, []);
-
- // Space-to-play, but only when unlocked, no game running, and not typing.
- useEffect(() => {
- if (!isUnlocked || gameActive) return;
-
- const onKeyDown = (e: KeyboardEvent) => {
- if (e.code !== "Space") return;
- const active = document.activeElement;
- const typing =
- active instanceof HTMLElement &&
- (active.tagName === "TEXTAREA" || active.tagName === "INPUT" || active.isContentEditable);
- if (typing) return;
-
- e.preventDefault();
- setGameActive(true);
- };
-
- window.addEventListener("keydown", onKeyDown);
- return () => window.removeEventListener("keydown", onKeyDown);
- }, [isUnlocked, gameActive]);
-
- // Clear any pending toast timer on unmount.
- useEffect(() => {
- return () => {
- if (toastTimerRef.current) clearTimeout(toastTimerRef.current);
- };
- }, []);
+ const handleIconClick = useRepeatClicks(UNLOCK_THRESHOLD, toggleUnlock);
return {
- isUnlocked,
- gameActive,
- toast,
+ kind,
handleIconClick,
- setGameActive,
};
}
diff --git a/src/features/space-invaders/components/SpaceInvadersModal.tsx b/src/features/space-invaders/components/SpaceInvadersModal.tsx
deleted file mode 100644
index 2b1c1d778..000000000
--- a/src/features/space-invaders/components/SpaceInvadersModal.tsx
+++ /dev/null
@@ -1,68 +0,0 @@
-import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
-import { useScrollLock } from "../../../components/ui/useScrollLock";
-import { getModalDialogVariants, modalBackdropVariants } from "../../../styles/tokens";
-import { SpaceInvaders } from "../../easter-eggs/components/SpaceInvaders.tsx";
-
-/**
- * Props for {@link SpaceInvadersModal}.
- */
-interface SpaceInvadersModalProps {
- /** When true, the modal is visible and the game is mounted. */
- open: boolean;
- /**
- * Called when the user requests to close (overlay click, or via the
- * SpaceInvaders' own Esc / exit button — SpaceInvaders calls `onExit`
- * on Escape and on its in-game "Esc ✕" button, which we route here).
- */
- onClose: () => void;
-}
-
-/**
- * Dashboard easter-egg wrapper that mounts the existing {@link SpaceInvaders}
- * game inside a Framer Motion modal.
- *
- * Mirrors {@link DinoGameModal}: no header bar and no modal-level Escape
- * listener — {@link SpaceInvaders} already renders its own score /
- * "Esc ✕" overlay on top of the canvas and calls `onExit` on Escape, so a
- * second header or Esc handler would duplicate chrome and double-fire on
- * Esc. Opened from the dashboard via the Ctrl+Shift+3 chord
- * ({@link useSpaceInvadersShortcut}); also reused by the 404 page.
- */
-export function SpaceInvadersModal({ open, onClose }: SpaceInvadersModalProps) {
- // Not a `Modal`: the game owns the keyboard, and Modal's focus trap
- // would fight it for the arrow keys. The one thing every overlay needs
- // regardless is the page behind it holding still.
- useScrollLock(open);
-
- const prefersReducedMotion = useReducedMotion();
- const dialogVariants = getModalDialogVariants(Boolean(prefersReducedMotion));
-
- return (
-
- {open && (
-
- e.stopPropagation()}
- >
-
-
-
- )}
-
- );
-}
diff --git a/src/features/space-invaders/hooks/useSpaceInvadersShortcut.ts b/src/features/space-invaders/hooks/useSpaceInvadersShortcut.ts
deleted file mode 100644
index bf139eee1..000000000
--- a/src/features/space-invaders/hooks/useSpaceInvadersShortcut.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-import { useEffect } from "react";
-
-/**
- * Listen for the Ctrl+Shift+3 keyboard chord and fire `onTrigger`.
- *
- * The "3" identifies the third easter-egg game on the dashboard (after the
- * dino runner on Ctrl+Shift+1 and 2048 on Ctrl+Shift+2). Mirrors the
- * {@link useDinoShortcut} / {@link useGame2048Shortcut} pattern: ignores
- * the chord when the user is typing in a textarea/input/contentEditable so
- * we don't hijack regular editing.
- *
- * @param onTrigger called once per chord press (auto-repeat suppressed)
- */
-export function useSpaceInvadersShortcut(onTrigger: () => void): void {
- useEffect(() => {
- const isTypingTarget = (el: Element | null) =>
- el instanceof HTMLElement &&
- (el.tagName === "TEXTAREA" || el.tagName === "INPUT" || el.isContentEditable);
-
- const onKeyDown = (e: KeyboardEvent) => {
- // Use e.code (physical key) instead of e.key (produced character):
- // Shift+3 produces "#" on US QWERTY and "§" on German QWERTZ, so
- // e.key === "3" only matches when Shift is NOT pressed — which
- // defeats the whole chord. e.code === "Digit3" is layout-stable.
- if (!(e.ctrlKey && e.shiftKey && e.code === "Digit3")) return;
- if (isTypingTarget(document.activeElement)) return;
- if (e.repeat) return;
-
- e.preventDefault();
- onTrigger();
- };
-
- window.addEventListener("keydown", onKeyDown);
- return () => window.removeEventListener("keydown", onKeyDown);
- }, [onTrigger]);
-}
diff --git a/src/keycloak-theme/login/components/SidebarLogo.tsx b/src/keycloak-theme/login/components/SidebarLogo.tsx
index 0fb055b67..73e61d50d 100644
--- a/src/keycloak-theme/login/components/SidebarLogo.tsx
+++ b/src/keycloak-theme/login/components/SidebarLogo.tsx
@@ -1,13 +1,18 @@
/**
- * Verbatim copy of the app's `SidebarLogo`
- * (src/components/layout/SidebarLogo.tsx) for the Keycloak login theme.
+ * The app's `SidebarLogo` (src/components/layout/SidebarLogo.tsx) for the
+ * Keycloak login theme: same rocket, badge, hover lift and flame, copied
+ * rather than re-exported.
*
* No app-specific dependency to strip here (unlike AuroraBackground.tsx /
* SpotlightCard.tsx in this same folder) — it only needs `framer-motion`,
- * which is already pulled into this bundle by SpotlightCard. Kept as an exact
- * copy rather than a re-export so the login theme doesn't reach into
- * `src/components/layout/` (a different part of the app's own bundle graph)
- * — copy stays in sync by hand if the original changes.
+ * which is already pulled into this bundle by SpotlightCard. Kept as a copy
+ * rather than a re-export so the login theme doesn't reach into
+ * `src/components/layout/` (a different part of the app's own bundle graph).
+ *
+ * This copy deliberately stops at the mark. The app version's gravity egg
+ * (five clicks drop the badge off its shelf) belongs to the signed-in app
+ * only — a login form is no place to hide a game. Changes to the *mark*
+ * still have to be carried over by hand; that is the price of the copy.
*/
import { motion, useReducedMotion, type Variants } from "framer-motion";
diff --git a/src/pages/BuddyPage.tsx b/src/pages/BuddyPage.tsx
index 9a275ba0c..62ffb11f5 100644
--- a/src/pages/BuddyPage.tsx
+++ b/src/pages/BuddyPage.tsx
@@ -175,6 +175,9 @@ function BuddyMentorHome() {
confirmAction,
dismissAction,
openError,
+ dinoGameActive,
+ closeDinoGame,
+ registerDinoSurface,
ensureOpened,
retryOpen,
startFreshVisit,
@@ -186,6 +189,10 @@ function BuddyMentorHome() {
isDeciding,
} = useBuddySession();
+ // The page shows the thread, so Space may open the dino game here; leaving the page releases
+ // it (and closes a game still running) — see `registerDinoSurface`.
+ useEffect(() => registerDinoSurface(), [registerDinoSurface]);
+
// A greeting written while the hire was somewhere else still gets the buddy thinking and
// writing it, the first time it is on screen — the same as in the dock.
const greeting = useGreetingReveal({
@@ -365,12 +372,15 @@ function BuddyMentorHome() {
) => {
- const text = newRequest.trim().toLowerCase();
- if (text === "do a barrel roll" || text === "do barrel roll" || text === "do barrel") {
- e.preventDefault();
- setIsBarrelRolling(true);
- setNewRequest("");
- return;
- }
- if (text === "the matrix" || text === "do matrix" || text === "matrix") {
- e.preventDefault();
- setIsMatrixActive(true);
- setNewRequest("");
- return;
- }
- handleSubmit(e);
- },
- [newRequest, setNewRequest, handleSubmit],
- );
+ // Easter eggs play through the app-wide bus + EggEffectsLayer, so the
+ // buddy chat can trigger the same effects without this page owning them.
+ const dinoUnlocked = useDinoUnlocked();
+ const [gameActive, closeGame] = useSpaceOpensDino(isThinking, dinoUnlocked, {
+ keepActiveUntilExit: true,
+ });
+
+ // When switching between chats while a waiting game is open, exit cleanly.
+ // An effect, not a render-phase adjustment: closing releases the shared
+ // module-level game slot, and mutating module state during render is unsafe
+ // (StrictMode double renders, discarded concurrent renders).
+ const prevChatIdRef = useRef(chatId);
+ useEffect(() => {
+ if (prevChatIdRef.current === chatId) return;
+ prevChatIdRef.current = chatId;
+ closeGame();
+ }, [chatId, closeGame]);
const handleToggleFilters = useCallback(() => {
setShowFilters((v) => !v);
}, [setShowFilters]);
- // Barrel roll side-effect
- useEffect(() => {
- if (isBarrelRolling) {
- document.body.classList.add("barrel-roll-active");
- const timeout = setTimeout(() => {
- document.body.classList.remove("barrel-roll-active");
- setIsBarrelRolling(false);
- }, 2000);
- return () => {
- clearTimeout(timeout);
- document.body.classList.remove("barrel-roll-active");
- };
- }
- }, [isBarrelRolling]);
-
- // Dino easter egg: while the assistant is thinking, pressing Space drops the
- // AI avatar into a tiny endless runner. Doing nothing leaves the chat untouched.
- const [gameActive, setGameActive] = useState(false);
- const [isUnlocked, setIsUnlocked] = useState(
- () => localStorage.getItem("dinoUnlocked") === "true",
- );
-
- // Close the game as soon as the answer arrives. Uses React's documented
- // "adjust state when a value changes" pattern (guarded setState during
- // render) instead of an effect — avoids cascading renders and the
- // set-state-in-effect lint rule. See:
- // https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes
- const [prevIsThinking, setPrevIsThinking] = useState(isThinking);
- if (prevIsThinking !== isThinking) {
- setPrevIsThinking(isThinking);
- if (!isThinking && gameActive) {
- setGameActive(false);
- }
- }
-
// E5: replacement for the live region that used to wrap the message list.
// `ThinkingIndicator` already carries `role="status"` for the working
// state, so this only needs to report that a turn has finished. Gated on
@@ -237,54 +201,37 @@ export function ChatPage() {
}
// Submitting blurs the composer so Space can start the dino game while the assistant
- // works. Once the turn is over that reason is gone, so focus goes back — otherwise every
+ // works. Once the turn is over that reason is gone, focus goes back — otherwise every
// follow-up question needs a click first. Skipped when something else already holds focus,
// so this never steals the caret from wherever the user went in the meantime.
+ // Also held back while the dino game is open: the game ignores keys aimed at text
+ // fields, so stealing focus for the composer mid-run would kill the dino, make Esc
+ // dead and type Spaces into the composer. Closing the game re-runs this effect, which
+ // is when the composer gets focus back.
useEffect(() => {
- if (busy) return;
+ if (busy || gameActive) return;
if (!chatId) return;
const active = document.activeElement;
if (active && active !== document.body) return;
textareaRef.current?.focus();
- }, [busy, chatId, textareaRef]);
+ }, [busy, chatId, gameActive, textareaRef]);
- // Keep isUnlocked state perfectly in sync with localStorage and close game if locked
- useEffect(() => {
- const handleUnlockChange = () => {
- const unlocked = localStorage.getItem("dinoUnlocked") === "true";
- setIsUnlocked(unlocked);
- if (!unlocked) {
- setGameActive(false);
+ // Custom submit handler to intercept easter eggs: a phrase match plays
+ // the app-wide effect (via the bus) and swallows the message — it never
+ // reaches the AI; a normal message goes through unchanged.
+ const handleChatSubmit = useCallback(
+ (e: React.FormEvent) => {
+ const effect = matchEggPhrase(newRequest);
+ if (!effect) {
+ handleSubmit(e);
+ return;
}
- };
- window.addEventListener("dinoUnlockChanged", handleUnlockChange);
- window.addEventListener("storage", handleUnlockChange);
- return () => {
- window.removeEventListener("dinoUnlockChanged", handleUnlockChange);
- window.removeEventListener("storage", handleUnlockChange);
- };
- }, []);
-
- useEffect(() => {
- if (!isThinking || gameActive || !isUnlocked) return;
-
- const onKeyDown = (e: KeyboardEvent) => {
- if (e.code !== "Space") return;
-
- // Don't hijack space while the user is typing their next message.
- const active = document.activeElement;
- const typing =
- active instanceof HTMLElement &&
- (active.tagName === "TEXTAREA" || active.tagName === "INPUT" || active.isContentEditable);
- if (typing) return;
-
e.preventDefault();
- setGameActive(true);
- };
-
- window.addEventListener("keydown", onKeyDown);
- return () => window.removeEventListener("keydown", onKeyDown);
- }, [isThinking, gameActive, isUnlocked]);
+ playEggEffect(effect);
+ setNewRequest("");
+ },
+ [newRequest, setNewRequest, handleSubmit],
+ );
/**
* Focuses the composer and puts the caret behind whatever is already in it.
@@ -377,6 +324,11 @@ export function ChatPage() {
useNewConversationShortcut(startNewChat, surfaceFromPathname(pathname) === "chat");
+ const activeStreamingMessage = streamingMessageId
+ ? messages.find((m) => m.id === streamingMessageId)
+ : null;
+ const hasReasoning = Boolean(activeStreamingMessage?.reasoning);
+
return (
// No height of its own any more: the page is a panel inside `AssistantShell`, which owns
// the viewport and the header above it.
@@ -520,7 +472,10 @@ export function ChatPage() {
isThinking={isThinking}
gameActive={gameActive}
thinkingState={thinkingState}
- onGameExit={() => setGameActive(false)}
+ hasReasoning={hasReasoning}
+ replyReady={gameActive && !isThinking && !isStreaming}
+ turnOutcome={turnOutcome}
+ onGameExit={closeGame}
/>
@@ -595,8 +550,6 @@ export function ChatPage() {
onDelete={() => {}}
/>
)}
-
- {isMatrixActive && setIsMatrixActive(false)} />}
);
}
diff --git a/src/pages/DashboardPage.tsx b/src/pages/DashboardPage.tsx
index 35c06264d..24c05bd62 100644
--- a/src/pages/DashboardPage.tsx
+++ b/src/pages/DashboardPage.tsx
@@ -1,14 +1,10 @@
-import { useCallback, useState } from "react";
+import { useState } from "react";
import { ChartColumn, Check, LayoutGrid, Plus, RotateCcw } from "lucide-react";
import { PageHeader } from "../components/layout/PageHeader";
import { AlertDialog } from "../components/ui/AlertDialog";
import { Button } from "../components/ui/Button";
-import { Game2048Modal } from "../features/game2048/components/Game2048Modal";
-import { useGame2048Shortcut } from "../features/game2048/hooks/useGame2048Shortcut";
-import { DinoGameModal } from "../features/dino/components/DinoGameModal";
-import { useDinoShortcut } from "../features/dino/hooks/useDinoShortcut";
-import { SpaceInvadersModal } from "../features/space-invaders/components/SpaceInvadersModal";
-import { useSpaceInvadersShortcut } from "../features/space-invaders/hooks/useSpaceInvadersShortcut";
+import { EggModalShell } from "../features/easter-eggs/components/EggModalShell";
+import { useRepeatClicks } from "../features/easter-eggs/hooks/useRepeatClicks";
import { WidgetPickerModal } from "../features/dashboard/components/WidgetPickerModal";
import { DashboardGrid } from "../features/dashboard/components/DashboardGrid";
import { useDashboardLayout } from "../features/dashboard/layout/useDashboardLayout";
@@ -37,22 +33,12 @@ export function DashboardPage() {
const [isPickerOpen, setPickerOpen] = useState(false);
const [isResetOpen, setResetOpen] = useState(false);
- // 2048 easter egg: Ctrl+Shift+2 opens the game in a modal.
+ // Easter egg: the dashboard header icon hides the 2048 game — three
+ // quick clicks open it, the same gesture language as the Settings
+ // cogwheel hiding the dino. Deliberately ungated: finding it *is*
+ // the fun, no localStorage flag involved.
const [game2048Open, setGame2048Open] = useState(false);
- const openGame2048 = useCallback(() => setGame2048Open(true), []);
- useGame2048Shortcut(openGame2048);
-
- // Dino easter egg: Ctrl+Shift+1 opens the runner in a modal.
- // Bypasses the `dinoUnlocked` gate that the sidebar/chat use — the
- // dashboard chord is a true easter egg, always available.
- const [dinoOpen, setDinoOpen] = useState(false);
- const openDino = useCallback(() => setDinoOpen(true), []);
- useDinoShortcut(openDino);
-
- // Space Invaders easter egg: Ctrl+Shift+3 opens the game in a modal.
- const [invadersOpen, setInvadersOpen] = useState(false);
- const openInvaders = useCallback(() => setInvadersOpen(true), []);
- useSpaceInvadersShortcut(openInvaders);
+ const onHeaderIconClick = useRepeatClicks(3, () => setGame2048Open(true));
return (
@@ -61,6 +47,8 @@ export function DashboardPage() {
setResetOpen(false)}
/>
- setGame2048Open(false)} />
- setDinoOpen(false)} />
- setInvadersOpen(false)} />
+ setGame2048Open(false)} />
);
}
diff --git a/src/pages/NotFoundPage.tsx b/src/pages/NotFoundPage.tsx
index 6e314915c..4a94c412f 100644
--- a/src/pages/NotFoundPage.tsx
+++ b/src/pages/NotFoundPage.tsx
@@ -1,19 +1,18 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
-import { SpaceInvadersModal } from "../features/space-invaders/components/SpaceInvadersModal.tsx";
+import { EggModalShell } from "../features/easter-eggs/components/EggModalShell.tsx";
import { PageHeader } from "../components/layout/PageHeader.tsx";
import { Button } from "../components/ui/Button.tsx";
import { Rocket } from "lucide-react";
/**
- * Catch-all 404 page. Shows a "not found" message with a dashboard link and
- * auto-opens the Space Invaders easter egg.
+ * Catch-all 404 page. Shows a "not found" message with a dashboard link
+ * and a small easter-egg teaser: a rocket that opens the Space Invaders
+ * game for whoever notices it while they are stranded here.
*/
export function NotFoundPage() {
const navigate = useNavigate();
- // Auto-open the game on the 404 page (the canonical easter-egg entry
- // point); closing it leaves the 404 message + dashboard link visible.
- const [invadersOpen, setInvadersOpen] = useState(true);
+ const [invadersOpen, setInvadersOpen] = useState(false);
return (
@@ -33,6 +32,26 @@ export function NotFoundPage() {
+ {/* Easter-egg teaser, styled to blend into the page: only people who
+ read the copy closely will think of clicking it. The whole row is
+ the button (text + rocket) so the target is generous, and it reuses
+ the page's "lost in space" line so the invitation reads as part of
+ the joke rather than a stray CTA.
+ The accessible name starts with the words on screen (WCAG 2.5.3):
+ an aria-label that replaced them would leave voice control with
+ nothing to say that it can see. The hint rides along as a suffix
+ inside the name instead. */}
+ setInvadersOpen(true)}
+ trailingIcon={🚀 }
+ className="mt-4"
+ >
+ While you're lost in space…
+ play Space Invaders
+
+
- setInvadersOpen(false)} />
+ setInvadersOpen(false)}
+ />
);
}
diff --git a/src/pages/OnBoardingPage.tsx b/src/pages/OnBoardingPage.tsx
index 8918f13a2..cc6434a71 100644
--- a/src/pages/OnBoardingPage.tsx
+++ b/src/pages/OnBoardingPage.tsx
@@ -44,6 +44,7 @@ import { StepWorkspace } from "../features/onboarding/components/journey/StepWor
import {
useOnboardingJourney,
type GenerationFailureReason,
+ type GenerationPhaseProgress,
type UnavailableReason,
} from "../features/onboarding/generation/OnboardingJourneyContext";
import { ProgressRing } from "../features/onboarding/graph/JourneyNodeCards";
@@ -221,6 +222,46 @@ export function OnBoardingPage() {
);
}, []);
const [confirmRegenerate, setConfirmRegenerate] = useState(false);
+ const [isDinoActiveInGeneration, setIsDinoActiveInGeneration] = useState(false);
+ /**
+ * The last running generation, kept so the generation screen can stay up while the dino game
+ * is still being played after the run has ended (see `isDinoActiveInGeneration`).
+ *
+ * `completed` outlives the `done` status on purpose: the page clears a finished generation as
+ * soon as the new path is fetched, so the status drops to `idle` while the game may still be
+ * open. Without the flag the screen would fall back to the frozen "working" snapshot and stop
+ * saying "Path ready".
+ */
+ const [lastGeneration, setLastGeneration] = useState<{
+ phases: GenerationPhaseProgress[];
+ startedAt: number;
+ completed: boolean;
+ }>(() =>
+ generation.status === "running"
+ ? { phases: generation.phases, startedAt: generation.startedAt, completed: false }
+ : { phases: [], startedAt: 0, completed: false },
+ );
+
+ // Follows the generation during render (React's "adjust state when a value changes" pattern)
+ // rather than in an effect, so the screen never paints one frame with a stale outcome.
+ const [seenGeneration, setSeenGeneration] = useState(generation);
+ if (seenGeneration !== generation) {
+ setSeenGeneration(generation);
+ if (generation.status === "running") {
+ setLastGeneration({
+ phases: generation.phases,
+ startedAt: generation.startedAt,
+ completed: false,
+ });
+ } else if (generation.status === "done") {
+ setLastGeneration((previous) => ({ ...previous, completed: true }));
+ } else if (generation.status === "error") {
+ // Failure wins over the game: the snapshot's phases are still "working", so keeping the
+ // generation screen up for the game would hide the failure behind a wait that is over.
+ // Dropping the flag unmounts the screen, which releases the game's shared slot with it.
+ setIsDinoActiveInGeneration(false);
+ }
+ }
// Set when the page itself moves the member on, so the item they land on is scrolled to.
const scrollToItemRef = useRef(focusItemId ?? null);
@@ -669,8 +710,27 @@ export function OnBoardingPage() {
// ── Render: generating ──────────────────────────────────────
- if (generation.status === "running") {
- return ;
+ // A failed generation never keeps this screen up, game or not: its failure (the retry below, or
+ // the toast beside an existing path) has to be what the member sees.
+ if (
+ generation.status === "running" ||
+ (isDinoActiveInGeneration && generation.status !== "error")
+ ) {
+ const isRunning = generation.status === "running";
+ const activePhases = isRunning ? generation.phases : lastGeneration.phases;
+ const startedAt = isRunning ? generation.startedAt : lastGeneration.startedAt;
+
+ return (
+
+ );
}
// ── Render: loading ─────────────────────────────────────────
diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx
index 76c3b0312..31feb56ce 100644
--- a/src/pages/SettingsPage.tsx
+++ b/src/pages/SettingsPage.tsx
@@ -9,6 +9,7 @@ import { SettingsSection } from "../features/settings/components/SettingsSection
import { ProfileSection } from "../features/settings/components/ProfileSection";
import { AppearanceSection } from "../features/settings/components/AppearanceSection";
import { AccessTokensSection } from "../features/settings/components/AccessTokensSection";
+import { DinoUnlockPopover } from "../features/settings/components/DinoUnlockPopover";
import { useDinoEasterEgg } from "../features/settings/hooks/useDinoEasterEgg";
type SectionId = "profile" | "appearance" | "tokens";
@@ -121,6 +122,8 @@ export function SettingsPage() {
title="Settings"
subtitle="Manage your profile, appearance and access tokens in one place."
onIconClick={dino.handleIconClick}
+ eggHint
+ iconPopover={ }
/>
@@ -157,16 +160,6 @@ export function SettingsPage() {
-
- {dino.toast && (
-
- {dino.toast}
-
- )}
);
}
diff --git a/src/styles/index.css b/src/styles/index.css
index 50f75bf6d..ea1bb9ed8 100644
--- a/src/styles/index.css
+++ b/src/styles/index.css
@@ -697,6 +697,42 @@ body {
animation: barrelRoll 2s ease-in-out;
}
+/* ── Page-header icon easter-egg hint ──
+ Header icons that hide a game (Settings cogwheel, dashboard chart) get two
+ affordances: they grow slightly on hover, and on mount the icon that owns
+ a game plays ONE short glow pulse — a detail-level wink, not an obvious
+ call to action. The animation is one-shot: it runs once per mount and is
+ never re-triggered, so it reads as a flicker, not as a notification.
+ Users who prefer reduced motion get neither effect (the hover scale is
+ motion, so it goes too). */
+@keyframes header-icon-egg-glow {
+ 0%,
+ 100% {
+ filter: drop-shadow(0 0 0 transparent);
+ }
+ 50% {
+ filter: drop-shadow(0 0 6px var(--brand-border-strong));
+ }
+}
+
+.page-header-icon-button:hover .page-header-icon {
+ transform: scale(1.15);
+}
+
+.page-header-icon-button[data-egg-hint="true"] .page-header-icon {
+ animation: header-icon-egg-glow 0.5s ease-in-out 1 both;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .page-header-icon-button:hover .page-header-icon {
+ transform: none;
+ }
+
+ .page-header-icon-button[data-egg-hint="true"] .page-header-icon {
+ animation: none;
+ }
+}
+
/*
* Override KaTeX's default error style (which is blinding red and makes the
* entire document look broken if a math block fails to close).
diff --git a/src/styles/tokens.ts b/src/styles/tokens.ts
index 5c8219c17..9f36c67f1 100644
--- a/src/styles/tokens.ts
+++ b/src/styles/tokens.ts
@@ -212,6 +212,19 @@ export const celebrationSpringToken: Transition = {
mass: 0.9,
};
+/**
+ * Spring for the sidebar logo's hidden "drop" egg — the badge landing and
+ * hopping back into place. Far more under-damped than `celebrationSpringToken`
+ * on purpose: the visible bounce on landing *is* the joke, and a critically
+ * damped settle would read as a layout glitch rather than a toy. Only used by
+ * an opt-in egg that reduced-motion users never trigger; keep it off routine UI.
+ */
+export const logoHopSpringToken: Transition = {
+ type: "spring",
+ stiffness: 260,
+ damping: 11,
+};
+
/** How long a rocket takes to cross the screen, in seconds. */
export const FLIGHT_DURATION_S = 1.15;
diff --git a/tests/unit/a11y/NotFoundPage.a11y.test.tsx b/tests/unit/a11y/NotFoundPage.a11y.test.tsx
index b48ff4d6e..9b58c6dd5 100644
--- a/tests/unit/a11y/NotFoundPage.a11y.test.tsx
+++ b/tests/unit/a11y/NotFoundPage.a11y.test.tsx
@@ -1,11 +1,13 @@
-import { render } from "@testing-library/react";
+import { render, screen } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import { axe } from "vitest-axe";
import { MemoryRouter } from "react-router-dom";
import { NotFoundPage } from "../../../src/pages/NotFoundPage";
-vi.mock("../../../src/features/space-invaders/components/SpaceInvadersModal", () => ({
- SpaceInvadersModal: () => null,
+// Keep the page hermetic: the egg modal (and its lazy game chunks) must not
+// load in an accessibility scan.
+vi.mock("../../../src/features/easter-eggs/components/EggModalShell", () => ({
+ EggModalShell: () => null,
}));
describe("NotFoundPage Accessibility", () => {
@@ -19,4 +21,18 @@ describe("NotFoundPage Accessibility", () => {
);
expect(await axe(baseElement)).toHaveNoViolations();
});
+
+ it("keeps the teaser's visible words at the start of its accessible name (WCAG 2.5.3)", () => {
+ render(
+
+
+ ,
+ );
+ const teaser = screen.getByRole("button", { name: /space invaders/i });
+ // Normalise the ellipsis/whitespace the same way a speech recogniser would
+ // hear it: the name must begin with what is drawn on screen.
+ const visible = (teaser.textContent ?? "").split(" play Space Invaders")[0].trim();
+ expect(visible).toBe("While you're lost in space…");
+ expect(teaser).toHaveAccessibleName(expect.stringMatching(/^While you're lost in space…/));
+ });
});
diff --git a/tests/unit/components/layout/PageHeader.test.tsx b/tests/unit/components/layout/PageHeader.test.tsx
new file mode 100644
index 000000000..67d07856f
--- /dev/null
+++ b/tests/unit/components/layout/PageHeader.test.tsx
@@ -0,0 +1,56 @@
+import { render, screen } from "@testing-library/react";
+import { describe, it, expect } from "vitest";
+import { PageHeader } from "../../../../src/components/layout/PageHeader";
+import { Home } from "lucide-react";
+
+describe("PageHeader", () => {
+ it("renders a plain (non-clickable) icon without an onIconClick handler", () => {
+ render( );
+ expect(screen.getByRole("heading", { name: "Plain" })).toBeInTheDocument();
+ expect(screen.queryByRole("button")).not.toBeInTheDocument();
+ });
+
+ it("renders the icon as a button when onIconClick is given", () => {
+ render( {}} />);
+ expect(screen.getByRole("button", { name: "Clickable icon" })).toBeInTheDocument();
+ });
+
+ it("marks the icon with data-egg-hint only when eggHint is set", () => {
+ const { rerender } = render(
+ {}} eggHint />,
+ );
+ expect(screen.getByRole("button", { name: "Egg icon" })).toHaveAttribute(
+ "data-egg-hint",
+ "true",
+ );
+
+ rerender( {}} />);
+ expect(screen.getByRole("button", { name: "No egg icon" })).not.toHaveAttribute(
+ "data-egg-hint",
+ );
+ });
+
+ it("keeps the hint purely decorative: the accessible name does not change", () => {
+ render(
+
+
{}} eggHint />
+ {}} />
+ ,
+ );
+ const buttons = screen.getAllByRole("button", { name: "Settings icon" });
+ expect(buttons).toHaveLength(2);
+ });
+
+ it("renders iconPopover inside the icon container", () => {
+ render(
+ {}}
+ iconPopover={Popover Content
}
+ />,
+ );
+ expect(screen.getByTestId("test-popover")).toBeInTheDocument();
+ expect(screen.getByText("Popover Content")).toBeInTheDocument();
+ });
+});
diff --git a/tests/unit/components/layout/SidebarLogo.test.tsx b/tests/unit/components/layout/SidebarLogo.test.tsx
new file mode 100644
index 000000000..dc6882953
--- /dev/null
+++ b/tests/unit/components/layout/SidebarLogo.test.tsx
@@ -0,0 +1,129 @@
+import { act, fireEvent, render, screen } from "@testing-library/react";
+import type { ComponentType, ReactNode } from "react";
+import { createElement } from "react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { SidebarLogo } from "../../../../src/components/layout/SidebarLogo";
+
+/**
+ * Test-controlled seams into framer-motion.
+ *
+ * `useReducedMotion` is stubbed instead of driven through `window.matchMedia`:
+ * framer-motion reads the media query once per module instance and caches it,
+ * so overriding `matchMedia` mid-file silently does nothing for later renders
+ * (and leaks into other tests if it is never restored). The badge root's
+ * `onAnimationComplete` is captured so the tests can step the drop
+ * choreography beat by beat — jsdom has no rAF-driven animation loop to do it.
+ */
+const motionControl = vi.hoisted(() => ({
+ reducedMotion: false,
+ completeRootAnimation: null as null | (() => void),
+}));
+
+vi.mock("framer-motion", async (importOriginal) => {
+ const actual = await importOriginal();
+ const MOTION_ONLY_PROPS = new Set([
+ "initial",
+ "animate",
+ "exit",
+ "variants",
+ "transition",
+ "whileHover",
+ "whileTap",
+ "onAnimationComplete",
+ ]);
+ // Cached per tag so React sees a stable element type across re-renders.
+ const cache = new Map>>();
+ const motion = new Proxy(
+ {},
+ {
+ get: (_target, tag) => {
+ if (typeof tag !== "string") return undefined;
+ const cached = cache.get(tag);
+ if (cached) return cached;
+ const Component = ({ children, ...props }: { children?: ReactNode }) => {
+ const all = props as Record;
+ if ("data-drop-phase" in all) {
+ motionControl.completeRootAnimation = all.onAnimationComplete as () => void;
+ }
+ const domProps = Object.fromEntries(
+ Object.entries(all).filter(([key]) => !MOTION_ONLY_PROPS.has(key)),
+ );
+ return createElement(tag, domProps, children);
+ };
+ cache.set(tag, Component);
+ return Component;
+ },
+ },
+ );
+ return { ...actual, motion, useReducedMotion: () => motionControl.reducedMotion };
+});
+
+describe("SidebarLogo gravity easter egg", () => {
+ beforeEach(() => {
+ motionControl.reducedMotion = false;
+ motionControl.completeRootAnimation = null;
+ });
+
+ const getLogo = () => {
+ const logo = document.querySelector("[data-drop-phase]");
+ expect(logo).not.toBeNull();
+ return logo!;
+ };
+
+ const clickTimes = (times: number) => {
+ const logo = getLogo();
+ for (let i = 0; i < times; i++) fireEvent.click(logo);
+ };
+
+ const finishBeat = () => {
+ act(() => motionControl.completeRootAnimation?.());
+ };
+
+ it("renders a decorative, non-focusable badge", () => {
+ render( );
+ const logo = getLogo();
+ expect(logo.tagName).toBe("DIV");
+ expect(logo).not.toHaveAttribute("tabindex");
+ expect(screen.queryByRole("button")).toBeNull();
+ });
+
+ it("stays idle for fewer than five clicks", () => {
+ render( );
+ clickTimes(4);
+ expect(getLogo()).toHaveAttribute("data-drop-phase", "idle");
+ });
+
+ it("starts the drop on the fifth click", () => {
+ render( );
+ clickTimes(5);
+ expect(getLogo()).toHaveAttribute("data-drop-phase", "falling");
+ });
+
+ it("walks falling → impact → bouncing → hopping → idle as each beat completes", () => {
+ render( );
+ clickTimes(5);
+
+ const seen: (string | null)[] = [];
+ for (let i = 0; i < 4; i++) {
+ finishBeat();
+ seen.push(getLogo().getAttribute("data-drop-phase"));
+ }
+
+ expect(seen).toEqual(["impact", "bouncing", "hopping", "idle"]);
+ });
+
+ it("ignores another five clicks while a drop is already playing", () => {
+ render( );
+ clickTimes(5);
+ finishBeat(); // → impact
+ clickTimes(5);
+ expect(getLogo()).toHaveAttribute("data-drop-phase", "impact");
+ });
+
+ it("never starts the drop under reduced motion", () => {
+ motionControl.reducedMotion = true;
+ render( );
+ clickTimes(5);
+ expect(getLogo()).toHaveAttribute("data-drop-phase", "idle");
+ });
+});
diff --git a/tests/unit/components/ui/SidePanel.escape.test.tsx b/tests/unit/components/ui/SidePanel.escape.test.tsx
new file mode 100644
index 000000000..fbddc1bd3
--- /dev/null
+++ b/tests/unit/components/ui/SidePanel.escape.test.tsx
@@ -0,0 +1,27 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { describe, it, expect, vi } from "vitest";
+import { SidePanel } from "../../../../src/components/ui/SidePanel";
+
+// Kept apart from SidePanel.test.tsx so this contract (a game inside the
+// drawer owns Escape first) can evolve without colliding with edits there.
+describe("SidePanel Escape handling", () => {
+ it("ignores an Escape that an inner element already consumed via preventDefault", () => {
+ const onClose = vi.fn();
+ render(
+
+ {
+ if (event.key === "Escape") event.preventDefault();
+ }}
+ />
+ ,
+ );
+
+ fireEvent.keyDown(screen.getByLabelText("Inner consumer"), { key: "Escape" });
+ expect(onClose).not.toHaveBeenCalled();
+
+ fireEvent.keyDown(document.body, { key: "Escape" });
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/tests/unit/features/buddy/BuddyComposerCaret.test.tsx b/tests/unit/features/buddy/BuddyComposerCaret.test.tsx
new file mode 100644
index 000000000..d475846e0
--- /dev/null
+++ b/tests/unit/features/buddy/BuddyComposerCaret.test.tsx
@@ -0,0 +1,65 @@
+import { describe, expect, it, vi } from "vitest";
+import { fireEvent, render, screen } from "@testing-library/react";
+import { BuddyComposer } from "../../../../src/features/buddy/components/BuddyComposer";
+
+// The caret contract of the composer: a send hands the caret to the page (so
+// Space can open the dino game), a swallowed submission keeps it where the
+// hire is typing. See `submit` in the component.
+describe("BuddyComposer caret handoff", () => {
+ const submitWith = (handleSubmit: (event: React.FormEvent) => boolean) => {
+ render( );
+ const field = screen.getByLabelText("Message");
+ field.focus();
+ fireEvent.submit(field.closest("form")!);
+ return field;
+ };
+
+ it("keeps the caret when the submission started no turn", () => {
+ const field = submitWith(() => false);
+ expect(document.activeElement).toBe(field);
+ });
+
+ it("hands the caret off when a turn started, so Space can open the game", () => {
+ const field = submitWith(() => true);
+ expect(document.activeElement).not.toBe(field);
+ });
+
+ it("does not steal focus back while the dino game is open, and returns it when the game closes", () => {
+ const handleSubmit = () => true;
+ const { rerender } = render(
+ ,
+ );
+ const field = screen.getByLabelText("Message");
+ field.focus();
+ fireEvent.submit(field.closest("form")!);
+ expect(document.activeElement).not.toBe(field);
+
+ // Turn in flight, game opened.
+ rerender(
+ ,
+ );
+ // Reply lands mid-game: the composer must not grab the keys the game is using.
+ rerender(
+ ,
+ );
+ expect(document.activeElement).not.toBe(field);
+
+ // Game closed: the caret comes back to the composer.
+ rerender(
+ ,
+ );
+ expect(document.activeElement).toBe(field);
+ });
+});
diff --git a/tests/unit/features/buddy/BuddyTypingMessage.test.tsx b/tests/unit/features/buddy/BuddyTypingMessage.test.tsx
new file mode 100644
index 000000000..52905d882
--- /dev/null
+++ b/tests/unit/features/buddy/BuddyTypingMessage.test.tsx
@@ -0,0 +1,24 @@
+import { render, screen } from "@testing-library/react";
+import { describe, it, expect, vi } from "vitest";
+import { BuddyTypingMessage } from "../../../../src/features/buddy/components/BuddyMessage.tsx";
+
+// The dino waiting-game as the buddy hosts it: its badge must say how the turn ended, and the
+// game must not sit inside the typing indicator's live region.
+describe("BuddyTypingMessage with the dino game", () => {
+ it("labels a failed reply as failed, not ready", () => {
+ render( );
+ expect(screen.getByTestId("dino-game-reply-ready")).toHaveTextContent("Reply failed");
+ expect(screen.getByTestId("dino-game-reply-ready")).toHaveAttribute("data-tone", "danger");
+ });
+
+ it("says Reply ready when the reply arrived", () => {
+ render( );
+ expect(screen.getByTestId("dino-game-reply-ready")).toHaveTextContent("Reply ready");
+ });
+
+ it("keeps the running game out of any live region", () => {
+ render( );
+ expect(screen.getByTestId("dino-game").closest('[role="status"]')).toBeNull();
+ expect(screen.getByTestId("dino-game").closest("[aria-live]")).toBeNull();
+ });
+});
diff --git a/tests/unit/features/buddy/buddyDinoSurface.test.tsx b/tests/unit/features/buddy/buddyDinoSurface.test.tsx
new file mode 100644
index 000000000..2adac4cde
--- /dev/null
+++ b/tests/unit/features/buddy/buddyDinoSurface.test.tsx
@@ -0,0 +1,164 @@
+import { renderHook, act, waitFor } from "@testing-library/react";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import type { ProjectSelectionSlice } from "../../../../src/features/buddy/hooks/useBuddyConversation.ts";
+import { useBuddyConversation } from "../../../../src/features/buddy/hooks/useBuddyConversation.ts";
+import { AuthContext, type AuthContextType } from "../../../../src/context/AuthContext.ts";
+import type { UserProfile } from "../../../../src/services/types.ts";
+import { http, HttpResponse } from "msw";
+import { server } from "../../setup/vitest.setup.ts";
+
+/**
+ * The dino waiting-game in the buddy session. The session lives app-wide, so the game may
+ * only open while a surface that shows the thread is on screen, and must close when that
+ * surface goes away or the conversation it belongs to is swapped out.
+ */
+
+const authValue: AuthContextType = {
+ status: "authenticated",
+ profile: { id: "user-1" } as unknown as UserProfile,
+ login: async () => {},
+ logout: async () => {},
+ refetchProfile: async () => {},
+};
+
+const authWrapper = ({ children }: { children: React.ReactNode }) => (
+ {children}
+);
+
+function sel(selectedProjectId: string, canManageSelected: boolean): ProjectSelectionSlice {
+ return {
+ selectedProjectId,
+ hasSelectedProject: selectedProjectId !== "",
+ canManageSelected,
+ isLoading: false,
+ setSelectedProjectId: vi.fn(),
+ };
+}
+
+const encoder = new TextEncoder();
+
+function doneStream() {
+ return new HttpResponse(
+ new ReadableStream({
+ start(controller) {
+ controller.enqueue(encoder.encode('data: {"type":"done"}\n\n'));
+ controller.close();
+ },
+ }),
+ { headers: { "Content-Type": "text/event-stream" } },
+ );
+}
+
+/** Replies stay open until `finishReply` is called, so the buddy keeps "thinking". */
+let finishReply: () => void = () => {};
+
+function pressSpace() {
+ act(() => {
+ window.dispatchEvent(new KeyboardEvent("keydown", { code: "Space", key: " ", bubbles: true }));
+ });
+}
+
+async function mount() {
+ const hook = renderHook(
+ ({ selection }: { selection: ProjectSelectionSlice }) => useBuddyConversation(selection),
+ { initialProps: { selection: sel("", false) }, wrapper: authWrapper },
+ );
+ await act(async () => {
+ await hook.result.current.ensureOpened();
+ });
+ return hook;
+}
+
+async function startTurn(result: { current: ReturnType }) {
+ act(() => {
+ void result.current.sendMessage("How do I start?");
+ });
+ await waitFor(() => expect(result.current.isThinking).toBe(true));
+}
+
+describe("buddy dino waiting-game surfaces", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ localStorage.setItem("dinoUnlocked", "true");
+ server.use(
+ http.get("/api/v1/onboarding/me/buddy/messages", () => HttpResponse.json([])),
+ http.post("/api/v1/onboarding/me/buddy/open/stream", () => doneStream()),
+ http.post("/api/v1/onboarding/me/buddy/messages", () => {
+ let ctrl: ReadableStreamDefaultController | null = null;
+ finishReply = () => {
+ ctrl?.enqueue(encoder.encode('data: {"type":"token","content":"Hi"}\n\n'));
+ ctrl?.enqueue(encoder.encode('data: {"type":"done"}\n\n'));
+ ctrl?.close();
+ ctrl = null;
+ };
+ return new HttpResponse(
+ new ReadableStream({
+ start(controller) {
+ ctrl = controller;
+ },
+ }),
+ { headers: { "Content-Type": "text/event-stream" } },
+ );
+ }),
+ );
+ });
+
+ afterEach(() => {
+ finishReply();
+ vi.restoreAllMocks();
+ });
+
+ it("does not open the game while no surface shows the thread (minimised dock)", async () => {
+ const { result, unmount } = await mount();
+ await startTurn(result);
+
+ pressSpace();
+ expect(result.current.dinoGameActive).toBe(false);
+ unmount();
+ });
+
+ it("opens on a visible surface and closes when that surface goes away", async () => {
+ const { result, unmount } = await mount();
+ let release: () => void = () => {};
+ act(() => {
+ release = result.current.registerDinoSurface();
+ });
+ await startTurn(result);
+
+ pressSpace();
+ expect(result.current.dinoGameActive).toBe(true);
+
+ // The dock is minimised mid-game: the game must not keep running behind it.
+ act(() => release());
+ expect(result.current.dinoGameActive).toBe(false);
+ unmount();
+ });
+
+ it("closes the game when the conversation is switched", async () => {
+ const { result, rerender, unmount } = await mount();
+ act(() => {
+ result.current.registerDinoSurface();
+ });
+ await startTurn(result);
+ pressSpace();
+ expect(result.current.dinoGameActive).toBe(true);
+
+ // The reply lands; the game outlives the turn (keepActiveUntilExit)…
+ act(() => {
+ finishReply();
+ });
+ await waitFor(() =>
+ expect(result.current.isStreaming || result.current.isThinking).toBe(false),
+ );
+ expect(result.current.dinoGameActive).toBe(true);
+
+ // …until the thread it was reporting on is swapped for the team conversation.
+ act(() => {
+ result.current.switchTeamProject("p1");
+ rerender({ selection: sel("p1", true) });
+ });
+ await waitFor(() => expect(result.current.teamProjectId).toBe("p1"));
+ expect(result.current.dinoGameActive).toBe(false);
+ unmount();
+ });
+});
diff --git a/tests/unit/features/buddy/buddyEasterEggs.test.tsx b/tests/unit/features/buddy/buddyEasterEggs.test.tsx
new file mode 100644
index 000000000..27f4fe2a0
--- /dev/null
+++ b/tests/unit/features/buddy/buddyEasterEggs.test.tsx
@@ -0,0 +1,102 @@
+import { renderHook, act, waitFor } from "@testing-library/react";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { useBuddy } from "../../../../src/features/buddy/hooks/useBuddy";
+import { BuddyProviderWithStubs } from "./buddyTestHarness";
+import { http, HttpResponse } from "msw";
+import { server } from "../../setup/vitest.setup";
+
+/**
+ * A greeting that opens the visit and writes nothing — same stub as
+ * `useBuddy.test.tsx`: an unhandled greeting request would be a failed
+ * greeting and put a turn in front of everything these tests index into.
+ */
+function silentGreeting() {
+ const encoder = new TextEncoder();
+ return new HttpResponse(
+ new ReadableStream({
+ start(controller) {
+ controller.enqueue(encoder.encode('data: {"type":"done"}\n\n'));
+ controller.close();
+ },
+ }),
+ { headers: { "Content-Type": "text/event-stream" } },
+ );
+}
+
+describe("buddy easter eggs", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ window.HTMLElement.prototype.scrollIntoView = vi.fn();
+ window.localStorage.clear();
+ });
+
+ /** Mounts the session with a silent greeting and waits for it to settle. */
+ async function mountConversation() {
+ server.use(http.get("/api/v1/onboarding/me/buddy/messages", () => HttpResponse.json([])));
+ server.use(http.post("/api/v1/onboarding/me/buddy/open/stream", () => silentGreeting()));
+
+ const harness = renderHook(() => useBuddy(), { wrapper: BuddyProviderWithStubs });
+ await waitFor(() => {
+ expect(harness.result.current.messages).toHaveLength(0);
+ });
+ return harness;
+ }
+
+ it("exposes the dino waiting-game state from the shared hook", async () => {
+ const { result } = await mountConversation();
+
+ // Locked by default: no game state leaks out as active.
+ expect(result.current.dinoUnlocked).toBe(false);
+ expect(result.current.dinoGameActive).toBe(false);
+
+ // Unlocking (as the Settings cogwheel does) arms the trigger.
+ act(() => {
+ window.localStorage.setItem("dinoUnlocked", "true");
+ window.dispatchEvent(new Event("dinoUnlockChanged"));
+ });
+ expect(result.current.dinoUnlocked).toBe(true);
+ });
+
+ it("swallows egg phrases silently instead of sending them to the buddy", async () => {
+ const { result } = await mountConversation();
+
+ const sendSpy = vi.spyOn(result.current, "sendMessage");
+ let messagePosted = false;
+ server.use(
+ http.post("/api/v1/onboarding/me/buddy/messages", () => {
+ messagePosted = true;
+ return silentGreeting();
+ }),
+ );
+
+ act(() => {
+ result.current.setDraft("Do a barrel roll");
+ });
+ let eggHandled: boolean | undefined;
+ act(() => {
+ eggHandled = result.current.handleSubmit(new Event("submit") as unknown as React.FormEvent);
+ });
+
+ // The draft is cleared, nothing is sent, and no reply arrives.
+ expect(result.current.draft).toBe("");
+ expect(sendSpy).not.toHaveBeenCalled();
+ expect(messagePosted).toBe(false);
+ // Reported as "no turn started", which is what keeps the composer's caret
+ // where it was instead of handing it to the page on a send that never
+ // happened.
+ expect(eggHandled).toBe(false);
+
+ // A normal word goes through the normal path.
+ act(() => {
+ result.current.setDraft("what is my next step?");
+ });
+ let sent: boolean | undefined;
+ act(() => {
+ sent = result.current.handleSubmit(new Event("submit") as unknown as React.FormEvent);
+ });
+ expect(sent).toBe(true);
+ await waitFor(() => {
+ expect(messagePosted).toBe(true);
+ });
+ });
+});
diff --git a/tests/unit/features/chatbot/chatQueue.test.tsx b/tests/unit/features/chatbot/chatQueue.test.tsx
index 42539b0ef..4d1ab2124 100644
--- a/tests/unit/features/chatbot/chatQueue.test.tsx
+++ b/tests/unit/features/chatbot/chatQueue.test.tsx
@@ -335,8 +335,10 @@ describe("chat message queue", () => {
first.fail("LLM overload");
});
- await waitFor(() => expect(prompts).toEqual(["Q1", "Q2"]));
- expect(result.current.queuedMessages).toHaveLength(0);
+ await waitFor(() => {
+ expect(prompts).toEqual(["Q1", "Q2"]);
+ expect(result.current.queuedMessages).toHaveLength(0);
+ });
});
it("interrupts the running answer when the message belongs to another chat", async () => {
diff --git a/tests/unit/features/chatbot/components/DinoGame.test.tsx b/tests/unit/features/chatbot/components/DinoGame.test.tsx
new file mode 100644
index 000000000..8c76c0c90
--- /dev/null
+++ b/tests/unit/features/chatbot/components/DinoGame.test.tsx
@@ -0,0 +1,60 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { describe, it, expect, vi } from "vitest";
+import { DinoGame } from "../../../../../src/features/chatbot/components/DinoGame.tsx";
+
+/**
+ * The game chrome around the canvas: what it tells assistive tech, and how it hands Escape
+ * back to its host. The canvas loop itself is not exercised (jsdom has no 2D context).
+ */
+describe("DinoGame chrome", () => {
+ it("keeps the running score out of the accessibility tree", () => {
+ render( );
+ const status = screen.getByTestId("dino-game-status");
+ // The one live region stays quiet while the game runs.
+ expect(status).toHaveAttribute("role", "status");
+ expect(status).toHaveTextContent("");
+ // The game itself is not a live region.
+ expect(screen.getByTestId("dino-game").closest("[aria-live]")).toBeNull();
+ });
+
+ it("announces the completion once, with a text label", () => {
+ render( );
+ expect(screen.getByTestId("dino-game-status")).toHaveTextContent("Reply ready");
+ expect(screen.getByTestId("dino-game-reply-ready")).toHaveAttribute("data-tone", "success");
+ });
+
+ it("labels a non-success outcome in words, not only colour", () => {
+ render(
+ ,
+ );
+ expect(screen.getByTestId("dino-game-reply-ready")).toHaveTextContent("Stopped");
+ expect(screen.getByTestId("dino-game-reply-ready")).toHaveAttribute("data-tone", "neutral");
+ expect(screen.getByTestId("dino-game-status")).toHaveTextContent("Stopped");
+ });
+
+ it("claims Escape before document listeners see it", () => {
+ const onExit = vi.fn();
+ const documentListener = vi.fn();
+ document.addEventListener("keydown", documentListener);
+ render( );
+
+ fireEvent.keyDown(document.body, { key: "Escape", code: "Escape" });
+
+ expect(onExit).toHaveBeenCalledTimes(1);
+ expect(documentListener).not.toHaveBeenCalled();
+ document.removeEventListener("keydown", documentListener);
+ });
+
+ it("exits from the close button", () => {
+ const onExit = vi.fn();
+ render( );
+ fireEvent.click(screen.getByTestId("dino-game-close"));
+ expect(onExit).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/tests/unit/features/chatbot/components/ThinkingIndicator.test.tsx b/tests/unit/features/chatbot/components/ThinkingIndicator.test.tsx
new file mode 100644
index 000000000..b22d9b911
--- /dev/null
+++ b/tests/unit/features/chatbot/components/ThinkingIndicator.test.tsx
@@ -0,0 +1,108 @@
+import { render, screen, fireEvent } from "@testing-library/react";
+import { describe, it, expect, vi } from "vitest";
+import { ThinkingIndicator } from "../../../../../src/features/chatbot/components/ThinkingIndicator";
+
+// Mock DinoGame to inspect props and interactions
+vi.mock("../../../../../src/features/chatbot/components/DinoGame", () => ({
+ DinoGame: ({ onExit, replyReady }: { onExit: () => void; replyReady?: boolean }) => (
+
+ Exit Game
+
+ ),
+}));
+
+describe("ThinkingIndicator", () => {
+ it("renders null when not thinking and game is not active", () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.firstChild).toBeNull();
+ });
+
+ it("renders bouncing dots when thinking and game is not active without reasoning", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByRole("status")).toBeInTheDocument();
+ expect(screen.queryByTestId("dino-game")).not.toBeInTheDocument();
+ });
+
+ it("suppresses bouncing dots when thoughts/reasoning are streaming and game is not active", () => {
+ const { container } = render(
+ ,
+ );
+
+ // Suppressed so ReasoningPanel shows thoughts without a redundant bouncing dots avatar
+ expect(container.firstChild).toBeNull();
+ });
+
+ it("renders DinoGame when game is active even if thoughts/reasoning are streaming", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByTestId("dino-game")).toBeInTheDocument();
+ expect(screen.getByTestId("dino-game")).toHaveAttribute("data-reply-ready", "false");
+ });
+
+ it("keeps DinoGame active with replyReady when reply arrives (isThinking flips to false)", () => {
+ const onExit = vi.fn();
+ render(
+ ,
+ );
+
+ expect(screen.getByTestId("dino-game")).toBeInTheDocument();
+ expect(screen.getByTestId("dino-game")).toHaveAttribute("data-reply-ready", "true");
+
+ fireEvent.click(screen.getByText("Exit Game"));
+ expect(onExit).toHaveBeenCalledTimes(1);
+ });
+
+ it("keeps the game out of the live region and announces only a concise status", () => {
+ render(
+ ,
+ );
+
+ const status = screen.getByTestId("thinking-status");
+ expect(status).toHaveAttribute("role", "status");
+ expect(status).toHaveTextContent("Thinking…");
+ expect(status).not.toContainElement(screen.getByTestId("dino-game"));
+ expect(screen.getByTestId("dino-game").closest('[role="status"]')).toBeNull();
+ });
+});
diff --git a/tests/unit/features/chatbot/useChat.test.tsx b/tests/unit/features/chatbot/useChat.test.tsx
index 5df0b9311..6f4bb2c93 100644
--- a/tests/unit/features/chatbot/useChat.test.tsx
+++ b/tests/unit/features/chatbot/useChat.test.tsx
@@ -426,6 +426,8 @@ describe("useChat", () => {
expect(await screen.findByText("The answer failed")).toBeInTheDocument();
expect(screen.getByText("LLM overload")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument();
+ // The dino game reads this: a failed stream must not be labelled "Reply ready".
+ expect(result.current.turnOutcome).toBe("failed");
});
it("exposes stopStreaming function that can abort a stream", async () => {
@@ -557,6 +559,97 @@ describe("useChat", () => {
result.current.stopStreaming();
});
+ await waitFor(() => {
+ expect(result.current.isThinking).toBe(false);
+ expect(result.current.isStreaming).toBe(false);
+ });
+ // A Stop is its own outcome, so the dino game does not claim a reply arrived.
+ expect(result.current.turnOutcome).toBe("stopped");
+ });
+
+ it("keeps isThinking true during reasoning streaming and clears isThinking on the first content token", async () => {
+ mockNavigate.mockReset();
+
+ const encoder = new TextEncoder();
+ let controllerRef!: ReadableStreamDefaultController;
+ const stream = new ReadableStream({
+ start(c) {
+ controllerRef = c;
+ },
+ });
+
+ server.use(
+ http.get("/api/v1/chats/me", () => HttpResponse.json({ chats: [] })),
+ http.get("/api/v1/chats/me/chat1", () => HttpResponse.json({ messages: [] })),
+ http.get("/api/v1/users/me", () =>
+ HttpResponse.json({
+ id: "user1",
+ authId: "auth-1",
+ username: "testuser",
+ email: "test@example.com",
+ firstName: "Test",
+ lastName: "User",
+ projectRoles: [],
+ permissionGroup: "USER",
+ enabled: true,
+ profileIcon: null,
+ hasCompletedOnboarding: true,
+ }),
+ ),
+ http.post(
+ "/api/v1/chats/me/prompt",
+ () =>
+ new HttpResponse(stream, {
+ headers: { "Content-Type": "text/event-stream" },
+ }),
+ ),
+ http.post("/api/v1/chats/me", () => HttpResponse.json({ id: "newChatId" })),
+ );
+
+ const { result } = renderHook(() => useChat(), { wrapper });
+
+ await waitFor(() => {
+ expect(result.current.chats).toEqual([]);
+ });
+
+ act(() => {
+ void result.current.addMessage("hello");
+ });
+
+ await waitFor(() => {
+ expect(result.current.isThinking).toBe(true);
+ });
+
+ // Enqueue reasoning event
+ act(() => {
+ controllerRef.enqueue(
+ encoder.encode('data: {"type":"reasoning","reasoning":"Thinking..."}\n\n'),
+ );
+ });
+
+ // isThinking must remain true while thoughts are streaming, and isStreaming must be true
+ await waitFor(() => {
+ expect(result.current.isThinking).toBe(true);
+ expect(result.current.isStreaming).toBe(true);
+ });
+
+ // Enqueue first content token
+ act(() => {
+ controllerRef.enqueue(encoder.encode('data: {"type":"token","content":"Answer"}\n\n'));
+ });
+
+ // isThinking flips to false once reply content arrives!
+ await waitFor(() => {
+ expect(result.current.isThinking).toBe(false);
+ expect(result.current.isStreaming).toBe(true);
+ });
+
+ // Finish stream
+ act(() => {
+ controllerRef.enqueue(encoder.encode('data: {"type":"done"}\n\n'));
+ controllerRef.close();
+ });
+
await waitFor(() => {
expect(result.current.isThinking).toBe(false);
expect(result.current.isStreaming).toBe(false);
diff --git a/tests/unit/features/data-ingestion/components/SourceDetailsPanel.test.tsx b/tests/unit/features/data-ingestion/components/SourceDetailsPanel.test.tsx
index 3c1e59914..e3037e3ec 100644
--- a/tests/unit/features/data-ingestion/components/SourceDetailsPanel.test.tsx
+++ b/tests/unit/features/data-ingestion/components/SourceDetailsPanel.test.tsx
@@ -1,7 +1,8 @@
-import { render as rtlRender, screen, waitFor, within } from "@testing-library/react";
+import { fireEvent, render as rtlRender, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { GitBranch } from "lucide-react";
import { beforeEach, describe, expect, it, vi } from "vitest";
+import { SidePanel } from "../../../../../src/components/ui/SidePanel";
import { ToastProvider } from "../../../../../src/context/ToastProvider";
import { SourceDetailsPanel } from "../../../../../src/features/data-ingestion/components/SourceDetailsPanel";
import type {
@@ -89,6 +90,7 @@ const confluenceSource: DataSource = {
describe("SourceDetailsPanel", () => {
beforeEach(() => {
+ window.localStorage.clear();
vi.clearAllMocks();
});
@@ -501,4 +503,165 @@ describe("SourceDetailsPanel", () => {
expect(screen.getByText("Issues")).toBeInTheDocument();
expect(screen.getByText("Pull requests")).toBeInTheDocument();
});
+
+ it("does not show space hint or open DinoGame when dino is locked", () => {
+ const syncingSource: DataSource = {
+ ...mockSource,
+ statusView: {
+ state: "syncing",
+ label: "Syncing",
+ icon: mockSource.icon,
+ tone: "brand",
+ spinning: true,
+ },
+ };
+
+ render( );
+
+ expect(screen.queryByText(/to pass the time/i)).not.toBeInTheDocument();
+ fireEvent.keyDown(window, { code: "Space" });
+ expect(screen.queryByRole("application", { name: /mini dino game/i })).not.toBeInTheDocument();
+ });
+
+ it("shows space hint when syncing and dino is unlocked, and starts DinoGame on Space", () => {
+ window.localStorage.setItem("dinoUnlocked", "true");
+ const syncingSource: DataSource = {
+ ...mockSource,
+ statusView: {
+ state: "syncing",
+ label: "Syncing",
+ icon: mockSource.icon,
+ tone: "brand",
+ spinning: true,
+ },
+ };
+
+ render( );
+
+ expect(screen.getByText(/to pass the time/i)).toBeInTheDocument();
+ fireEvent.keyDown(window, { code: "Space" });
+
+ expect(screen.getByRole("application", { name: /mini dino game/i })).toBeInTheDocument();
+ });
+
+ it("shows 'Sync complete' badge when syncing finishes while game is active", () => {
+ window.localStorage.setItem("dinoUnlocked", "true");
+ const syncingSource: DataSource = {
+ ...mockSource,
+ statusView: {
+ state: "syncing",
+ label: "Syncing",
+ icon: mockSource.icon,
+ tone: "brand",
+ spinning: true,
+ },
+ };
+
+ const { rerender } = render( );
+
+ fireEvent.keyDown(window, { code: "Space" });
+ expect(screen.getByRole("application", { name: /mini dino game/i })).toBeInTheDocument();
+ expect(screen.queryByText(/sync complete/i)).not.toBeInTheDocument();
+
+ // Source finishes syncing
+ rerender( );
+
+ expect(screen.getByTestId("dino-game-reply-ready")).toHaveTextContent(/sync complete/i);
+ });
+
+ it("withholds the completion badge while an update request is still in flight", async () => {
+ window.localStorage.setItem("dinoUnlocked", "true");
+ const user = userEvent.setup();
+ let resolveUpdate: () => void = () => {};
+ const onUpdateSource = vi.fn(
+ () =>
+ new Promise((resolve) => {
+ resolveUpdate = resolve;
+ }),
+ );
+
+ render(
+ ,
+ );
+ // SidePanel moves focus into the drawer on the next animation frame; let it
+ // land before the click, or it could pull focus off the Update button after.
+ await waitFor(() =>
+ expect(screen.getByRole("dialog")).toContainElement(document.activeElement as HTMLElement),
+ );
+ await user.click(screen.getByRole("button", { name: /Update repo/ }));
+ // Focus stays on the Update button, now disabled while the request runs.
+ // Space on a disabled control has no meaning of its own, so it opens the game.
+ expect(screen.getByRole("button", { name: /Update repo/ })).toHaveFocus();
+ fireEvent.keyDown(window, { code: "Space" });
+ expect(screen.getByTestId("dino-game")).toBeInTheDocument();
+
+ // Status still reads "connected", but the update has not settled yet.
+ expect(screen.queryByTestId("dino-game-reply-ready")).not.toBeInTheDocument();
+
+ resolveUpdate();
+
+ await waitFor(() =>
+ expect(screen.getByTestId("dino-game-reply-ready")).toHaveTextContent(/sync complete/i),
+ );
+ expect(screen.getByTestId("dino-game-reply-ready")).toHaveAttribute("data-tone", "success");
+ });
+
+ it("reports 'Sync failed' instead of 'Sync complete' when the sync ends in attention", () => {
+ window.localStorage.setItem("dinoUnlocked", "true");
+ const syncingSource: DataSource = {
+ ...mockSource,
+ statusView: {
+ state: "syncing",
+ label: "Syncing",
+ icon: mockSource.icon,
+ tone: "brand",
+ spinning: true,
+ },
+ };
+ const failedSource: DataSource = {
+ ...mockSource,
+ statusView: deriveSourceStatus({ hasErrors: true, hasNeverSynced: false }),
+ };
+
+ const { rerender } = render( );
+ fireEvent.keyDown(window, { code: "Space" });
+
+ rerender( );
+
+ const badge = screen.getByTestId("dino-game-reply-ready");
+ expect(badge).toHaveTextContent(/sync failed/i);
+ expect(badge).toHaveAttribute("data-tone", "danger");
+ expect(screen.queryByText(/sync complete/i)).not.toBeInTheDocument();
+ });
+
+ it("first Escape closes the dino game but not the drawer; second Escape closes the drawer", () => {
+ window.localStorage.setItem("dinoUnlocked", "true");
+ const onDrawerClose = vi.fn();
+ const syncingSource: DataSource = {
+ ...mockSource,
+ statusView: {
+ state: "syncing",
+ label: "Syncing",
+ icon: mockSource.icon,
+ tone: "brand",
+ spinning: true,
+ },
+ };
+
+ render(
+
+
+ ,
+ );
+
+ fireEvent.keyDown(window, { code: "Space" });
+ expect(screen.getByTestId("dino-game")).toBeInTheDocument();
+
+ fireEvent.keyDown(document.body, { key: "Escape", code: "Escape" });
+ expect(screen.queryByTestId("dino-game")).not.toBeInTheDocument();
+ expect(onDrawerClose).not.toHaveBeenCalled();
+
+ fireEvent.keyDown(document.body, { key: "Escape", code: "Escape" });
+ expect(onDrawerClose).toHaveBeenCalledTimes(1);
+ });
});
diff --git a/tests/unit/features/dino/DinoGameModal.test.tsx b/tests/unit/features/dino/DinoGameModal.test.tsx
deleted file mode 100644
index c7fab233b..000000000
--- a/tests/unit/features/dino/DinoGameModal.test.tsx
+++ /dev/null
@@ -1,54 +0,0 @@
-import { describe, it, expect, vi } from "vitest";
-import { render, screen, fireEvent } from "@testing-library/react";
-import { DinoGameModal } from "../../../../src/features/dino/components/DinoGameModal";
-
-describe("DinoGameModal", () => {
- it("renders the modal with the dino game when open", () => {
- render( );
-
- // DinoGame exposes its own aria-label on the role="application" container.
- const game = screen.getByLabelText(
- "Mini dino game — space jumps (hold for higher), arrow down ducks, escape exits",
- );
- expect(game).toBeInTheDocument();
- });
-
- it("renders nothing when closed", () => {
- render( );
- expect(
- screen.queryByLabelText(
- "Mini dino game — space jumps (hold for higher), arrow down ducks, escape exits",
- ),
- ).not.toBeInTheDocument();
- });
-
- it("has the correct dialog attributes", () => {
- render( );
- const dialog = screen.getByRole("dialog");
- expect(dialog).toHaveAttribute("aria-modal", "true");
- expect(dialog).toHaveAttribute("aria-label", "Dino game");
- });
-
- it("calls onClose when the overlay is clicked", () => {
- const onClose = vi.fn();
- render( );
- // The dialog overlay is the element with role="dialog".
- const overlay = screen.getByRole("dialog");
- fireEvent.click(overlay);
- expect(onClose).toHaveBeenCalledTimes(1);
- });
-
- it("calls onClose when Escape is pressed (routed through DinoGame's own handler)", () => {
- const onClose = vi.fn();
- render( );
- fireEvent.keyDown(window, { key: "Escape" });
- expect(onClose).toHaveBeenCalledTimes(1);
- });
-
- it("does not call onClose when Escape is pressed while closed", () => {
- const onClose = vi.fn();
- render( );
- fireEvent.keyDown(window, { key: "Escape" });
- expect(onClose).not.toHaveBeenCalled();
- });
-});
diff --git a/tests/unit/features/easter-eggs/ConfettiBurst.test.tsx b/tests/unit/features/easter-eggs/ConfettiBurst.test.tsx
new file mode 100644
index 000000000..129c9a7c3
--- /dev/null
+++ b/tests/unit/features/easter-eggs/ConfettiBurst.test.tsx
@@ -0,0 +1,149 @@
+import { act, render, screen } from "@testing-library/react";
+import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
+import { EggEffectsLayer } from "../../../../src/features/easter-eggs/components/EggEffectsLayer";
+import { clearEggEffect, playEggEffect } from "../../../../src/features/easter-eggs/eggEffectBus";
+
+// jsdom has no canvas implementation, so the rAF loop is a no-op there —
+// these tests assert mount/unmount behavior and the reduced-motion path,
+// not pixel output (same boundary the game tests use).
+vi.mock("framer-motion", async () => {
+ const actual = await vi.importActual("framer-motion");
+ return {
+ ...actual,
+ useReducedMotion: vi.fn(() => false),
+ };
+});
+
+const { useReducedMotion } = await import("framer-motion");
+const mockReducedMotion = vi.mocked(useReducedMotion);
+
+// jsdom has no canvas implementation (getContext returns null), so the
+// burst's rAF loop would exit before doing anything. Stub a minimal 2D
+// context and fake rAF + performance so the whole lifecycle (spawn →
+// simulate → fade → bus clear) runs deterministically under fake timers.
+const ctxStub = {
+ save: () => {},
+ restore: () => {},
+ translate: () => {},
+ rotate: () => {},
+ fillRect: () => {},
+ beginPath: () => {},
+ arc: () => {},
+ fill: () => {},
+ setTransform: () => {},
+ clearRect: () => {},
+};
+
+beforeAll(() => {
+ vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(
+ ctxStub as unknown as CanvasRenderingContext2D,
+ );
+});
+
+afterAll(() => {
+ vi.restoreAllMocks();
+});
+
+describe("ConfettiBurst (via EggEffectsLayer)", () => {
+ beforeEach(() => {
+ clearEggEffect();
+ mockReducedMotion.mockReturnValue(false);
+ vi.useFakeTimers({
+ toFake: [
+ "setTimeout",
+ "clearTimeout",
+ "setInterval",
+ "clearInterval",
+ "requestAnimationFrame",
+ "cancelAnimationFrame",
+ "performance",
+ ],
+ });
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("mounts a pointer-events-none, aria-hidden canvas when party fires", () => {
+ render( );
+ act(() => playEggEffect("party"));
+
+ const canvas = document.querySelector("canvas");
+ expect(canvas).not.toBeNull();
+ expect(canvas).toHaveAttribute("aria-hidden", "true");
+ expect(canvas?.className).toContain("pointer-events-none");
+ });
+
+ it("clears the effect after the burst has finished", async () => {
+ const { unmount } = render( );
+ act(() => playEggEffect("party"));
+ expect(document.querySelector("canvas")).not.toBeNull();
+
+ // Longest possible run: spawn window + max particle age + fade margin.
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(6000);
+ });
+
+ expect(document.querySelector("canvas")).toBeNull();
+ unmount();
+ });
+
+ it("shows a static status chip instead of particles under reduced motion", async () => {
+ mockReducedMotion.mockReturnValue(true);
+ render( );
+
+ act(() => playEggEffect("party"));
+
+ expect(document.querySelector("canvas")).toBeNull();
+ // The live region mounts empty and is filled a moment later, so screen
+ // readers see a change to announce instead of a region born with text.
+ expect(screen.getByRole("status")).toBeEmptyDOMElement();
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(150);
+ });
+ expect(screen.getByRole("status")).toHaveTextContent(/party/i);
+ });
+
+ it("the reduced-motion chip also clears its bus effect", async () => {
+ mockReducedMotion.mockReturnValue(true);
+ render( );
+
+ act(() => playEggEffect("party"));
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(2000);
+ });
+
+ expect(screen.queryByRole("status")).toBeNull();
+ });
+
+ it("re-firing during the fade-out is not killed by the finished burst's timer", async () => {
+ render( );
+ act(() => playEggEffect("party"));
+
+ // Walk the burst to the frame it starts fading: the canvas is told to
+ // fade, and a timer goes out ~280ms later to clear the bus.
+ await act(async () => {
+ for (let i = 0; i < 200; i++) {
+ const canvas = document.querySelector("canvas");
+ if (canvas && canvas.style.opacity === "0") return;
+ await vi.advanceTimersByTimeAsync(50);
+ }
+ });
+ expect(document.querySelector("canvas")?.style.opacity).toBe("0");
+
+ // Re-fired while that fade is still running: a fresh burst replaces it.
+ act(() => playEggEffect("party"));
+
+ // Past the old timer's deadline. It belongs to the unmounted instance, so
+ // it must have been cancelled with it — otherwise it clears the new burst
+ // here and the screen goes empty mid-celebration.
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(400);
+ });
+
+ const canvas = document.querySelector("canvas");
+ expect(canvas).not.toBeNull();
+ expect(canvas?.style.opacity).not.toBe("0");
+ });
+});
diff --git a/tests/unit/features/easter-eggs/EggEffectsLayer.test.tsx b/tests/unit/features/easter-eggs/EggEffectsLayer.test.tsx
new file mode 100644
index 000000000..5c76fa701
--- /dev/null
+++ b/tests/unit/features/easter-eggs/EggEffectsLayer.test.tsx
@@ -0,0 +1,137 @@
+import { act, render, screen } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { EggEffectsLayer } from "../../../../src/features/easter-eggs/components/EggEffectsLayer.tsx";
+import {
+ clearEggEffect,
+ playEggEffect,
+ useActiveEggEffect,
+} from "../../../../src/features/easter-eggs/eggEffectBus.ts";
+
+// jsdom only: no canvas implementation, and no rAF-driven pixel output to
+// assert. These tests are about the layer's timing contract — when an effect
+// is re-fired while it is still running, it gets its own full run instead of
+// inheriting the deadline of the one before it.
+vi.mock("framer-motion", async () => {
+ const actual = await vi.importActual("framer-motion");
+ return {
+ ...actual,
+ useReducedMotion: vi.fn(() => false),
+ };
+});
+
+const { useReducedMotion } = await import("framer-motion");
+const mockReducedMotion = vi.mocked(useReducedMotion);
+
+const ctxStub = {
+ save: () => {},
+ restore: () => {},
+ translate: () => {},
+ rotate: () => {},
+ fillRect: () => {},
+ beginPath: () => {},
+ arc: () => {},
+ fill: () => {},
+ fillText: () => {},
+ setTransform: () => {},
+ clearRect: () => {},
+};
+
+describe("EggEffectsLayer re-fires", () => {
+ beforeEach(() => {
+ clearEggEffect();
+ document.body.classList.remove("barrel-roll-active");
+ vi.useFakeTimers();
+ vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(
+ ctxStub as unknown as CanvasRenderingContext2D,
+ );
+ });
+
+ it("gives a second barrel roll its full spin", async () => {
+ render( );
+ act(() => playEggEffect("barrel-roll"));
+ expect(document.body.classList.contains("barrel-roll-active")).toBe(true);
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(1900);
+ });
+
+ // Fired again just before the first roll's 2s timer: the clock starts over.
+ act(() => playEggEffect("barrel-roll"));
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(300);
+ });
+ // Past the first trigger's deadline — still spinning.
+ expect(document.body.classList.contains("barrel-roll-active")).toBe(true);
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(1800);
+ });
+ expect(document.body.classList.contains("barrel-roll-active")).toBe(false);
+ });
+
+ it("restarts the matrix rain instead of letting it end mid-fall", async () => {
+ render( );
+ act(() => playEggEffect("matrix"));
+ expect(document.querySelector("canvas")).not.toBeNull();
+
+ // The rain ends itself 6s after it started; re-fire at 5s.
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(5000);
+ });
+ act(() => playEggEffect("matrix"));
+
+ // 6.5s after the first trigger: the remount owns its own clock, so the
+ // rain is still falling (keyed by seq — without the key the first
+ // instance's timer would have torn it down here).
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(1500);
+ });
+ expect(document.querySelector("canvas")).not.toBeNull();
+ });
+});
+
+describe("EggEffectsLayer under reduced motion", () => {
+ beforeEach(() => {
+ clearEggEffect();
+ vi.useFakeTimers();
+ mockReducedMotion.mockReturnValue(true);
+ vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(
+ ctxStub as unknown as CanvasRenderingContext2D,
+ );
+ });
+
+ afterEach(() => {
+ mockReducedMotion.mockReturnValue(false);
+ vi.useRealTimers();
+ });
+
+ it("replaces the matrix rain with a static, announced chip that clears itself", async () => {
+ let active: ReturnType = null;
+ function Probe() {
+ active = useActiveEggEffect();
+ return null;
+ }
+ render(
+ <>
+
+
+ >,
+ );
+
+ act(() => playEggEffect("matrix"));
+ // Nothing that falls: no canvas at all.
+ expect(document.querySelector("canvas")).toBeNull();
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(150);
+ });
+ expect(screen.getByRole("status")).toHaveTextContent(/neo/i);
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(1500);
+ });
+ expect(screen.queryByRole("status")).toBeNull();
+ expect(active).toBeNull();
+ });
+});
diff --git a/tests/unit/features/easter-eggs/EggModalShell.test.tsx b/tests/unit/features/easter-eggs/EggModalShell.test.tsx
new file mode 100644
index 000000000..d3f7947bf
--- /dev/null
+++ b/tests/unit/features/easter-eggs/EggModalShell.test.tsx
@@ -0,0 +1,159 @@
+import { afterEach, describe, it, expect, vi } from "vitest";
+import { render, screen, fireEvent } from "@testing-library/react";
+import { EggModalShell } from "../../../../src/features/easter-eggs/components/EggModalShell.tsx";
+
+// Both games are lazy-loaded, and these tests are about the shell: the header
+// bar, the overlay click, the Escape handling and the `onExit` routing.
+vi.mock("../../../../src/features/easter-eggs/components/SpaceInvaders", () => ({
+ SpaceInvaders: ({ onExit }: { onExit: () => void }) => (
+ invaders-exit
+ ),
+}));
+
+// The stand-in focuses itself on mount, like the real frame does once its
+// page has loaded — which is what could make a late-recording shell mistake
+// the game for the opener.
+vi.mock("../../../../src/features/easter-eggs/components/Game2048Frame", async () => {
+ const { useEffect, useRef } = await import("react");
+ return {
+ Game2048Frame: ({ onExit }: { onExit: () => void }) => {
+ const ref = useRef(null);
+ useEffect(() => ref.current?.focus(), []);
+ return (
+
+ frame-exit
+
+ );
+ },
+ };
+});
+
+describe("EggModalShell", () => {
+ it("renders nothing when closed", () => {
+ render( );
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
+ });
+
+ it.each([
+ ["game-2048", "2048"],
+ ["space-invaders", "Space Invaders game"],
+ ] as const)("renders %s with the right dialog label", (eggId, label) => {
+ render( );
+ expect(screen.getByRole("dialog", { name: label })).toBeInTheDocument();
+ });
+
+ it("routes a canvas game's onExit to onClose", () => {
+ const onClose = vi.fn();
+ render( );
+ fireEvent.click(screen.getByText("invaders-exit"));
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it("closes on overlay click", () => {
+ const onClose = vi.fn();
+ render( );
+ fireEvent.click(screen.getByRole("dialog"));
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it("closes on Escape for iframe games (2048)", () => {
+ const onClose = vi.fn();
+ render( );
+ fireEvent.keyDown(window, { key: "Escape" });
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ describe("focus contract", () => {
+ afterEach(() => {
+ document.body.innerHTML = "";
+ });
+
+ function makeOpener(): HTMLButtonElement {
+ const opener = document.createElement("button");
+ opener.textContent = "opener";
+ document.body.appendChild(opener);
+ opener.focus();
+ return opener;
+ }
+
+ it("moves focus into the dialog on open and back to the opener on close", async () => {
+ const opener = makeOpener();
+ const { rerender } = render(
+ ,
+ );
+ rerender( );
+
+ const dialog = screen.getByRole("dialog");
+ expect(dialog.contains(document.activeElement)).toBe(true);
+ await screen.findByText("invaders-exit");
+
+ rerender( );
+ expect(document.activeElement).toBe(opener);
+ });
+
+ it("records the real opener even when the game focuses itself on a cached reopen", async () => {
+ const opener = makeOpener();
+ const { rerender } = render( );
+ await screen.findByText("frame-exit");
+ rerender( );
+ expect(document.activeElement).toBe(opener);
+
+ // Second open: the chunk is cached, so the game mounts in the same
+ // commit and focuses itself in its own effect.
+ rerender( );
+ expect(document.activeElement).toBe(screen.getByText("frame-exit"));
+
+ rerender( );
+ expect(document.activeElement).toBe(opener);
+ });
+
+ it("does not try to restore focus to an opener that has left the document", async () => {
+ const opener = makeOpener();
+ const focusSpy = vi.spyOn(opener, "focus");
+ const { rerender } = render( );
+ await screen.findByText("invaders-exit");
+ focusSpy.mockClear();
+
+ opener.remove();
+ rerender( );
+ expect(focusSpy).not.toHaveBeenCalled();
+ });
+
+ it("wraps Tab and Shift+Tab inside the dialog", async () => {
+ render( );
+ const close = screen.getByTestId("game-2048-close");
+ const last = await screen.findByText("frame-exit");
+
+ last.focus();
+ fireEvent.keyDown(last, { key: "Tab" });
+ expect(document.activeElement).toBe(close);
+
+ fireEvent.keyDown(close, { key: "Tab", shiftKey: true });
+ expect(document.activeElement).toBe(last);
+ });
+
+ it("pulls focus back when it lands on the page behind the overlay", async () => {
+ const behind = document.createElement("button");
+ behind.textContent = "behind";
+ render( );
+ await screen.findByText("frame-exit");
+ // After the dialog in document order, like tabbing out of the iframe's
+ // last control: focus wraps to the first focusable.
+ document.body.appendChild(behind);
+
+ behind.focus();
+ expect(document.activeElement).toBe(screen.getByTestId("game-2048-close"));
+ });
+
+ it("stops guarding focus once closed", async () => {
+ const { rerender } = render( );
+ await screen.findByText("frame-exit");
+ rerender( );
+
+ const behind = document.createElement("button");
+ document.body.appendChild(behind);
+ behind.focus();
+ expect(document.activeElement).toBe(behind);
+ });
+ });
+});
diff --git a/tests/unit/features/easter-eggs/EggModalShellError.test.tsx b/tests/unit/features/easter-eggs/EggModalShellError.test.tsx
new file mode 100644
index 000000000..0eac30749
--- /dev/null
+++ b/tests/unit/features/easter-eggs/EggModalShellError.test.tsx
@@ -0,0 +1,72 @@
+import type { ComponentType } from "react";
+import { afterEach, beforeEach, describe, it, expect, vi, type MockInstance } from "vitest";
+import { render, screen, fireEvent } from "@testing-library/react";
+
+// A registry whose chunks fail, the way a stale deploy's missing hashed file
+// or a dropped connection does: the lazy import rejects.
+vi.mock("../../../../src/features/easter-eggs/registry", async () => {
+ const { lazy } = await import("react");
+ const failing = () =>
+ lazy void }>>(() =>
+ Promise.reject(new Error("Failed to fetch dynamically imported module")),
+ );
+ return {
+ EGG_REGISTRY: {
+ "space-invaders": { label: "Space Invaders game", kind: "canvas", component: failing() },
+ "game-2048": { label: "2048", kind: "iframe", component: failing() },
+ },
+ };
+});
+
+import { EggModalShell } from "../../../../src/features/easter-eggs/components/EggModalShell.tsx";
+
+describe("EggModalShell when the game chunk fails to load", () => {
+ let consoleError: MockInstance;
+
+ beforeEach(() => {
+ // React and the boundary both report the failure; keep the output clean.
+ consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ consoleError.mockRestore();
+ });
+
+ it("shows a friendly message instead of tearing down the app, and logs the error", async () => {
+ render( );
+
+ expect(await screen.findByText("Couldn't load Space Invaders game")).toBeInTheDocument();
+ expect(screen.getByRole("dialog")).toBeInTheDocument();
+ expect(consoleError).toHaveBeenCalledWith(
+ "Easter egg failed to load",
+ expect.any(Error),
+ expect.anything(),
+ );
+ });
+
+ it("stays closable by button and by Escape for a canvas game", async () => {
+ const onClose = vi.fn();
+ render( );
+
+ fireEvent.click(await screen.findByRole("button", { name: "Close" }));
+ expect(onClose).toHaveBeenCalledTimes(1);
+
+ fireEvent.keyDown(window, { key: "Escape" });
+ expect(onClose).toHaveBeenCalledTimes(2);
+ });
+
+ it("keeps the iframe game's header close and fires Escape exactly once", async () => {
+ const onClose = vi.fn();
+ render( );
+
+ expect(await screen.findByText("Couldn't load 2048")).toBeInTheDocument();
+ // The header already closes; the failure state adds no second button.
+ expect(screen.queryByRole("button", { name: "Close" })).toBeNull();
+
+ fireEvent.keyDown(window, { key: "Escape" });
+ expect(onClose).toHaveBeenCalledTimes(1);
+
+ fireEvent.click(screen.getByRole("button", { name: "Close 2048" }));
+ expect(onClose).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/tests/unit/features/easter-eggs/EggModalShellLoading.test.tsx b/tests/unit/features/easter-eggs/EggModalShellLoading.test.tsx
new file mode 100644
index 000000000..8b1ca3a74
--- /dev/null
+++ b/tests/unit/features/easter-eggs/EggModalShellLoading.test.tsx
@@ -0,0 +1,35 @@
+import type { ComponentType } from "react";
+import { describe, it, expect, vi } from "vitest";
+import { render, screen, fireEvent } from "@testing-library/react";
+
+// A registry whose only egg never arrives: the chunk stays unresolved, so the
+// shell sits in exactly the window this test is about — the modal is open, no
+// game has mounted, and nothing else can close it from the keyboard.
+vi.mock("../../../../src/features/easter-eggs/registry", async () => {
+ const { lazy } = await import("react");
+ return {
+ EGG_REGISTRY: {
+ "space-invaders": {
+ label: "Space Invaders game",
+ kind: "canvas",
+ component: lazy void }>>(() => new Promise(() => {})),
+ },
+ },
+ };
+});
+
+import { EggModalShell } from "../../../../src/features/easter-eggs/components/EggModalShell";
+
+describe("EggModalShell while the game chunk is still loading", () => {
+ it("still closes on Escape, because the game that owns that key is not there yet", () => {
+ const onClose = vi.fn();
+ render( );
+
+ // Suspended: no canvas game, so no game-side Escape handler exists.
+ expect(screen.getByRole("dialog")).toBeInTheDocument();
+ expect(document.querySelector("canvas")).toBeNull();
+
+ fireEvent.keyDown(window, { key: "Escape" });
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/tests/unit/features/easter-eggs/Game2048Frame.test.tsx b/tests/unit/features/easter-eggs/Game2048Frame.test.tsx
new file mode 100644
index 000000000..a9d51e812
--- /dev/null
+++ b/tests/unit/features/easter-eggs/Game2048Frame.test.tsx
@@ -0,0 +1,62 @@
+import { describe, it, expect, vi } from "vitest";
+import { render, screen, fireEvent } from "@testing-library/react";
+import { Game2048Frame } from "../../../../src/features/easter-eggs/components/Game2048Frame.tsx";
+
+function getFrame(): HTMLIFrameElement {
+ return screen.getByTitle("2048 game");
+}
+
+function postFromSource(source: Window | null, origin = window.location.origin) {
+ fireEvent(window, new MessageEvent("message", { data: { type: "EGG_EXIT" }, origin, source }));
+}
+
+describe("Game2048Frame", () => {
+ it("renders the 2048 iframe", () => {
+ render( );
+ const iframe = getFrame();
+ expect(iframe).toBeInTheDocument();
+ expect(iframe).toHaveAttribute("src", "/easter-eggs/2048.html");
+ });
+
+ it("calls onExit when its own game page reports EGG_EXIT", () => {
+ const onExit = vi.fn();
+ render( );
+
+ postFromSource(getFrame().contentWindow);
+
+ expect(onExit).toHaveBeenCalledTimes(1);
+ });
+
+ it("ignores an EGG_EXIT report from another origin", () => {
+ const onExit = vi.fn();
+ render( );
+
+ postFromSource(getFrame().contentWindow, "https://somewhere-else.example");
+
+ expect(onExit).not.toHaveBeenCalled();
+ });
+
+ it("ignores a same-origin EGG_EXIT that did not come from its own frame", () => {
+ const onExit = vi.fn();
+ render( );
+
+ postFromSource(window);
+ postFromSource(null);
+
+ expect(onExit).not.toHaveBeenCalled();
+ });
+
+ it("focuses the frame only once its page has loaded", () => {
+ render( );
+ const frameWindow = getFrame().contentWindow;
+ expect(frameWindow).not.toBeNull();
+ const focusSpy = vi.spyOn(frameWindow as Window, "focus");
+
+ // The initial about:blank document is already "complete" — not a reason
+ // to take focus.
+ expect(focusSpy).not.toHaveBeenCalled();
+
+ fireEvent.load(getFrame());
+ expect(focusSpy).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/tests/unit/features/easter-eggs/SpaceInvaders.test.tsx b/tests/unit/features/easter-eggs/SpaceInvaders.test.tsx
new file mode 100644
index 000000000..f69ab9784
--- /dev/null
+++ b/tests/unit/features/easter-eggs/SpaceInvaders.test.tsx
@@ -0,0 +1,48 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { describe, it, expect, vi } from "vitest";
+import { SpaceInvaders } from "../../../../src/features/easter-eggs/components/SpaceInvaders.tsx";
+
+/**
+ * Keyboard ownership of the Space Invaders game. The canvas loop itself is not
+ * exercised (jsdom has no 2D context). `fireEvent` returns false when the
+ * handler called `preventDefault`, which is how "the game took the key" shows.
+ */
+describe("SpaceInvaders keyboard", () => {
+ it("takes the movement keys", () => {
+ render( );
+ expect(fireEvent.keyDown(document.body, { key: "a", code: "KeyA" })).toBe(false);
+ expect(fireEvent.keyDown(document.body, { key: "ArrowRight", code: "ArrowRight" })).toBe(false);
+ });
+
+ it("leaves modified presses to the browser, so Ctrl/Cmd+A still selects all", () => {
+ render( );
+ expect(fireEvent.keyDown(document.body, { key: "a", code: "KeyA", ctrlKey: true })).toBe(true);
+ expect(fireEvent.keyDown(document.body, { key: "a", code: "KeyA", metaKey: true })).toBe(true);
+ expect(fireEvent.keyDown(document.body, { key: "d", code: "KeyD", altKey: true })).toBe(true);
+ });
+
+ it("leaves keys typed into a text field alone", () => {
+ render(
+ <>
+
+
+ >,
+ );
+ const field = screen.getByLabelText("Message");
+ expect(fireEvent.keyDown(field, { key: "a", code: "KeyA" })).toBe(true);
+ expect(fireEvent.keyDown(field, { key: " ", code: "Space" })).toBe(true);
+ });
+
+ it("claims Escape before document listeners see it", () => {
+ const onExit = vi.fn();
+ const documentListener = vi.fn();
+ document.addEventListener("keydown", documentListener);
+ render( );
+
+ fireEvent.keyDown(document.body, { key: "Escape", code: "Escape" });
+
+ expect(onExit).toHaveBeenCalledTimes(1);
+ expect(documentListener).not.toHaveBeenCalled();
+ document.removeEventListener("keydown", documentListener);
+ });
+});
diff --git a/tests/unit/features/easter-eggs/eggEffectBus.test.tsx b/tests/unit/features/easter-eggs/eggEffectBus.test.tsx
new file mode 100644
index 000000000..b2b916097
--- /dev/null
+++ b/tests/unit/features/easter-eggs/eggEffectBus.test.tsx
@@ -0,0 +1,110 @@
+import { act, fireEvent, render, screen } from "@testing-library/react";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import {
+ playEggEffect,
+ clearEggEffect,
+ useActiveEggEffect,
+} from "../../../../src/features/easter-eggs/eggEffectBus";
+import { EggEffectsLayer } from "../../../../src/features/easter-eggs/components/EggEffectsLayer";
+
+// The matrix canvas needs a real 2D context; stub it so the layer test can
+// assert on the overlay itself.
+vi.mock("../../../../src/features/easter-eggs/components/MatrixRain", () => ({
+ MatrixRain: ({ onClose }: { onClose: () => void }) => (
+
+ matrix
+
+ ),
+}));
+
+function Probe() {
+ const effect = useActiveEggEffect();
+ return {effect ? `${effect.id}:${effect.seq}` : "none"}
;
+}
+
+describe("eggEffectBus", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ clearEggEffect();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ document.body.classList.remove("barrel-roll-active");
+ });
+
+ it("starts with no active effect and publishes fired ones", () => {
+ render(
+ <>
+
+
+ >,
+ );
+ expect(screen.getByTestId("probe")).toHaveTextContent("none");
+
+ act(() => {
+ playEggEffect("barrel-roll");
+ });
+ expect(screen.getByTestId("probe")).toHaveTextContent(/^barrel-roll:\d+$/);
+ });
+
+ it("applies and cleans up the barrel-roll body class", () => {
+ render(
+ <>
+
+
+ >,
+ );
+ expect(document.body).not.toHaveClass("barrel-roll-active");
+
+ act(() => {
+ playEggEffect("barrel-roll");
+ });
+ expect(document.body).toHaveClass("barrel-roll-active");
+
+ act(() => {
+ vi.advanceTimersByTime(2100);
+ });
+ expect(document.body).not.toHaveClass("barrel-roll-active");
+ expect(screen.getByTestId("probe")).toHaveTextContent("none");
+ });
+
+ it("renders MatrixRain for the matrix effect and closes it via its callback", () => {
+ render(
+ <>
+
+
+ >,
+ );
+
+ act(() => {
+ playEggEffect("matrix");
+ });
+ expect(screen.getByTestId("matrix-rain")).toBeInTheDocument();
+
+ fireEvent.click(screen.getByTestId("matrix-rain"));
+ expect(screen.queryByTestId("matrix-rain")).not.toBeInTheDocument();
+ expect(screen.getByTestId("probe")).toHaveTextContent("none");
+ });
+
+ it("clearing with nothing active is a no-op (does not notify)", () => {
+ const probeSeqs: string[] = [];
+ function Collecting() {
+ const effect = useActiveEggEffect();
+ probeSeqs.push(effect ? `${effect.id}` : "none");
+ return null;
+ }
+ render(
+ <>
+
+
+ >,
+ );
+
+ act(() => {
+ clearEggEffect();
+ });
+ // Only the initial mount render, no re-render for a no-op clear.
+ expect(probeSeqs).toEqual(["none"]);
+ });
+});
diff --git a/tests/unit/features/easter-eggs/eggPhrases.test.ts b/tests/unit/features/easter-eggs/eggPhrases.test.ts
new file mode 100644
index 000000000..4b82fa9f0
--- /dev/null
+++ b/tests/unit/features/easter-eggs/eggPhrases.test.ts
@@ -0,0 +1,38 @@
+import { describe, it, expect } from "vitest";
+import { matchEggPhrase } from "../../../../src/features/easter-eggs/lib/eggPhrases";
+
+describe("matchEggPhrase", () => {
+ it.each(["do a barrel roll", "do barrel roll", "do barrel", " DO A Barrel Roll "])(
+ "recognizes %j as a barrel roll",
+ (text) => {
+ expect(matchEggPhrase(text)).toBe("barrel-roll");
+ },
+ );
+
+ it.each(["matrix", "the matrix", "do matrix", "MATRIX"])("recognizes %j as matrix", (text) => {
+ expect(matchEggPhrase(text)).toBe("matrix");
+ });
+
+ it.each([
+ "party",
+ "party time",
+ "let's party",
+ "let\u2019s party",
+ "Let\u2018s Party",
+ "🎉",
+ " PARTY ",
+ ])("recognizes %j as party", (text) => {
+ expect(matchEggPhrase(text)).toBe("party");
+ });
+
+ it.each([
+ "how do I do a barrel roll?",
+ "the matrix movie",
+ "",
+ " ",
+ "partying",
+ "a party for two",
+ ])("treats %j as a normal message", (text) => {
+ expect(matchEggPhrase(text)).toBeNull();
+ });
+});
diff --git a/tests/unit/features/easter-eggs/useDinoWaitingGame.test.tsx b/tests/unit/features/easter-eggs/useDinoWaitingGame.test.tsx
new file mode 100644
index 000000000..3b45d92f2
--- /dev/null
+++ b/tests/unit/features/easter-eggs/useDinoWaitingGame.test.tsx
@@ -0,0 +1,294 @@
+import { afterEach, describe, it, expect, beforeEach, vi } from "vitest";
+import { renderHook, act } from "@testing-library/react";
+import { fireEvent } from "@testing-library/react";
+import {
+ useDinoUnlocked,
+ useSpaceOpensDino,
+} from "../../../../src/features/easter-eggs/hooks/useDinoWaitingGame.ts";
+import {
+ isInteractiveTarget,
+ isTypingTarget,
+} from "../../../../src/features/easter-eggs/lib/keyTargets.ts";
+
+describe("useDinoWaitingGame hooks", () => {
+ beforeEach(() => {
+ window.localStorage.clear();
+ });
+
+ afterEach(() => {
+ window.localStorage.clear();
+ document.body.innerHTML = "";
+ });
+
+ describe("useDinoUnlocked", () => {
+ it("reads the persisted flag and follows dinoUnlockChanged events", () => {
+ window.localStorage.setItem("dinoUnlocked", "true");
+ const { result } = renderHook(() => useDinoUnlocked());
+ expect(result.current).toBe(true);
+
+ act(() => {
+ fireEvent(window, new Event("dinoUnlockChanged"));
+ });
+
+ // Flag still true — state stays true.
+ expect(result.current).toBe(true);
+
+ window.localStorage.setItem("dinoUnlocked", "false");
+ act(() => {
+ fireEvent(window, new Event("dinoUnlockChanged"));
+ });
+ expect(result.current).toBe(false);
+ });
+ });
+
+ describe("useSpaceOpensDino", () => {
+ it("opens only when armed AND unlocked, on Space outside inputs", () => {
+ window.localStorage.setItem("dinoUnlocked", "true");
+ const { result } = renderHook(() => useSpaceOpensDino(true, true));
+
+ // Space while "typing" in an input is ignored.
+ const input = document.createElement("input");
+ document.body.appendChild(input);
+ input.focus();
+ act(() => {
+ fireEvent.keyDown(window, { code: "Space" });
+ });
+ expect(result.current[0]).toBe(false);
+ input.blur();
+
+ act(() => {
+ fireEvent.keyDown(window, { code: "Space" });
+ });
+ expect(result.current[0]).toBe(true);
+
+ act(() => {
+ result.current[1]();
+ });
+ expect(result.current[0]).toBe(false);
+ });
+
+ it("ignores Space when not unlocked or not armed", () => {
+ const locked = renderHook(() => useSpaceOpensDino(true, false));
+ act(() => {
+ fireEvent.keyDown(window, { code: "Space" });
+ });
+ expect(locked.result.current[0]).toBe(false);
+ locked.unmount();
+
+ const disarmed = renderHook(() => useSpaceOpensDino(false, true));
+ act(() => {
+ fireEvent.keyDown(window, { code: "Space" });
+ });
+ expect(disarmed.result.current[0]).toBe(false);
+ });
+
+ it("closes automatically when the passed unlock flag flips off mid-game", () => {
+ const { result, rerender } = renderHook(({ unlocked }) => useSpaceOpensDino(true, unlocked), {
+ initialProps: { unlocked: true },
+ });
+
+ act(() => {
+ fireEvent.keyDown(window, { code: "Space" });
+ });
+ expect(result.current[0]).toBe(true);
+
+ // Consumer re-renders with a fresh unlock value (e.g. another tab
+ // or the Settings cogwheel re-locked the game).
+ rerender({ unlocked: false });
+ expect(result.current[0]).toBe(false);
+ });
+
+ it("closes when the wait ends and frees the slot for the next surface", () => {
+ window.localStorage.setItem("dinoUnlocked", "true");
+ const first = renderHook(({ armed }) => useSpaceOpensDino(armed, true), {
+ initialProps: { armed: true },
+ });
+
+ act(() => {
+ fireEvent.keyDown(window, { code: "Space" });
+ });
+ expect(first.result.current[0]).toBe(true);
+
+ // The wait ended (the answer arrived, the path was generated): the game
+ // goes with it — its DOM already did.
+ first.rerender({ armed: false });
+ expect(first.result.current[0]).toBe(false);
+
+ // And the shared slot is free again: a second armed host can open its
+ // own game. Before, the finished wait kept the slot claimed and Space
+ // did nothing for the rest of the visit.
+ const second = renderHook(() => useSpaceOpensDino(true, true));
+ act(() => {
+ fireEvent.keyDown(window, { code: "Space" });
+ });
+ expect(second.result.current[0]).toBe(true);
+
+ second.unmount();
+ first.unmount();
+ });
+
+ it("keeps the current game active until manual close when keepActiveUntilExit is true", () => {
+ window.localStorage.setItem("dinoUnlocked", "true");
+ const { result, rerender } = renderHook(
+ ({ armed }) => useSpaceOpensDino(armed, true, { keepActiveUntilExit: true }),
+ {
+ initialProps: { armed: true },
+ },
+ );
+
+ act(() => {
+ fireEvent.keyDown(window, { code: "Space" });
+ });
+ expect(result.current[0]).toBe(true);
+
+ // When armed flips off (reply arrives), game stays active so user can finish their run.
+ rerender({ armed: false });
+ expect(result.current[0]).toBe(true);
+
+ // Exiting manually closes the game and frees the slot.
+ act(() => {
+ result.current[1]();
+ });
+ expect(result.current[0]).toBe(false);
+
+ // Cannot open a new game while armed is false.
+ act(() => {
+ fireEvent.keyDown(window, { code: "Space" });
+ });
+ expect(result.current[0]).toBe(false);
+ });
+
+ it("opens exactly one game when two armed hosts see the same Space press", () => {
+ window.localStorage.setItem("dinoUnlocked", "true");
+ const first = renderHook(() => useSpaceOpensDino(true, true));
+ const second = renderHook(() => useSpaceOpensDino(true, true));
+
+ act(() => {
+ fireEvent.keyDown(window, { code: "Space" });
+ });
+
+ const openCount = [first.result.current[0], second.result.current[0]].filter(Boolean).length;
+ expect(openCount).toBe(1);
+ });
+
+ it.each([
+ ["button", () => document.createElement("button")],
+ ["select", () => document.createElement("select")],
+ [
+ "link",
+ () => {
+ const a = document.createElement("a");
+ a.href = "#x";
+ return a;
+ },
+ ],
+ [
+ "role=switch",
+ () => {
+ const el = document.createElement("div");
+ el.setAttribute("role", "switch");
+ el.tabIndex = 0;
+ return el;
+ },
+ ],
+ ])("leaves Space to a focused %s", (_name, make) => {
+ window.localStorage.setItem("dinoUnlocked", "true");
+ const { result } = renderHook(() => useSpaceOpensDino(true, true));
+ const control = make();
+ document.body.appendChild(control);
+ control.focus();
+
+ let event!: KeyboardEvent;
+ act(() => {
+ event = new KeyboardEvent("keydown", { code: "Space", bubbles: true, cancelable: true });
+ control.dispatchEvent(event);
+ });
+
+ expect(result.current[0]).toBe(false);
+ expect(event.defaultPrevented).toBe(false);
+ });
+
+ it.each([
+ ["ctrlKey", { ctrlKey: true }],
+ ["metaKey", { metaKey: true }],
+ ["altKey", { altKey: true }],
+ ["shiftKey", { shiftKey: true }],
+ ["repeat", { repeat: true }],
+ ])("ignores Space with %s", (_name, init) => {
+ window.localStorage.setItem("dinoUnlocked", "true");
+ const { result } = renderHook(() => useSpaceOpensDino(true, true));
+
+ act(() => {
+ fireEvent.keyDown(window, { code: "Space", ...init });
+ });
+
+ expect(result.current[0]).toBe(false);
+ });
+
+ it("ignores a Space press someone else already handled", () => {
+ window.localStorage.setItem("dinoUnlocked", "true");
+ const { result } = renderHook(() => useSpaceOpensDino(true, true));
+ const claim = (e: KeyboardEvent) => e.preventDefault();
+ window.addEventListener("keydown", claim, { capture: true });
+
+ try {
+ act(() => {
+ fireEvent.keyDown(window, { code: "Space" });
+ });
+ expect(result.current[0]).toBe(false);
+ } finally {
+ window.removeEventListener("keydown", claim, { capture: true });
+ }
+ });
+ });
+
+ describe("target helpers", () => {
+ it("keeps isTypingTarget to text fields while isInteractiveTarget adds controls", () => {
+ const input = document.createElement("input");
+ const button = document.createElement("button");
+ const div = document.createElement("div");
+
+ expect(isTypingTarget(input)).toBe(true);
+ expect(isTypingTarget(button)).toBe(false);
+ expect(isInteractiveTarget(input)).toBe(true);
+ expect(isInteractiveTarget(button)).toBe(true);
+ expect(isInteractiveTarget(div)).toBe(false);
+ expect(isInteractiveTarget(null)).toBe(false);
+ });
+
+ it("lets Space through on a disabled control, which has no Space action", () => {
+ // A busy ui/Button is disabled while it can still hold focus; it must not
+ // keep the game shut for the very wait the game exists for.
+ const busy = document.createElement("button");
+ busy.disabled = true;
+ const ariaBusy = document.createElement("div");
+ ariaBusy.setAttribute("role", "button");
+ ariaBusy.setAttribute("aria-disabled", "true");
+ const disabledInput = document.createElement("input");
+ disabledInput.disabled = true;
+
+ expect(isInteractiveTarget(busy)).toBe(false);
+ expect(isInteractiveTarget(ariaBusy)).toBe(false);
+ // Text fields stay protected either way: the typing guard is unchanged.
+ expect(isInteractiveTarget(disabledInput)).toBe(true);
+ });
+ });
+
+ describe("useDinoUnlocked storage guard", () => {
+ it("treats unreadable storage as locked instead of throwing", () => {
+ const getItem = vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
+ throw new DOMException("denied", "SecurityError");
+ });
+ try {
+ const { result } = renderHook(() => useDinoUnlocked());
+ expect(result.current).toBe(false);
+ act(() => {
+ fireEvent(window, new Event("dinoUnlockChanged"));
+ });
+ expect(result.current).toBe(false);
+ } finally {
+ getItem.mockRestore();
+ }
+ });
+ });
+});
diff --git a/tests/unit/features/easter-eggs/useRepeatClicks.test.tsx b/tests/unit/features/easter-eggs/useRepeatClicks.test.tsx
new file mode 100644
index 000000000..03ba06233
--- /dev/null
+++ b/tests/unit/features/easter-eggs/useRepeatClicks.test.tsx
@@ -0,0 +1,119 @@
+import { describe, it, expect, vi } from "vitest";
+import { renderHook } from "@testing-library/react";
+import { useRepeatClicks } from "../../../../src/features/easter-eggs/hooks/useRepeatClicks";
+
+describe("useRepeatClicks", () => {
+ it("fires only on the required consecutive click and resets", () => {
+ const onReached = vi.fn();
+ const { result } = renderHook(() => useRepeatClicks(3, onReached));
+
+ result.current();
+ result.current();
+ expect(onReached).not.toHaveBeenCalled();
+
+ result.current();
+ expect(onReached).toHaveBeenCalledTimes(1);
+
+ // Counter reset: two more clicks are not enough again.
+ result.current();
+ result.current();
+ expect(onReached).toHaveBeenCalledTimes(1);
+ result.current();
+ expect(onReached).toHaveBeenCalledTimes(2);
+ });
+
+ it("keeps working when the callback identity changes between renders", () => {
+ let counter = 0;
+ const { result, rerender } = renderHook(() => useRepeatClicks(2, () => void counter++));
+
+ result.current();
+ rerender();
+ result.current();
+ expect(counter).toBe(1);
+ });
+
+ it("starts the count over when the clicks stop being consecutive", () => {
+ const onReached = vi.fn();
+ const { result } = renderHook(() => useRepeatClicks(3, onReached, 1000));
+
+ const nowSpy = vi.spyOn(Date, "now");
+ let clock = 0;
+ nowSpy.mockImplementation(() => clock);
+
+ try {
+ result.current();
+ result.current();
+
+ // A pause longer than the window: the two clicks before it no longer
+ // count towards the next gesture.
+ clock = 1500;
+ result.current();
+ result.current();
+ expect(onReached).not.toHaveBeenCalled();
+
+ result.current();
+ expect(onReached).toHaveBeenCalledTimes(1);
+ } finally {
+ nowSpy.mockRestore();
+ }
+ });
+
+ it("defaults to one second per click, so a counted gesture still lands", () => {
+ const onReached = vi.fn();
+ const { result } = renderHook(() => useRepeatClicks(3, onReached));
+
+ const nowSpy = vi.spyOn(Date, "now");
+ let clock = 0;
+ nowSpy.mockImplementation(() => clock);
+
+ try {
+ // ~800ms apart — unhurried, but unmistakably the gesture.
+ for (const tick of [0, 800, 1600]) {
+ clock = tick;
+ result.current();
+ }
+ expect(onReached).toHaveBeenCalledTimes(1);
+
+ // And the 3s budget still rules out accumulation: it is measured from
+ // the first click of the gesture, so 5600 → 9000 (3.4s) starts over
+ // at 9000, and 12000 is only the second click of that new count.
+ clock = 5600;
+ result.current();
+ clock = 9000;
+ result.current();
+ clock = 12000;
+ result.current();
+ expect(onReached).toHaveBeenCalledTimes(1);
+ } finally {
+ nowSpy.mockRestore();
+ }
+ });
+
+ it("measures the window from the first click of the gesture, not the last gap", () => {
+ const onReached = vi.fn();
+ const { result } = renderHook(() => useRepeatClicks(3, onReached));
+
+ const nowSpy = vi.spyOn(Date, "now");
+ let clock = 0;
+ nowSpy.mockImplementation(() => clock);
+
+ try {
+ // Every gap is under 3s, but the three clicks span 3.5s: not a triple
+ // click, just a slow drip.
+ for (const tick of [0, 2000, 3500]) {
+ clock = tick;
+ result.current();
+ }
+ expect(onReached).not.toHaveBeenCalled();
+
+ // 3500 started a new gesture; two more inside its window complete it.
+ clock = 4500;
+ result.current();
+ clock = 6500;
+ result.current();
+ expect(onReached).toHaveBeenCalledTimes(1);
+ } finally {
+ nowSpy.mockRestore();
+ }
+ });
+});
diff --git a/tests/unit/features/game2048/Game2048Modal.test.tsx b/tests/unit/features/game2048/Game2048Modal.test.tsx
deleted file mode 100644
index 719d4f41a..000000000
--- a/tests/unit/features/game2048/Game2048Modal.test.tsx
+++ /dev/null
@@ -1,65 +0,0 @@
-import { describe, it, expect, vi } from "vitest";
-import { render, screen, fireEvent } from "@testing-library/react";
-import { Game2048Modal } from "../../../../src/features/game2048/components/Game2048Modal";
-
-describe("Game2048Modal", () => {
- it("renders the modal with iframe when open", () => {
- render( );
-
- const iframe = screen.getByTitle("2048 game");
- expect(iframe).toBeInTheDocument();
- expect(iframe).toHaveAttribute("src", "/easter-eggs/2048.html");
- });
-
- it("renders nothing when closed", () => {
- render( );
- expect(screen.queryByTitle("2048 game")).not.toBeInTheDocument();
- });
-
- it("renders the close button with aria-label", () => {
- render( );
- expect(screen.getByLabelText("Close 2048 game")).toBeInTheDocument();
- });
-
- it("renders the dialog title", () => {
- render( );
- expect(screen.getByText("2048")).toBeInTheDocument();
- });
-
- it("calls onClose when the close button is clicked", () => {
- const onClose = vi.fn();
- render( );
- fireEvent.click(screen.getByLabelText("Close 2048 game"));
- expect(onClose).toHaveBeenCalledTimes(1);
- });
-
- it("calls onClose when the overlay is clicked", () => {
- const onClose = vi.fn();
- render( );
- // The dialog overlay is the element with role="dialog".
- const overlay = screen.getByRole("dialog");
- fireEvent.click(overlay);
- expect(onClose).toHaveBeenCalledTimes(1);
- });
-
- it("has the correct dialog attributes", () => {
- render( );
- const dialog = screen.getByRole("dialog");
- expect(dialog).toHaveAttribute("aria-modal", "true");
- expect(dialog).toHaveAttribute("aria-labelledby", "game2048-title");
- });
-
- it("calls onClose when Escape is pressed", () => {
- const onClose = vi.fn();
- render( );
- fireEvent.keyDown(window, { key: "Escape" });
- expect(onClose).toHaveBeenCalledTimes(1);
- });
-
- it("does not call onClose when Escape is pressed while closed", () => {
- const onClose = vi.fn();
- render( );
- fireEvent.keyDown(window, { key: "Escape" });
- expect(onClose).not.toHaveBeenCalled();
- });
-});
diff --git a/tests/unit/features/onboarding/components/journey/GenerationScreen.test.tsx b/tests/unit/features/onboarding/components/journey/GenerationScreen.test.tsx
new file mode 100644
index 000000000..fbfbee0b1
--- /dev/null
+++ b/tests/unit/features/onboarding/components/journey/GenerationScreen.test.tsx
@@ -0,0 +1,161 @@
+import { act, render, screen, fireEvent } from "@testing-library/react";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { MemoryRouter } from "react-router-dom";
+import { GenerationScreen } from "../../../../../../src/features/onboarding/components/journey/GenerationScreen";
+import type { GenerationPhaseProgress } from "../../../../../../src/features/onboarding/generation/OnboardingJourneyContext";
+
+const mockPhases: GenerationPhaseProgress[] = [
+ { name: "Orientation", state: "done", detail: "Ready" },
+ { name: "Setup", state: "working", detail: "Configuring IDE" },
+ { name: "First Task", state: "waiting", detail: "Queued" },
+];
+
+describe("GenerationScreen", () => {
+ beforeEach(() => {
+ window.localStorage.clear();
+ vi.clearAllMocks();
+ });
+
+ it("renders phases and assembling status", () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByText("Building your onboarding path")).toBeInTheDocument();
+ expect(screen.getByText("Orientation")).toBeInTheDocument();
+ expect(screen.getByText("Setup")).toBeInTheDocument();
+ expect(screen.getByText("First Task")).toBeInTheDocument();
+ });
+
+ it("does not show space hint or start game when dino is locked", () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.queryByText(/to pass the time/i)).not.toBeInTheDocument();
+
+ fireEvent.keyDown(window, { code: "Space" });
+ expect(screen.queryByRole("application", { name: /mini dino game/i })).not.toBeInTheDocument();
+ });
+
+ it("shows space hint when dino is unlocked and starts game on Space", () => {
+ window.localStorage.setItem("dinoUnlocked", "true");
+ const onGameActiveChange = vi.fn();
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByText(/to pass the time/i)).toBeInTheDocument();
+
+ fireEvent.keyDown(window, { code: "Space" });
+
+ expect(screen.getByRole("application", { name: /mini dino game/i })).toBeInTheDocument();
+ expect(onGameActiveChange).toHaveBeenCalledWith(true);
+ });
+
+ it("passes replyReady when isCompleted is true while game is active", () => {
+ window.localStorage.setItem("dinoUnlocked", "true");
+
+ const { rerender } = render(
+
+
+ ,
+ );
+
+ fireEvent.keyDown(window, { code: "Space" });
+ expect(screen.queryByText(/reply ready/i)).not.toBeInTheDocument();
+
+ // Rerender as completed
+ rerender(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("dino-game-reply-ready")).toHaveTextContent(/path ready/i);
+ });
+
+ it("keeps generating while the run is in flight, even with every phase reported done", () => {
+ // Last phase reported "Completed…" but the path is still being persisted: the run can still fail.
+ window.localStorage.setItem("dinoUnlocked", "true");
+ const allDone: GenerationPhaseProgress[] = mockPhases.map((phase) => ({
+ ...phase,
+ state: "done",
+ }));
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByText(/to pass the time/i)).toBeInTheDocument();
+ fireEvent.keyDown(window, { code: "Space" });
+ expect(screen.getByRole("application", { name: /mini dino game/i })).toBeInTheDocument();
+ expect(screen.queryByTestId("dino-game-reply-ready")).not.toBeInTheDocument();
+ });
+
+ it("shows no phase still working once completed, and stops the clock", () => {
+ vi.useFakeTimers();
+ try {
+ const startedAt = Date.now() - 5000;
+ render(
+
+
+ ,
+ );
+
+ for (const phase of screen.getAllByTestId("generation-phase")) {
+ expect(phase).toHaveAttribute("data-state", "done");
+ }
+ expect(screen.getByText("3 of 3 phases assembled")).toBeInTheDocument();
+
+ const elapsedBefore = screen.getByTestId("generation-elapsed").textContent;
+ act(() => {
+ vi.advanceTimersByTime(5000);
+ });
+ expect(screen.getByTestId("generation-elapsed").textContent).toBe(elapsedBefore);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it("keeps a failed phase failed when completed", () => {
+ render(
+
+
+ ,
+ );
+
+ const states = screen
+ .getAllByTestId("generation-phase")
+ .map((phase) => phase.getAttribute("data-state"));
+ expect(states).toEqual(["done", "done", "done", "failed"]);
+ });
+});
diff --git a/tests/unit/features/settings/components/DinoUnlockPopover.test.tsx b/tests/unit/features/settings/components/DinoUnlockPopover.test.tsx
new file mode 100644
index 000000000..40cf47e5e
--- /dev/null
+++ b/tests/unit/features/settings/components/DinoUnlockPopover.test.tsx
@@ -0,0 +1,58 @@
+import { render, screen } from "@testing-library/react";
+import { describe, it, expect } from "vitest";
+import { DinoUnlockPopover } from "../../../../../src/features/settings/components/DinoUnlockPopover.tsx";
+
+describe("DinoUnlockPopover", () => {
+ it("keeps an empty status live region mounted while hidden", () => {
+ render( );
+
+ const status = screen.getByRole("status");
+ expect(status).toHaveAttribute("aria-live", "polite");
+ expect(status).toHaveTextContent("");
+ expect(screen.queryByTestId("dino-unlock-popover")).not.toBeInTheDocument();
+ });
+
+ it("updates the same live region's text instead of remounting it", () => {
+ const { rerender } = render( );
+ const status = screen.getByRole("status");
+
+ rerender( );
+ expect(screen.getByRole("status")).toBe(status);
+ expect(status).toHaveTextContent("shh... press Space whenever you're waiting");
+
+ rerender( );
+ expect(screen.getByRole("status")).toBe(status);
+ expect(status).toHaveTextContent("you saw nothing...");
+ });
+
+ it("renders the unlock whisper with a kbd badge when kind is unlocked", () => {
+ render( );
+
+ const bubble = screen.getByTestId("dino-unlock-popover");
+ expect(bubble).toHaveAttribute("data-kind", "unlocked");
+ expect(bubble).toHaveAttribute("aria-hidden", "true");
+ expect(screen.getByText("Space").tagName.toLowerCase()).toBe("kbd");
+ expect(
+ screen.getByText(/whenever you're waiting/i, { selector: "span.text-app-text-muted" }),
+ ).toBeInTheDocument();
+ });
+
+ it("renders the lock whisper when kind is locked", () => {
+ render( );
+
+ expect(screen.getByTestId("dino-unlock-popover")).toHaveAttribute("data-kind", "locked");
+ expect(screen.queryByText("Space")).not.toBeInTheDocument();
+ expect(
+ screen.getByText("you saw nothing...", { selector: "span:not([role])" }),
+ ).toBeInTheDocument();
+ });
+
+ it("hides the decorative emoji from assistive tech", () => {
+ const { rerender } = render( );
+ expect(screen.getByTestId("dino-unlock-emoji")).toHaveAttribute("aria-hidden", "true");
+ expect(screen.queryByRole("img")).not.toBeInTheDocument();
+
+ rerender( );
+ expect(screen.getByTestId("dino-unlock-emoji")).toHaveAttribute("aria-hidden", "true");
+ });
+});
diff --git a/tests/unit/features/settings/useDinoEasterEgg.test.ts b/tests/unit/features/settings/useDinoEasterEgg.test.ts
index 20b7130a6..8a039438a 100644
--- a/tests/unit/features/settings/useDinoEasterEgg.test.ts
+++ b/tests/unit/features/settings/useDinoEasterEgg.test.ts
@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
-import { useDinoEasterEgg } from "../../../../src/features/settings/hooks/useDinoEasterEgg";
+import { useDinoEasterEgg } from "../../../../src/features/settings/hooks/useDinoEasterEgg.ts";
describe("useDinoEasterEgg", () => {
beforeEach(() => {
@@ -11,12 +11,10 @@ describe("useDinoEasterEgg", () => {
vi.restoreAllMocks();
});
- it("starts locked and with no game active", () => {
+ it("starts with no notice", () => {
const { result } = renderHook(() => useDinoEasterEgg());
- expect(result.current.isUnlocked).toBe(false);
- expect(result.current.gameActive).toBe(false);
- expect(result.current.toast).toBeNull();
+ expect(result.current.kind).toBeNull();
});
it("unlocks after three cogwheel clicks and shows a toast", () => {
@@ -24,41 +22,82 @@ describe("useDinoEasterEgg", () => {
act(() => result.current.handleIconClick());
act(() => result.current.handleIconClick());
- expect(result.current.isUnlocked).toBe(false);
+ expect(result.current.kind).toBeNull();
+ expect(window.localStorage.getItem("dinoUnlocked")).toBeNull();
act(() => result.current.handleIconClick());
- expect(result.current.isUnlocked).toBe(true);
- expect(result.current.toast).toContain("press Space");
+ expect(result.current.kind).toBe("unlocked");
expect(window.localStorage.getItem("dinoUnlocked")).toBe("true");
});
+ it("broadcasts dinoUnlockChanged so other surfaces can arm the game", () => {
+ const { result } = renderHook(() => useDinoEasterEgg());
+ const listener = vi.fn();
+ window.addEventListener("dinoUnlockChanged", listener, { once: true });
+
+ act(() => result.current.handleIconClick());
+ act(() => result.current.handleIconClick());
+ act(() => result.current.handleIconClick());
+
+ // The event is dispatched on a microtask, deliberately deferred past
+ // the render cycle — flush microtasks before asserting.
+ return Promise.resolve().then(() => {
+ expect(listener).toHaveBeenCalled();
+ });
+ });
+
it("locks again after three more clicks", () => {
window.localStorage.setItem("dinoUnlocked", "true");
const { result } = renderHook(() => useDinoEasterEgg());
- expect(result.current.isUnlocked).toBe(true);
-
act(() => result.current.handleIconClick());
act(() => result.current.handleIconClick());
act(() => result.current.handleIconClick());
- expect(result.current.isUnlocked).toBe(false);
expect(window.localStorage.getItem("dinoUnlocked")).toBe("false");
+ expect(result.current.kind).toBe("locked");
+ });
+
+ it("reports kind 'unlocked' then 'locked' across two toggles", () => {
+ vi.useFakeTimers();
+ try {
+ const { result } = renderHook(() => useDinoEasterEgg());
+ const tripleClick = () => {
+ act(() => result.current.handleIconClick());
+ act(() => result.current.handleIconClick());
+ act(() => result.current.handleIconClick());
+ };
+
+ tripleClick();
+ expect(result.current.kind).toBe("unlocked");
+
+ // Step past the 2s toggle debounce (and the 3s notice) before re-toggling.
+ act(() => {
+ vi.advanceTimersByTime(3500);
+ });
+ expect(result.current.kind).toBeNull();
+
+ tripleClick();
+ expect(result.current.kind).toBe("locked");
+ expect(window.localStorage.getItem("dinoUnlocked")).toBe("false");
+ } finally {
+ vi.useRealTimers();
+ }
});
- it("debounces rapid toggles (three clicks within 2s only toggles once)", () => {
+ it("debounces rapid toggles (second triple-click within 2s is ignored)", () => {
const { result } = renderHook(() => useDinoEasterEgg());
// First triple-click unlocks.
act(() => result.current.handleIconClick());
act(() => result.current.handleIconClick());
act(() => result.current.handleIconClick());
- expect(result.current.isUnlocked).toBe(true);
+ expect(window.localStorage.getItem("dinoUnlocked")).toBe("true");
// Immediate second triple-click is debounced — stays unlocked.
act(() => result.current.handleIconClick());
act(() => result.current.handleIconClick());
act(() => result.current.handleIconClick());
- expect(result.current.isUnlocked).toBe(true);
+ expect(window.localStorage.getItem("dinoUnlocked")).toBe("true");
});
});
diff --git a/tests/unit/features/space-invaders/SpaceInvadersModal.test.tsx b/tests/unit/features/space-invaders/SpaceInvadersModal.test.tsx
deleted file mode 100644
index 4807bfbad..000000000
--- a/tests/unit/features/space-invaders/SpaceInvadersModal.test.tsx
+++ /dev/null
@@ -1,59 +0,0 @@
-import { describe, it, expect, vi } from "vitest";
-import { render, screen, fireEvent } from "@testing-library/react";
-import { SpaceInvadersModal } from "../../../../src/features/space-invaders/components/SpaceInvadersModal";
-
-describe("SpaceInvadersModal", () => {
- it("renders the modal with the game when open", () => {
- render( );
-
- // SpaceInvaders exposes its own aria-label on the role="application" container.
- const game = screen.getByLabelText(
- "Mini space invaders game — arrow keys or A/D move, space shoots, escape exits",
- );
- expect(game).toBeInTheDocument();
- });
-
- it("renders nothing when closed", () => {
- render( );
- expect(
- screen.queryByLabelText(
- "Mini space invaders game — arrow keys or A/D move, space shoots, escape exits",
- ),
- ).not.toBeInTheDocument();
- });
-
- it("has the correct dialog attributes", () => {
- render( );
- const dialog = screen.getByRole("dialog");
- expect(dialog).toHaveAttribute("aria-modal", "true");
- expect(dialog).toHaveAttribute("aria-label", "Space Invaders game");
- });
-
- it("calls onClose when the overlay is clicked", () => {
- const onClose = vi.fn();
- render( );
- // The dialog overlay is the element with role="dialog".
- const overlay = screen.getByRole("dialog");
- fireEvent.click(overlay);
- expect(onClose).toHaveBeenCalledTimes(1);
- });
-
- it("calls onClose when Escape is pressed (routed through SpaceInvaders' own handler)", () => {
- const onClose = vi.fn();
- render( );
- fireEvent.keyDown(window, { key: "Escape" });
- expect(onClose).toHaveBeenCalledTimes(1);
- });
-
- it("does not call onClose when Escape is pressed while closed", () => {
- const onClose = vi.fn();
- render( );
- fireEvent.keyDown(window, { key: "Escape" });
- expect(onClose).not.toHaveBeenCalled();
- });
-
- it("renders the play-again and exit buttons with data-testids", () => {
- render( );
- expect(screen.getByTestId("invaders-exit")).toBeInTheDocument();
- });
-});
diff --git a/tests/unit/pages/ChatPage.dino.test.tsx b/tests/unit/pages/ChatPage.dino.test.tsx
new file mode 100644
index 000000000..e33c6b79d
--- /dev/null
+++ b/tests/unit/pages/ChatPage.dino.test.tsx
@@ -0,0 +1,173 @@
+import { act, fireEvent, render, screen } from "@testing-library/react";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { MemoryRouter } from "react-router-dom";
+import { ChatPage } from "../../../src/pages/ChatPage.tsx";
+import type { ChatMessage, ChatQueueItem } from "../../../src/features/chatbot/types.ts";
+import type { DinoTurnOutcome } from "../../../src/features/chatbot/dinoOutcome.ts";
+
+/**
+ * The dino waiting-game as the chat page hosts it: focus must stay with the game while it
+ * runs, the completion badge must tell the truth about how the turn ended, and switching
+ * chats closes it.
+ */
+
+vi.mock("../../../src/context/useAuth", () => ({
+ useAuth: () => ({
+ profile: { id: "u1", firstName: "Test", lastName: "User", profileIcon: null },
+ }),
+}));
+
+vi.mock("../../../src/features/projects/useProjectContext", () => ({
+ useProjectContext: () => ({
+ selectedProjectId: "project1",
+ selectedProject: { id: "project1", name: "Project Alpha" },
+ hasSelectedProject: true,
+ }),
+}));
+
+// The canvas game itself is not under test; its props are.
+vi.mock("../../../src/features/chatbot/components/DinoGame", () => ({
+ DinoGame: ({
+ onExit,
+ replyReady,
+ completionLabel,
+ }: {
+ onExit: () => void;
+ replyReady?: boolean;
+ completionLabel?: string;
+ }) => (
+
+
+ Exit Game
+
+
+ ),
+}));
+
+const mockChatState = {
+ messages: [
+ { id: "m1", role: "USER" as const, content: "Hello bot", chat: undefined },
+ ] as ChatMessage[],
+ chatId: "chat1" as string | undefined,
+ activeChat: { id: "chat1", userId: "u1", projectId: "project1", title: "Chat 1", createdAt: "" },
+ chats: [{ id: "chat1", userId: "u1", projectId: "project1", title: "Chat 1", createdAt: "" }],
+ handleSubmit: vi.fn(),
+ hasProject: true,
+ addMessage: vi.fn(),
+ isThinking: true,
+ isStreaming: false,
+ thinkingState: null,
+ streamingMessageId: null,
+ turnOutcome: null as DinoTurnOutcome,
+ newRequest: "",
+ setNewRequest: vi.fn(),
+ selectedCitation: null,
+ setSelectedCitation: vi.fn(),
+ isRailOpen: false,
+ setRailOpen: vi.fn(),
+ isRailOverlay: true,
+ textareaRef: { current: null },
+ bottomRef: { current: null },
+ scrollContainerRef: { current: null },
+ showFilters: false,
+ setShowFilters: vi.fn(),
+ from: "",
+ setFrom: vi.fn(),
+ to: "",
+ setTo: vi.fn(),
+ sourceSystems: [] as const,
+ toggleSourceSystem: vi.fn(),
+ activeFilterCount: 0,
+ clearFilters: vi.fn(),
+ queuedMessages: [] as ChatQueueItem[],
+ queuePaused: false,
+ removeQueuedMessage: vi.fn(),
+ editQueuedMessage: vi.fn(),
+ sendQueuedNow: vi.fn(),
+};
+
+vi.mock("../../../src/features/chatbot/hooks/useChat", () => ({
+ useChat: () => ({ ...mockChatState }),
+}));
+
+function ui() {
+ return (
+
+
+
+ );
+}
+
+function openGame() {
+ const view = render(ui());
+ act(() => {
+ window.dispatchEvent(new KeyboardEvent("keydown", { code: "Space", key: " ", bubbles: true }));
+ });
+ expect(screen.getByTestId("dino-game")).toBeInTheDocument();
+ return view;
+}
+
+describe("ChatPage dino waiting-game", () => {
+ beforeEach(() => {
+ window.HTMLElement.prototype.scrollIntoView = vi.fn();
+ localStorage.setItem("dinoUnlocked", "true");
+ mockChatState.chatId = "chat1";
+ mockChatState.isThinking = true;
+ mockChatState.isStreaming = false;
+ mockChatState.turnOutcome = null;
+ mockChatState.textareaRef = { current: null };
+ (document.activeElement as HTMLElement | null)?.blur();
+ });
+
+ afterEach(() => {
+ localStorage.clear();
+ });
+
+ it("keeps focus off the composer when the reply lands mid-game, and returns it on close", () => {
+ const { rerender } = openGame();
+ const composer = screen.getByLabelText("Message");
+
+ mockChatState.isThinking = false;
+ mockChatState.turnOutcome = "done";
+ rerender(ui());
+
+ // The game still owns the keyboard.
+ expect(document.activeElement).not.toBe(composer);
+ expect(screen.getByTestId("dino-game")).toHaveAttribute("data-reply-ready", "true");
+ expect(screen.getByTestId("dino-game")).toHaveAttribute("data-completion-label", "");
+
+ fireEvent.click(screen.getByRole("button", { name: "Exit Game" }));
+ expect(screen.queryByTestId("dino-game")).not.toBeInTheDocument();
+ expect(document.activeElement).toBe(composer);
+ });
+
+ it("says the turn was stopped, not that a reply is ready", () => {
+ const { rerender } = openGame();
+ mockChatState.isThinking = false;
+ mockChatState.turnOutcome = "stopped";
+ rerender(ui());
+ expect(screen.getByTestId("dino-game")).toHaveAttribute("data-completion-label", "Stopped");
+ });
+
+ it("says the reply failed after a stream error", () => {
+ const { rerender } = openGame();
+ mockChatState.isThinking = false;
+ mockChatState.turnOutcome = "failed";
+ rerender(ui());
+ expect(screen.getByTestId("dino-game")).toHaveAttribute(
+ "data-completion-label",
+ "Reply failed",
+ );
+ });
+
+ it("closes the game when switching chats", () => {
+ const { rerender } = openGame();
+ mockChatState.chatId = "chat2";
+ rerender(ui());
+ expect(screen.queryByTestId("dino-game")).not.toBeInTheDocument();
+ });
+});
diff --git a/tests/unit/pages/DashboardPage.test.tsx b/tests/unit/pages/DashboardPage.test.tsx
index 9a8b7f02f..f2ff9ab27 100644
--- a/tests/unit/pages/DashboardPage.test.tsx
+++ b/tests/unit/pages/DashboardPage.test.tsx
@@ -488,4 +488,26 @@ describe("DashboardPage", () => {
expect(within(picker).queryByRole("button", { name: "Projects" })).not.toBeInTheDocument();
});
});
+
+ describe("easter egg", () => {
+ it("renders the header icon with eggHint enabled", () => {
+ renderPage();
+
+ const iconButton = screen.getByRole("button", { name: "Dashboard icon" });
+ expect(iconButton).toHaveAttribute("data-egg-hint", "true");
+ });
+
+ it("opens the 2048 game modal after three clicks on the header icon", async () => {
+ renderPage();
+
+ const iconButton = screen.getByRole("button", { name: "Dashboard icon" });
+ expect(screen.queryByRole("dialog", { name: "2048" })).not.toBeInTheDocument();
+
+ await userEvent.click(iconButton);
+ await userEvent.click(iconButton);
+ await userEvent.click(iconButton);
+
+ expect(screen.getByRole("dialog", { name: "2048" })).toBeInTheDocument();
+ });
+ });
});
diff --git a/tests/unit/pages/NotFoundPage.test.tsx b/tests/unit/pages/NotFoundPage.test.tsx
index c6d6e6e3e..61235ae20 100644
--- a/tests/unit/pages/NotFoundPage.test.tsx
+++ b/tests/unit/pages/NotFoundPage.test.tsx
@@ -1,4 +1,4 @@
-import { render, screen } from "@testing-library/react";
+import { render, screen, fireEvent } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import { MemoryRouter } from "react-router-dom";
import { NotFoundPage } from "../../../src/pages/NotFoundPage";
@@ -8,8 +8,12 @@ vi.mock("react-router-dom", async () => {
return { ...actual, useNavigate: () => vi.fn() };
});
-vi.mock("../../../src/features/space-invaders/components/SpaceInvadersModal", () => ({
- SpaceInvadersModal: () => null,
+const mockShell = vi.fn();
+vi.mock("../../../src/features/easter-eggs/components/EggModalShell", () => ({
+ EggModalShell: (props: { open: boolean; eggId: string }) => {
+ mockShell(props);
+ return props.open ?
: null;
+ },
}));
describe("NotFoundPage", () => {
@@ -23,4 +27,27 @@ describe("NotFoundPage", () => {
expect(screen.getByText("404 Not Found")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /return to dashboard/i })).toBeInTheDocument();
});
+
+ it("does NOT open the game on arrival - only after clicking the rocket teaser", () => {
+ render(
+
+
+ ,
+ );
+
+ // The page arrives alone: no modal covering it.
+ expect(screen.queryByTestId("egg-modal")).not.toBeInTheDocument();
+
+ // The teaser is a small muted row, not an obvious CTA. Its accessible
+ // name has to *start with* the visible words (WCAG 2.5.3), with the
+ // "play Space Invaders" hint appended rather than replacing them.
+ const teaser = screen.getByRole("button", { name: /^while you're lost in space/i });
+ expect(teaser).toHaveTextContent("While you're lost in space");
+ expect(teaser).toHaveTextContent("🚀");
+ expect(teaser).toHaveAccessibleName(/^While you're lost in space….*play Space Invaders$/);
+
+ fireEvent.click(teaser);
+ expect(screen.getByTestId("egg-modal")).toBeInTheDocument();
+ expect(mockShell).toHaveBeenCalledWith(expect.objectContaining({ eggId: "space-invaders" }));
+ });
});
diff --git a/tests/unit/pages/OnBoardingPage.generationGame.test.tsx b/tests/unit/pages/OnBoardingPage.generationGame.test.tsx
new file mode 100644
index 000000000..9cf760c4b
--- /dev/null
+++ b/tests/unit/pages/OnBoardingPage.generationGame.test.tsx
@@ -0,0 +1,153 @@
+import { act, fireEvent, render, screen } from "@testing-library/react";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { MemoryRouter } from "react-router-dom";
+import { http, HttpResponse } from "msw";
+import { OnBoardingPage } from "../../../src/pages/OnBoardingPage.tsx";
+import { server } from "../../unit/setup/vitest.setup";
+import {
+ OnboardingJourneyContext,
+ type OnboardingGeneration,
+ type OnboardingJourneyValue,
+} from "../../../src/features/onboarding/generation/OnboardingJourneyContext.ts";
+
+// The generation screen's dino game outlives the run it was opened for. These cases pin down what
+// the page shows once that run ends while the game is still up: a failure must take over, a success
+// must read as finished.
+
+vi.mock("../../../src/context/useAuth", () => ({
+ useAuth: () => ({ profile: { id: "user1" } }),
+}));
+
+vi.mock("../../../src/features/moments", () => ({
+ useMoments: () => ({
+ celebrate: vi.fn(),
+ flyby: vi.fn(),
+ completeMission: vi.fn(),
+ revealPath: vi.fn(),
+ playLaunchSequence: vi.fn(),
+ isLaunching: false,
+ }),
+}));
+
+vi.mock("../../../src/features/projects/useProjectContext", async () => {
+ const { createProjectContextValue, createSelectableProject } =
+ await import("../setup/projectContext");
+ return {
+ useProjectContext: () =>
+ createProjectContextValue({
+ selectedProjectId: "proj1",
+ selectedProject: createSelectableProject({ id: "proj1", name: "Project One" }),
+ isLoading: false,
+ isSwitcherEnabled: true,
+ }),
+ };
+});
+
+const RUNNING: OnboardingGeneration = {
+ status: "running",
+ projectId: "proj1",
+ startedAt: Date.now(),
+ phases: [
+ { name: "Project Overview", detail: "Completed", state: "done" },
+ { name: "Architecture", detail: "Searching the project for: ADRs", state: "working" },
+ { name: "First Task", detail: "", state: "waiting" },
+ ],
+};
+
+function journey(generation: OnboardingGeneration): OnboardingJourneyValue {
+ return {
+ generation,
+ startGeneration: vi.fn(),
+ clearGeneration: vi.fn(),
+ availability: "buildable",
+ unavailableReason: null,
+ refreshAvailability: vi.fn(),
+ };
+}
+
+function page(generation: OnboardingGeneration) {
+ return (
+
+
+
+
+
+ );
+}
+
+/** Renders a running generation and opens the dino game over it, as a member pressing Space would. */
+async function renderWithGameOpen() {
+ const view = render(page(RUNNING));
+ expect(await screen.findByText("Building your onboarding path")).toBeInTheDocument();
+ fireEvent.keyDown(window, { code: "Space" });
+ expect(screen.getByTestId("dino-game")).toBeInTheDocument();
+ return view;
+}
+
+// Testing Library unmounts after every case, which releases the game's module-level slot, so each
+// case can open the game again.
+describe("OnBoardingPage — generation ending while the dino game is open", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ localStorage.clear();
+ localStorage.setItem("dinoUnlocked", "true");
+ });
+
+ it("shows the failure and its retry instead of the frozen generation screen", async () => {
+ server.use(
+ http.get("/api/v1/onboarding/me/path", () => new HttpResponse(null, { status: 404 })),
+ );
+ const view = await renderWithGameOpen();
+
+ view.rerender(page({ status: "error", message: "The AI service is unavailable." }));
+
+ expect(await screen.findByRole("alert")).toHaveTextContent("The AI service is unavailable.");
+ expect(screen.getByRole("button", { name: "Try again" })).toBeInTheDocument();
+ expect(screen.queryByTestId("dino-game")).not.toBeInTheDocument();
+ expect(screen.queryByText("Building your onboarding path")).not.toBeInTheDocument();
+ expect(screen.queryByTestId("generation-elapsed")).not.toBeInTheDocument();
+ expect(screen.queryByTestId("generation-phase")).not.toBeInTheDocument();
+ });
+
+ it("keeps the failure up after the page clears the error", async () => {
+ server.use(
+ http.get("/api/v1/onboarding/me/path", () => new HttpResponse(null, { status: 404 })),
+ );
+ const view = await renderWithGameOpen();
+
+ view.rerender(page({ status: "error", message: "The AI service is unavailable." }));
+ await screen.findByRole("alert");
+ // A cleared failure must not bring the generation screen back just because the game was open.
+ view.rerender(page({ status: "idle" }));
+
+ expect(
+ await screen.findByRole("button", { name: "Start personalization" }),
+ ).toBeInTheDocument();
+ expect(screen.queryByText("Building your onboarding path")).not.toBeInTheDocument();
+ expect(screen.queryByTestId("dino-game")).not.toBeInTheDocument();
+ });
+
+ it("says 'Path ready' with every phase done, and keeps saying it once the status is cleared", async () => {
+ const view = await renderWithGameOpen();
+
+ view.rerender(page({ status: "done", path: null }));
+
+ expect(screen.getByTestId("dino-game-reply-ready")).toHaveTextContent(/path ready/i);
+ const phases = screen.getAllByTestId("generation-phase");
+ expect(phases).toHaveLength(3);
+ for (const phase of phases) expect(phase).toHaveAttribute("data-state", "done");
+ expect(screen.getByText("3 of 3 phases assembled")).toBeInTheDocument();
+
+ // The page clears a finished generation once it has fetched the new path.
+ await act(async () => {
+ view.rerender(page({ status: "idle" }));
+ await Promise.resolve();
+ });
+
+ expect(screen.getByTestId("dino-game")).toBeInTheDocument();
+ expect(screen.getByTestId("dino-game-reply-ready")).toHaveTextContent(/path ready/i);
+ for (const phase of screen.getAllByTestId("generation-phase")) {
+ expect(phase).toHaveAttribute("data-state", "done");
+ }
+ });
+});