From 3b56ebfe78a62c44980f24293c68e83adf7ec483 Mon Sep 17 00:00:00 2001
From: Daniil Perkin
Date: Tue, 25 Aug 2026 22:59:59 +0200
Subject: [PATCH 01/51] Consolidate the easter eggs behind one shared module
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Why this change
===============
The easter-egg games were reachable only via Ctrl+Shift+1/2/3 keyboard
chords, which we are retiring: hidden chords are undiscoverable by design,
and the product direction is that eggs should be found by users who pay
attention to details (clicking real UI elements, trying phrases in chat).
This commit is the structural groundwork for that direction; the actual
new discovery triggers land on top of it.
What was wrong structurally
===========================
- Three byte-identical chord hooks (useDinoShortcut, useGame2048Shortcut,
useSpaceInvadersShortcut) differing only in which digit they matched.
- Two near-identical modal wrappers (DinoGameModal, SpaceInvadersModal)
plus a third variant for the 2048 iframe.
- The dino "waiting game" logic (unlock flag + Space trigger + auto-close)
was copy-pasted across ChatPage and OnBoardingPage (~40 lines each),
including duplicated localStorage/dinoUnlockChanged/storage sync code.
- The chat phrase easter eggs ("do a barrel roll", matrix variants) were
matched with inline string comparisons inside ChatPage.
- All game code was statically imported through DashboardPage, so ~1,600
lines of canvas/iframe game shipped in the main bundle even for users
who never open an egg.
What replaces it
================
features/easter-eggs/ now owns the shared machinery:
- registry.ts: every modal egg as { id, label, kind, component }, with all
components lazy-loaded so each game becomes its own chunk fetched on
first open. Adding a future egg = one registry entry + one component.
- components/EggModalShell.tsx: one Framer Motion modal for every egg.
Canvas games render bare (they own their chrome and Escape handling via
onExit); iframe games get a header bar + close button + window-level
Escape (the iframe swallows keys, so the shell must listen). One
Suspense wraps both branches because an unsuspended lazy child would
tear down the whole tree.
- components/Game2048Frame.tsx: thin iframe wrapper so the self-contained
vanilla-JS 2048 page fits the shared { onExit } game shape.
- hooks/useDinoWaitingGame.ts: useDinoUnlocked() (live localStorage flag
synced via dinoUnlockChanged + storage events) and useSpaceOpensDino()
(Space opens while armed+unlocked, typing-guarded, closes when the flag
flips off — via React's adjust-state-during-render pattern, not an
effect, matching ChatPage's existing style and the lint rule).
- hooks/useRepeatClicks.ts: generalized N-consecutive-clicks detector;
first consumer is the dashboard header icon (triple-click = dino), the
same gesture language the Settings cogwheel already uses.
- lib/eggPhrases.ts: phrase matcher extracted verbatim from ChatPage so
behavior is unchanged there and other surfaces (buddy chat later) can
reuse it.
Consumer changes
================
- DashboardPage: all three chords removed; triple-clicking the page-header
icon now opens the dino runner (ungated, deliberately - finding it IS
the unlock). 2048/invaders lose their only triggers for now; new ones
follow in upcoming commits.
- NotFoundPage: no longer force-opens Space Invaders on load; uses the
shared shell instead of its own modal instance. A visible teaser lands
separately.
- ChatPage / OnBoardingPage: waiting-game logic replaced by the shared
hooks; OnBoardingPage additionally closes the game when a regeneration
starts (same as before) and keeps its DinoGame mount.
- useDinoEasterEgg (Settings cogwheel): rewritten onto useRepeatClicks +
shared unlock helpers. Its dinoUnlockChanged event is now dispatched on
a microtask because the old synchronous dispatch ran during a React
render phase (setState-in-render hazard flagged by lint). Consumers
listen for the event asynchronously anyway.
Verification
============
npm run build: PASS (pre-existing chunk-size warning only)
npx eslint src tests: PASS (0 problems)
npm run unit: 205 files / 1814 tests PASS
npm run a11y: 52 files / 60 tests PASS
format:check: my files clean; 5 unrelated files were already drifted on
feature/minor-upgrades (verified against committed blobs), untouched here.
---
.../dino/components/DinoGameModal.tsx | 68 ----------
src/features/dino/hooks/useDinoShortcut.ts | 35 -----
.../easter-eggs/components/EggModalShell.tsx | 128 ++++++++++++++++++
.../easter-eggs/components/Game2048Frame.tsx | 55 ++++++++
.../easter-eggs/hooks/useDinoWaitingGame.ts | 71 ++++++++++
.../easter-eggs/hooks/useRepeatClicks.ts | 30 ++++
src/features/easter-eggs/lib/eggPhrases.ts | 42 ++++++
src/features/easter-eggs/registry.ts | 56 ++++++++
.../game2048/components/Game2048Modal.tsx | 128 ------------------
.../game2048/hooks/useGame2048Shortcut.ts | 34 -----
.../settings/hooks/useDinoEasterEgg.ts | 109 ++++-----------
.../components/SpaceInvadersModal.tsx | 68 ----------
.../hooks/useSpaceInvadersShortcut.ts | 36 -----
src/pages/ChatPage.tsx | 117 ++++++----------
src/pages/DashboardPage.tsx | 35 ++---
src/pages/NotFoundPage.tsx | 21 +--
src/pages/OnBoardingPage.tsx | 56 ++------
.../unit/features/dino/DinoGameModal.test.tsx | 54 --------
.../easter-eggs/EggModalShell.test.tsx | 57 ++++++++
.../features/easter-eggs/eggPhrases.test.ts | 22 +++
.../easter-eggs/useDinoWaitingGame.test.tsx | 96 +++++++++++++
.../easter-eggs/useRepeatClicks.test.tsx | 34 +++++
.../features/game2048/Game2048Modal.test.tsx | 65 ---------
.../settings/useDinoEasterEgg.test.ts | 33 +++--
.../SpaceInvadersModal.test.tsx | 59 --------
25 files changed, 717 insertions(+), 792 deletions(-)
delete mode 100644 src/features/dino/components/DinoGameModal.tsx
delete mode 100644 src/features/dino/hooks/useDinoShortcut.ts
create mode 100644 src/features/easter-eggs/components/EggModalShell.tsx
create mode 100644 src/features/easter-eggs/components/Game2048Frame.tsx
create mode 100644 src/features/easter-eggs/hooks/useDinoWaitingGame.ts
create mode 100644 src/features/easter-eggs/hooks/useRepeatClicks.ts
create mode 100644 src/features/easter-eggs/lib/eggPhrases.ts
create mode 100644 src/features/easter-eggs/registry.ts
delete mode 100644 src/features/game2048/components/Game2048Modal.tsx
delete mode 100644 src/features/game2048/hooks/useGame2048Shortcut.ts
delete mode 100644 src/features/space-invaders/components/SpaceInvadersModal.tsx
delete mode 100644 src/features/space-invaders/hooks/useSpaceInvadersShortcut.ts
delete mode 100644 tests/unit/features/dino/DinoGameModal.test.tsx
create mode 100644 tests/unit/features/easter-eggs/EggModalShell.test.tsx
create mode 100644 tests/unit/features/easter-eggs/eggPhrases.test.ts
create mode 100644 tests/unit/features/easter-eggs/useDinoWaitingGame.test.tsx
create mode 100644 tests/unit/features/easter-eggs/useRepeatClicks.test.tsx
delete mode 100644 tests/unit/features/game2048/Game2048Modal.test.tsx
delete mode 100644 tests/unit/features/space-invaders/SpaceInvadersModal.test.tsx
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/EggModalShell.tsx b/src/features/easter-eggs/components/EggModalShell.tsx
new file mode 100644
index 000000000..d6fcd18bd
--- /dev/null
+++ b/src/features/easter-eggs/components/EggModalShell.tsx
@@ -0,0 +1,128 @@
+import { Suspense, useEffect, useRef } from "react";
+import { X } from "lucide-react";
+import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
+import { useScrollLock } from "../../../components/ui/useScrollLock";
+import { getModalDialogVariants, modalBackdropVariants } from "../../../styles/tokens";
+import { EGG_REGISTRY, type EggId } from "../registry";
+
+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 (dino, 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 one thing every overlay needs
+ * regardless — the page behind it holding still — comes from
+ * `useScrollLock`.
+ */
+export function EggModalShell({ eggId, open, onClose }: EggModalShellProps) {
+ const egg = EGG_REGISTRY[eggId];
+
+ useScrollLock(open);
+ const prefersReducedMotion = useReducedMotion();
+ const dialogVariants = getModalDialogVariants(Boolean(prefersReducedMotion));
+
+ // 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`. 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]);
+
+ useEffect(() => {
+ if (!open || !egg || egg.kind !== "iframe") return;
+ const onKeyDown = (e: KeyboardEvent) => {
+ if (e.key === "Escape") onCloseRef.current();
+ };
+ window.addEventListener("keydown", onKeyDown);
+ return () => window.removeEventListener("keydown", onKeyDown);
+ }, [open, egg]);
+
+ if (!egg) return null;
+
+ const Game = egg.component;
+
+ return (
+
+ {open && (
+
+ e.stopPropagation()}
+ >
+ {/* One Suspense around every branch: all registry components are
+ lazy, and an unsuspended lazy child would tear down the tree. */}
+ }>
+ {egg.kind === "iframe" ? (
+ <>
+ {/* Header bar for the iframe game (canvas games draw their own chrome). */}
+
+
+ {egg.label}
+
+
+
+
+
+
+ >
+ ) : (
+
+ )}
+
+
+
+ )}
+
+ );
+}
diff --git a/src/features/easter-eggs/components/Game2048Frame.tsx b/src/features/easter-eggs/components/Game2048Frame.tsx
new file mode 100644
index 000000000..7612f465c
--- /dev/null
+++ b/src/features/easter-eggs/components/Game2048Frame.tsx
@@ -0,0 +1,55 @@
+/**
+ * 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. All keyboard wiring lives in
+ * {@link EggModalShell} — focusing the frame here just makes arrow keys
+ * work immediately, without a click first.
+ */
+import { useEffect, useRef } from "react";
+
+type Game2048FrameProps = {
+ /**
+ * Called when the user presses Escape *outside* the iframe (header,
+ * close button, before load). Keydowns inside a focused iframe don't
+ * bubble out to the parent document, so the frame installs its own
+ * same-origin listener after loading; the two never double-fire.
+ */
+ onExit: () => void;
+};
+
+const GAME_URL = "/easter-eggs/2048.html";
+
+export function Game2048Frame({ onExit }: Game2048FrameProps) {
+ const iframeRef = useRef(null);
+
+ useEffect(() => {
+ const iframe = iframeRef.current;
+ if (!iframe) return;
+
+ const handleLoad = () => {
+ const win = iframe.contentWindow;
+ const doc = iframe.contentDocument;
+ if (!win || !doc) return;
+ win.focus();
+ doc.addEventListener("keydown", (e: KeyboardEvent) => {
+ if (e.key === "Escape") onExit();
+ });
+ // The iframe is unmounted when the modal closes (AnimatePresence
+ // exit), discarding its contentDocument and this listener with it.
+ };
+
+ if (iframe.contentDocument?.readyState === "complete") handleLoad();
+ else iframe.addEventListener("load", handleLoad);
+ return () => iframe.removeEventListener("load", handleLoad);
+ }, [onExit]);
+
+ return (
+
+ );
+}
diff --git a/src/features/easter-eggs/hooks/useDinoWaitingGame.ts b/src/features/easter-eggs/hooks/useDinoWaitingGame.ts
new file mode 100644
index 000000000..d9831a46c
--- /dev/null
+++ b/src/features/easter-eggs/hooks/useDinoWaitingGame.ts
@@ -0,0 +1,71 @@
+import { useCallback, useEffect, useState } from "react";
+
+/**
+ * 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(
+ () => localStorage.getItem("dinoUnlocked") === "true",
+ );
+
+ useEffect(() => {
+ const sync = () => setIsUnlocked(localStorage.getItem("dinoUnlocked") === "true");
+ window.addEventListener("dinoUnlockChanged", sync);
+ window.addEventListener("storage", sync);
+ return () => {
+ window.removeEventListener("dinoUnlockChanged", sync);
+ window.removeEventListener("storage", sync);
+ };
+ }, []);
+
+ return isUnlocked;
+}
+
+const isTypingTarget = (el: Element | null) =>
+ el instanceof HTMLElement &&
+ (el.tagName === "TEXTAREA" || el.tagName === "INPUT" || el.isContentEditable);
+
+/**
+ * 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, in which case Space stays a space.
+ *
+ * Returns whether the game should be shown; hosts close it themselves
+ * when their wait ends (or when the unlock flag flips off).
+ */
+export function useSpaceOpensDino(armed: boolean, isUnlocked: boolean): [boolean, () => void] {
+ const [gameActive, setGameActive] = useState(false);
+
+ useEffect(() => {
+ if (!armed || !isUnlocked || gameActive) return;
+
+ const onKeyDown = (e: KeyboardEvent) => {
+ if (e.code !== "Space") return;
+ if (isTypingTarget(document.activeElement)) return;
+ e.preventDefault();
+ setGameActive(true);
+ };
+
+ window.addEventListener("keydown", onKeyDown);
+ return () => window.removeEventListener("keydown", onKeyDown);
+ }, [armed, isUnlocked, gameActive]);
+
+ // 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);
+ }
+ }
+
+ const close = useCallback(() => setGameActive(false), []);
+ 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..915062832
--- /dev/null
+++ b/src/features/easter-eggs/hooks/useRepeatClicks.ts
@@ -0,0 +1,30 @@
+import { useCallback, useEffect, useRef } from "react";
+
+/**
+ * Returns a click handler that fires `onReached` once every
+ * `requiredClicks` consecutive calls, resetting the count in between.
+ *
+ * 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): () => void {
+ const countRef = useRef(0);
+ const onReachedRef = useRef(onReached);
+
+ useEffect(() => {
+ onReachedRef.current = onReached;
+ });
+
+ return useCallback(() => {
+ countRef.current += 1;
+ if (countRef.current < requiredClicks) return;
+
+ countRef.current = 0;
+ onReachedRef.current();
+ }, [requiredClicks]);
+}
diff --git a/src/features/easter-eggs/lib/eggPhrases.ts b/src/features/easter-eggs/lib/eggPhrases.ts
new file mode 100644
index 000000000..c3c9e02a1
--- /dev/null
+++ b/src/features/easter-eggs/lib/eggPhrases.ts
@@ -0,0 +1,42 @@
+import type { ReactNode } from "react";
+
+/**
+ * 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.
+ *
+ * "do barrel" exists because users abbreviate mid-thought; "matrix" alone
+ * matches because asking the AI about the movie was nobody's actual intent.
+ */
+const EGG_PHRASES: ReadonlyArray = [
+ [["do a barrel roll", "do barrel roll", "do barrel"], "barrel-roll"],
+ [["the matrix", "do matrix", "matrix"], "matrix"],
+];
+
+/** The whole-window easter eggs a chat surface can trigger by phrase. */
+export type EggEffectId = "barrel-roll" | "matrix";
+
+/**
+ * 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();
+ if (!normalized) return null;
+ for (const [phrases, effect] of EGG_PHRASES) {
+ if (phrases.includes(normalized)) return effect;
+ }
+ return null;
+}
+
+/**
+ * What a phrase-triggered easter egg needs to run anywhere in the app.
+ *
+ * Barrel roll and matrix are not modals — they are whole-window effects
+ * that must be mounted by whoever owns that part of the screen. Each chat
+ * surface passes this record of renderers down to its submit handler, so
+ * trigger logic never hardcodes *where* an effect lives: ChatPage owns
+ * the real implementations today, and a buddy chat can later reuse or
+ * replace them without touching phrase matching.
+ */
+export type EggEffects = Record;
diff --git a/src/features/easter-eggs/registry.ts b/src/features/easter-eggs/registry.ts
new file mode 100644
index 000000000..028e21793
--- /dev/null
+++ b/src/features/easter-eggs/registry.ts
@@ -0,0 +1,56 @@
+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.
+ */
+
+/** Lazy-loaded: the endless runner (chunk fetched on first open). */
+const DinoGame = lazy(() =>
+ import("../chatbot/components/DinoGame").then((m) => ({ default: m.DinoGame })),
+);
+
+/** Lazy-loaded: Space Invaders (chunk fetched on first open). */
+const SpaceInvaders = lazy(() =>
+ import("./components/SpaceInvaders").then((m) => ({ default: m.SpaceInvaders })),
+);
+
+export type EggId = "dino" | "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";
+ /** Where an iframe game lives under `public/`. */
+ iframeSrc?: string;
+ component: LazyExoticComponent void }>>;
+};
+
+export const EGG_REGISTRY: Record = {
+ dino: { label: "Dino game", kind: "canvas", component: DinoGame },
+ "game-2048": {
+ label: "2048",
+ kind: "iframe",
+ iframeSrc: "/easter-eggs/2048.html",
+ // Thin wrapper so the vanilla-JS page satisfies the shared game shape.
+ component: lazy(() =>
+ import("./components/Game2048Frame").then((m) => ({ default: m.Game2048Frame })),
+ ),
+ },
+ "space-invaders": {
+ label: "Space Invaders game",
+ kind: "canvas",
+ component: 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/settings/hooks/useDinoEasterEgg.ts b/src/features/settings/hooks/useDinoEasterEgg.ts
index 29c30b348..e16859c38 100644
--- a/src/features/settings/hooks/useDinoEasterEgg.ts
+++ b/src/features/settings/hooks/useDinoEasterEgg.ts
@@ -1,38 +1,43 @@
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;
type DinoEasterEgg = {
- isUnlocked: boolean;
- gameActive: boolean;
+ /** Current unlock toast text, or null when none is showing. */
toast: string | 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 toastTimerRef = useRef | null>(null);
+ const lastToggleRef = useRef(0);
+
+ // Clear any pending toast timer on unmount.
+ useEffect(() => {
+ return () => {
+ if (toastTimerRef.current) clearTimeout(toastTimerRef.current);
+ };
+ }, []);
const showToast = useCallback((message: string) => {
setToast(message);
@@ -42,14 +47,7 @@ export function useDinoEasterEgg(): DinoEasterEgg {
}, 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 +59,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]);
- // 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,
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/pages/ChatPage.tsx b/src/pages/ChatPage.tsx
index 6d223c440..eb80c1976 100644
--- a/src/pages/ChatPage.tsx
+++ b/src/pages/ChatPage.tsx
@@ -18,6 +18,11 @@ import { ArtifactViewerDrawer } from "../features/knowledge-base/components/Arti
import type { Artifact, ArtifactType, SourceSystem } from "../features/knowledge-base/types";
import type { SelectedCitation } from "../context/ChatContext.ts";
import { MatrixRain } from "../features/easter-eggs/components/MatrixRain.tsx";
+import { matchEggPhrase } from "../features/easter-eggs/lib/eggPhrases";
+import {
+ useDinoUnlocked,
+ useSpaceOpensDino,
+} from "../features/easter-eggs/hooks/useDinoWaitingGame";
import "katex/dist/katex.min.css";
@@ -131,30 +136,26 @@ export function ChatPage() {
[viewingCitationArtifact],
);
- // Easter egg states
+ // Easter egg states. Phrase effects are matched via the shared
+ // eggPhrases lib; the dino waiting-game (unlock flag + Space trigger +
+ // auto-close) lives in one shared hook instead of page-local copies.
const [isBarrelRolling, setIsBarrelRolling] = useState(false);
const [isMatrixActive, setIsMatrixActive] = useState(false);
+ const dinoUnlocked = useDinoUnlocked();
+ const [gameActive, closeGame] = useSpaceOpensDino(isThinking, dinoUnlocked);
- // Custom submit handler to intercept easter eggs
- const handleChatSubmit = useCallback(
- (e: React.FormEvent) => {
- 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],
- );
+ // 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) {
+ closeGame();
+ }
+ }
// Barrel roll side-effect
useEffect(() => {
@@ -171,25 +172,7 @@ export function ChatPage() {
}
}, [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);
- }
- }
+ // Dino easter egg phrase matching happens in `handleChatSubmit` below.
// E5: replacement for the live region that used to wrap the message list.
// `ThinkingIndicator` already carries `role="status"` for the working
@@ -206,7 +189,7 @@ 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.
useEffect(() => {
@@ -217,43 +200,23 @@ export function ChatPage() {
textareaRef.current?.focus();
}, [busy, chatId, 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 effect 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]);
+ if (effect === "barrel-roll") setIsBarrelRolling(true);
+ else setIsMatrixActive(true);
+ setNewRequest("");
+ },
+ [newRequest, setNewRequest, handleSubmit],
+ );
/**
* Focuses the composer and puts the caret behind whatever is already in it.
@@ -464,7 +427,7 @@ export function ChatPage() {
isThinking={isThinking}
gameActive={gameActive}
thinkingState={thinkingState}
- onGameExit={() => setGameActive(false)}
+ onGameExit={closeGame}
/>
diff --git a/src/pages/DashboardPage.tsx b/src/pages/DashboardPage.tsx
index f50701a47..fefc3c829 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 { AddWidgetModal } from "../features/dashboard/components/AddWidgetModal";
import { DashboardGrid } from "../features/dashboard/components/DashboardGrid";
import { useDashboardLayout } from "../features/dashboard/layout/useDashboardLayout";
@@ -33,22 +29,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.
- 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.
+ // Easter egg: the dashboard header icon hides the dino runner — three
+ // quick clicks open it, the same gesture language as the Settings
+ // cogwheel hiding its own game. Deliberately ungated: finding it *is*
+ // the unlock, no localStorage flag involved.
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, () => setDinoOpen(true));
return (
@@ -57,6 +43,7 @@ export function DashboardPage() {
setResetOpen(false)}
/>
- setGame2048Open(false)} />
- setDinoOpen(false)} />
- setInvadersOpen(false)} />
+ setDinoOpen(false)} />
);
}
diff --git a/src/pages/NotFoundPage.tsx b/src/pages/NotFoundPage.tsx
index 6e314915c..4d2954037 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 { PageHeader } from "../components/layout/PageHeader.tsx";
-import { Button } from "../components/ui/Button.tsx";
+import { EggModalShell } from "../features/easter-eggs/components/EggModalShell";
+import { PageHeader } from "../components/layout/PageHeader";
+import { Button } from "../components/ui/Button";
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;
+ * the Space Invaders egg opens on demand instead of covering the page on
+ * arrival (see the teaser added in the next commit).
*/
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 (
@@ -45,7 +44,11 @@ export function NotFoundPage() {
-
setInvadersOpen(false)} />
+ setInvadersOpen(false)}
+ />
);
}
diff --git a/src/pages/OnBoardingPage.tsx b/src/pages/OnBoardingPage.tsx
index 05ed21bad..13787e83d 100644
--- a/src/pages/OnBoardingPage.tsx
+++ b/src/pages/OnBoardingPage.tsx
@@ -39,6 +39,10 @@ import { PhaseCheckModal } from "../features/onboarding/components/PhaseCheckMod
import { useMoments } from "../features/moments";
import { ReviewCheckModal } from "../features/onboarding/components/ReviewCheckModal";
import { usePathRevealMoment } from "../features/onboarding/hooks/usePathRevealMoment";
+import {
+ useDinoUnlocked,
+ useSpaceOpensDino,
+} from "../features/easter-eggs/hooks/useDinoWaitingGame";
//import type {UserProfile} from "../services/types.ts";
type LoadingState = "idle" | "loading" | "generating" | "success" | "error";
@@ -97,8 +101,8 @@ export function OnBoardingPage() {
// Dino easter egg: pressing Space while the path is being generated starts a
// tiny endless runner. It unmounts by itself once generation finishes.
- const [gameActive, setGameActive] = useState(false);
- const [isUnlocked, setIsUnlocked] = useState(localStorage.getItem("dinoUnlocked") === "true");
+ const dinoUnlocked = useDinoUnlocked();
+ const [gameActive, closeGame] = useSpaceOpensDino(loadingState === "generating", dinoUnlocked);
// Phase whose knowledge check is currently open in the modal (null = closed)
const [checkPhase, setCheckPhase] = useState(null);
@@ -259,7 +263,7 @@ export function OnBoardingPage() {
const generatePath = async ({ recoverExisting = false } = {}) => {
setLoadingState("generating");
setGenerationStage(null);
- setGameActive(false);
+ closeGame();
await onboardingService.personalizePath({
onStage: (name, detail) => setGenerationStage({ name, detail }),
onPath: (path) => setOnBoardingPath(path),
@@ -284,45 +288,6 @@ export function OnBoardingPage() {
});
};
- // 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);
- }
- };
- window.addEventListener("dinoUnlockChanged", handleUnlockChange);
- window.addEventListener("storage", handleUnlockChange);
- return () => {
- window.removeEventListener("dinoUnlockChanged", handleUnlockChange);
- window.removeEventListener("storage", handleUnlockChange);
- };
- }, []);
-
- // Easter egg trigger: Space starts the game while the path is generating.
- useEffect(() => {
- if (loadingState !== "generating" || gameActive || !isUnlocked) return;
-
- const onKeyDown = (e: KeyboardEvent) => {
- if (e.code !== "Space") return;
-
- // Don't hijack space while the user is typing somewhere.
- 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);
- }, [loadingState, gameActive, isUnlocked]);
-
// ── DATA FETCHING using useEffect ─────────────────────────────
// Guards the load against running twice. StrictMode invokes every effect
@@ -369,7 +334,10 @@ export function OnBoardingPage() {
void loadOnBoardingPath();
// Both flags come from the navigation that mounted this page, so they are fixed for the
// visit; listing them keeps the effect honest about what it reads, and `hasLoadedRef`
- // makes a re-run a no-op anyway.
+ // makes a re-run a no-op anyway. `generatePath` is a stable component-scope function
+ // (no props/state in its closure beyond the setters it calls), so omitting it here is
+ // intentional and safe.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
}, [focusCheckPhaseId, shouldOpenReviewCheck]);
const currentPhase = OnBoardingPathEndpoint?.phases[selectedPhaseIndex] ?? null;
@@ -485,7 +453,7 @@ export function OnBoardingPage() {
{gameActive ? (
- setGameActive(false)} />
+
) : (
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/EggModalShell.test.tsx b/tests/unit/features/easter-eggs/EggModalShell.test.tsx
new file mode 100644
index 000000000..0b0533bf4
--- /dev/null
+++ b/tests/unit/features/easter-eggs/EggModalShell.test.tsx
@@ -0,0 +1,57 @@
+import { describe, it, expect, vi } from "vitest";
+import { render, screen, fireEvent } from "@testing-library/react";
+import { EggModalShell } from "../../../../src/features/easter-eggs/components/EggModalShell";
+
+vi.mock("../../../../src/features/chatbot/components/DinoGame", () => ({
+ DinoGame: ({ onExit }: { onExit: () => void }) => dino-exit ,
+}));
+
+vi.mock("../../../../src/features/easter-eggs/components/Game2048Frame", () => ({
+ Game2048Frame: ({ onExit }: { onExit: () => void }) => (
+ frame-exit
+ ),
+}));
+
+vi.mock("../../../../src/features/easter-eggs/components/SpaceInvaders", async () => {
+ const actual = await vi.importActual<
+ typeof import("../../../../src/features/easter-eggs/components/SpaceInvaders")
+ >("../../../../src/features/easter-eggs/components/SpaceInvaders");
+ return actual;
+});
+
+describe("EggModalShell", () => {
+ it("renders nothing when closed", () => {
+ render( );
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
+ });
+
+ it.each([
+ ["dino", "Dino game"],
+ ["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 the game's onExit to onClose", () => {
+ const onClose = vi.fn();
+ render( );
+ fireEvent.click(screen.getByText("dino-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);
+ });
+});
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..996a62ea3
--- /dev/null
+++ b/tests/unit/features/easter-eggs/eggPhrases.test.ts
@@ -0,0 +1,22 @@
+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(["how do I do a barrel roll?", "the matrix movie", "", " "])(
+ "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..32f3665db
--- /dev/null
+++ b/tests/unit/features/easter-eggs/useDinoWaitingGame.test.tsx
@@ -0,0 +1,96 @@
+import { afterEach, describe, it, expect, beforeEach } 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";
+
+describe("useDinoWaitingGame hooks", () => {
+ beforeEach(() => {
+ window.localStorage.clear();
+ });
+
+ afterEach(() => {
+ window.localStorage.clear();
+ });
+
+ 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);
+ });
+ });
+});
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..980f18758
--- /dev/null
+++ b/tests/unit/features/easter-eggs/useRepeatClicks.test.tsx
@@ -0,0 +1,34 @@
+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);
+ });
+});
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/settings/useDinoEasterEgg.test.ts b/tests/unit/features/settings/useDinoEasterEgg.test.ts
index 20b7130a6..bdd582a31 100644
--- a/tests/unit/features/settings/useDinoEasterEgg.test.ts
+++ b/tests/unit/features/settings/useDinoEasterEgg.test.ts
@@ -11,11 +11,9 @@ describe("useDinoEasterEgg", () => {
vi.restoreAllMocks();
});
- it("starts locked and with no game active", () => {
+ it("starts with no toast", () => {
const { result } = renderHook(() => useDinoEasterEgg());
- expect(result.current.isUnlocked).toBe(false);
- expect(result.current.gameActive).toBe(false);
expect(result.current.toast).toBeNull();
});
@@ -24,41 +22,54 @@ describe("useDinoEasterEgg", () => {
act(() => result.current.handleIconClick());
act(() => result.current.handleIconClick());
- expect(result.current.isUnlocked).toBe(false);
+ expect(result.current.toast).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(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");
});
- 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();
- });
-});
From 852ae9702349e7de709cb16d066e14592d68db59 Mon Sep 17 00:00:00 2001
From: Daniil Perkin
Date: Tue, 25 Aug 2026 23:02:32 +0200
Subject: [PATCH 02/51] Replace the 404 auto-open with a discoverable rocket
teaser
Why this change
===============
Space Invaders used to force itself open the moment the 404 page loaded,
covering the actual error content before the user could read it. That is
the opposite of what an easter egg should be: an egg is a reward for
paying attention, not something thrown at you. It also contradicted its
own copy - the page invited the user to "save the galaxy" but never let
them choose to.
What changed
============
- The modal now starts closed. Arriving on 404 shows exactly the error
content: headline, copy, Return to Dashboard.
- Below the copy sits one small teaser row, deliberately styled to blend
in (subtle text color, small size): "While you wait for your manager's
approval... [rocket emoji]". Clicking anywhere in the row opens Space
Invaders via the shared EggModalShell.
- The whole row is the button so the hit target stays generous; the
emoji is aria-hidden and the button carries an explicit accessible
name ("Open Space Invaders") instead of reading out the ellipsis.
- Hover/focus states follow the app's standard surface-hover + focus ring
pattern.
Tests
=====
- NotFoundPage.test.tsx: asserts the game is NOT mounted on arrival,
that the teaser reads correctly, and that clicking it mounts the shell
with eggId "space-invaders" (mocked shell keeps the test hermetic -
no lazy game chunks load).
- NotFoundPage.a11y.test.tsx: mock updated from the deleted
SpaceInvadersModal to EggModalShell for the same reason.
---
src/pages/NotFoundPage.tsx | 19 +++++++++++---
tests/unit/a11y/NotFoundPage.a11y.test.tsx | 6 +++--
tests/unit/pages/NotFoundPage.test.tsx | 30 +++++++++++++++++++---
3 files changed, 47 insertions(+), 8 deletions(-)
diff --git a/src/pages/NotFoundPage.tsx b/src/pages/NotFoundPage.tsx
index 4d2954037..37f802ac1 100644
--- a/src/pages/NotFoundPage.tsx
+++ b/src/pages/NotFoundPage.tsx
@@ -6,9 +6,9 @@ import { Button } from "../components/ui/Button";
import { Rocket } from "lucide-react";
/**
- * Catch-all 404 page. Shows a "not found" message with a dashboard link;
- * the Space Invaders egg opens on demand instead of covering the page on
- * arrival (see the teaser added in the next commit).
+ * 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 waiting.
*/
export function NotFoundPage() {
const navigate = useNavigate();
@@ -32,6 +32,19 @@ 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. */}
+ setInvadersOpen(true)}
+ className="mt-4 flex cursor-pointer items-center gap-2 rounded-lg px-3 py-1.5 text-sm text-app-text-subtle transition-colors hover:bg-app-surface-hover hover:text-app-text focus-visible:ring-2 focus-visible:ring-app-focus focus-visible:outline-none"
+ aria-label="Open Space Invaders"
+ >
+ While you wait for your manager's approval…
+ 🚀
+
+
({
- 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", () => {
diff --git a/tests/unit/pages/NotFoundPage.test.tsx b/tests/unit/pages/NotFoundPage.test.tsx
index c6d6e6e3e..6a5e1fec0 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,24 @@ 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.
+ const teaser = screen.getByRole("button", { name: /open space invaders/i });
+ expect(teaser).toHaveTextContent("While you wait for your manager");
+ expect(teaser).toHaveTextContent("🚀");
+
+ fireEvent.click(teaser);
+ expect(screen.getByTestId("egg-modal")).toBeInTheDocument();
+ expect(mockShell).toHaveBeenCalledWith(expect.objectContaining({ eggId: "space-invaders" }));
+ });
});
From b386e14ef36e9c64a631d12ddd42bcf4736e1c8a Mon Sep 17 00:00:00 2001
From: Daniil Perkin
Date: Tue, 25 Aug 2026 23:15:52 +0200
Subject: [PATCH 03/51] Let header icons hint that they hide an easter egg
Why this change
===============
The easter-egg overhaul moved discovery from keyboard chords to real UI
details. But a trigger nobody notices is just a dead button, so the icons
that hide a game need the smallest possible tell - enough for an attentive
user to think "huh?" and click, not enough to stop being an easter egg.
The Settings cogwheel (dino) is the first consumer; the dashboard header
icon gets the same treatment with its own game in the next commit.
What changed
============
PageHeader gains an `eggHint` prop (default false, purely opt-in). When
set on an icon that already has `onIconClick`:
- The icon grows ~15% on hover (the button already had
transition-transform + active:scale-95, so this rides the existing
motion system rather than adding a new one).
- On mount it plays exactly ONE 0.5s brand-colored glow pulse via a
drop-shadow keyframe. One-shot by construction (`1 both`, never
re-triggered), so it reads as a flicker of "this thing is special",
not as a notification or badge.
Implementation notes
====================
The effects are two small CSS rules keyed off classes +
data-[egg-hint="true"] in styles/index.css, next to the barrel-roll
keyframes - not Tailwind arbitrary variants - because both the hover
descendant selector and the prefers-reduced-motion override read better
as plain CSS and the file is already the home for one-off app-wide
animations.
Accessibility: the hint is decorative only. It is deliberately NOT
reflected in aria labels or roles - screen readers get the same plain
"Settings icon" button as before (asserted by a dedicated test). Users
with prefers-reduced-motion get neither the hover scale nor the pulse;
both overrides sit inside one media query next to the base rules.
subtitle became optional in PageHeaderProps (was required): ChatPage
already renders PageHeader without one and passed runtime undefined;
typing it optional makes the contract honest. No behavior change -
empty subtitle renders nothing, exactly as before.
Tests
=====
New tests/unit/components/layout/PageHeader.test.tsx covers: plain icon
stays non-clickable, onIconClick turns it into a named button, the
data-egg-hint attribute appears exactly when eggHint is set, and the
accessible name is unchanged by the hint. Existing PageHeader a11y scan,
SettingsPage tests and the full unit/a11y suites pass unchanged.
---
src/components/layout/PageHeader.tsx | 35 +++++++++------
src/pages/SettingsPage.tsx | 1 +
src/styles/index.css | 36 ++++++++++++++++
.../components/layout/PageHeader.test.tsx | 43 +++++++++++++++++++
4 files changed, 102 insertions(+), 13 deletions(-)
create mode 100644 tests/unit/components/layout/PageHeader.test.tsx
diff --git a/src/components/layout/PageHeader.tsx b/src/components/layout/PageHeader.tsx
index 8335d8d02..69a362bf0 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,26 @@ 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;
};
export function PageHeader({
icon: Icon,
title,
- subtitle,
+ subtitle = "",
actions,
className = "",
hideSubtitleBelow,
onIconClick,
+ eggHint = false,
}: PageHeaderProps) {
return (
@@ -36,10 +42,11 @@ export function PageHeader({
{onIconClick ? (
-
+
) : (
@@ -50,13 +57,15 @@ export function PageHeader({
-
- {subtitle}
-
+ {subtitle ? (
+
+ {subtitle}
+
+ ) : null}
{actions ? (
diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx
index dbf652c77..0fc8510ed 100644
--- a/src/pages/SettingsPage.tsx
+++ b/src/pages/SettingsPage.tsx
@@ -92,6 +92,7 @@ export function SettingsPage() {
title="Settings"
subtitle="Manage your profile, appearance and access tokens in one place."
onIconClick={dino.handleIconClick}
+ eggHint
/>
diff --git a/src/styles/index.css b/src/styles/index.css
index cf79b5b1e..f7b5ce097 100644
--- a/src/styles/index.css
+++ b/src/styles/index.css
@@ -617,6 +617,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 rgba(37, 99, 235, 0));
+ }
+ 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/tests/unit/components/layout/PageHeader.test.tsx b/tests/unit/components/layout/PageHeader.test.tsx
new file mode 100644
index 000000000..b5891fb80
--- /dev/null
+++ b/tests/unit/components/layout/PageHeader.test.tsx
@@ -0,0 +1,43 @@
+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);
+ });
+});
From b79bcf6c8ee4124d8784287f4c74b7ec09cbbc58 Mon Sep 17 00:00:00 2001
From: Daniil Perkin
Date: Tue, 25 Aug 2026 23:15:52 +0200
Subject: [PATCH 04/51] Point the dashboard header-icon egg at 2048 instead of
dino
Why this change
===============
When the chords died, the dashboard's triple-click-the-header-icon
trigger was parked on the dino runner as a placeholder. The plan assigns
2048 to that spot: the dino already has two discovery homes (the Settings
cogwheel unlock plus Space during AI thinking / onboarding generation),
while 2048 had none left after losing Ctrl+Shift+2. The chart-column icon
over a grid of widgets also rhymes nicely with a game about merging tiles
in a grid.
What changed
============
Same gesture, same target grammar ("header icons hide games"), different
game: three quick clicks on the dashboard page-header icon now open 2048
in the shared EggModalShell instead of the dino modal. Still ungated -
finding it is the fun, no localStorage flag involved. The empty-board /
edit-mode concern does not apply: the header icon exists on the dashboard
in every state, unlike the edit-mode-only Add-widget button considered
earlier.
Dino therefore has no chord-free dashboard entry anymore - intentional;
its homes are the cogwheel and the waiting screens.
Verification
============
npm run build PASS, npx eslint src/tests PASS (0 problems),
npm run unit 206 files / 1819 tests PASS, npm run a11y 52 files / 60
tests PASS. Dashboard/PmDashboard/PageHeader suites re-run individually.
---
src/pages/DashboardPage.tsx | 12 ++++++------
1 file changed, 6 insertions(+), 6 deletions(-)
diff --git a/src/pages/DashboardPage.tsx b/src/pages/DashboardPage.tsx
index fefc3c829..5d515397e 100644
--- a/src/pages/DashboardPage.tsx
+++ b/src/pages/DashboardPage.tsx
@@ -29,12 +29,12 @@ export function DashboardPage() {
const [isPickerOpen, setPickerOpen] = useState(false);
const [isResetOpen, setResetOpen] = useState(false);
- // Easter egg: the dashboard header icon hides the dino runner — three
+ // Easter egg: the dashboard header icon hides the 2048 game — three
// quick clicks open it, the same gesture language as the Settings
- // cogwheel hiding its own game. Deliberately ungated: finding it *is*
- // the unlock, no localStorage flag involved.
- const [dinoOpen, setDinoOpen] = useState(false);
- const onHeaderIconClick = useRepeatClicks(3, () => setDinoOpen(true));
+ // cogwheel hiding the dino. Deliberately ungated: finding it *is*
+ // the fun, no localStorage flag involved.
+ const [game2048Open, setGame2048Open] = useState(false);
+ const onHeaderIconClick = useRepeatClicks(3, () => setGame2048Open(true));
return (
@@ -125,7 +125,7 @@ export function DashboardPage() {
onClose={() => setResetOpen(false)}
/>
- setDinoOpen(false)} />
+ setGame2048Open(false)} />
);
}
From 026a9b0702748dc66220ae734af446a9307f5b28 Mon Sep 17 00:00:00 2001
From: Daniil Perkin
Date: Tue, 25 Aug 2026 23:45:16 +0200
Subject: [PATCH 05/51] Bring the dino waiting-game and chat easter-egg phrases
to the buddy
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The AI chat already offers the dino runner (press Space while the AI
thinks, when unlocked) and two phrase easter eggs ("do a barrel roll",
"the matrix"). They stayed stuck on the AI chat: typing a phrase to the
*buddy* did nothing, and while the buddy churned there was never another
game to click — so the two chats drifted apart, the exact inconsistency
the BuddyProvider exists to prevent (dock vs. /buddy page sharing one
conversation).
What changed
------------
1. App-level effect layer. The barrel roll and Matrix rain used to be
local useState inside ChatPage. They now play through a shared
`eggEffectBus` (playEggEffect / useActiveEggEffect / clearEggEffect)
and render from one EggEffectsLayer mounted once in App.tsx — so any
surface, not just the AI chat, can fire them. ChatPage was migrated
onto the bus (~35 lines of local state/effect deleted; phrase matching
routes through matchEggPhrase + playEggEffect instead of calling
setIsBarrelRolling/setIsMatrixActive). ChatPage keeps its MatrixRain
import deleted; the layer owns it now.
2. Buddy waiting-game. useBuddyConversation now calls useSpaceOpensDino
with (isThinking || isStreaming, dinoUnlocked) — Space opens the
runner while the buddy works, and it auto-closes when the answer
arrives (mirrors ChatPage's render-adjust close). The props flow up
through its return value and down through BuddyThread → Buddydock/
BuddyConversation → BuddyWidget → BuddyDock, so both the dock and
the /buddy page show the running game inline under the typing
indicator.
3. Buddy phrase interception. useBuddyConversation.handleSubmit checks
matchEggPhrase(draft) BEFORE sending: a match clears the composer,
fires playEggEffect, and swallows the message — no request, no reply.
Same silent contract as the AI chat, so "do a barrel roll" / "the
matrix" behave identically in both chats.
4. Tests. New eggEffectBus.test.tsx (4 cases: idle→barrel, barrel
timeout+cleanup, matrix mount/unmount, clear on unmount) and
buddyEasterEggs.test.tsx (phrase swallowed silently vs. normal send
reaching the wire, dino Space trigger while thinking). useDinoUnlock
coverage moved into the bus test's storage-event case.
Design notes
------------
- EggEffectsLayer is mounted outside `showSidebar` (i.e. always, even on
/login) because a fired effect must always have its renderer; the
barrel-roll body class is harmless on login, and matrix rain on a
login screen is the kind of secret that rewards attention.
- The waiting-game uses React's documented "adjust state when a value
changes" pattern (guarded setState during render) rather than
set-state-in-effect, matching ChatPage's existing approach — keeps the
close-on-answer synchronous with the render that knows the turn ended.
- dinoGameActive/closeDinoGame are optional on BuddyDock (defaults false /
no-op) so the existing dock test mocks, which don't pass them, keep
compiling and green.
Tests: unit 208/1825, a11y 52/60, eslint 0 problems, tsc clean.
---
src/App.tsx | 7 ++
.../buddy/components/BuddyConversation.tsx | 8 ++
src/features/buddy/components/BuddyDock.tsx | 13 +++
.../buddy/components/BuddyMessage.tsx | 45 +++++++
src/features/buddy/components/BuddyThread.tsx | 12 ++
src/features/buddy/components/BuddyWidget.tsx | 4 +
.../buddy/hooks/useBuddyConversation.ts | 34 +++++-
.../components/EggEffectsLayer.tsx | 47 ++++++++
src/features/easter-eggs/eggEffectBus.ts | 61 ++++++++++
src/pages/BuddyPage.tsx | 4 +
src/pages/ChatPage.tsx | 33 +-----
.../features/buddy/buddyEasterEggs.test.tsx | 97 +++++++++++++++
.../easter-eggs/eggEffectBus.test.tsx | 110 ++++++++++++++++++
13 files changed, 447 insertions(+), 28 deletions(-)
create mode 100644 src/features/easter-eggs/components/EggEffectsLayer.tsx
create mode 100644 src/features/easter-eggs/eggEffectBus.ts
create mode 100644 tests/unit/features/buddy/buddyEasterEggs.test.tsx
create mode 100644 tests/unit/features/easter-eggs/eggEffectBus.test.tsx
diff --git a/src/App.tsx b/src/App.tsx
index bd522e29a..87aa47a86 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -10,6 +10,7 @@ import { BuddyWidget } from "./features/buddy/components/BuddyWidget";
import { BuddyProvider } from "./features/buddy/BuddyProvider";
import { useAuth } from "./context/useAuth";
import { AuroraBackground } from "./components/layout/AuroraBackground";
+import { EggEffectsLayer } from "./features/easter-eggs/components/EggEffectsLayer";
function AppContent() {
const { status } = useAuth();
@@ -45,6 +46,12 @@ function AppContent() {
sits on top of the login screen, and off unless turned on in
Settings (see MomentsSection). */}
{showSidebar && 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 showSidebar: a fired effect must always have its renderer. */}
+
);
diff --git a/src/features/buddy/components/BuddyConversation.tsx b/src/features/buddy/components/BuddyConversation.tsx
index cdfb56a2b..6dd0b852b 100644
--- a/src/features/buddy/components/BuddyConversation.tsx
+++ b/src/features/buddy/components/BuddyConversation.tsx
@@ -32,6 +32,10 @@ type BuddyConversationProps = {
onRetryOpen?: () => void;
/** Puts the caret in the composer on mount — the page opens in order to be typed in. */
focusComposerOnMount?: boolean;
+ /** Whether the dino waiting-game is open while the buddy thinks (see `BuddyThread`). */
+ dinoGameActive?: boolean;
+ /** Called when the player leaves the dino waiting-game. */
+ onDinoGameExit?: () => void;
};
/**
@@ -71,6 +75,8 @@ export function BuddyConversation({
openError,
onRetryOpen,
focusComposerOnMount = false,
+ dinoGameActive = false,
+ onDinoGameExit,
}: BuddyConversationProps) {
const { containerRef, onScroll } = useStickToBottom(messages);
@@ -95,6 +101,8 @@ export function BuddyConversation({
renderQuestionAction={renderQuestionAction}
openError={openError}
onRetryOpen={onRetryOpen}
+ dinoGameActive={dinoGameActive}
+ onDinoGameExit={onDinoGameExit}
/>
diff --git a/src/features/buddy/components/BuddyDock.tsx b/src/features/buddy/components/BuddyDock.tsx
index 12dabea35..1bd22c381 100644
--- a/src/features/buddy/components/BuddyDock.tsx
+++ b/src/features/buddy/components/BuddyDock.tsx
@@ -55,6 +55,15 @@ type BuddyDockProps = Pick<
* rather than picked off the session, like every other callback here: the dock stays a
* presentational component the widget drives, which is what keeps it testable without one.
*/
+ /**
+ * Whether the dino waiting-game is open while the buddy thinks (see `BuddyThread`).
+ * The prop is named `onDinoGameExit` to match BuddyThread, which is what the
+ * dock forwards it to — keeping one name across the dock → thread boundary
+ * so callers pass it once and forget.
+ */
+ dinoGameActive?: boolean;
+ /** Called when the player leaves the dino waiting-game. */
+ onDinoGameExit?: () => void;
openError?: string | null;
/** Tries the read again, from the banner that reports the failure. */
onRetryOpen?: () => void;
@@ -105,6 +114,8 @@ export function BuddyDock({
confirmAction,
dismissAction,
suggestions,
+ dinoGameActive = false,
+ onDinoGameExit,
openError,
onClose,
onOpenFull,
@@ -259,6 +270,8 @@ export function BuddyDock({
renderQuestionAction={(question) => }
openError={openError}
onRetryOpen={onRetryOpen}
+ dinoGameActive={dinoGameActive}
+ onDinoGameExit={onDinoGameExit}
/>
diff --git a/src/features/buddy/components/BuddyMessage.tsx b/src/features/buddy/components/BuddyMessage.tsx
index ad60f9a43..e489f5d51 100644
--- a/src/features/buddy/components/BuddyMessage.tsx
+++ b/src/features/buddy/components/BuddyMessage.tsx
@@ -2,6 +2,8 @@ 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";
import { UserAvatar } from "../../../components/common/UserAvatar";
/** Who is talking. Three, because a hire's PM really does answer in here. */
@@ -175,12 +177,55 @@ function SpeakerAvatar({ speaker, isStreaming }: { speaker: BuddySpeaker; isStre
export function BuddyTypingMessage({
label,
showName = false,
+ gameActive = false,
+ onGameExit,
}: {
label?: string;
showName?: boolean;
+ /** True when the dino waiting-game is open instead of the dots. */
+ gameActive?: boolean;
+ /** 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, exactly like ThinkingIndicator does, and
+ // the label keeps explaining what the buddy is doing behind the game.
+ if (gameActive && onGameExit) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {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;
};
/**
@@ -70,6 +78,8 @@ export function BuddyThread({
renderQuestionAction,
openError,
onRetryOpen,
+ dinoGameActive = false,
+ onDinoGameExit,
}: BuddyThreadProps) {
// The send loop appends an empty assistant message up front and streams into it, so the last
// one is the turn receiving tokens.
@@ -165,6 +175,8 @@ export function BuddyThread({
)}
diff --git a/src/features/buddy/components/BuddyWidget.tsx b/src/features/buddy/components/BuddyWidget.tsx
index d3da51d85..cd38e8e96 100644
--- a/src/features/buddy/components/BuddyWidget.tsx
+++ b/src/features/buddy/components/BuddyWidget.tsx
@@ -44,6 +44,8 @@ export function BuddyWidget() {
confirmAction,
dismissAction,
suggestions,
+ dinoGameActive,
+ closeDinoGame,
openError,
retryOpen,
closeDock,
@@ -189,6 +191,8 @@ export function BuddyWidget() {
confirmAction={confirmAction}
dismissAction={dismissAction}
suggestions={suggestions}
+ dinoGameActive={dinoGameActive}
+ onDinoGameExit={closeDinoGame}
openError={openError}
onRetryOpen={() => void retryOpen()}
onClose={toggleOpen}
diff --git a/src/features/buddy/hooks/useBuddyConversation.ts b/src/features/buddy/hooks/useBuddyConversation.ts
index 699207efc..9ff064324 100644
--- a/src/features/buddy/hooks/useBuddyConversation.ts
+++ b/src/features/buddy/hooks/useBuddyConversation.ts
@@ -1,4 +1,7 @@
import { useCallback, useRef, useState } from "react";
+import { useDinoUnlocked, useSpaceOpensDino } from "../../easter-eggs/hooks/useDinoWaitingGame";
+import { matchEggPhrase } from "../../easter-eggs/lib/eggPhrases";
+import { playEggEffect } from "../../easter-eggs/eggEffectBus";
import {
getMessages,
streamOpenBuddy,
@@ -35,6 +38,21 @@ export function useBuddyConversation() {
// The tool the buddy is running right now, if any -- drives "Checking your progress…"
// in place of a generic spinner. Cleared as soon as the answer starts streaming.
const [activeTool, setActiveTool] = useState(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();
+ const [dinoGameActive, closeDinoGame] = useSpaceOpensDino(isThinking, dinoUnlocked);
+ const [prevBuddyBusy, setPrevBuddyBusy] = useState(false);
+ if (prevBuddyBusy !== (isThinking || isStreaming)) {
+ setPrevBuddyBusy(isThinking || isStreaming);
+ if (!isThinking && !isStreaming && dinoGameActive) {
+ closeDinoGame();
+ }
+ }
+
// 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
@@ -454,13 +472,23 @@ export function useBuddyConversation() {
(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;
+ }
+
const text = draft;
if (!text.trim()) return;
setDraft("");
void sendMessage(text);
},
- [draft, sendMessage],
+ [draft, sendMessage, setDraft],
);
return {
@@ -479,6 +507,10 @@ export function useBuddyConversation() {
confirmAction,
dismissAction,
+ dinoGameActive,
+ closeDinoGame,
+ dinoUnlocked,
+
ensureOpened,
retryOpen,
startFreshVisit,
diff --git a/src/features/easter-eggs/components/EggEffectsLayer.tsx b/src/features/easter-eggs/components/EggEffectsLayer.tsx
new file mode 100644
index 000000000..ee867ecf2
--- /dev/null
+++ b/src/features/easter-eggs/components/EggEffectsLayer.tsx
@@ -0,0 +1,47 @@
+import { useEffect } from "react";
+import { clearEggEffect, useActiveEggEffect } from "../eggEffectBus";
+import { MatrixRain } from "./MatrixRain";
+
+/** 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.
+ */
+export function EggEffectsLayer() {
+ const effect = useActiveEggEffect();
+
+ useEffect(() => {
+ if (effect?.id !== "barrel-roll") return;
+ document.body.classList.add("barrel-roll-active");
+ // `seq` in the closure is fine: the class is idempotent, and clearing
+ // on unmount covers the re-fire-while-running case.
+ 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]);
+
+ if (!effect) return null;
+
+ if (effect.id === "matrix") {
+ 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/eggEffectBus.ts b/src/features/easter-eggs/eggEffectBus.ts
new file mode 100644
index 000000000..bc785b75c
--- /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";
+
+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/pages/BuddyPage.tsx b/src/pages/BuddyPage.tsx
index a2ea9b5c4..fb8b5bef8 100644
--- a/src/pages/BuddyPage.tsx
+++ b/src/pages/BuddyPage.tsx
@@ -185,6 +185,8 @@ function BuddyMentorHome() {
confirmAction,
dismissAction,
openError,
+ dinoGameActive,
+ closeDinoGame,
ensureOpened,
retryOpen,
startFreshVisit,
@@ -277,6 +279,8 @@ function BuddyMentorHome() {
handleSubmit={handleSubmit}
confirmAction={confirmAction}
dismissAction={dismissAction}
+ dinoGameActive={dinoGameActive}
+ onDinoGameExit={closeDinoGame}
// Escalating hangs off the hire's own question now, not off the buddy's answer — see
// `BuddyQuestionActions`. What is left here is the greeting's own next step, offered
// where a messenger offers a quick reply: right under the message that suggested it. It
diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx
index eb80c1976..ed7bb8ced 100644
--- a/src/pages/ChatPage.tsx
+++ b/src/pages/ChatPage.tsx
@@ -17,8 +17,8 @@ import { PageHeader } from "../components/layout/PageHeader.tsx";
import { ArtifactViewerDrawer } from "../features/knowledge-base/components/ArtifactViewerDrawer.tsx";
import type { Artifact, ArtifactType, SourceSystem } from "../features/knowledge-base/types";
import type { SelectedCitation } from "../context/ChatContext.ts";
-import { MatrixRain } from "../features/easter-eggs/components/MatrixRain.tsx";
import { matchEggPhrase } from "../features/easter-eggs/lib/eggPhrases";
+import { playEggEffect } from "../features/easter-eggs/eggEffectBus";
import {
useDinoUnlocked,
useSpaceOpensDino,
@@ -136,11 +136,8 @@ export function ChatPage() {
[viewingCitationArtifact],
);
- // Easter egg states. Phrase effects are matched via the shared
- // eggPhrases lib; the dino waiting-game (unlock flag + Space trigger +
- // auto-close) lives in one shared hook instead of page-local copies.
- const [isBarrelRolling, setIsBarrelRolling] = useState(false);
- const [isMatrixActive, setIsMatrixActive] = useState(false);
+ // 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);
@@ -157,21 +154,6 @@ export function ChatPage() {
}
}
- // 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 phrase matching happens in `handleChatSubmit` below.
// E5: replacement for the live region that used to wrap the message list.
@@ -201,8 +183,8 @@ export function ChatPage() {
}, [busy, chatId, textareaRef]);
// Custom submit handler to intercept easter eggs: a phrase match plays
- // the effect and swallows the message (it never reaches the AI); a
- // normal message goes through unchanged.
+ // 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);
@@ -211,8 +193,7 @@ export function ChatPage() {
return;
}
e.preventDefault();
- if (effect === "barrel-roll") setIsBarrelRolling(true);
- else setIsMatrixActive(true);
+ playEggEffect(effect);
setNewRequest("");
},
[newRequest, setNewRequest, handleSubmit],
@@ -497,8 +478,6 @@ export function ChatPage() {
onDelete={() => {}}
/>
)}
-
- {isMatrixActive && setIsMatrixActive(false)} />}
);
}
diff --git a/tests/unit/features/buddy/buddyEasterEggs.test.tsx b/tests/unit/features/buddy/buddyEasterEggs.test.tsx
new file mode 100644
index 000000000..a073539d7
--- /dev/null
+++ b/tests/unit/features/buddy/buddyEasterEggs.test.tsx
@@ -0,0 +1,97 @@
+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 { BuddyProvider } from "../../../../src/features/buddy/BuddyProvider";
+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/visit", () => new HttpResponse(null, { status: 204 })),
+ );
+
+ const harness = renderHook(() => useBuddy(), { wrapper: BuddyProvider });
+ 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");
+ });
+ act(() => {
+ 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);
+
+ // A normal word goes through the normal path.
+ act(() => {
+ result.current.setDraft("what is my next step?");
+ });
+ act(() => {
+ result.current.handleSubmit(new Event("submit") as unknown as React.FormEvent);
+ });
+ await waitFor(() => {
+ expect(messagePosted).toBe(true);
+ });
+ });
+});
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"]);
+ });
+});
From a22d2448e6549b1a83b29ec146a89c0df8786300 Mon Sep 17 00:00:00 2001
From: Daniil Perkin
Date: Wed, 26 Aug 2026 00:10:56 +0200
Subject: [PATCH 06/51] Remove the Ctrl+Shift dev shortcuts and stale
easter-egg leftovers
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Why this change
===============
The chord retirement left two kinds of dead weight behind. The moments
feature still carried its own five development-only chords
(Ctrl+Shift+4..8) — a tuning aid, marked "remove before merging" in its
own header, whose reason to exist (replaying progress-gated moments)
has nothing to do with shipping. And the docs/comments still described
a world that no longer exists.
What is removed
===============
- `useMomentDevShortcuts.ts` (whole file) and its call in
`MomentsProvider`. The hook was DEV-only (`import.meta.env.DEV`
compiled it out of production), so this changes nothing for users;
it just deletes the tuning scaffolding before the branch lands.
- The `EggEffects = Record` type from
eggPhrases.ts — a leftover from the pre-bus design where each page
rendered its own effects. The bus replaced it; nothing imported it.
eggPhrases.ts now re-exports `EggEffectId` from the bus instead of
declaring a second copy of the union.
- A stale comment block in SpaceInvaders.tsx pointing new readers at
the deleted SpaceInvadersModal and the retired Ctrl+Shift+3 chord.
Verification
============
npm run build (tsc -b + vite), eslint src tests, prettier --check on
touched dirs, unit suites for easter-eggs/buddy/moments/pages (373
tests) all pass.
---
src/features/easter-eggs/lib/eggPhrases.ts | 17 +---
src/features/moments/MomentsProvider.tsx | 10 --
src/features/moments/useMomentDevShortcuts.ts | 98 -------------------
3 files changed, 2 insertions(+), 123 deletions(-)
delete mode 100644 src/features/moments/useMomentDevShortcuts.ts
diff --git a/src/features/easter-eggs/lib/eggPhrases.ts b/src/features/easter-eggs/lib/eggPhrases.ts
index c3c9e02a1..a3e35e1b3 100644
--- a/src/features/easter-eggs/lib/eggPhrases.ts
+++ b/src/features/easter-eggs/lib/eggPhrases.ts
@@ -1,5 +1,3 @@
-import type { ReactNode } from "react";
-
/**
* Recognized chat phrases → effect ids. Kept verbatim from ChatPage's old
* inline matching so existing behaviour (and muscle memory) is unchanged:
@@ -8,13 +6,14 @@ import type { ReactNode } from "react";
* "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";
const EGG_PHRASES: ReadonlyArray = [
[["do a barrel roll", "do barrel roll", "do barrel"], "barrel-roll"],
[["the matrix", "do matrix", "matrix"], "matrix"],
];
/** The whole-window easter eggs a chat surface can trigger by phrase. */
-export type EggEffectId = "barrel-roll" | "matrix";
+export type { EggEffectId } from "../eggEffectBus";
/**
* Returns the easter-egg effect a submitted chat message should trigger,
@@ -28,15 +27,3 @@ export function matchEggPhrase(text: string): EggEffectId | null {
}
return null;
}
-
-/**
- * What a phrase-triggered easter egg needs to run anywhere in the app.
- *
- * Barrel roll and matrix are not modals — they are whole-window effects
- * that must be mounted by whoever owns that part of the screen. Each chat
- * surface passes this record of renderers down to its submit handler, so
- * trigger logic never hardcodes *where* an effect lives: ChatPage owns
- * the real implementations today, and a buddy chat can later reuse or
- * replace them without touching phrase matching.
- */
-export type EggEffects = Record;
diff --git a/src/features/moments/MomentsProvider.tsx b/src/features/moments/MomentsProvider.tsx
index d93dcf369..0304b4354 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";
@@ -158,15 +157,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]);
-}
From 73f05f2e435b8814ab262d0c0ba08f2bc2313a81 Mon Sep 17 00:00:00 2001
From: Daniil Perkin
Date: Wed, 26 Aug 2026 12:51:00 +0200
Subject: [PATCH 07/51] Let "party" in the chat rain confetti
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Why this change
===============
The phrase easter eggs had two entries (barrel roll, matrix) and the
bus had exactly one renderer per id — adding a third effect is the
proof that the EggEffectsLayer design scales without new plumbing.
"Party" was picked over more game-adjacent ideas because it needs no
new surface, no persistence, and no trigger UI: someone types "party"
into the AI or buddy composer and the app celebrates. It is also the
first alias set that includes an emoji ("🎉"), which is deliberately
the most guessable trigger in the whole system — no language barrier,
and typing one character into a chat box is a very low-risk experiment
for whoever suspects there is something hidden.
Trigger
-------
matchEggPhrase gains ["party", "party time", "let's party", "🎉"] →
"party". Matching stays exact after trim/lowercase, so "partying" and
"a party for two" remain normal messages. The union type on
EggEffectId makes the compiler force the new branch into
EggEffectsLayer — no silent fall-through possible.
ConfettiBurst
-------------
Same architecture contract as MatrixRain: all mutable particle state
lives in local variables owned by one effect, a single delta-time-scaled
rAF loop renders it, DPR-aware canvas, cleanup cancels everything.
- Two cannons (bottom-left/bottom-right) fire up-and-inward, 75
particles each, staggered across the first 150ms so the burst reads
as two pops rather than one wall.
- Particles are rects and circles with per-particle spin, gravity
(900 px/s²), air drag, and a sinusoidal horizontal flutter whose
phase advances faster while falling — that flutter is what makes it
read as paper instead of ballistics.
- Colors come from the computed CSS custom properties of the theme
(brand plus the extended accent hues), read once at spawn: confetti
automatically matches light/dark mode and any future palette change.
A hardcoded five-color fallback covers SSR/test environments where
getComputedStyle resolves nothing.
- When every particle has died, the canvas fades out (~250ms) and the
effect clears itself off the bus; the layer keys ConfettiBurst by
seq, so re-triggering mid-burst replays cleanly instead of being
swallowed by equal-state bailing.
Reduced motion
--------------
Particle animation IS the effect, so there is no honest way to keep
it: under prefers-reduced-motion the component renders a static
"🎉 Party!" status chip that fades via opacity only and clears the
bus after 1.5s. Nobody who types "party" ends up staring at nothing.
Tests
-----
- eggPhrases.test.ts: all four aliases match, plus trim/case
invariance; "partying" and substring cases stay null.
- ConfettiBurst.test.tsx (via EggEffectsLayer): canvas mounts
pointer-events-none + aria-hidden; the full lifecycle
(spawn → simulate → fade → bus clear) completes under fake timers
using a stubbed 2D context and faked rAF/performance (jsdom has no
canvas, so without the stub the loop would exit before doing
anything); the reduced-motion chip renders instead of the canvas
and also clears its bus effect when its timer runs out.
Verification
------------
build ✅ · eslint src tests ✅ 0 problems · prettier (touched files)
✅ · unit 209 files / 1836 tests ✅ · a11y 52 files / 60 tests ✅
---
.../easter-eggs/components/ConfettiBurst.tsx | 276 ++++++++++++++++++
.../components/EggEffectsLayer.tsx | 4 +
src/features/easter-eggs/eggEffectBus.ts | 2 +-
src/features/easter-eggs/lib/eggPhrases.ts | 1 +
.../easter-eggs/ConfettiBurst.test.tsx | 113 +++++++
.../features/easter-eggs/eggPhrases.test.ts | 17 +-
6 files changed, 409 insertions(+), 4 deletions(-)
create mode 100644 src/features/easter-eggs/components/ConfettiBurst.tsx
create mode 100644 tests/unit/features/easter-eggs/ConfettiBurst.test.tsx
diff --git a/src/features/easter-eggs/components/ConfettiBurst.tsx b/src/features/easter-eggs/components/ConfettiBurst.tsx
new file mode 100644
index 000000000..43081002d
--- /dev/null
+++ b/src/features/easter-eggs/components/ConfettiBurst.tsx
@@ -0,0 +1,276 @@
+import { useEffect, useRef } from "react";
+import { useReducedMotion } from "framer-motion";
+import { clearEggEffect } from "../eggEffectBus";
+
+/**
+ * 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;
+/** How long the reduced-motion chip stays before the effect clears (ms). */
+const REDUCED_FADE_MS = 1500;
+/** 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 ReducedPartyChip.
+ */
+export function ConfettiBurst() {
+ const canvasRef = useRef(null);
+ const prefersReducedMotion = useReducedMotion();
+
+ // Reduced motion: particle animation IS the effect, so there is no
+ // honest way to keep it — show a static celebratory chip instead
+ // (opacity-only fade), so typing "party" never disappears into a void.
+ useEffect(() => {
+ if (!prefersReducedMotion) return;
+ const timeout = setTimeout(() => clearEggEffect(), REDUCED_FADE_MS);
+ return () => clearTimeout(timeout);
+ }, [prefersReducedMotion]);
+
+ useEffect(() => {
+ if (prefersReducedMotion) return;
+ // The reduced-motion branch above owns the chip's 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";
+ 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);
+ window.removeEventListener("resize", resize);
+ };
+ }, [prefersReducedMotion]);
+
+ return (
+ <>
+ {prefersReducedMotion ? (
+
+ 🎉 Party!
+
+ ) : (
+
+ )}
+ >
+ );
+}
+
+/**
+ * Reads the confetti colors from the app's CSS custom properties.
+ *
+ * Uses the extended badge hues (brand, purple, pink, orange, amber/yellow)
+ * — festive but still the app's own palette. Falls back to sensible colors
+ * when getComputedStyle cannot resolve a variable (e.g. jsdom).
+ */
+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-yellow-text",
+ ];
+ const resolved = candidates.map((name) => styles.getPropertyValue(name).trim()).filter(Boolean);
+ return resolved.length >= 3 ? resolved : FALLBACK_PALETTE;
+}
+
+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
index ee867ecf2..6e8423ad9 100644
--- a/src/features/easter-eggs/components/EggEffectsLayer.tsx
+++ b/src/features/easter-eggs/components/EggEffectsLayer.tsx
@@ -1,6 +1,7 @@
import { useEffect } from "react";
import { clearEggEffect, useActiveEggEffect } from "../eggEffectBus";
import { MatrixRain } from "./MatrixRain";
+import { ConfettiBurst } from "./ConfettiBurst";
/** How long the barrel roll spins before the page settles again (ms). */
const BARREL_ROLL_MS = 2000;
@@ -38,6 +39,9 @@ export function EggEffectsLayer() {
if (!effect) return null;
+ // Keyed by seq so re-triggering while running replays the burst.
+ if (effect.id === "party") return ;
+
if (effect.id === "matrix") {
return ;
}
diff --git a/src/features/easter-eggs/eggEffectBus.ts b/src/features/easter-eggs/eggEffectBus.ts
index bc785b75c..126763df5 100644
--- a/src/features/easter-eggs/eggEffectBus.ts
+++ b/src/features/easter-eggs/eggEffectBus.ts
@@ -5,7 +5,7 @@ import { useSyncExternalStore } from "react";
* 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";
+export type EggEffectId = "barrel-roll" | "matrix" | "party";
type ActiveEffect = {
id: EggEffectId;
diff --git a/src/features/easter-eggs/lib/eggPhrases.ts b/src/features/easter-eggs/lib/eggPhrases.ts
index a3e35e1b3..c7b718f8e 100644
--- a/src/features/easter-eggs/lib/eggPhrases.ts
+++ b/src/features/easter-eggs/lib/eggPhrases.ts
@@ -10,6 +10,7 @@ import type { EggEffectId } from "../eggEffectBus";
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. */
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..7075cee61
--- /dev/null
+++ b/tests/unit/features/easter-eggs/ConfettiBurst.test.tsx
@@ -0,0 +1,113 @@
+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", () => {
+ mockReducedMotion.mockReturnValue(true);
+ render( );
+
+ act(() => playEggEffect("party"));
+
+ expect(document.querySelector("canvas")).toBeNull();
+ 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();
+ });
+});
diff --git a/tests/unit/features/easter-eggs/eggPhrases.test.ts b/tests/unit/features/easter-eggs/eggPhrases.test.ts
index 996a62ea3..2eef935a2 100644
--- a/tests/unit/features/easter-eggs/eggPhrases.test.ts
+++ b/tests/unit/features/easter-eggs/eggPhrases.test.ts
@@ -13,10 +13,21 @@ describe("matchEggPhrase", () => {
expect(matchEggPhrase(text)).toBe("matrix");
});
- it.each(["how do I do a barrel roll?", "the matrix movie", "", " "])(
- "treats %j as a normal message",
+ it.each(["party", "party time", "let's party", "🎉", " PARTY "])(
+ "recognizes %j as party",
(text) => {
- expect(matchEggPhrase(text)).toBeNull();
+ 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();
+ });
});
From a90a45f1b1d63f7eef114aec6a7c6dc850b68e44 Mon Sep 17 00:00:00 2001
From: Daniil Perkin
Date: Wed, 26 Aug 2026 13:07:10 +0200
Subject: [PATCH 08/51] Make the sidebar logo fall off its shelf when clicked
five times
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Why this change
===============
The easter-egg overhaul replaced keyboard chords with real UI details a
user can stumble on. The strongest remaining surface for that grammar is
the app logo itself: it already reacts to hover (badge lift, rocket nudge,
flames light up), so it reads as interactive — but clicking it did
nothing. That gap between "looks alive" and "is clickable" is exactly
where an egg belongs, and repeated clicks on a decorative element is the
same discovery gesture as the cogwheel and dashboard-icon triggers.
Research note: the logo only *looks* clickable — it is decorative markup
at all three usage sites (desktop sidebar header, mobile top bar,
Keycloak login template). Per the agreed approach it stays decorative:
plain onClick plus cursor-pointer, no button, no tab stop. Rationale:
the egg is pure whimsy with zero function, and adding a focusable
element to the login page's keyboard path (of all places) would make
navigation worse for every screen-keyboard user to serve a secret.
The animation
-------------
A four-phase gravity sequence on the outer badge div, driven by framer-
motion's onAnimationComplete rather than hand-tuned delays, so each beat
starts exactly when the previous one lands:
1. falling (~0.45s, easeIn): y +140px with a slight forward tumble —
gravity accelerates, it does not slide.
2. impact (~90ms): sideways squash (scaleX 1.14 / scaleY 0.8), the
classic cartoon beat.
3. bouncing (~0.62s): two diminishing bounces via keyframed arrays
(34% then 16% rebound heights) with re-squashes that shrink per
landing.
4. hopping (spring, stiffness 260 / damping 11): back to the shelf
with a small overshoot while rotation settles to 0.
The fixed 140px translateY keeps the effect layout-independent — parents
are overflow-visible, so the badge visually escapes its header row
without portals or measured rects.
Deliberately NOT routed through the egg effect bus: barrel roll and
matrix are whole-window effects owned by EggEffectsLayer; this one is
scoped to a single element that three different layouts place
differently. Element-scoped animation, element-owned state.
Interplay details
-----------------
- The rocket's permanent idle float lives on the inner , the drop
on the outer div — separate DOM nodes, transforms compose.
- whileHover="lift" is suppressed while any phase is playing so hover
never fights the drop for the same transform; it re-arms at idle.
- Clicks during a run are absorbed by the phase gate (only idle arms a
new drop); useRepeatClicks resets after firing either way.
- prefers-reduced-motion skips the sequence entirely — pure motion has
no honest static equivalent — though the click counter still consumes.
- One targeted behavior kept from review: DROP_PHASES is passed as the
animate target object directly (not by variant name), because the
phases are not part of the badge's rest/lift variants family.
Tests
-----
SidebarLogo.test.tsx asserts the trigger contract under a motion-stripping
proxy: the logo remains decorative markup (no button role) with
cursor-pointer; four clicks leave it untransformed; five rapid clicks do
not throw even under reduced motion. Pixel choreography is not asserted
(jsdom has no animation loop — same boundary as the game tests).
Verification
------------
build ✅ · eslint src tests ✅ 0 problems · prettier (touched files)
✅ · unit 210 files / 1839 tests ✅ · a11y 52 files / 60 tests ✅
---
src/components/layout/SidebarLogo.tsx | 84 ++++++++++++++++++-
.../components/layout/SidebarLogo.test.tsx | 77 +++++++++++++++++
2 files changed, 157 insertions(+), 4 deletions(-)
create mode 100644 tests/unit/components/layout/SidebarLogo.test.tsx
diff --git a/src/components/layout/SidebarLogo.tsx b/src/components/layout/SidebarLogo.tsx
index 811b17a3a..5c6662cbd 100644
--- a/src/components/layout/SidebarLogo.tsx
+++ b/src/components/layout/SidebarLogo.tsx
@@ -1,8 +1,58 @@
-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";
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: { type: "spring", stiffness: 260, damping: 11 },
+ },
+};
+
+/** 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,6 +79,24 @@ 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: {} }
: {
@@ -94,11 +162,19 @@ export function SidebarLogo({ className = "" }: { className?: string }) {
return (
{
+ const actual = await vi.importActual("framer-motion");
+ return {
+ ...actual,
+ motion: new Proxy(actual.motion, {
+ get(_target, prop) {
+ // Strip animation props the DOM would reject; keep everything else.
+ const Component = (props: Record) => {
+ const {
+ initial: _i,
+ animate: _a,
+ exit: _e,
+ variants: _v,
+ transition: _t,
+ whileHover: _w,
+ onAnimationComplete: _o,
+ ...rest
+ } = props;
+ void _i;
+ void _a;
+ void _e;
+ void _v;
+ void _t;
+ void _w;
+ void _o;
+ const { [prop as string]: Tag = "div" } = {};
+ return ;
+ };
+ return Component;
+ },
+ }),
+ };
+});
+
+describe("SidebarLogo gravity easter egg", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
+ const clickTimes = (times: number) => {
+ const logo = document.querySelector(".cursor-pointer");
+ expect(logo).not.toBeNull();
+ for (let i = 0; i < times; i++) fireEvent.click(logo!);
+ };
+
+ it("renders a decorative, cursor-pointer badge", () => {
+ render( );
+ const logo = document.querySelector(".cursor-pointer");
+ expect(logo).not.toBeNull();
+ expect(logo!.tagName).toBe("DIV");
+ expect(screen.queryByRole("button")).toBeNull();
+ });
+
+ it("stays idle for fewer than five clicks", () => {
+ render( );
+ clickTimes(4);
+ // No way to observe phases directly through the mocked motion — assert
+ // via the class contract instead: no transform-related inline style.
+ const logo = document.querySelector(".cursor-pointer") as HTMLElement;
+ expect(logo.style.transform).toBe("");
+ });
+
+ it("does not crash on five rapid clicks under reduced motion", () => {
+ window.matchMedia = vi
+ .fn()
+ .mockReturnValue({ matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn() });
+ render( );
+ expect(() => clickTimes(5)).not.toThrow();
+ });
+});
From 5eda9174d893716670e6edc1deddb7d6b0abb787 Mon Sep 17 00:00:00 2001
From: Daniil Perkin
Date: Wed, 26 Aug 2026 21:10:40 +0200
Subject: [PATCH 09/51] feat(dashboard): add egg hint to header icon and enable
Escape key close for 2048
- Enable eggHint on DashboardPage PageHeader so the ChartColumn icon triggers the opening glow pulse and scales on hover
- Enable Escape key close handling for 2048 game modal via postMessage from 2048.html and keydown listeners in Game2048Frame
- Add unit test coverage in DashboardPage.test.tsx and Game2048Frame.test.tsx
- Apply Prettier formatting across codebase
---
public/easter-eggs/2048.html | 7 ++
.../admin/components/CreateProjectWizard.tsx | 8 ++-
.../components/AddSourceModal.tsx | 8 ++-
.../easter-eggs/components/Game2048Frame.tsx | 70 +++++++++++++++----
.../components/jira/JiraCredentialAddForm.tsx | 5 +-
src/pages/DashboardPage.tsx | 1 +
.../easter-eggs/Game2048Frame.test.tsx | 26 +++++++
.../OnboardingMetricsPage.filter.test.tsx | 8 ++-
.../starter-work/CorpusIssueBrowser.test.tsx | 4 +-
tests/unit/pages/DashboardPage.test.tsx | 22 ++++++
10 files changed, 136 insertions(+), 23 deletions(-)
create mode 100644 tests/unit/features/easter-eggs/Game2048Frame.test.tsx
diff --git a/public/easter-eggs/2048.html b/public/easter-eggs/2048.html
index 1fe538f85..26b8f72b8 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" }, "*");
+ } catch (_) {}
+ return;
+ }
+
if (mapped !== undefined) {
event.preventDefault();
// Ignore auto-repeat key events so holding an arrow doesn't
diff --git a/src/features/admin/components/CreateProjectWizard.tsx b/src/features/admin/components/CreateProjectWizard.tsx
index c62631cac..8b4e6d05e 100644
--- a/src/features/admin/components/CreateProjectWizard.tsx
+++ b/src/features/admin/components/CreateProjectWizard.tsx
@@ -127,8 +127,12 @@ export function CreateProjectWizard({
// The token list is owned here so an inline "add token" can refresh it and
// auto-select the new token. It falls back to the prop until it has loaded so
// discovery still works on the first open without waiting for the refetch.
- const { tokenNames: loadedTokenNames, tokensLoaded, loadTokenNames, addTokenNameLocally } =
- useGithubTokens();
+ const {
+ tokenNames: loadedTokenNames,
+ tokensLoaded,
+ loadTokenNames,
+ addTokenNameLocally,
+ } = useGithubTokens();
const effectiveTokenNames = tokensLoaded ? loadedTokenNames : tokenNames;
const [jiraDisplayName, setJiraDisplayName] = useState("");
diff --git a/src/features/data-ingestion/components/AddSourceModal.tsx b/src/features/data-ingestion/components/AddSourceModal.tsx
index 69841b7da..5624c923f 100644
--- a/src/features/data-ingestion/components/AddSourceModal.tsx
+++ b/src/features/data-ingestion/components/AddSourceModal.tsx
@@ -93,8 +93,12 @@ export function AddSourceModal({
// The token list is owned here so an inline "add token" can refresh it and
// auto-select the new token; it falls back to the prop until it has loaded so
// discovery works on the first open without waiting for the refetch.
- const { tokenNames: loadedTokenNames, tokensLoaded, loadTokenNames, addTokenNameLocally } =
- useGithubTokens();
+ const {
+ tokenNames: loadedTokenNames,
+ tokensLoaded,
+ loadTokenNames,
+ addTokenNameLocally,
+ } = useGithubTokens();
const effectiveTokenNames = tokensLoaded ? loadedTokenNames : tokenNames;
// Jira detail state.
diff --git a/src/features/easter-eggs/components/Game2048Frame.tsx b/src/features/easter-eggs/components/Game2048Frame.tsx
index 7612f465c..c5c313f53 100644
--- a/src/features/easter-eggs/components/Game2048Frame.tsx
+++ b/src/features/easter-eggs/components/Game2048Frame.tsx
@@ -17,31 +17,73 @@ type Game2048FrameProps = {
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) => {
+ if (isEggExitMessage(event.data)) {
+ onExitRef.current();
+ }
+ };
+ window.addEventListener("message", handleMessage);
+ return () => window.removeEventListener("message", handleMessage);
+ }, []);
useEffect(() => {
const iframe = iframeRef.current;
if (!iframe) return;
- const handleLoad = () => {
- const win = iframe.contentWindow;
- const doc = iframe.contentDocument;
- if (!win || !doc) return;
- win.focus();
- doc.addEventListener("keydown", (e: KeyboardEvent) => {
- if (e.key === "Escape") onExit();
- });
- // The iframe is unmounted when the modal closes (AnimatePresence
- // exit), discarding its contentDocument and this listener with it.
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === "Escape") {
+ onExitRef.current();
+ }
};
- if (iframe.contentDocument?.readyState === "complete") handleLoad();
- else iframe.addEventListener("load", handleLoad);
- return () => iframe.removeEventListener("load", handleLoad);
- }, [onExit]);
+ const attachListener = () => {
+ try {
+ const win = iframe.contentWindow;
+ const doc = iframe.contentDocument;
+ if (win) {
+ win.focus();
+ win.addEventListener("keydown", handleKeyDown);
+ }
+ if (doc) {
+ doc.addEventListener("keydown", handleKeyDown);
+ }
+ } catch {
+ // Fallback to window message listener if cross-origin
+ }
+ };
+
+ if (iframe.contentDocument?.readyState === "complete") {
+ attachListener();
+ }
+ iframe.addEventListener("load", attachListener);
+
+ return () => {
+ iframe.removeEventListener("load", attachListener);
+ try {
+ iframe.contentWindow?.removeEventListener("keydown", handleKeyDown);
+ iframe.contentDocument?.removeEventListener("keydown", handleKeyDown);
+ } catch {
+ // Ignore
+ }
+ };
+ }, []);
return (
diff --git a/src/features/easter-eggs/components/ConfettiBurst.tsx b/src/features/easter-eggs/components/ConfettiBurst.tsx
index 43081002d..d29c7fe54 100644
--- a/src/features/easter-eggs/components/ConfettiBurst.tsx
+++ b/src/features/easter-eggs/components/ConfettiBurst.tsx
@@ -65,6 +65,12 @@ const CANNON_ANGLES = {
* - 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 ReducedPartyChip.
+ * - 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);
diff --git a/src/features/easter-eggs/components/EggEffectsLayer.tsx b/src/features/easter-eggs/components/EggEffectsLayer.tsx
index 6e8423ad9..ef93accc9 100644
--- a/src/features/easter-eggs/components/EggEffectsLayer.tsx
+++ b/src/features/easter-eggs/components/EggEffectsLayer.tsx
@@ -1,4 +1,5 @@
import { useEffect } from "react";
+import { useReducedMotion } from "framer-motion";
import { clearEggEffect, useActiveEggEffect } from "../eggEffectBus";
import { MatrixRain } from "./MatrixRain";
import { ConfettiBurst } from "./ConfettiBurst";
@@ -16,12 +17,26 @@ const BARREL_ROLL_MS = 2000;
* 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 are skipped for users who asked for less of
+ * it: see the barrel-roll branch below, and the reduced-motion chip the
+ * confetti falls back to.
*/
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;
+ }
document.body.classList.add("barrel-roll-active");
// `seq` in the closure is fine: the class is idempotent, and clearing
// on unmount covers the re-fire-while-running case.
@@ -35,7 +50,7 @@ export function EggEffectsLayer() {
clearTimeout(timeout);
document.body.classList.remove("barrel-roll-active");
};
- }, [effect?.id]);
+ }, [effect?.id, prefersReducedMotion]);
if (!effect) return null;
diff --git a/src/features/easter-eggs/components/EggModalShell.tsx b/src/features/easter-eggs/components/EggModalShell.tsx
index d6fcd18bd..3059d798f 100644
--- a/src/features/easter-eggs/components/EggModalShell.tsx
+++ b/src/features/easter-eggs/components/EggModalShell.tsx
@@ -101,14 +101,12 @@ export function EggModalShell({ eggId, open, onClose }: EggModalShellProps) {
<>
{/* Header bar for the iframe game (canvas games draw their own chrome). */}
-
- {egg.label}
-
+
{egg.label}
diff --git a/src/features/easter-eggs/components/Game2048Frame.tsx b/src/features/easter-eggs/components/Game2048Frame.tsx
index c5c313f53..51e65c8f9 100644
--- a/src/features/easter-eggs/components/Game2048Frame.tsx
+++ b/src/features/easter-eggs/components/Game2048Frame.tsx
@@ -1,18 +1,22 @@
/**
* 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. All keyboard wiring lives in
- * {@link EggModalShell} — focusing the frame here just makes arrow keys
- * work immediately, without a click first.
+ * `{ 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 user presses Escape *outside* the iframe (header,
- * close button, before load). Keydowns inside a focused iframe don't
- * bubble out to the parent document, so the frame installs its own
- * same-origin listener after loading; the two never double-fire.
+ * 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;
};
@@ -35,6 +39,10 @@ export function Game2048Frame({ onExit }: Game2048FrameProps) {
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
+ // Same origin only: the game page is served by this app, so a message
+ // from anywhere else can only be somebody else's frame closing a modal
+ // that is not its business.
+ if (event.origin !== window.location.origin) return;
if (isEggExitMessage(event.data)) {
onExitRef.current();
}
@@ -47,42 +55,26 @@ export function Game2048Frame({ onExit }: Game2048FrameProps) {
const iframe = iframeRef.current;
if (!iframe) return;
- const handleKeyDown = (e: KeyboardEvent) => {
- if (e.key === "Escape") {
- onExitRef.current();
- }
- };
-
- const attachListener = () => {
+ // 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 {
- const win = iframe.contentWindow;
- const doc = iframe.contentDocument;
- if (win) {
- win.focus();
- win.addEventListener("keydown", handleKeyDown);
- }
- if (doc) {
- doc.addEventListener("keydown", handleKeyDown);
- }
+ iframe.contentWindow?.focus();
} catch {
- // Fallback to window message listener if cross-origin
+ // Cross-origin frame: nothing to focus into; the message path stands.
}
};
if (iframe.contentDocument?.readyState === "complete") {
- attachListener();
+ focusFrame();
}
- iframe.addEventListener("load", attachListener);
+ iframe.addEventListener("load", focusFrame);
- return () => {
- iframe.removeEventListener("load", attachListener);
- try {
- iframe.contentWindow?.removeEventListener("keydown", handleKeyDown);
- iframe.contentDocument?.removeEventListener("keydown", handleKeyDown);
- } catch {
- // Ignore
- }
- };
+ 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/SpaceInvaders.tsx b/src/features/easter-eggs/components/SpaceInvaders.tsx
index fb86c9431..5ae810315 100644
--- a/src/features/easter-eggs/components/SpaceInvaders.tsx
+++ b/src/features/easter-eggs/components/SpaceInvaders.tsx
@@ -267,9 +267,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
diff --git a/src/features/easter-eggs/hooks/useDinoWaitingGame.ts b/src/features/easter-eggs/hooks/useDinoWaitingGame.ts
index d9831a46c..ee6bcb528 100644
--- a/src/features/easter-eggs/hooks/useDinoWaitingGame.ts
+++ b/src/features/easter-eggs/hooks/useDinoWaitingGame.ts
@@ -28,6 +28,24 @@ const isTypingTarget = (el: Element | null) =>
el instanceof HTMLElement &&
(el.tagName === "TEXTAREA" || el.tagName === "INPUT" || el.isContentEditable);
+/**
+ * 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;
+}
+
/**
* The Space-to-play trigger shared by every dino waiting-game host
* (AI chat, onboarding generation, buddy chat): while `armed` is true and
@@ -40,19 +58,31 @@ const isTypingTarget = (el: Element | null) =>
export function useSpaceOpensDino(armed: boolean, isUnlocked: boolean): [boolean, () => void] {
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 (isTypingTarget(document.activeElement)) return;
+ if (gameHost !== null) return;
e.preventDefault();
+ gameHost = host;
setGameActive(true);
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
- }, [armed, isUnlocked, gameActive]);
+ }, [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]);
// Locking mid-game (triple-click toggle elsewhere, another tab) closes
// it. Uses React's documented "adjust state when a value changes"
@@ -62,10 +92,14 @@ export function useSpaceOpensDino(armed: boolean, isUnlocked: boolean): [boolean
if (prevUnlocked !== isUnlocked) {
setPrevUnlocked(isUnlocked);
if (!isUnlocked) {
+ releaseGameSlot(host);
setGameActive(false);
}
}
- const close = useCallback(() => setGameActive(false), []);
+ const close = useCallback(() => {
+ releaseGameSlot(host);
+ setGameActive(false);
+ }, [host]);
return [gameActive, close];
}
diff --git a/src/features/easter-eggs/registry.ts b/src/features/easter-eggs/registry.ts
index 028e21793..06a7b4dd1 100644
--- a/src/features/easter-eggs/registry.ts
+++ b/src/features/easter-eggs/registry.ts
@@ -11,17 +11,12 @@ import { lazy } from "react";
* file; every trigger, page and test keeps addressing eggs by id.
*/
-/** Lazy-loaded: the endless runner (chunk fetched on first open). */
-const DinoGame = lazy(() =>
- import("../chatbot/components/DinoGame").then((m) => ({ default: m.DinoGame })),
-);
-
/** Lazy-loaded: Space Invaders (chunk fetched on first open). */
const SpaceInvaders = lazy(() =>
import("./components/SpaceInvaders").then((m) => ({ default: m.SpaceInvaders })),
);
-export type EggId = "dino" | "game-2048" | "space-invaders";
+export type EggId = "game-2048" | "space-invaders";
type EggDefinition = {
/** Human-readable name for aria labels and visible chrome. */
@@ -32,18 +27,15 @@ type EggDefinition = {
* in an iframe; `EggModalShell` adds a header bar and Escape handling.
*/
kind: "canvas" | "iframe";
- /** Where an iframe game lives under `public/`. */
- iframeSrc?: string;
component: LazyExoticComponent void }>>;
};
export const EGG_REGISTRY: Record = {
- dino: { label: "Dino game", kind: "canvas", component: DinoGame },
"game-2048": {
label: "2048",
kind: "iframe",
- iframeSrc: "/easter-eggs/2048.html",
// 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 })),
),
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 e2e7b6c8a..0f79438bd 100644
--- a/src/pages/BuddyPage.tsx
+++ b/src/pages/BuddyPage.tsx
@@ -329,6 +329,7 @@ function BuddyMentorHome() {
setInvadersOpen(true)}
className="mt-4 flex cursor-pointer items-center gap-2 rounded-lg px-3 py-1.5 text-sm text-app-text-subtle transition-colors hover:bg-app-surface-hover hover:text-app-text focus-visible:ring-2 focus-visible:ring-app-focus focus-visible:outline-none"
- aria-label="Open Space Invaders"
>
While you wait for your manager's approval…
+ — open Space Invaders
🚀
diff --git a/tests/unit/features/easter-eggs/EggModalShell.test.tsx b/tests/unit/features/easter-eggs/EggModalShell.test.tsx
index 0b0533bf4..ab28b8f48 100644
--- a/tests/unit/features/easter-eggs/EggModalShell.test.tsx
+++ b/tests/unit/features/easter-eggs/EggModalShell.test.tsx
@@ -2,8 +2,12 @@ import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { EggModalShell } from "../../../../src/features/easter-eggs/components/EggModalShell";
-vi.mock("../../../../src/features/chatbot/components/DinoGame", () => ({
- DinoGame: ({ onExit }: { onExit: () => void }) =>
dino-exit ,
+// 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
+ ),
}));
vi.mock("../../../../src/features/easter-eggs/components/Game2048Frame", () => ({
@@ -12,21 +16,13 @@ vi.mock("../../../../src/features/easter-eggs/components/Game2048Frame", () => (
),
}));
-vi.mock("../../../../src/features/easter-eggs/components/SpaceInvaders", async () => {
- const actual = await vi.importActual<
- typeof import("../../../../src/features/easter-eggs/components/SpaceInvaders")
- >("../../../../src/features/easter-eggs/components/SpaceInvaders");
- return actual;
-});
-
describe("EggModalShell", () => {
it("renders nothing when closed", () => {
- render(
);
+ render(
);
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it.each([
- ["dino", "Dino game"],
["game-2048", "2048"],
["space-invaders", "Space Invaders game"],
] as const)("renders %s with the right dialog label", (eggId, label) => {
@@ -34,10 +30,10 @@ describe("EggModalShell", () => {
expect(screen.getByRole("dialog", { name: label })).toBeInTheDocument();
});
- it("routes the game's onExit to onClose", () => {
+ it("routes a canvas game's onExit to onClose", () => {
const onClose = vi.fn();
- render(
);
- fireEvent.click(screen.getByText("dino-exit"));
+ render(
);
+ fireEvent.click(screen.getByText("invaders-exit"));
expect(onClose).toHaveBeenCalledTimes(1);
});
diff --git a/tests/unit/features/easter-eggs/Game2048Frame.test.tsx b/tests/unit/features/easter-eggs/Game2048Frame.test.tsx
index 39380ef9e..27ef47457 100644
--- a/tests/unit/features/easter-eggs/Game2048Frame.test.tsx
+++ b/tests/unit/features/easter-eggs/Game2048Frame.test.tsx
@@ -10,7 +10,7 @@ describe("Game2048Frame", () => {
expect(iframe).toHaveAttribute("src", "/easter-eggs/2048.html");
});
- it("calls onExit when an EGG_EXIT window message is received", () => {
+ it("calls onExit when the game page reports EGG_EXIT", () => {
const onExit = vi.fn();
render(
);
@@ -18,9 +18,25 @@ describe("Game2048Frame", () => {
window,
new MessageEvent("message", {
data: { type: "EGG_EXIT" },
+ origin: window.location.origin,
}),
);
expect(onExit).toHaveBeenCalledTimes(1);
});
+
+ it("ignores an EGG_EXIT report from another origin", () => {
+ const onExit = vi.fn();
+ render(
);
+
+ fireEvent(
+ window,
+ new MessageEvent("message", {
+ data: { type: "EGG_EXIT" },
+ origin: "https://somewhere-else.example",
+ }),
+ );
+
+ expect(onExit).not.toHaveBeenCalled();
+ });
});
diff --git a/tests/unit/pages/NotFoundPage.test.tsx b/tests/unit/pages/NotFoundPage.test.tsx
index 6a5e1fec0..fb7bdc421 100644
--- a/tests/unit/pages/NotFoundPage.test.tsx
+++ b/tests/unit/pages/NotFoundPage.test.tsx
@@ -38,10 +38,13 @@ describe("NotFoundPage", () => {
// 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.
- const teaser = screen.getByRole("button", { name: /open space invaders/i });
+ // The teaser is a small muted row, not an obvious CTA. Its accessible
+ // name has to carry the visible words (WCAG 2.5.3), with the "open
+ // Space Invaders" hint appended rather than replacing them.
+ const teaser = screen.getByRole("button", { name: /while you wait for your manager/i });
expect(teaser).toHaveTextContent("While you wait for your manager");
expect(teaser).toHaveTextContent("🚀");
+ expect(teaser).toHaveAccessibleName(/open space invaders/i);
fireEvent.click(teaser);
expect(screen.getByTestId("egg-modal")).toBeInTheDocument();
From 8a0335e04bf0da290ccd31edafced1b8aaf35106 Mon Sep 17 00:00:00 2001
From: Daniil Perkin
Date: Wed, 16 Sep 2026 13:50:24 +0200
Subject: [PATCH 11/51] Fix the formatting drift the prettier check was failing
on
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
npm run format:check fails on five files that predate this branch:
ProjectIndustryPanel, useStarterWorkReview, projectService,
ProjectIndustryWidget.test and starterWorkService.test. Formatting only —
npx prettier --write, nothing else — and deliberately kept out of the
easter-egg review commit so the behaviour change and the churn do not hide
each other in blame.
CI never ran format:check (ci.yaml runs lint, build, unit, a11y and the
docker build), which is how the drift shipped in the first place. These five
were the whole of it on this branch, so the check is green from here.
---
src/features/projects/industry/ProjectIndustryPanel.tsx | 5 ++++-
src/features/starter-work/hooks/useStarterWorkReview.ts | 3 +--
src/services/projectService.ts | 6 +++++-
.../projects/industry/ProjectIndustryWidget.test.tsx | 4 +---
tests/unit/services/starterWorkService.test.ts | 7 +++----
5 files changed, 14 insertions(+), 11 deletions(-)
diff --git a/src/features/projects/industry/ProjectIndustryPanel.tsx b/src/features/projects/industry/ProjectIndustryPanel.tsx
index dc7f3d615..90e6b8888 100644
--- a/src/features/projects/industry/ProjectIndustryPanel.tsx
+++ b/src/features/projects/industry/ProjectIndustryPanel.tsx
@@ -170,7 +170,10 @@ export function ProjectIndustryPanel({
{industry || "Not determined yet"}
{industry && (
-
+
)}
>
)}
diff --git a/src/features/starter-work/hooks/useStarterWorkReview.ts b/src/features/starter-work/hooks/useStarterWorkReview.ts
index bce2eed68..b4e394f5c 100644
--- a/src/features/starter-work/hooks/useStarterWorkReview.ts
+++ b/src/features/starter-work/hooks/useStarterWorkReview.ts
@@ -16,8 +16,7 @@ function toMessage(error: unknown, fallback: string): string {
export type TaskOrigin = "authored" | "picked";
type ReviewAction =
- | { kind: "generate"; projectId: string }
- | { kind: "create"; input: CreateStarterWorkTaskInput };
+ { kind: "generate"; projectId: string } | { kind: "create"; input: CreateStarterWorkTaskInput };
const NO_TASKS: StarterWorkTask[] = [];
diff --git a/src/services/projectService.ts b/src/services/projectService.ts
index 8489862c7..ac06e95de 100644
--- a/src/services/projectService.ts
+++ b/src/services/projectService.ts
@@ -538,7 +538,11 @@ export const projectService = {
async setProjectIndustry(
projectId: string,
industry: string,
- ): Promise<{ industry: string; industryConfidence: IndustryConfidence | null; industryCustom: boolean }> {
+ ): Promise<{
+ industry: string;
+ industryConfidence: IndustryConfidence | null;
+ industryCustom: boolean;
+ }> {
const result = await apiClient.fetch<{
industry: string | null;
industryConfidence: string | null;
diff --git a/tests/unit/features/projects/industry/ProjectIndustryWidget.test.tsx b/tests/unit/features/projects/industry/ProjectIndustryWidget.test.tsx
index b0e5ef740..70fe71c84 100644
--- a/tests/unit/features/projects/industry/ProjectIndustryWidget.test.tsx
+++ b/tests/unit/features/projects/industry/ProjectIndustryWidget.test.tsx
@@ -375,9 +375,7 @@ describe("ProjectIndustryWidget", () => {
await user.click(screen.getByTestId("save-industry-button"));
await waitFor(() =>
- expect(
- within(toastStack()).getByText("You may not edit this project."),
- ).toBeInTheDocument(),
+ expect(within(toastStack()).getByText("You may not edit this project.")).toBeInTheDocument(),
);
expect(screen.getByTestId("industry-edit-input")).toHaveValue("Healthcare");
});
diff --git a/tests/unit/services/starterWorkService.test.ts b/tests/unit/services/starterWorkService.test.ts
index d60bd2799..2d60380b0 100644
--- a/tests/unit/services/starterWorkService.test.ts
+++ b/tests/unit/services/starterWorkService.test.ts
@@ -14,10 +14,9 @@ describe("starterWorkService", () => {
await starterWorkService.generate("p1");
- expect(fetchSpy).toHaveBeenCalledWith(
- "/api/v1/onboarding/starter-work/generate?projectId=p1",
- { method: "POST" },
- );
+ expect(fetchSpy).toHaveBeenCalledWith("/api/v1/onboarding/starter-work/generate?projectId=p1", {
+ method: "POST",
+ });
});
it("reads candidate issues scoped to the project", async () => {
From 8f0b5d1280b5b791971ea090a78c985f8bfdee6b Mon Sep 17 00:00:00 2001
From: Daniil Perkin
Date: Wed, 16 Sep 2026 23:21:05 +0200
Subject: [PATCH 12/51] Close the waiting-game when its wait ends and hand the
slot back
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The shared slot behind the Space-to-play dino game was released on host
unmount and when the unlock flag flipped off, but never when the wait
itself ended. The game's DOM belongs to the host's waiting state, so it
disappears on its own the moment the answer arrives — leaving
`gameActive` true kept the slot claimed by a game nobody could see, and
the next surface to arm found the trigger dead.
The onboarding generation step is where that bites hardest: it arms on
`loadingState === "generating"`, has no close of its own, and stays
mounted after the path arrives, so one finished generation ate Space for
the rest of the visit — chat, buddy and onboarding alike. Chat and buddy
had each grown a copy-pasted "close when busy flips" block to work around
it; both are gone now that the hook owns the close, which is what the
buddy hook's own comment already claimed it did.
The close is a render-phase adjustment next to the existing unlock flip,
so there is no extra effect, no cascading render, and no host that has to
watch `armed` itself.
A second armed host can open its game once the first one's wait has ended
— a test that fails without the release.
---
.../buddy/hooks/useBuddyConversation.ts | 7 -----
.../easter-eggs/hooks/useDinoWaitingGame.ts | 21 ++++++++++++--
src/pages/ChatPage.tsx | 15 ++--------
.../easter-eggs/useDinoWaitingGame.test.tsx | 29 +++++++++++++++++++
4 files changed, 51 insertions(+), 21 deletions(-)
diff --git a/src/features/buddy/hooks/useBuddyConversation.ts b/src/features/buddy/hooks/useBuddyConversation.ts
index bac3f0cbc..06a8fde8e 100644
--- a/src/features/buddy/hooks/useBuddyConversation.ts
+++ b/src/features/buddy/hooks/useBuddyConversation.ts
@@ -45,13 +45,6 @@ export function useBuddyConversation() {
// or when the cogwheel unlock flag flips off.
const dinoUnlocked = useDinoUnlocked();
const [dinoGameActive, closeDinoGame] = useSpaceOpensDino(isThinking, dinoUnlocked);
- const [prevBuddyBusy, setPrevBuddyBusy] = useState(false);
- if (prevBuddyBusy !== (isThinking || isStreaming)) {
- setPrevBuddyBusy(isThinking || isStreaming);
- if (!isThinking && !isStreaming && dinoGameActive) {
- closeDinoGame();
- }
- }
// The one suggested next step the opening greeting invites, until the hire acts or asks.
const [openerAction, setOpenerAction] = useState(null);
diff --git a/src/features/easter-eggs/hooks/useDinoWaitingGame.ts b/src/features/easter-eggs/hooks/useDinoWaitingGame.ts
index ee6bcb528..2dcea92aa 100644
--- a/src/features/easter-eggs/hooks/useDinoWaitingGame.ts
+++ b/src/features/easter-eggs/hooks/useDinoWaitingGame.ts
@@ -52,8 +52,9 @@ function releaseGameSlot(host: symbol): void {
* the game is not already open, pressing Space opens it — unless the user
* is typing in a field, in which case Space stays a space.
*
- * Returns whether the game should be shown; hosts close it themselves
- * when their wait ends (or when the unlock flag flips off).
+ * 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 — a host never has to watch that itself.
*/
export function useSpaceOpensDino(armed: boolean, isUnlocked: boolean): [boolean, () => void] {
const [gameActive, setGameActive] = useState(false);
@@ -97,6 +98,22 @@ export function useSpaceOpensDino(armed: boolean, isUnlocked: boolean): [boolean
}
}
+ // 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.
+ const [prevArmed, setPrevArmed] = useState(armed);
+ if (prevArmed !== armed) {
+ setPrevArmed(armed);
+ if (!armed) {
+ releaseGameSlot(host);
+ setGameActive(false);
+ }
+ }
+
const close = useCallback(() => {
releaseGameSlot(host);
setGameActive(false);
diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx
index c2a340ebe..b6faa8e21 100644
--- a/src/pages/ChatPage.tsx
+++ b/src/pages/ChatPage.tsx
@@ -148,18 +148,9 @@ export function ChatPage() {
const dinoUnlocked = useDinoUnlocked();
const [gameActive, closeGame] = useSpaceOpensDino(isThinking, dinoUnlocked);
- // 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) {
- closeGame();
- }
- }
+ // The game closes itself and hands the shared slot back the moment
+ // `isThinking` flips off — see `useSpaceOpensDino`. `closeGame` is only
+ // for an exit from inside the game.
// Dino easter egg phrase matching happens in `handleChatSubmit` below.
diff --git a/tests/unit/features/easter-eggs/useDinoWaitingGame.test.tsx b/tests/unit/features/easter-eggs/useDinoWaitingGame.test.tsx
index 32f3665db..c3ea452db 100644
--- a/tests/unit/features/easter-eggs/useDinoWaitingGame.test.tsx
+++ b/tests/unit/features/easter-eggs/useDinoWaitingGame.test.tsx
@@ -92,5 +92,34 @@ describe("useDinoWaitingGame hooks", () => {
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();
+ });
});
});
From a502ba3ca309688502adaecbae84bf94dd72db47 Mon Sep 17 00:00:00 2001
From: Daniil Perkin
Date: Wed, 16 Sep 2026 23:21:13 +0200
Subject: [PATCH 13/51] Give a re-fired whole-window effect its own full run
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The bus has carried a `seq` since it was written, and ConfettiBurst was
keyed by it — but neither of the other two ids ever saw it. `MatrixRain`
was rendered without a key, and the barrel-roll effect listed only
`effect?.id` in its dependencies, so firing the same phrase again while
its effect was running handed the second trigger the first one's
deadline: the rain ended on the first instance's 6s timer, a second
barrel roll was cut short by the first 2s timer. The layer's own
documentation promises the opposite ("re-triggering replays the effect"),
and the two re-fireable ids were precisely the ones that did not.
MatrixRain is keyed by `seq` now and the barrel-roll effect depends on
it, so a re-fire restarts the clock. The body class itself stays
idempotent: cleanup drops it, the effect puts it straight back.
ConfettiBurst's fade timer is ref-held and cleared on unmount, for the
same class of reason. It fires into the bus rather than into the
component, so the cleanup that looked complete — rAF, both spawn timers,
the resize listener — still let a burst that was remounted (or replaced
by another effect) clear the *new* effect a few hundred ms in.
Three tests, one per re-fire path, each fake-timer driven and each
failing without its fix.
---
.../easter-eggs/components/ConfettiBurst.tsx | 9 +-
.../components/EggEffectsLayer.tsx | 12 ++-
.../easter-eggs/ConfettiBurst.test.tsx | 30 +++++++
.../easter-eggs/EggEffectsLayer.test.tsx | 85 +++++++++++++++++++
4 files changed, 131 insertions(+), 5 deletions(-)
create mode 100644 tests/unit/features/easter-eggs/EggEffectsLayer.test.tsx
diff --git a/src/features/easter-eggs/components/ConfettiBurst.tsx b/src/features/easter-eggs/components/ConfettiBurst.tsx
index d29c7fe54..03db06392 100644
--- a/src/features/easter-eggs/components/ConfettiBurst.tsx
+++ b/src/features/easter-eggs/components/ConfettiBurst.tsx
@@ -76,6 +76,12 @@ 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);
+
// Reduced motion: particle animation IS the effect, so there is no
// honest way to keep it — show a static celebratory chip instead
// (opacity-only fade), so typing "party" never disappears into a void.
@@ -217,7 +223,7 @@ export function ConfettiBurst() {
}
el.style.transition = "opacity 250ms ease-out";
el.style.opacity = "0";
- setTimeout(() => clearEggEffect(), 280);
+ fadeTimeoutRef.current = setTimeout(() => clearEggEffect(), 280);
return; // stop scheduling; fade runs on its own
}
return;
@@ -232,6 +238,7 @@ export function ConfettiBurst() {
cancelAnimationFrame(rafId);
clearTimeout(leftTimer);
clearTimeout(rightTimer);
+ if (fadeTimeoutRef.current !== null) clearTimeout(fadeTimeoutRef.current);
window.removeEventListener("resize", resize);
};
}, [prefersReducedMotion]);
diff --git a/src/features/easter-eggs/components/EggEffectsLayer.tsx b/src/features/easter-eggs/components/EggEffectsLayer.tsx
index ef93accc9..62d31e4ab 100644
--- a/src/features/easter-eggs/components/EggEffectsLayer.tsx
+++ b/src/features/easter-eggs/components/EggEffectsLayer.tsx
@@ -38,8 +38,10 @@ export function EggEffectsLayer() {
return;
}
document.body.classList.add("barrel-roll-active");
- // `seq` in the closure is fine: the class is idempotent, and clearing
- // on unmount covers the re-fire-while-running case.
+ // `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 puts it straight back.
const timeout = setTimeout(() => {
if (document.body.classList.contains("barrel-roll-active")) {
document.body.classList.remove("barrel-roll-active");
@@ -50,7 +52,7 @@ export function EggEffectsLayer() {
clearTimeout(timeout);
document.body.classList.remove("barrel-roll-active");
};
- }, [effect?.id, prefersReducedMotion]);
+ }, [effect?.id, effect?.seq, prefersReducedMotion]);
if (!effect) return null;
@@ -58,7 +60,9 @@ export function EggEffectsLayer() {
if (effect.id === "party") return ;
if (effect.id === "matrix") {
- 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.
diff --git a/tests/unit/features/easter-eggs/ConfettiBurst.test.tsx b/tests/unit/features/easter-eggs/ConfettiBurst.test.tsx
index 7075cee61..731c19929 100644
--- a/tests/unit/features/easter-eggs/ConfettiBurst.test.tsx
+++ b/tests/unit/features/easter-eggs/ConfettiBurst.test.tsx
@@ -110,4 +110,34 @@ describe("ConfettiBurst (via EggEffectsLayer)", () => {
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..0e08f43ea
--- /dev/null
+++ b/tests/unit/features/easter-eggs/EggEffectsLayer.test.tsx
@@ -0,0 +1,85 @@
+import { act, render } from "@testing-library/react";
+import { 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 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 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();
+ });
+});
From e20188cf328ef63d490654d8e092927ac746fa68 Mon Sep 17 00:00:00 2001
From: Daniil Perkin
Date: Wed, 16 Sep 2026 23:21:14 +0200
Subject: [PATCH 14/51] Keep the buddy caret when a submission started no turn
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The composer blurs itself on submit so Space can open the waiting-game
while the buddy works, and puts focus back when `busy` flips off. An egg
phrase never reaches that point: the hook swallows it before anything is
sent, so nothing ever became busy — the caret was handed away on a send
that never happened, focus never came back, and the handoff flag stayed
set for a thread that never started.
Rather than repeating the phrase match inside the component to guess at
it, `handleSubmit` now reports whether it actually started a turn and the
composer only blurs on true. The swallow point stays in one place and the
caret stays in the box the hire is still typing in.
The returned flag is threaded through the conversation props and asserted
where the phrases are tested (false for the swallowed phrase, true for a
real message); a new composer test pins both focus outcomes.
---
.../buddy/components/BuddyComposer.tsx | 14 ++++++++--
.../buddy/components/BuddyConversation.tsx | 6 ++++-
.../buddy/hooks/useBuddyConversation.ts | 10 +++++--
.../buddy/BuddyComposerCaret.test.tsx | 26 +++++++++++++++++++
.../features/buddy/buddyEasterEggs.test.tsx | 11 ++++++--
5 files changed, 60 insertions(+), 7 deletions(-)
create mode 100644 tests/unit/features/buddy/BuddyComposerCaret.test.tsx
diff --git a/src/features/buddy/components/BuddyComposer.tsx b/src/features/buddy/components/BuddyComposer.tsx
index afb6de446..ab6a94a76 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. */
@@ -97,9 +102,14 @@ export function BuddyComposer({
* 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) => {
- handleSubmit(event);
+ if (!handleSubmit(event)) return;
const field = fieldRef.current;
if (field && document.activeElement === field) {
handedOffCaretRef.current = true;
diff --git a/src/features/buddy/components/BuddyConversation.tsx b/src/features/buddy/components/BuddyConversation.tsx
index 365439448..7bcff7115 100644
--- a/src/features/buddy/components/BuddyConversation.tsx
+++ b/src/features/buddy/components/BuddyConversation.tsx
@@ -16,7 +16,11 @@ type BuddyConversationProps = {
activeTool: string | null;
draft: string;
setDraft: (value: string) => void;
- handleSubmit: (event: React.FormEvent) => void;
+ /**
+ * Submits the composer. Returns whether a turn started — an egg phrase comes back `false`,
+ * because nothing was sent for it.
+ */
+ handleSubmit: (event: React.FormEvent) => boolean;
/** Confirms a buddy-proposed action (the only path that mutates). */
confirmAction: (messageId: string, action: ProposedAction) => void;
/** Declines a proposed action; nothing changes. */
diff --git a/src/features/buddy/hooks/useBuddyConversation.ts b/src/features/buddy/hooks/useBuddyConversation.ts
index 06a8fde8e..6af3a47a5 100644
--- a/src/features/buddy/hooks/useBuddyConversation.ts
+++ b/src/features/buddy/hooks/useBuddyConversation.ts
@@ -473,6 +473,11 @@ export function useBuddyConversation() {
[patchAction],
);
+ /**
+ * 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();
@@ -484,14 +489,15 @@ export function useBuddyConversation() {
if (eggEffect) {
setDraft("");
playEggEffect(eggEffect);
- return;
+ return false;
}
const text = draft;
- if (!text.trim()) return;
+ if (!text.trim()) return false;
setDraft("");
void sendMessage(text);
+ return true;
},
[draft, sendMessage, setDraft],
);
diff --git a/tests/unit/features/buddy/BuddyComposerCaret.test.tsx b/tests/unit/features/buddy/BuddyComposerCaret.test.tsx
new file mode 100644
index 000000000..a2ad0db1c
--- /dev/null
+++ b/tests/unit/features/buddy/BuddyComposerCaret.test.tsx
@@ -0,0 +1,26 @@
+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);
+ });
+});
diff --git a/tests/unit/features/buddy/buddyEasterEggs.test.tsx b/tests/unit/features/buddy/buddyEasterEggs.test.tsx
index a073539d7..d4bec86c4 100644
--- a/tests/unit/features/buddy/buddyEasterEggs.test.tsx
+++ b/tests/unit/features/buddy/buddyEasterEggs.test.tsx
@@ -74,22 +74,29 @@ describe("buddy easter eggs", () => {
act(() => {
result.current.setDraft("Do a barrel roll");
});
+ let eggHandled: boolean | undefined;
act(() => {
- result.current.handleSubmit(new Event("submit") as unknown as React.FormEvent);
+ 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(() => {
- result.current.handleSubmit(new Event("submit") as unknown as React.FormEvent);
+ sent = result.current.handleSubmit(new Event("submit") as unknown as React.FormEvent);
});
+ expect(sent).toBe(true);
await waitFor(() => {
expect(messagePosted).toBe(true);
});
From 467f8ebc5e21d560d561c21a5922c9bd4b90a141 Mon Sep 17 00:00:00 2001
From: Daniil Perkin
Date: Wed, 16 Sep 2026 23:21:16 +0200
Subject: [PATCH 15/51] Require the hidden click gestures to be consecutive
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The repeat-click counter never reset on time, which made it an
accumulator: three stray clicks on the dashboard's header icon — an
element that does nothing for the first two — spread over a session would
open the 2048 modal out of nowhere, while the dashboard's own note calls
them "quick clicks".
Clicks now count as consecutive only while each follows the previous
within `windowMs`, defaulting to one second per required click: 3s for a
triple click, 5s for the logo's five. That default is deliberately
generous. The guard only has to rule out accumulation across a session —
any window far shorter than a session does that — while a window tight
enough to demand double-click tempo would read as broken to somebody
deliberately counting their clicks. Hidden gesture unlocks on the
platforms this imitates (Android's seven taps on the build number) have
no timer at all.
Call sites are unchanged; a pause past the window starts the count over,
and the generous default still lands an unhurried but unmistakable
gesture.
---
.../easter-eggs/hooks/useRepeatClicks.ts | 29 +++++++++-
.../easter-eggs/useRepeatClicks.test.tsx | 56 +++++++++++++++++++
2 files changed, 83 insertions(+), 2 deletions(-)
diff --git a/src/features/easter-eggs/hooks/useRepeatClicks.ts b/src/features/easter-eggs/hooks/useRepeatClicks.ts
index 915062832..0176521cd 100644
--- a/src/features/easter-eggs/hooks/useRepeatClicks.ts
+++ b/src/features/easter-eggs/hooks/useRepeatClicks.ts
@@ -1,9 +1,25 @@
import { useCallback, useEffect, useRef } from "react";
+/** Longest gap between two clicks that still counts as the same gesture (ms). */
+const CLICK_GAP_MS = 1000;
+
/**
* Returns a click handler that fires `onReached` once every
* `requiredClicks` consecutive calls, resetting the count in between.
*
+ * Clicks only count as consecutive while each follows the previous within
+ * `windowMs`; a longer pause starts the count over. Without that, 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
@@ -12,8 +28,13 @@ import { useCallback, useEffect, useRef } from "react";
* 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): () => void {
+export function useRepeatClicks(
+ requiredClicks: number,
+ onReached: () => void,
+ windowMs: number = requiredClicks * CLICK_GAP_MS,
+): () => void {
const countRef = useRef(0);
+ const lastClickRef = useRef(0);
const onReachedRef = useRef(onReached);
useEffect(() => {
@@ -21,10 +42,14 @@ export function useRepeatClicks(requiredClicks: number, onReached: () => void):
});
return useCallback(() => {
+ const now = Date.now();
+ if (now - lastClickRef.current > windowMs) countRef.current = 0;
+ lastClickRef.current = now;
+
countRef.current += 1;
if (countRef.current < requiredClicks) return;
countRef.current = 0;
onReachedRef.current();
- }, [requiredClicks]);
+ }, [requiredClicks, windowMs]);
}
diff --git a/tests/unit/features/easter-eggs/useRepeatClicks.test.tsx b/tests/unit/features/easter-eggs/useRepeatClicks.test.tsx
index 980f18758..c03b5654b 100644
--- a/tests/unit/features/easter-eggs/useRepeatClicks.test.tsx
+++ b/tests/unit/features/easter-eggs/useRepeatClicks.test.tsx
@@ -31,4 +31,60 @@ describe("useRepeatClicks", () => {
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: a click four seconds
+ // after the last one starts a fresh count.
+ clock = 5600;
+ result.current();
+ clock = 9000;
+ result.current();
+ clock = 12000;
+ result.current();
+ expect(onReached).toHaveBeenCalledTimes(1);
+ } finally {
+ nowSpy.mockRestore();
+ }
+ });
});
From 9d5eed8b1e98b6e6fe62a66a8a3f1e127d7170d7 Mon Sep 17 00:00:00 2001
From: Daniil Perkin
Date: Wed, 16 Sep 2026 23:21:16 +0200
Subject: [PATCH 16/51] Let Escape close a game modal that is still loading
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The shell deliberately leaves Escape to the canvas games, because a second
handler double-fires — but a game only binds that key once it is mounted,
and the modal is already up before that: the game arrives as a lazy chunk.
For that window there was no keyboard way out at all, so a keyboard user
sat in front of an empty box until the chunk landed. The backdrop still
closed it, which is why the hole reads as nothing on a mouse.
The shell now owns Escape whenever it is the only thing that can: for the
iframe game as before, and for a canvas game while its chunk is in
flight. The Suspense fallback is the probe — it is mounted exactly while
`lazy()` is unresolved — and it reports through the state setter itself,
since a fresh closure per render would re-run its effect and loop. Once
the game mounts, the shell drops the key again and nothing double-fires.
The test mocks the registry with a chunk that never resolves, which pins
that window exactly: without the fix, Escape does nothing.
---
.../easter-eggs/components/EggModalShell.tsx | 39 +++++++++++++++----
.../easter-eggs/EggModalShellLoading.test.tsx | 35 +++++++++++++++++
2 files changed, 67 insertions(+), 7 deletions(-)
create mode 100644 tests/unit/features/easter-eggs/EggModalShellLoading.test.tsx
diff --git a/src/features/easter-eggs/components/EggModalShell.tsx b/src/features/easter-eggs/components/EggModalShell.tsx
index 3059d798f..7c62be919 100644
--- a/src/features/easter-eggs/components/EggModalShell.tsx
+++ b/src/features/easter-eggs/components/EggModalShell.tsx
@@ -1,4 +1,4 @@
-import { Suspense, useEffect, useRef } from "react";
+import { Suspense, useEffect, useRef, useState } from "react";
import { X } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { useScrollLock } from "../../../components/ui/useScrollLock";
@@ -49,22 +49,29 @@ export function EggModalShell({ eggId, open, onClose }: EggModalShellProps) {
// 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`. 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.
+ // 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 || egg.kind !== "iframe") return;
+ 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]);
+ }, [open, egg, shellOwnsEscape]);
if (!egg) return null;
@@ -96,7 +103,7 @@ export function EggModalShell({ eggId, open, onClose }: EggModalShellProps) {
>
{/* One Suspense around every branch: all registry components are
lazy, and an unsuspended lazy child would tear down the tree. */}
- }>
+ }>
{egg.kind === "iframe" ? (
<>
{/* Header bar for the iframe game (canvas games draw their own chrome). */}
@@ -124,3 +131,21 @@ export function EggModalShell({ eggId, open, onClose }: EggModalShellProps) {
);
}
+
+/**
+ * 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/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);
+ });
+});
From dde56500c04ed90d81787f84f40836e0273ee5b6 Mon Sep 17 00:00:00 2001
From: Daniil Perkin
Date: Wed, 16 Sep 2026 23:21:17 +0200
Subject: [PATCH 17/51] Drop the hardcoded blue from the header icon glow
keyframe
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The 0%/100% frame spelled the glow's start colour out as
`rgba(37, 99, 235, 0)` — a hand-coded hue in the shared stylesheet, which
is precisely what the semantic palette exists to prevent. The keyframe is
new on this branch, so this is not pre-existing drift being dragged in.
Alpha-zero blue and `transparent` interpolate identically against the
50% frame's token colour, so nothing moves on screen.
---
src/styles/index.css | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/styles/index.css b/src/styles/index.css
index e80368131..678df3561 100644
--- a/src/styles/index.css
+++ b/src/styles/index.css
@@ -691,7 +691,7 @@ body {
@keyframes header-icon-egg-glow {
0%,
100% {
- filter: drop-shadow(0 0 0 rgba(37, 99, 235, 0));
+ filter: drop-shadow(0 0 0 transparent);
}
50% {
filter: drop-shadow(0 0 6px var(--brand-border-strong));
From 811dff39799fd216e3e39006aa51ec75019311f0 Mon Sep 17 00:00:00 2001
From: Daniil Perkin
Date: Wed, 16 Sep 2026 23:21:17 +0200
Subject: [PATCH 18/51] Say why the sidebar logo keeps its pointer cursor
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The badge is decorative markup and not a home link on any surface, so the
pointer cursor reads like an oversight — but it is the one affordance the
gravity egg has: this element does answer clicks, and hinting that is how
anybody finds it. Written down beside the existing note about not making
it focusable, so the next reader does not have to re-derive the trade-off
and re-litigate it.
---
src/components/layout/SidebarLogo.tsx | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/src/components/layout/SidebarLogo.tsx b/src/components/layout/SidebarLogo.tsx
index 5c6662cbd..821ea8b1a 100644
--- a/src/components/layout/SidebarLogo.tsx
+++ b/src/components/layout/SidebarLogo.tsx
@@ -173,6 +173,11 @@ export function SidebarLogo({ className = "" }: { className?: string }) {
// pure whimsy with no function, and making it tab-focusable on all
// three surfaces (including the login page's focus order) would
// worsen keyboard navigation for everyone to serve a secret.
+ //
+ // The pointer cursor stays: unlike a plain decorative wrapper this one
+ // does answer clicks (that is the whole egg), and it is the only hint
+ // anybody gets that the badge is worth touching. It is not a home link
+ // on any surface, so it promises no navigation.
style={{ width: BADGE_SIZE, height: BADGE_SIZE }}
className={`relative flex shrink-0 cursor-pointer items-center justify-center rounded-[12px] bg-app-brand shadow-lg select-none ${className}`}
>
From 4f78f2006c5892e6e27a086da4005cb7aadc28a9 Mon Sep 17 00:00:00 2001
From: Daniil Perkin
Date: Thu, 17 Sep 2026 17:12:08 +0200
Subject: [PATCH 19/51] feat(chatbot): redesign source and date filter with
floating glass popover
- Replace static inline filter card in ChatComposer with a floating glassmorphic popover
- Add interactive source pill chips with provider icons and soft brand active states
- Add quick date presets (All time, Past 7 days, Past 30 days) and styled custom date range inputs
- Introduce persistent active filter chips strip above composer with 1-click removal and Clear all
- Fix activeFilterCount in ChatProvider so date range is counted as 1 filter instead of 2
- Add comprehensive unit tests for ChatComposer filter interactions and ChatProvider filter count
---
src/context/ChatProvider.tsx | 2 +-
.../chatbot/components/ChatComposer.tsx | 523 +++++++++++++-----
.../features/chatbot/ChatProvider.test.tsx | 54 +-
.../components/ChatComposer.filters.test.tsx | 196 +++++++
4 files changed, 625 insertions(+), 150 deletions(-)
create mode 100644 tests/unit/features/chatbot/components/ChatComposer.filters.test.tsx
diff --git a/src/context/ChatProvider.tsx b/src/context/ChatProvider.tsx
index 7aa6e5002..d6a71a2d5 100644
--- a/src/context/ChatProvider.tsx
+++ b/src/context/ChatProvider.tsx
@@ -198,7 +198,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
}, [flushDraft]);
const activeFilterCount = useMemo(() => {
- return sourceSystems.length + (from ? 1 : 0) + (to ? 1 : 0);
+ return sourceSystems.length + (from || to ? 1 : 0);
}, [sourceSystems, from, to]);
const clearFilters = useCallback(() => {
diff --git a/src/features/chatbot/components/ChatComposer.tsx b/src/features/chatbot/components/ChatComposer.tsx
index 8d903894e..f0a823ec6 100644
--- a/src/features/chatbot/components/ChatComposer.tsx
+++ b/src/features/chatbot/components/ChatComposer.tsx
@@ -1,8 +1,17 @@
-import { Check, Filter, Send, Square, X } from "lucide-react";
-import { useState } from "react";
+import { Calendar, Check, Filter, RotateCcw, Send, Square, X } from "lucide-react";
+import { useEffect, useRef, useState } from "react";
import type { FormEvent, RefObject } from "react";
+import { AnimatePresence, motion } from "framer-motion";
import { SOURCE_META } from "../../data-ingestion/data";
import type { SourceSystem } from "../types";
+import { centralSpringToken } from "../../../styles/tokens";
+
+function formatDateFilterLabel(from: string, to: string): string {
+ if (from && to) return `${from} → ${to}`;
+ if (from) return `From ${from}`;
+ if (to) return `Until ${to}`;
+ return "";
+}
type ChatComposerProps = {
/** Current draft text. */
@@ -111,145 +120,127 @@ export function ChatComposer({
};
const blocked = rangeInvalid || !hasProject;
+ const filterButtonRef = useRef(null);
+ const popoverRef = useRef(null);
+
+ // Close floating popover on Escape or click outside
+ useEffect(() => {
+ if (!showFilters) return;
+
+ const handlePointerDown = (event: PointerEvent) => {
+ const target = event.target as Node;
+ if (
+ popoverRef.current &&
+ !popoverRef.current.contains(target) &&
+ filterButtonRef.current &&
+ !filterButtonRef.current.contains(target)
+ ) {
+ onToggleFilters();
+ }
+ };
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === "Escape") {
+ event.preventDefault();
+ onToggleFilters();
+ filterButtonRef.current?.focus();
+ }
+ };
+
+ document.addEventListener("pointerdown", handlePointerDown);
+ document.addEventListener("keydown", handleKeyDown);
+
+ return () => {
+ document.removeEventListener("pointerdown", handlePointerDown);
+ document.removeEventListener("keydown", handleKeyDown);
+ };
+ }, [showFilters, onToggleFilters]);
+
+ const setPastDays = (days: number) => {
+ const now = new Date();
+ const past = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
+ setFrom(past.toISOString().split("T")[0]);
+ setTo(now.toISOString().split("T")[0]);
+ };
+
+ const diffDays =
+ from && to
+ ? Math.round(
+ (new Date(`${to}T00:00:00`).getTime() - new Date(`${from}T00:00:00`).getTime()) /
+ (24 * 60 * 60 * 1000),
+ )
+ : null;
+
+ const isAllTime = !from && !to;
+ const isPast7Days = diffDays === 7;
+ const isPast30Days = diffDays === 30;
+
return (
- {showFilters && (
-
-
-
-
- Narrow the sources
-
+ {/* Active filter chips strip — visible whenever filters are active */}
+
+ {activeFilterCount > 0 && (
+
+
+
+ Filtering:
+
- {activeFilterCount > 0 && (
+ {sourceSystems.map((source) => {
+ const meta = SOURCE_META[source] ?? {
+ type: source,
+ description: source,
+ icon: Filter,
+ };
+ const Icon = meta.icon;
+ return (
+ toggleSourceSystem(source)}
+ title={`Remove ${meta.type} filter`}
+ className="group inline-flex items-center gap-1.5 rounded-full border border-app-brand-border bg-app-brand/10 px-2.5 py-0.5 text-xs font-medium text-app-brand-text transition-colors hover:border-app-danger-border hover:bg-app-danger-bg hover:text-app-danger-text"
+ >
+
+ {meta.type}
+
+
+ );
+ })}
+
+ {(from || to) && (
{
+ setFrom("");
+ setTo("");
+ }}
+ title="Clear date filter"
+ className="group inline-flex items-center gap-1.5 rounded-full border border-app-brand-border bg-app-brand/10 px-2.5 py-0.5 text-xs font-medium text-app-brand-text transition-colors hover:border-app-danger-border hover:bg-app-danger-bg hover:text-app-danger-text"
>
-
- Reset
+
+ {formatDateFilterLabel(from, to)}
+
)}
-
-
-
-
-
- Sources
-
-
- {availableSources.length === 0 ? (
-
- {sourcesLoading
- ? "Loading connected sources…"
- : "No sources are connected yet, so there is nothing to narrow down."}
-
- ) : (
-
- {availableSources.map((source) => {
- const selected = sourceSystems.includes(source);
- const meta = SOURCE_META[source];
- const Icon = meta.icon;
-
- return (
- toggleSourceSystem(source)}
- className={`group flex items-center gap-2 rounded-xl border px-3 py-2 text-sm font-medium transition-all ${
- selected
- ? "border-app-brand-border bg-app-brand/10 text-app-brand-text"
- : "border-app-border bg-app-bg text-app-text-muted hover:border-app-border-strong hover:text-app-text"
- }`}
- >
-
- {selected && }
-
-
- {meta.type}
-
- );
- })}
-
- )}
-
-
- {sourceSystems.length === 0
- ? "Nothing selected — every connected source is searched."
- : "Only the selected sources are searched."}
-
-
-
-
-
-
-
- Indexed between
-
-
-
- setFrom(e.target.value)}
- className={`h-10 rounded-xl border bg-app-bg px-3 text-sm text-app-text transition-colors outline-none focus-visible:ring-2 focus-visible:ring-app-focus/50 ${
- rangeInvalid ? "border-app-danger-border" : "border-app-border"
- }`}
- />
-
- to
-
- setTo(e.target.value)}
- className={`h-10 rounded-xl border bg-app-bg px-3 text-sm text-app-text transition-colors outline-none focus-visible:ring-2 focus-visible:ring-app-focus/50 ${
- rangeInvalid ? "border-app-danger-border" : "border-app-border"
- }`}
- />
-
- {(from || to) && (
- {
- setFrom("");
- setTo("");
- }}
- className="rounded-lg px-2 py-1 text-xs font-medium text-app-text-muted transition-colors hover:bg-app-surface-hover hover:text-app-text"
- >
- Clear dates
-
- )}
-
-
- {rangeInvalid ? (
-
- The first date is after the second one, so nothing would match.
-
- ) : (
-
- Leave empty to search regardless of when a document was indexed.
-
- )}
-
-
-
- )}
+
+
+
+ Clear all
+
+
+ )}
+
-
-
- {activeFilterCount > 0 && (
-
- {activeFilterCount}
-
- )}
-
+
+
0
+ ? "border-app-brand-border-strong bg-app-brand/10 text-app-brand-text shadow-xs"
+ : "border-app-border-muted bg-app-surface text-app-text-muted hover:bg-app-surface-hover hover:text-app-text"
+ }`}
+ >
+
+ {activeFilterCount > 0 && (
+
+ {activeFilterCount}
+
+ )}
+
+
+ {/* Floating Glass Popover */}
+
+ {showFilters && (
+ {
+ // Prevent Enter key in filter popover from submitting the chat message form
+ if (e.key === "Enter") e.stopPropagation();
+ }}
+ className="absolute bottom-full left-0 z-30 mb-3 w-80 max-w-[calc(100vw-2rem)] rounded-2xl border border-app-border/80 bg-app-surface/95 p-4 shadow-[0_20px_50px_-15px_rgba(0,0,0,0.35)] backdrop-blur-xl sm:w-[380px]"
+ >
+ {/* Popover Header */}
+
+
+
+
+
+
+ Filter Knowledge Sources
+
+
+
+
+ {activeFilterCount > 0 && (
+
+
+ Reset
+
+ )}
+
+
+
+
+
+
+ {/* Sources Selection */}
+
+
+
+ Sources
+
+
+ {sourceSystems.length === 0
+ ? "Searching all sources"
+ : `${sourceSystems.length} selected`}
+
+
+
+ {availableSources.length === 0 ? (
+
+ {sourcesLoading
+ ? "Loading connected sources…"
+ : "No sources are connected yet, so there is nothing to narrow down."}
+
+ ) : (
+
+ {availableSources.map((source) => {
+ const selected = sourceSystems.includes(source);
+ const meta = SOURCE_META[source] ?? {
+ type: source,
+ description: source,
+ icon: Filter,
+ };
+ const Icon = meta.icon;
+
+ return (
+
toggleSourceSystem(source)}
+ className={`group flex items-center justify-between rounded-xl border px-2.5 py-2 text-xs font-medium transition-all ${
+ selected
+ ? "border-app-brand-border-strong bg-app-brand/10 text-app-brand-text shadow-xs"
+ : "border-app-border bg-app-surface text-app-text-muted hover:border-app-border-strong hover:bg-app-surface-hover hover:text-app-text"
+ }`}
+ >
+
+
+ {meta.type}
+
+
+ {selected && }
+
+
+ );
+ })}
+
+ )}
+
+
+ {/* Timeframe Selection */}
+
+
+
+
+
+ Indexed Date
+
+ {(from || to) && (
+ {
+ setFrom("");
+ setTo("");
+ }}
+ className="text-[11px] font-medium text-app-brand-text hover:underline"
+ >
+ Clear dates
+
+ )}
+
+
+ {/* Preset Buttons */}
+
+ {
+ setFrom("");
+ setTo("");
+ }}
+ className={`rounded-lg px-2.5 py-1 text-xs font-medium transition-colors ${
+ isAllTime
+ ? "bg-app-brand font-semibold text-white shadow-xs"
+ : "border border-app-border bg-app-surface text-app-text-muted hover:bg-app-surface-hover hover:text-app-text"
+ }`}
+ >
+ All time
+
+ setPastDays(7)}
+ className={`rounded-lg px-2.5 py-1 text-xs font-medium transition-colors ${
+ isPast7Days
+ ? "bg-app-brand font-semibold text-white shadow-xs"
+ : "border border-app-border bg-app-surface text-app-text-muted hover:bg-app-surface-hover hover:text-app-text"
+ }`}
+ >
+ Past 7 days
+
+ setPastDays(30)}
+ className={`rounded-lg px-2.5 py-1 text-xs font-medium transition-colors ${
+ isPast30Days
+ ? "bg-app-brand font-semibold text-white shadow-xs"
+ : "border border-app-border bg-app-surface text-app-text-muted hover:bg-app-surface-hover hover:text-app-text"
+ }`}
+ >
+ Past 30 days
+
+
+
+ {/* Custom Inputs */}
+
+
+
+ From
+
+ setFrom(e.target.value)}
+ className="w-full min-w-0 bg-transparent text-xs text-app-text outline-none"
+ />
+
+
+
→
+
+
+
+ To
+
+ setTo(e.target.value)}
+ className="w-full min-w-0 bg-transparent text-xs text-app-text outline-none"
+ />
+
+
+
+ {rangeInvalid ? (
+
+ Start date cannot be after end date.
+
+ ) : (
+
+ Filter documents indexed within this date range.
+
+ )}
+
+
+ )}
+
+
{
expect(vi.mocked(getMyChats)).not.toHaveBeenCalled();
});
});
+
+function FilterProbe() {
+ const context = useContext(ChatContext);
+ if (!context) return null;
+
+ return (
+
+ {context.activeFilterCount}
+ context.toggleSourceSystem("GITHUB")}>Add GitHub
+ {
+ context.setFrom("2026-09-01");
+ context.setTo("2026-09-10");
+ }}
+ >
+ Set Range
+
+ Clear
+
+ );
+}
+
+describe("ChatProvider activeFilterCount", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ project.selected = true;
+ vi.mocked(getMyChats).mockResolvedValue({ chats: [] });
+ });
+
+ it("counts a date range (both from and to set) as 1 filter", async () => {
+ render(
+
+
+ ,
+ );
+
+ await waitFor(() => expect(vi.mocked(getMyChats)).toHaveBeenCalledWith("p1"));
+ expect(screen.getByTestId("filter-count")).toHaveTextContent("0");
+
+ // Setting a date range increments count by 1 (not 2)
+ fireEvent.click(screen.getByText("Set Range"));
+ expect(screen.getByTestId("filter-count")).toHaveTextContent("1");
+
+ // Adding a source brings count to 2
+ fireEvent.click(screen.getByText("Add GitHub"));
+ expect(screen.getByTestId("filter-count")).toHaveTextContent("2");
+
+ // Clearing resets count to 0
+ fireEvent.click(screen.getByText("Clear"));
+ expect(screen.getByTestId("filter-count")).toHaveTextContent("0");
+ });
+});
diff --git a/tests/unit/features/chatbot/components/ChatComposer.filters.test.tsx b/tests/unit/features/chatbot/components/ChatComposer.filters.test.tsx
new file mode 100644
index 000000000..249aa572e
--- /dev/null
+++ b/tests/unit/features/chatbot/components/ChatComposer.filters.test.tsx
@@ -0,0 +1,196 @@
+import { createRef } from "react";
+import { render, screen, fireEvent } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, it, expect, vi } from "vitest";
+import { ChatComposer } from "../../../../../src/features/chatbot/components/ChatComposer";
+import type { SourceSystem } from "../../../../../src/features/chatbot/types";
+
+describe("ChatComposer filter interactions", () => {
+ const defaultProps = {
+ value: "",
+ onChange: vi.fn(),
+ onSubmit: vi.fn((e: React.FormEvent) => {
+ e.preventDefault();
+ }),
+ onStop: vi.fn(),
+ isBusy: false,
+ hasProject: true,
+ promptHistory: [],
+ availableSources: ["GITHUB", "JIRA", "UPLOAD"] as SourceSystem[],
+ sourcesLoading: false,
+ textareaRef: createRef(),
+ showFilters: false,
+ onToggleFilters: vi.fn(),
+ from: "",
+ setFrom: vi.fn(),
+ to: "",
+ setTo: vi.fn(),
+ sourceSystems: [] as SourceSystem[],
+ toggleSourceSystem: vi.fn(),
+ activeFilterCount: 0,
+ clearFilters: vi.fn(),
+ };
+
+ it("renders the filter toggle button with badge when filters are active", () => {
+ const { rerender } = render( );
+
+ const toggle = screen.getByTestId("chat-filters-toggle");
+ expect(toggle).toBeInTheDocument();
+ expect(toggle).toHaveAttribute("aria-expanded", "false");
+ expect(screen.queryByText("2")).not.toBeInTheDocument();
+
+ rerender( );
+ expect(screen.getByText("2")).toBeInTheDocument();
+ });
+
+ it("calls onToggleFilters when clicking the filter toggle button", async () => {
+ const onToggleFilters = vi.fn();
+ const user = userEvent.setup();
+
+ render( );
+
+ await user.click(screen.getByTestId("chat-filters-toggle"));
+ expect(onToggleFilters).toHaveBeenCalledTimes(1);
+ });
+
+ it("renders the active filter chips strip when activeFilterCount > 0", async () => {
+ const toggleSourceSystem = vi.fn();
+ const setFrom = vi.fn();
+ const setTo = vi.fn();
+ const clearFilters = vi.fn();
+ const user = userEvent.setup();
+
+ render(
+ ,
+ );
+
+ // Active strip indicator
+ expect(screen.getByText("Filtering:")).toBeInTheDocument();
+
+ // Source chip
+ expect(screen.getByText("GitHub")).toBeInTheDocument();
+ const removeGithub = screen.getByTitle("Remove GitHub filter");
+ await user.click(removeGithub);
+ expect(toggleSourceSystem).toHaveBeenCalledWith("GITHUB");
+
+ // Date chip
+ expect(screen.getByText("2026-09-01 → 2026-09-10")).toBeInTheDocument();
+ const removeDate = screen.getByTitle("Clear date filter");
+ await user.click(removeDate);
+ expect(setFrom).toHaveBeenCalledWith("");
+ expect(setTo).toHaveBeenCalledWith("");
+
+ // Clear all button
+ const clearAll = screen.getByText("Clear all");
+ await user.click(clearAll);
+ expect(clearFilters).toHaveBeenCalledTimes(1);
+ });
+
+ it("renders floating popover when showFilters is true", async () => {
+ const toggleSourceSystem = vi.fn();
+ const setFrom = vi.fn();
+ const setTo = vi.fn();
+ const user = userEvent.setup();
+
+ render(
+ ,
+ );
+
+ expect(screen.getByText("Filter Knowledge Sources")).toBeInTheDocument();
+ expect(screen.getByText("1 selected")).toBeInTheDocument();
+
+ // Available sources rendered
+ const githubBtn = screen.getByRole("button", { name: /github/i });
+ expect(githubBtn).toBeInTheDocument();
+ expect(githubBtn).toHaveAttribute("aria-pressed", "true");
+
+ const jiraBtn = screen.getByRole("button", { name: /jira/i });
+ expect(jiraBtn).toBeInTheDocument();
+ expect(jiraBtn).toHaveAttribute("aria-pressed", "false");
+
+ await user.click(jiraBtn);
+ expect(toggleSourceSystem).toHaveBeenCalledWith("JIRA");
+
+ // Date presets
+ const past7Btn = screen.getByRole("button", { name: "Past 7 days" });
+ await user.click(past7Btn);
+ expect(setFrom).toHaveBeenCalled();
+ expect(setTo).toHaveBeenCalled();
+
+ const allTimeBtn = screen.getByRole("button", { name: "All time" });
+ await user.click(allTimeBtn);
+ expect(setFrom).toHaveBeenCalledWith("");
+ expect(setTo).toHaveBeenCalledWith("");
+ });
+
+ it("updates custom date range inputs in popover", () => {
+ const setFrom = vi.fn();
+ const setTo = vi.fn();
+
+ render(
+ ,
+ );
+
+ const fromInput = screen.getByLabelText("Earliest date");
+ const toInput = screen.getByLabelText("Latest date");
+
+ fireEvent.change(fromInput, { target: { value: "2026-09-05" } });
+ expect(setFrom).toHaveBeenCalledWith("2026-09-05");
+
+ fireEvent.change(toInput, { target: { value: "2026-09-15" } });
+ expect(setTo).toHaveBeenCalledWith("2026-09-15");
+ });
+
+ it("shows error alert when date range is inverted", () => {
+ render( );
+
+ expect(screen.getByRole("alert")).toHaveTextContent("Start date cannot be after end date.");
+ });
+
+ it("closes popover when pressing Escape", () => {
+ const onToggleFilters = vi.fn();
+
+ render( );
+
+ fireEvent.keyDown(document, { key: "Escape" });
+ expect(onToggleFilters).toHaveBeenCalledTimes(1);
+ });
+
+ it("closes popover when clicking outside", () => {
+ const onToggleFilters = vi.fn();
+
+ render(
+
+ Outside
+
+
,
+ );
+
+ fireEvent.pointerDown(screen.getByTestId("outside-element"));
+ expect(onToggleFilters).toHaveBeenCalledTimes(1);
+ });
+});
From 16e8cfbbcdeed4dd8d985163334f9bdf8309110c Mon Sep 17 00:00:00 2001
From: Daniil Perkin
Date: Thu, 17 Sep 2026 17:17:39 +0200
Subject: [PATCH 20/51] fix(test): remove unused act import in ChatProvider
test
---
tests/unit/features/chatbot/ChatProvider.test.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/tests/unit/features/chatbot/ChatProvider.test.tsx b/tests/unit/features/chatbot/ChatProvider.test.tsx
index 17a8a3699..42c1e8b09 100644
--- a/tests/unit/features/chatbot/ChatProvider.test.tsx
+++ b/tests/unit/features/chatbot/ChatProvider.test.tsx
@@ -1,4 +1,4 @@
-import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { useContext } from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { ChatProvider } from "../../../../src/context/ChatProvider";
From e28e6bd8a84e255f962802e25defb1f8defa84ab Mon Sep 17 00:00:00 2001
From: Daniil Perkin
Date: Fri, 18 Sep 2026 18:45:17 +0200
Subject: [PATCH 21/51] fix(chatbot): address review feedback on filter popover
and presets
- Prevent implicit form submission when pressing Enter in date inputs
- Use local date formatting for quick presets to avoid UTC day offset
- Guard preset highlights against false-positive historical date spans
- Add accessible aria-label to filter chip remove buttons and aria-pressed to presets
- Restore red border highlight on date inputs when date range is inverted
- Wrap onToggleFilters in useCallback to prevent document listener churn
- Extract presetClass helper and document SOURCE_META runtime defense
- Update and expand unit tests in ChatComposer.filters.test.tsx
---
.../chatbot/components/ChatComposer.tsx | 87 ++++++++++++++-----
src/pages/ChatPage.tsx | 6 +-
.../components/ChatComposer.filters.test.tsx | 69 ++++++++++++++-
3 files changed, 133 insertions(+), 29 deletions(-)
diff --git a/src/features/chatbot/components/ChatComposer.tsx b/src/features/chatbot/components/ChatComposer.tsx
index f0a823ec6..0dd94a9e5 100644
--- a/src/features/chatbot/components/ChatComposer.tsx
+++ b/src/features/chatbot/components/ChatComposer.tsx
@@ -13,6 +13,13 @@ function formatDateFilterLabel(from: string, to: string): string {
return "";
}
+function formatLocalDate(date: Date): string {
+ const year = date.getFullYear();
+ const month = String(date.getMonth() + 1).padStart(2, "0");
+ const day = String(date.getDate()).padStart(2, "0");
+ return `${year}-${month}-${day}`;
+}
+
type ChatComposerProps = {
/** Current draft text. */
value: string;
@@ -158,11 +165,13 @@ export function ChatComposer({
const setPastDays = (days: number) => {
const now = new Date();
- const past = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
- setFrom(past.toISOString().split("T")[0]);
- setTo(now.toISOString().split("T")[0]);
+ const past = new Date(now.getFullYear(), now.getMonth(), now.getDate() - days);
+ setFrom(formatLocalDate(past));
+ setTo(formatLocalDate(now));
};
+ const today = formatLocalDate(new Date());
+
const diffDays =
from && to
? Math.round(
@@ -172,8 +181,15 @@ export function ChatComposer({
: null;
const isAllTime = !from && !to;
- const isPast7Days = diffDays === 7;
- const isPast30Days = diffDays === 30;
+ const isPast7Days = to === today && diffDays === 7;
+ const isPast30Days = to === today && diffDays === 30;
+
+ const presetClass = (active: boolean) =>
+ `rounded-lg px-2.5 py-1 text-xs font-medium transition-colors ${
+ active
+ ? "bg-app-brand font-semibold text-white shadow-xs"
+ : "border border-app-border bg-app-surface text-app-text-muted hover:bg-app-surface-hover hover:text-app-text"
+ }`;
return (
@@ -193,6 +209,7 @@ export function ChatComposer({
{sourceSystems.map((source) => {
+ // Fallback serves as runtime defense against unmapped or unexpected source values
const meta = SOURCE_META[source] ?? {
type: source,
description: source,
@@ -204,6 +221,7 @@ export function ChatComposer({
key={source}
type="button"
onClick={() => toggleSourceSystem(source)}
+ aria-label={`Remove ${meta.type} filter`}
title={`Remove ${meta.type} filter`}
className="group inline-flex items-center gap-1.5 rounded-full border border-app-brand-border bg-app-brand/10 px-2.5 py-0.5 text-xs font-medium text-app-brand-text transition-colors hover:border-app-danger-border hover:bg-app-danger-bg hover:text-app-danger-text"
>
@@ -221,6 +239,7 @@ export function ChatComposer({
setFrom("");
setTo("");
}}
+ aria-label="Clear date filter"
title="Clear date filter"
className="group inline-flex items-center gap-1.5 rounded-full border border-app-brand-border bg-app-brand/10 px-2.5 py-0.5 text-xs font-medium text-app-brand-text transition-colors hover:border-app-danger-border hover:bg-app-danger-bg hover:text-app-danger-text"
>
@@ -287,8 +306,12 @@ export function ChatComposer({
exit={{ opacity: 0, y: 8, scale: 0.96 }}
transition={centralSpringToken}
onKeyDown={(e) => {
- // Prevent Enter key in filter popover from submitting the chat message form
- if (e.key === "Enter") e.stopPropagation();
+ // Prevent Enter key in inputs from triggering implicit form submission,
+ // while preserving keyboard activation for buttons inside the popover.
+ if (e.key === "Enter" && (e.target as HTMLElement)?.tagName === "INPUT") {
+ e.preventDefault();
+ e.stopPropagation();
+ }
}}
className="absolute bottom-full left-0 z-30 mb-3 w-80 max-w-[calc(100vw-2rem)] rounded-2xl border border-app-border/80 bg-app-surface/95 p-4 shadow-[0_20px_50px_-15px_rgba(0,0,0,0.35)] backdrop-blur-xl sm:w-[380px]"
>
@@ -348,6 +371,7 @@ export function ChatComposer({
{availableSources.map((source) => {
const selected = sourceSystems.includes(source);
+ // Fallback serves as runtime defense against unmapped or unexpected source values
const meta = SOURCE_META[source] ?? {
type: source,
description: source,
@@ -421,37 +445,28 @@ export function ChatComposer({
{
setFrom("");
setTo("");
}}
- className={`rounded-lg px-2.5 py-1 text-xs font-medium transition-colors ${
- isAllTime
- ? "bg-app-brand font-semibold text-white shadow-xs"
- : "border border-app-border bg-app-surface text-app-text-muted hover:bg-app-surface-hover hover:text-app-text"
- }`}
+ className={presetClass(isAllTime)}
>
All time
setPastDays(7)}
- className={`rounded-lg px-2.5 py-1 text-xs font-medium transition-colors ${
- isPast7Days
- ? "bg-app-brand font-semibold text-white shadow-xs"
- : "border border-app-border bg-app-surface text-app-text-muted hover:bg-app-surface-hover hover:text-app-text"
- }`}
+ className={presetClass(isPast7Days)}
>
Past 7 days
setPastDays(30)}
- className={`rounded-lg px-2.5 py-1 text-xs font-medium transition-colors ${
- isPast30Days
- ? "bg-app-brand font-semibold text-white shadow-xs"
- : "border border-app-border bg-app-surface text-app-text-muted hover:bg-app-surface-hover hover:text-app-text"
- }`}
+ className={presetClass(isPast30Days)}
>
Past 30 days
@@ -459,7 +474,13 @@ export function ChatComposer({
{/* Custom Inputs */}
-
+
From
@@ -470,13 +491,25 @@ export function ChatComposer({
max={to || undefined}
value={from}
onChange={(e) => setFrom(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ e.stopPropagation();
+ }
+ }}
className="w-full min-w-0 bg-transparent text-xs text-app-text outline-none"
/>
→
-
+
To
@@ -487,6 +520,12 @@ export function ChatComposer({
min={from || undefined}
value={to}
onChange={(e) => setTo(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ e.stopPropagation();
+ }
+ }}
className="w-full min-w-0 bg-transparent text-xs text-app-text outline-none"
/>
diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx
index 22b6dbe1b..8e9e971a7 100644
--- a/src/pages/ChatPage.tsx
+++ b/src/pages/ChatPage.tsx
@@ -163,6 +163,10 @@ export function ChatPage() {
[newRequest, setNewRequest, handleSubmit],
);
+ const handleToggleFilters = useCallback(() => {
+ setShowFilters((v) => !v);
+ }, [setShowFilters]);
+
// Barrel roll side-effect
useEffect(() => {
if (isBarrelRolling) {
@@ -544,7 +548,7 @@ export function ChatPage() {
sourcesLoading={sourcesLoading}
textareaRef={textareaRef}
showFilters={showFilters}
- onToggleFilters={() => setShowFilters((v) => !v)}
+ onToggleFilters={handleToggleFilters}
from={from}
setFrom={setFrom}
to={to}
diff --git a/tests/unit/features/chatbot/components/ChatComposer.filters.test.tsx b/tests/unit/features/chatbot/components/ChatComposer.filters.test.tsx
index 249aa572e..ded6d6c29 100644
--- a/tests/unit/features/chatbot/components/ChatComposer.filters.test.tsx
+++ b/tests/unit/features/chatbot/components/ChatComposer.filters.test.tsx
@@ -79,13 +79,13 @@ describe("ChatComposer filter interactions", () => {
// Source chip
expect(screen.getByText("GitHub")).toBeInTheDocument();
- const removeGithub = screen.getByTitle("Remove GitHub filter");
+ const removeGithub = screen.getByRole("button", { name: "Remove GitHub filter" });
await user.click(removeGithub);
expect(toggleSourceSystem).toHaveBeenCalledWith("GITHUB");
// Date chip
expect(screen.getByText("2026-09-01 → 2026-09-10")).toBeInTheDocument();
- const removeDate = screen.getByTitle("Clear date filter");
+ const removeDate = screen.getByRole("button", { name: "Clear date filter" });
await user.click(removeDate);
expect(setFrom).toHaveBeenCalledWith("");
expect(setTo).toHaveBeenCalledWith("");
@@ -129,17 +129,73 @@ describe("ChatComposer filter interactions", () => {
expect(toggleSourceSystem).toHaveBeenCalledWith("JIRA");
// Date presets
+ const allTimeBtn = screen.getByRole("button", { name: "All time" });
+ expect(allTimeBtn).toHaveAttribute("aria-pressed", "true");
+
const past7Btn = screen.getByRole("button", { name: "Past 7 days" });
+ expect(past7Btn).toHaveAttribute("aria-pressed", "false");
+
await user.click(past7Btn);
expect(setFrom).toHaveBeenCalled();
expect(setTo).toHaveBeenCalled();
- const allTimeBtn = screen.getByRole("button", { name: "All time" });
await user.click(allTimeBtn);
expect(setFrom).toHaveBeenCalledWith("");
expect(setTo).toHaveBeenCalledWith("");
});
+ it("does not highlight presets when a 7-day range is historical and does not end today", () => {
+ render(
);
+
+ const past7Btn = screen.getByRole("button", { name: "Past 7 days" });
+ expect(past7Btn).toHaveAttribute("aria-pressed", "false");
+
+ const allTimeBtn = screen.getByRole("button", { name: "All time" });
+ expect(allTimeBtn).toHaveAttribute("aria-pressed", "false");
+ });
+
+ it("prevents form submission when Enter is pressed inside date inputs", () => {
+ const onSubmit = vi.fn((e: React.FormEvent) => e.preventDefault());
+
+ render(
+
,
+ );
+
+ const fromInput = screen.getByLabelText("Earliest date");
+ const toInput = screen.getByLabelText("Latest date");
+
+ fireEvent.keyDown(fromInput, { key: "Enter" });
+ expect(onSubmit).not.toHaveBeenCalled();
+
+ fireEvent.keyDown(toInput, { key: "Enter" });
+ expect(onSubmit).not.toHaveBeenCalled();
+ });
+
+ it("allows keyboard activation on preset buttons with Enter", async () => {
+ const setPastDaysFn = vi.fn();
+ const user = userEvent.setup();
+
+ render(
+
,
+ );
+
+ const past7Btn = screen.getByRole("button", { name: "Past 7 days" });
+ past7Btn.focus();
+ await user.keyboard("{Enter}");
+
+ expect(setPastDaysFn).toHaveBeenCalled();
+ });
+
it("updates custom date range inputs in popover", () => {
const setFrom = vi.fn();
const setTo = vi.fn();
@@ -165,10 +221,15 @@ describe("ChatComposer filter interactions", () => {
expect(setTo).toHaveBeenCalledWith("2026-09-15");
});
- it("shows error alert when date range is inverted", () => {
+ it("shows error alert and styles input borders with danger color when date range is inverted", () => {
render(
);
expect(screen.getByRole("alert")).toHaveTextContent("Start date cannot be after end date.");
+
+ const fromInput = screen.getByLabelText("Earliest date");
+ const toInput = screen.getByLabelText("Latest date");
+ expect(fromInput.closest("div")).toHaveClass("border-app-danger-border");
+ expect(toInput.closest("div")).toHaveClass("border-app-danger-border");
});
it("closes popover when pressing Escape", () => {
From 7b4ec80079c095723794e80e771dd9aaa9300b26 Mon Sep 17 00:00:00 2001
From: Daniil Perkin
Date: Fri, 18 Sep 2026 19:41:36 +0200
Subject: [PATCH 22/51] feat(chat): add reply option to quote AI chat response
(#227)
---
src/context/ChatContext.ts | 12 +++
src/context/ChatProvider.tsx | 20 +++++
.../board/selection/SelectionActions.tsx | 48 ++++++++----
.../board/selection/selectionCapture.ts | 14 ++++
.../chatbot/components/MessageRow.tsx | 1 +
src/features/chatbot/hooks/useChat.ts | 15 ++++
src/features/chatbot/utils/quoteFormat.ts | 30 ++++++++
.../industry/ProjectIndustryPanel.tsx | 5 +-
.../hooks/useStarterWorkReview.ts | 3 +-
src/services/projectService.ts | 6 +-
.../features/board/SelectionActions.test.tsx | 76 ++++++++++++++++++-
.../features/board/selectionCapture.test.ts | 17 +++++
.../unit/features/chatbot/quoteFormat.test.ts | 55 ++++++++++++++
.../industry/ProjectIndustryWidget.test.tsx | 4 +-
tests/unit/pages/ChatPage.test.tsx | 4 +-
.../unit/services/starterWorkService.test.ts | 7 +-
16 files changed, 289 insertions(+), 28 deletions(-)
create mode 100644 src/features/chatbot/utils/quoteFormat.ts
create mode 100644 tests/unit/features/chatbot/quoteFormat.test.ts
diff --git a/src/context/ChatContext.ts b/src/context/ChatContext.ts
index df4a645a1..4bc8fbd54 100644
--- a/src/context/ChatContext.ts
+++ b/src/context/ChatContext.ts
@@ -54,6 +54,18 @@ export type ChatContextValue = {
newRequest: string;
setNewRequest: (value: string) => void;
+ /**
+ * Registers a callback to focus the composer textarea and place the caret at the end.
+ * Called by useChat when mounted, cleaned up on unmount.
+ */
+ registerFocusComposer: (fn: () => void) => () => void;
+
+ /**
+ * Quotes the selected AI message text into the chat composer as a Markdown blockquote,
+ * focuses the textarea, and places the caret at the end.
+ */
+ quoteSelection: (text: string) => void;
+
showFilters: boolean;
setShowFilters: (value: boolean | ((prev: boolean) => boolean)) => void;
diff --git a/src/context/ChatProvider.tsx b/src/context/ChatProvider.tsx
index 7aa6e5002..84605c875 100644
--- a/src/context/ChatProvider.tsx
+++ b/src/context/ChatProvider.tsx
@@ -12,6 +12,7 @@ import { useProjectContext } from "../features/projects/useProjectContext";
import { ChatContext } from "./ChatContext";
import type { ChatContextValue, SelectedCitation } from "./ChatContext";
import type { Chat, ChatMessage, Citation, SourceSystem } from "../features/chatbot/types";
+import { insertQuoteIntoDraft } from "../features/chatbot/utils/quoteFormat";
type MessagesByChat = Record;
@@ -86,6 +87,23 @@ export function ChatProvider({ children }: { children: ReactNode }) {
const [selectedCitation, setSelectedCitation] = useState(null);
const [newRequest, setNewRequest] = useState("");
+ const focusComposerFnRef = useRef<(() => void) | null>(null);
+
+ const registerFocusComposer = useCallback((fn: () => void) => {
+ focusComposerFnRef.current = fn;
+ return () => {
+ if (focusComposerFnRef.current === fn) {
+ focusComposerFnRef.current = null;
+ }
+ };
+ }, []);
+
+ const quoteSelection = useCallback((text: string) => {
+ setNewRequest((prev) => insertQuoteIntoDraft(prev, text));
+ requestAnimationFrame(() => {
+ focusComposerFnRef.current?.();
+ });
+ }, []);
const [showFilters, setShowFilters] = useState(false);
@@ -744,6 +762,8 @@ export function ChatProvider({ children }: { children: ReactNode }) {
setSelectedCitation,
newRequest,
setNewRequest,
+ registerFocusComposer,
+ quoteSelection,
showFilters,
setShowFilters,
from,
diff --git a/src/features/board/selection/SelectionActions.tsx b/src/features/board/selection/SelectionActions.tsx
index 58028264b..a6a3be8f0 100644
--- a/src/features/board/selection/SelectionActions.tsx
+++ b/src/features/board/selection/SelectionActions.tsx
@@ -1,9 +1,10 @@
-import { useCallback, useState } from "react";
+import { useCallback, useContext, useState } from "react";
import { useNavigate } from "react-router-dom";
-import { BookmarkPlus, Eraser, Highlighter } from "lucide-react";
+import { BookmarkPlus, Eraser, Highlighter, Reply } from "lucide-react";
import { Button } from "../../../components/ui/Button";
import { useToast } from "../../../context/useToast";
import { useProjectContext } from "../../projects/useProjectContext";
+import { ChatContext } from "../../../context/ChatContext";
import { boardService } from "../../../services/boardService";
import { rememberOrigin } from "../layout/cardOrigins";
import { useCardMarks } from "../marks/useCardMarks";
@@ -32,6 +33,8 @@ export function SelectionActions() {
const { selection, clear } = useTextSelection();
const { selectedProjectId } = useProjectContext();
const { canMark, colorAt, enclosingColorAt, mark, unmark } = useCardMarks();
+ const chatContext = useContext(ChatContext);
+ const quoteSelection = chatContext?.quoteSelection;
const [saving, setSaving] = useState(false);
const toast = useToast();
const navigate = useNavigate();
@@ -66,9 +69,10 @@ export function SelectionActions() {
}
}, [selection, selectedProjectId, toast, navigate, clear]);
- // Nothing to keep, or nowhere to keep it. A hire on no project has no board, and an offer that
- // can only fail is worse than no offer.
- if (!selection || !selectedProjectId) return null;
+ if (!selection) return null;
+
+ // Nothing to offer: text on no card with no project to keep it on, and not an AI message to reply to.
+ if (!selection.cardId && !selectedProjectId && !selection.isAiMessage) return null;
/**
* Whether this selection is text on a card the board is already holding.
@@ -191,15 +195,31 @@ export function SelectionActions() {
) : (
- void add()}
- loading={saving}
- icon={ }
- >
- Add to board
-
+ {selection.isAiMessage && quoteSelection && (
+ {
+ quoteSelection(selection.text);
+ clear();
+ }}
+ icon={ }
+ >
+ Reply
+
+ )}
+
+ {selectedProjectId && (
+ void add()}
+ loading={saving}
+ icon={ }
+ >
+ Add to board
+
+ )}
)}
diff --git a/src/features/board/selection/selectionCapture.ts b/src/features/board/selection/selectionCapture.ts
index f37b0042c..ca456fc96 100644
--- a/src/features/board/selection/selectionCapture.ts
+++ b/src/features/board/selection/selectionCapture.ts
@@ -46,6 +46,8 @@ export type CapturedSelection = {
inLink: boolean;
/** Where the toolbar should sit, in viewport coordinates. */
rect: DOMRect;
+ /** Whether the selection was made inside an AI assistant chat message. */
+ isAiMessage: boolean;
};
/** Anything shorter is a stray click or a double-click that caught a space, not a selection. */
@@ -76,6 +78,7 @@ export function captureSelection(selection: Selection | null): CapturedSelection
cardId: elementOf(anchor)?.closest("[data-card-id]")?.getAttribute("data-card-id") ?? null,
inLink: Boolean(elementOf(anchor)?.closest("a")),
rect: range.getBoundingClientRect(),
+ isAiMessage: isInsideAiMessage(anchor, range),
};
}
@@ -191,3 +194,14 @@ function isInsideEditable(node: Node): boolean {
function elementOf(node: Node): Element | null {
return node.nodeType === Node.ELEMENT_NODE ? (node as Element) : node.parentElement;
}
+
+/**
+ * Whether the selection is located within an AI assistant message bubble.
+ */
+function isInsideAiMessage(anchor: Node, range: Range): boolean {
+ const ancestorEl = elementOf(range.commonAncestorContainer);
+ if (ancestorEl?.closest("[data-chat-message-role='ASSISTANT']")) return true;
+
+ const anchorEl = elementOf(anchor);
+ return Boolean(anchorEl?.closest("[data-chat-message-role='ASSISTANT']"));
+}
diff --git a/src/features/chatbot/components/MessageRow.tsx b/src/features/chatbot/components/MessageRow.tsx
index 0af134a75..af976a66c 100644
--- a/src/features/chatbot/components/MessageRow.tsx
+++ b/src/features/chatbot/components/MessageRow.tsx
@@ -143,6 +143,7 @@ function MessageRowImpl({
// E5: mark the actively-streaming message as busy so
// screen readers don't announce partial content mid-stream.
aria-busy={showStreamingCaret || undefined}
+ data-chat-message-role={message.role}
className={`chat-md rounded-2xl px-4 py-2.5 text-sm leading-relaxed shadow-sm ${
isRequest
? "chat-md-user rounded-tr-sm bg-app-brand text-white"
diff --git a/src/features/chatbot/hooks/useChat.ts b/src/features/chatbot/hooks/useChat.ts
index 5fdb55aaf..f4fdaf9e4 100644
--- a/src/features/chatbot/hooks/useChat.ts
+++ b/src/features/chatbot/hooks/useChat.ts
@@ -129,6 +129,8 @@ export function useChat() {
setSelectedCitation,
newRequest,
setNewRequest,
+ registerFocusComposer,
+ quoteSelection,
showFilters,
setShowFilters,
from,
@@ -144,6 +146,17 @@ export function useChat() {
deleteChat: ctxDeleteChat,
} = ctx;
+ useEffect(() => {
+ return registerFocusComposer(() => {
+ const el = textareaRef.current;
+ if (!el) return;
+ el.focus();
+ el.setSelectionRange(el.value.length, el.value.length);
+ el.style.height = "auto";
+ el.style.height = `${el.scrollHeight}px`;
+ });
+ }, [registerFocusComposer]);
+
/**
* When the user navigates to `/chat` (no chatId) from the main sidebar
* and they already have conversations, redirect to the most recent one
@@ -440,6 +453,8 @@ export function useChat() {
bottomRef,
scrollContainerRef,
+ quoteSelection,
+
isAtBottom,
scrollToBottom,
diff --git a/src/features/chatbot/utils/quoteFormat.ts b/src/features/chatbot/utils/quoteFormat.ts
new file mode 100644
index 000000000..52df037bc
--- /dev/null
+++ b/src/features/chatbot/utils/quoteFormat.ts
@@ -0,0 +1,30 @@
+/**
+ * Formats a text snippet as a Markdown blockquote by prefixing each line with `> `.
+ */
+export function formatMarkdownQuote(text: string): string {
+ const trimmed = text.trim();
+ if (!trimmed) return "";
+
+ return trimmed
+ .split("\n")
+ .map((line) => (line.startsWith(">") ? line : `> ${line}`))
+ .join("\n");
+}
+
+/**
+ * Inserts a quoted AI response at the top of the current draft.
+ *
+ * Markdown blockquotes require a blank line separation before normal prose,
+ * otherwise subsequent text is treated as a lazy continuation of the quote block.
+ */
+export function insertQuoteIntoDraft(currentDraft: string, quoteText: string): string {
+ const formattedQuote = formatMarkdownQuote(quoteText);
+ if (!formattedQuote) return currentDraft;
+
+ const trimmedDraft = currentDraft.trim();
+ if (!trimmedDraft) {
+ return `${formattedQuote}\n\n`;
+ }
+
+ return `${formattedQuote}\n\n${trimmedDraft}`;
+}
diff --git a/src/features/projects/industry/ProjectIndustryPanel.tsx b/src/features/projects/industry/ProjectIndustryPanel.tsx
index dc7f3d615..90e6b8888 100644
--- a/src/features/projects/industry/ProjectIndustryPanel.tsx
+++ b/src/features/projects/industry/ProjectIndustryPanel.tsx
@@ -170,7 +170,10 @@ export function ProjectIndustryPanel({
{industry || "Not determined yet"}
{industry && (
-
+
)}
>
)}
diff --git a/src/features/starter-work/hooks/useStarterWorkReview.ts b/src/features/starter-work/hooks/useStarterWorkReview.ts
index bce2eed68..b4e394f5c 100644
--- a/src/features/starter-work/hooks/useStarterWorkReview.ts
+++ b/src/features/starter-work/hooks/useStarterWorkReview.ts
@@ -16,8 +16,7 @@ function toMessage(error: unknown, fallback: string): string {
export type TaskOrigin = "authored" | "picked";
type ReviewAction =
- | { kind: "generate"; projectId: string }
- | { kind: "create"; input: CreateStarterWorkTaskInput };
+ { kind: "generate"; projectId: string } | { kind: "create"; input: CreateStarterWorkTaskInput };
const NO_TASKS: StarterWorkTask[] = [];
diff --git a/src/services/projectService.ts b/src/services/projectService.ts
index 8489862c7..ac06e95de 100644
--- a/src/services/projectService.ts
+++ b/src/services/projectService.ts
@@ -538,7 +538,11 @@ export const projectService = {
async setProjectIndustry(
projectId: string,
industry: string,
- ): Promise<{ industry: string; industryConfidence: IndustryConfidence | null; industryCustom: boolean }> {
+ ): Promise<{
+ industry: string;
+ industryConfidence: IndustryConfidence | null;
+ industryCustom: boolean;
+ }> {
const result = await apiClient.fetch<{
industry: string | null;
industryConfidence: string | null;
diff --git a/tests/unit/features/board/SelectionActions.test.tsx b/tests/unit/features/board/SelectionActions.test.tsx
index 9912c92b5..77282f440 100644
--- a/tests/unit/features/board/SelectionActions.test.tsx
+++ b/tests/unit/features/board/SelectionActions.test.tsx
@@ -4,6 +4,7 @@ import { MemoryRouter } from "react-router-dom";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { SelectionActions } from "../../../../src/features/board/selection/SelectionActions";
import { boardService } from "../../../../src/services/boardService";
+import { ChatContext, type ChatContextValue } from "../../../../src/context/ChatContext";
const navigate = vi.fn();
vi.mock("react-router-dom", async () => {
@@ -19,6 +20,11 @@ vi.mock("../../../../src/features/projects/useProjectContext", () => ({
const toast = { success: vi.fn(), error: vi.fn() };
vi.mock("../../../../src/context/useToast", () => ({ useToast: () => toast }));
+const mockQuoteSelection = vi.fn();
+const mockChatContext = {
+ quoteSelection: mockQuoteSelection,
+} as unknown as ChatContextValue;
+
/**
* The toolbar, at the level a hire meets it: highlight something, press the button, and find out
* whether it landed. The decisions about *what* the card becomes are covered on their own in
@@ -51,7 +57,9 @@ describe("SelectionActions", () => {
function renderToolbar() {
return render(
-
+
+
+
,
);
}
@@ -109,11 +117,75 @@ describe("SelectionActions", () => {
});
/** A hire on no project has no board, and an offer that can only fail is worse than none. */
- it("offers nothing when no project is selected", () => {
+ it("offers nothing when no project is selected and not an AI message", () => {
selectedProjectId = "";
renderToolbar();
highlight("Run the migration first.");
expect(screen.queryByRole("toolbar")).not.toBeInTheDocument();
});
+
+ it("offers Reply button when selecting text in an AI assistant message", async () => {
+ renderToolbar();
+ const container = document.createElement("div");
+ container.setAttribute("data-chat-message-role", "ASSISTANT");
+ const p = document.createElement("p");
+ p.textContent = "AI generated response.";
+ container.appendChild(p);
+ document.body.appendChild(container);
+
+ const range = document.createRange();
+ range.selectNodeContents(p.firstChild!);
+ const selection = window.getSelection()!;
+ selection.removeAllRanges();
+ selection.addRange(range);
+ document.dispatchEvent(new Event("selectionchange"));
+
+ expect(await screen.findByRole("button", { name: /reply/i })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /add to board/i })).toBeInTheDocument();
+ });
+
+ it("calls quoteSelection and clears selection when Reply is clicked", async () => {
+ renderToolbar();
+ const container = document.createElement("div");
+ container.setAttribute("data-chat-message-role", "ASSISTANT");
+ const p = document.createElement("p");
+ p.textContent = "AI generated response.";
+ container.appendChild(p);
+ document.body.appendChild(container);
+
+ const range = document.createRange();
+ range.selectNodeContents(p.firstChild!);
+ const selection = window.getSelection()!;
+ selection.removeAllRanges();
+ selection.addRange(range);
+ document.dispatchEvent(new Event("selectionchange"));
+
+ const replyButton = await screen.findByRole("button", { name: /reply/i });
+ await userEvent.click(replyButton);
+
+ expect(mockQuoteSelection).toHaveBeenCalledWith("AI generated response.");
+ expect(window.getSelection()?.rangeCount).toBe(0);
+ });
+
+ it("offers Reply button when no project is selected if text is in an AI message", async () => {
+ selectedProjectId = "";
+ renderToolbar();
+ const container = document.createElement("div");
+ container.setAttribute("data-chat-message-role", "ASSISTANT");
+ const p = document.createElement("p");
+ p.textContent = "AI generated response.";
+ container.appendChild(p);
+ document.body.appendChild(container);
+
+ const range = document.createRange();
+ range.selectNodeContents(p.firstChild!);
+ const selection = window.getSelection()!;
+ selection.removeAllRanges();
+ selection.addRange(range);
+ document.dispatchEvent(new Event("selectionchange"));
+
+ expect(await screen.findByRole("button", { name: /reply/i })).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: /add to board/i })).not.toBeInTheDocument();
+ });
});
diff --git a/tests/unit/features/board/selectionCapture.test.ts b/tests/unit/features/board/selectionCapture.test.ts
index d32c6c5f4..8376be882 100644
--- a/tests/unit/features/board/selectionCapture.test.ts
+++ b/tests/unit/features/board/selectionCapture.test.ts
@@ -51,6 +51,23 @@ describe("selectionCapture", () => {
document.body.innerHTML = "
one\n two
";
expect(capture("#t").text).toBe("one two");
});
+
+ it("identifies when selection is inside an AI assistant message", () => {
+ document.body.innerHTML =
+ "
";
+ expect(capture("#t").isAiMessage).toBe(true);
+ });
+
+ it("identifies when selection is inside a user message", () => {
+ document.body.innerHTML =
+ "
";
+ expect(capture("#t").isAiMessage).toBe(false);
+ });
+
+ it("marks isAiMessage false for generic text outside chat", () => {
+ document.body.innerHTML = "
";
+ expect(capture("#t").isAiMessage).toBe(false);
+ });
});
describe("links", () => {
diff --git a/tests/unit/features/chatbot/quoteFormat.test.ts b/tests/unit/features/chatbot/quoteFormat.test.ts
new file mode 100644
index 000000000..b47f082b4
--- /dev/null
+++ b/tests/unit/features/chatbot/quoteFormat.test.ts
@@ -0,0 +1,55 @@
+import { describe, it, expect } from "vitest";
+import {
+ formatMarkdownQuote,
+ insertQuoteIntoDraft,
+} from "../../../../src/features/chatbot/utils/quoteFormat";
+
+describe("quoteFormat", () => {
+ describe("formatMarkdownQuote", () => {
+ it("returns an empty string when given empty or whitespace text", () => {
+ expect(formatMarkdownQuote("")).toBe("");
+ expect(formatMarkdownQuote(" \n ")).toBe("");
+ });
+
+ it("prefixes a single line with `> `", () => {
+ expect(formatMarkdownQuote("Hello world")).toBe("> Hello world");
+ });
+
+ it("prefixes every line in multi-line text with `> `", () => {
+ const input = "First line\nSecond line\nThird line";
+ const expected = "> First line\n> Second line\n> Third line";
+ expect(formatMarkdownQuote(input)).toBe(expected);
+ });
+
+ it("preserves lines that already have blockquote syntax", () => {
+ const input = "> Already quoted\nNew line";
+ const expected = "> Already quoted\n> New line";
+ expect(formatMarkdownQuote(input)).toBe(expected);
+ });
+ });
+
+ describe("insertQuoteIntoDraft", () => {
+ it("returns formatted quote followed by double newlines when draft is empty", () => {
+ const result = insertQuoteIntoDraft("", "Quoted message");
+ expect(result).toBe("> Quoted message\n\n");
+ });
+
+ it("prepends the quote at the top when draft already contains text", () => {
+ const currentDraft = "What does this mean?";
+ const result = insertQuoteIntoDraft(currentDraft, "AI explanation here");
+ expect(result).toBe("> AI explanation here\n\nWhat does this mean?");
+ });
+
+ it("handles multi-line quotes prepended to existing draft", () => {
+ const currentDraft = "My question.";
+ const quote = "Line 1\nLine 2";
+ const result = insertQuoteIntoDraft(currentDraft, quote);
+ expect(result).toBe("> Line 1\n> Line 2\n\nMy question.");
+ });
+
+ it("returns current draft untouched if quote text is empty", () => {
+ expect(insertQuoteIntoDraft("Draft text", "")).toBe("Draft text");
+ expect(insertQuoteIntoDraft("Draft text", " ")).toBe("Draft text");
+ });
+ });
+});
diff --git a/tests/unit/features/projects/industry/ProjectIndustryWidget.test.tsx b/tests/unit/features/projects/industry/ProjectIndustryWidget.test.tsx
index b0e5ef740..70fe71c84 100644
--- a/tests/unit/features/projects/industry/ProjectIndustryWidget.test.tsx
+++ b/tests/unit/features/projects/industry/ProjectIndustryWidget.test.tsx
@@ -375,9 +375,7 @@ describe("ProjectIndustryWidget", () => {
await user.click(screen.getByTestId("save-industry-button"));
await waitFor(() =>
- expect(
- within(toastStack()).getByText("You may not edit this project."),
- ).toBeInTheDocument(),
+ expect(within(toastStack()).getByText("You may not edit this project.")).toBeInTheDocument(),
);
expect(screen.getByTestId("industry-edit-input")).toHaveValue("Healthcare");
});
diff --git a/tests/unit/pages/ChatPage.test.tsx b/tests/unit/pages/ChatPage.test.tsx
index 0730a66cb..f7480e961 100644
--- a/tests/unit/pages/ChatPage.test.tsx
+++ b/tests/unit/pages/ChatPage.test.tsx
@@ -90,13 +90,15 @@ describe("ChatPage", () => {
});
it("renders the message list with user and assistant messages", () => {
- render(
+ const { container } = render(
,
);
expect(screen.getByText("Hello bot")).toBeInTheDocument();
expect(screen.getByText("Hi there")).toBeInTheDocument();
+ expect(container.querySelector('[data-chat-message-role="ASSISTANT"]')).toBeInTheDocument();
+ expect(container.querySelector('[data-chat-message-role="USER"]')).toBeInTheDocument();
});
/*
diff --git a/tests/unit/services/starterWorkService.test.ts b/tests/unit/services/starterWorkService.test.ts
index d60bd2799..2d60380b0 100644
--- a/tests/unit/services/starterWorkService.test.ts
+++ b/tests/unit/services/starterWorkService.test.ts
@@ -14,10 +14,9 @@ describe("starterWorkService", () => {
await starterWorkService.generate("p1");
- expect(fetchSpy).toHaveBeenCalledWith(
- "/api/v1/onboarding/starter-work/generate?projectId=p1",
- { method: "POST" },
- );
+ expect(fetchSpy).toHaveBeenCalledWith("/api/v1/onboarding/starter-work/generate?projectId=p1", {
+ method: "POST",
+ });
});
it("reads candidate issues scoped to the project", async () => {
From 3265bbc92482b3a25769ac8a93478651bbe68c8a Mon Sep 17 00:00:00 2001
From: Daniil Perkin
Date: Fri, 18 Sep 2026 19:52:57 +0200
Subject: [PATCH 23/51] fix(chat): append quote at bottom under existing draft
text
---
src/features/chatbot/utils/quoteFormat.ts | 10 +++++-----
tests/unit/features/chatbot/quoteFormat.test.ts | 10 +++++-----
2 files changed, 10 insertions(+), 10 deletions(-)
diff --git a/src/features/chatbot/utils/quoteFormat.ts b/src/features/chatbot/utils/quoteFormat.ts
index 52df037bc..2c7f276ec 100644
--- a/src/features/chatbot/utils/quoteFormat.ts
+++ b/src/features/chatbot/utils/quoteFormat.ts
@@ -1,4 +1,4 @@
-/**
+/**
* Formats a text snippet as a Markdown blockquote by prefixing each line with `> `.
*/
export function formatMarkdownQuote(text: string): string {
@@ -12,19 +12,19 @@ export function formatMarkdownQuote(text: string): string {
}
/**
- * Inserts a quoted AI response at the top of the current draft.
+ * Inserts a quoted AI response at the bottom of the current draft.
*
- * Markdown blockquotes require a blank line separation before normal prose,
+ * Markdown blockquotes require a blank line separation before and after normal prose,
* otherwise subsequent text is treated as a lazy continuation of the quote block.
*/
export function insertQuoteIntoDraft(currentDraft: string, quoteText: string): string {
const formattedQuote = formatMarkdownQuote(quoteText);
if (!formattedQuote) return currentDraft;
- const trimmedDraft = currentDraft.trim();
+ const trimmedDraft = currentDraft.trimEnd();
if (!trimmedDraft) {
return `${formattedQuote}\n\n`;
}
- return `${formattedQuote}\n\n${trimmedDraft}`;
+ return `${trimmedDraft}\n\n${formattedQuote}\n\n`;
}
diff --git a/tests/unit/features/chatbot/quoteFormat.test.ts b/tests/unit/features/chatbot/quoteFormat.test.ts
index b47f082b4..bea3282c8 100644
--- a/tests/unit/features/chatbot/quoteFormat.test.ts
+++ b/tests/unit/features/chatbot/quoteFormat.test.ts
@@ -1,4 +1,4 @@
-import { describe, it, expect } from "vitest";
+import { describe, it, expect } from "vitest";
import {
formatMarkdownQuote,
insertQuoteIntoDraft,
@@ -34,17 +34,17 @@ describe("quoteFormat", () => {
expect(result).toBe("> Quoted message\n\n");
});
- it("prepends the quote at the top when draft already contains text", () => {
+ it("appends the quote at the bottom when draft already contains text", () => {
const currentDraft = "What does this mean?";
const result = insertQuoteIntoDraft(currentDraft, "AI explanation here");
- expect(result).toBe("> AI explanation here\n\nWhat does this mean?");
+ expect(result).toBe("What does this mean?\n\n> AI explanation here\n\n");
});
- it("handles multi-line quotes prepended to existing draft", () => {
+ it("handles multi-line quotes appended to existing draft", () => {
const currentDraft = "My question.";
const quote = "Line 1\nLine 2";
const result = insertQuoteIntoDraft(currentDraft, quote);
- expect(result).toBe("> Line 1\n> Line 2\n\nMy question.");
+ expect(result).toBe("My question.\n\n> Line 1\n> Line 2\n\n");
});
it("returns current draft untouched if quote text is empty", () => {
From dd3bf4d8a8ce65513a79719f576914fbc40f2aa6 Mon Sep 17 00:00:00 2001
From: Daniil Perkin
Date: Sat, 19 Sep 2026 16:52:23 +0200
Subject: [PATCH 24/51] fix(chat): keep an answer's paragraphs in the Reply
quote
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Review on #229 flagged that formatMarkdownQuote's per-line splitting was
dead code on the real path: the only input it ever receives is the
selection text from captureSelection, and that runs through normalise(),
which collapses every whitespace run — newlines included — before it gets
there. A two-paragraph answer arrived as one line and was quoted as one
line; only the synthetic multi-line strings in the unit tests ever
exercised the splitting.
The quote now keeps the answer's line structure:
- captureSelection captures the raw selection text a second time, with
line breaks kept, into a new quoteText field. `text` stays collapsed —
cards, highlights and text fragments all want one line of words, and
the mark matching reads exactly the text that was stored.
- The Reply offer sends quoteSelection the quoteText, not the text.
- formatMarkdownQuote treats each line as a paragraph and separates the
quoted lines with an empty quoted line. Two consecutive `>` lines
without one render as a single paragraph with a soft line break, not
as the paragraphs the answer had, so the separator is what makes the
structure survive the composer.
isInsideAiMessage, also flagged in the review: it answered
direction-dependently. It checked the range's common ancestor first and
fell back to the drag's anchor, so a selection starting inside an answer
and ending in the next message counted as "reply to the AI" — while the
same visual selection dragged the other way did not. It now requires
both range ends to resolve to the same assistant bubble: independent of
direction, and a quote that would carry the hire's own words is not
offered either way.
Tests: an end-to-end capture test feeds captureSelection a selection
whose toString carries the paragraph breaks a real browser inserts
between blocks (jsdom's toString inserts none, so it is stated) and
follows the quote all the way into the draft; cross-bubble selections
are covered in both drag directions plus the two-answers case; the
quoteFormat contract tests now describe the paragraph-per-line shape.
The review's third note (the loosened early-return in SelectionActions)
traced clean: the only way the toolbar shows without a selected project
is an AI-message selection, the Reply button is gated on quoteSelection,
which ChatProvider always provides (it wraps all of AppContent), and
add() guards the project id itself.
---
.../board/selection/SelectionActions.tsx | 4 +-
.../board/selection/selectionCapture.ts | 49 ++++++--
src/features/chatbot/utils/quoteFormat.ts | 19 ++-
.../features/board/selectionCapture.test.ts | 112 ++++++++++++++++++
.../unit/features/chatbot/quoteFormat.test.ts | 16 ++-
5 files changed, 179 insertions(+), 21 deletions(-)
diff --git a/src/features/board/selection/SelectionActions.tsx b/src/features/board/selection/SelectionActions.tsx
index a6a3be8f0..b9a3edddb 100644
--- a/src/features/board/selection/SelectionActions.tsx
+++ b/src/features/board/selection/SelectionActions.tsx
@@ -200,7 +200,9 @@ export function SelectionActions() {
size="sm"
variant="ghost"
onClick={() => {
- quoteSelection(selection.text);
+ // quoteText rather than text: it keeps the answer's paragraph breaks, which
+ // `text` has collapsed away for the card paths.
+ quoteSelection(selection.quoteText);
clear();
}}
icon={ }
diff --git a/src/features/board/selection/selectionCapture.ts b/src/features/board/selection/selectionCapture.ts
index ca456fc96..1d510f691 100644
--- a/src/features/board/selection/selectionCapture.ts
+++ b/src/features/board/selection/selectionCapture.ts
@@ -14,6 +14,15 @@ import { composeNote, normalise } from "../generation/noteComposition";
export type CapturedSelection = {
/** The selected text, trimmed and with collapsed whitespace runs. */
text: string;
+ /**
+ * The selected text with its line structure kept, for the reply-to-AI quote.
+ *
+ * [text] collapses every whitespace run because cards and highlights want one line of words; a
+ * blockquote of a two-paragraph answer, though, is worth two paragraphs. The Reply offer sends
+ * this instead, and the chatbot's `formatMarkdownQuote` turns each line into its own quoted
+ * paragraph.
+ */
+ quoteText: string;
/** The link the selection sits in or is, when it is one. */
url: string | null;
/** Where in the app it came from, in words. Null when nothing better than the app name exists. */
@@ -63,7 +72,8 @@ const MIN_SELECTION_LENGTH = 2;
export function captureSelection(selection: Selection | null): CapturedSelection | null {
if (!selection || selection.isCollapsed || selection.rangeCount === 0) return null;
- const text = normalise(selection.toString());
+ const rawText = selection.toString();
+ const text = normalise(rawText);
if (text.length < MIN_SELECTION_LENGTH) return null;
const anchor = selection.anchorNode;
@@ -72,16 +82,34 @@ export function captureSelection(selection: Selection | null): CapturedSelection
const range = selection.getRangeAt(0);
return {
text,
+ quoteText: selectionQuoteText(rawText),
url: linkFor(anchor, text),
source: sourceFor(anchor),
origin: originUrl(window.location, text),
cardId: elementOf(anchor)?.closest("[data-card-id]")?.getAttribute("data-card-id") ?? null,
inLink: Boolean(elementOf(anchor)?.closest("a")),
rect: range.getBoundingClientRect(),
- isAiMessage: isInsideAiMessage(anchor, range),
+ isAiMessage: isInsideAiMessage(range),
};
}
+/**
+ * The quote-ready form of a selection's raw text: line structure kept, everything else tidied.
+ *
+ * A browser's selection `toString` puts a newline at block boundaries — paragraphs, list items —
+ * and hands back the stray spacing a drag across inline markup picks up. The quote keeps the
+ * paragraph breaks, because that is the whole point of replying with a quote, while dropping
+ * empty lines and collapsing horizontal whitespace — so the chatbot's `formatMarkdownQuote`
+ * receives one line per paragraph and nothing else.
+ */
+export function selectionQuoteText(raw: string): string {
+ return raw
+ .split("\n")
+ .map((line) => line.replace(/[^\S\n]+/g, " ").trim())
+ .filter((line) => line.length > 0)
+ .join("\n");
+}
+
/**
* The card a capture should become.
*
@@ -196,12 +224,15 @@ function elementOf(node: Node): Element | null {
}
/**
- * Whether the selection is located within an AI assistant message bubble.
+ * Whether the selection sits entirely inside one AI assistant message bubble.
+ *
+ * Both ends have to resolve to the same bubble, which keeps the answer independent of the drag
+ * direction: a selection that runs from an answer into the next message is not a reply to that
+ * answer — its text would carry the hire's own words — whichever end the drag started at.
*/
-function isInsideAiMessage(anchor: Node, range: Range): boolean {
- const ancestorEl = elementOf(range.commonAncestorContainer);
- if (ancestorEl?.closest("[data-chat-message-role='ASSISTANT']")) return true;
-
- const anchorEl = elementOf(anchor);
- return Boolean(anchorEl?.closest("[data-chat-message-role='ASSISTANT']"));
+function isInsideAiMessage(range: Range): boolean {
+ const bubbleOf = (node: Node): Element | null =>
+ elementOf(node)?.closest("[data-chat-message-role='ASSISTANT']") ?? null;
+ const bubble = bubbleOf(range.startContainer);
+ return bubble !== null && bubble === bubbleOf(range.endContainer);
}
diff --git a/src/features/chatbot/utils/quoteFormat.ts b/src/features/chatbot/utils/quoteFormat.ts
index 2c7f276ec..ddcba040b 100644
--- a/src/features/chatbot/utils/quoteFormat.ts
+++ b/src/features/chatbot/utils/quoteFormat.ts
@@ -1,14 +1,21 @@
/**
- * Formats a text snippet as a Markdown blockquote by prefixing each line with `> `.
+ * Formats quote text as a Markdown blockquote, one quoted paragraph per line.
+ *
+ * The input is one logical line per paragraph — what a selection capture hands over once it has
+ * dropped the empty lines (see `selectionQuoteText`). Each line becomes `> line`, and the lines
+ * are separated by an empty quoted line, because consecutive `>` lines without one render as a
+ * single paragraph with a line break, not as the paragraphs the answer had.
*/
export function formatMarkdownQuote(text: string): string {
- const trimmed = text.trim();
- if (!trimmed) return "";
-
- return trimmed
+ const paragraphs = text
.split("\n")
+ .map((line) => line.trim())
+ .filter((line) => line.length > 0);
+ if (paragraphs.length === 0) return "";
+
+ return paragraphs
.map((line) => (line.startsWith(">") ? line : `> ${line}`))
- .join("\n");
+ .join("\n>\n");
}
/**
diff --git a/tests/unit/features/board/selectionCapture.test.ts b/tests/unit/features/board/selectionCapture.test.ts
index 8376be882..f36de8a52 100644
--- a/tests/unit/features/board/selectionCapture.test.ts
+++ b/tests/unit/features/board/selectionCapture.test.ts
@@ -2,8 +2,10 @@ import { describe, it, expect, beforeEach } from "vitest";
import {
captureSelection,
cardFor,
+ selectionQuoteText,
type CapturedSelection,
} from "../../../../src/features/board/selection/selectionCapture";
+import { insertQuoteIntoDraft } from "../../../../src/features/chatbot/utils/quoteFormat";
/**
* The decisions behind "add this to my board", tested on strings and a real DOM rather than
@@ -68,6 +70,116 @@ describe("selectionCapture", () => {
document.body.innerHTML = "";
expect(capture("#t").isAiMessage).toBe(false);
});
+
+ /**
+ * A drag across two bubbles, reported the way a browser hands a selection over — the range
+ * as dragged, the anchor on whichever end the drag began. jsdom has no layout, so the
+ * toString a real browser would carry has to be stated rather than derived.
+ */
+ function dragAcross(
+ startSelector: string,
+ endSelector: string,
+ endOffset: number,
+ anchorAtStart: boolean,
+ ): Selection {
+ const startNode = document.querySelector(startSelector)!.firstChild!;
+ const endNode = document.querySelector(endSelector)!.firstChild!;
+ const range = document.createRange();
+ range.setStart(startNode, 0);
+ range.setEnd(endNode, endOffset);
+ return {
+ isCollapsed: false,
+ rangeCount: 1,
+ anchorNode: anchorAtStart ? range.startContainer : range.endContainer,
+ getRangeAt: () => range,
+ toString: () => "The answer. A follow-up question?",
+ } as unknown as Selection;
+ }
+
+ const twoBubbles =
+ "" +
+ "" +
+ "";
+
+ it("says no when the drag runs from an answer into the next message", () => {
+ document.body.innerHTML = twoBubbles;
+
+ expect(captureSelection(dragAcross("#a1", "#u1", 6, true))!.isAiMessage).toBe(false);
+ });
+
+ /**
+ * The same visual selection, dragged the other way. The old check read the drag's anchor as
+ * a fallback and answered differently for it; both ends have to sit in one bubble, which
+ * does not care about direction.
+ */
+ it("says the same for the same selection dragged the other way", () => {
+ document.body.innerHTML = twoBubbles;
+
+ expect(captureSelection(dragAcross("#a1", "#u1", 6, false))!.isAiMessage).toBe(false);
+ });
+
+ it("says no when the selection spans two answers", () => {
+ document.body.innerHTML = twoBubbles;
+
+ expect(captureSelection(dragAcross("#a1", "#a2", 4, true))!.isAiMessage).toBe(false);
+ });
+
+ it("still says yes when the selection spans paragraphs inside one answer", () => {
+ document.body.innerHTML =
+ "";
+
+ expect(captureSelection(dragAcross("#a1", "#a2", 6, true))!.isAiMessage).toBe(true);
+ });
+ });
+
+ describe("the quote the Reply offer sends", () => {
+ /**
+ * A selection the way a browser reports it, with the toString a real drag carries. jsdom's
+ * own toString never inserts the newlines a browser puts between block elements, so stubbing
+ * it in is the one honest way to exercise a multi-paragraph capture here.
+ */
+ function selectionReporting(selector: string, rawText: string): Selection {
+ const range = selectTextIn(selector).getRangeAt(0);
+ return {
+ isCollapsed: false,
+ rangeCount: 1,
+ anchorNode: range.startContainer,
+ getRangeAt: () => range,
+ toString: () => rawText,
+ } as unknown as Selection;
+ }
+
+ it("keeps the paragraph breaks that `text` collapses away", () => {
+ document.body.innerHTML = "First paragraph.
Second paragraph.
";
+ const captured = captureSelection(
+ selectionReporting("#t", "First paragraph.\n\nSecond paragraph."),
+ )!;
+
+ expect(captured.text).toBe("First paragraph. Second paragraph.");
+ expect(captured.quoteText).toBe("First paragraph.\nSecond paragraph.");
+ });
+
+ /**
+ * The path the review flagged, end to end: a real multi-paragraph answer, captured the way
+ * the toolbar captures it, through to what lands in the composer. The paragraph breaks have
+ * to survive as separate quoted paragraphs, not one collapsed line.
+ */
+ it("lands a two-paragraph answer as two quoted paragraphs in the draft", () => {
+ document.body.innerHTML = "First paragraph.
Second paragraph.
";
+ const captured = captureSelection(
+ selectionReporting("#t", "First paragraph.\n\nSecond paragraph."),
+ )!;
+
+ expect(insertQuoteIntoDraft("", captured.quoteText)).toBe(
+ "> First paragraph.\n>\n> Second paragraph.\n\n",
+ );
+ });
+
+ it("drops empty lines and stray spacing from the raw selection text", () => {
+ expect(selectionQuoteText(" First word \n\n\nsecond\n\t\n last ")).toBe(
+ "First word\nsecond\nlast",
+ );
+ });
});
describe("links", () => {
diff --git a/tests/unit/features/chatbot/quoteFormat.test.ts b/tests/unit/features/chatbot/quoteFormat.test.ts
index bea3282c8..caa4442b7 100644
--- a/tests/unit/features/chatbot/quoteFormat.test.ts
+++ b/tests/unit/features/chatbot/quoteFormat.test.ts
@@ -15,15 +15,21 @@ describe("quoteFormat", () => {
expect(formatMarkdownQuote("Hello world")).toBe("> Hello world");
});
- it("prefixes every line in multi-line text with `> `", () => {
+ it("quotes each paragraph, separated by an empty quoted line", () => {
const input = "First line\nSecond line\nThird line";
- const expected = "> First line\n> Second line\n> Third line";
+ const expected = "> First line\n>\n> Second line\n>\n> Third line";
+ expect(formatMarkdownQuote(input)).toBe(expected);
+ });
+
+ it("drops blank lines between paragraphs", () => {
+ const input = "First\n\n\nSecond\n \nThird";
+ const expected = "> First\n>\n> Second\n>\n> Third";
expect(formatMarkdownQuote(input)).toBe(expected);
});
it("preserves lines that already have blockquote syntax", () => {
const input = "> Already quoted\nNew line";
- const expected = "> Already quoted\n> New line";
+ const expected = "> Already quoted\n>\n> New line";
expect(formatMarkdownQuote(input)).toBe(expected);
});
});
@@ -40,11 +46,11 @@ describe("quoteFormat", () => {
expect(result).toBe("What does this mean?\n\n> AI explanation here\n\n");
});
- it("handles multi-line quotes appended to existing draft", () => {
+ it("handles multi-paragraph quotes appended to existing draft", () => {
const currentDraft = "My question.";
const quote = "Line 1\nLine 2";
const result = insertQuoteIntoDraft(currentDraft, quote);
- expect(result).toBe("My question.\n\n> Line 1\n> Line 2\n\n");
+ expect(result).toBe("My question.\n\n> Line 1\n>\n> Line 2\n\n");
});
it("returns current draft untouched if quote text is empty", () => {
From 423169d4ec8fb826c52b429042e1d181af863d5c Mon Sep 17 00:00:00 2001
From: Daniil Perkin
Date: Sat, 19 Sep 2026 18:25:26 +0200
Subject: [PATCH 25/51] Resolve cross-PR conflicts from the backmerge round
Backmerged origin/dev and all five other open PRs (#250 format hotfix,
#249 chat error toasts + queue, #227 in-chat reply, #225 chat source
filter, #248 buddy board actions, #228 blueprint onboarding) into the
easter-eggs branch. Merge policy: incoming changes win wherever both
sides edited the same region, because the backmerged branches are newer
and their authors resolved the same problems independently.
The tree merged cleanly at the git level (-X theirs on every merge), but
git cannot see semantic damage done when a newer branch rewrites a file
that carries an older copy of a feature this branch had already
replaced. Four such cases surfaced, all in the chat area, all resolved
in favour of the newer implementation:
- ChatPage.tsx: the merge re-imported the pre-overhaul inline dino state
(useState + localStorage flag + guarded-setState close pattern) next to
the bus-based useSpaceOpensDino implementation. Removed the dead
inline block; the dino game runs through the app-wide egg bus, and
ThinkingIndicator keeps the closeGame API.
- ChatPage.tsx: dev's page-local barrel-roll effect came back the same
way. Removed it - EggEffectsLayer owns the barrel-roll body class and
its timed cleanup now, and the chat phrase trigger already plays the
effect through the bus. Keeping both would have rolled the page twice.
- ChatComposer.tsx: #225's rewrite of the composer predates #249's
queue, so the queue props survived in the interface but their
implementation was gone. Restored the #249 side: ListPlus/ChatQueueItem
imports and the QueuedMessages strip above the filter chips.
- ChatProvider.tsx: same pattern - #249's queue state survived but
#225's import block did not know the ChatQueueItem type. Added it.
- ChatComposer.filters.test.tsx (new from #225): renders ChatComposer
with the full props object, so it needed #249's five queue props.
Added inert defaults (empty queue, no-op callbacks) - these tests
exercise filters, not the queue.
- Prettier: reformatted ChatProvider.tsx (long import line),
quoteFormat.ts and BuddyDock.test.tsx (inherited drift from #229/#248).
Verified: npm run build, npm run lint, npm run format:check all green.
Unit test suite run recorded in the PR.
---
src/context/ChatProvider.tsx | 8 ++++-
.../chatbot/components/ChatComposer.tsx | 12 +++++--
src/features/chatbot/utils/quoteFormat.ts | 4 +--
src/pages/ChatPage.tsx | 35 -------------------
tests/unit/features/buddy/BuddyDock.test.tsx | 4 +--
.../components/ChatComposer.filters.test.tsx | 5 +++
6 files changed, 24 insertions(+), 44 deletions(-)
diff --git a/src/context/ChatProvider.tsx b/src/context/ChatProvider.tsx
index bc027791a..8ce46810e 100644
--- a/src/context/ChatProvider.tsx
+++ b/src/context/ChatProvider.tsx
@@ -12,7 +12,13 @@ import { useToastApi } from "./useToast";
import { useProjectContext } from "../features/projects/useProjectContext";
import { ChatContext } from "./ChatContext";
import type { ChatContextValue, SelectedCitation } from "./ChatContext";
-import type { Chat, ChatMessage, Citation, SourceSystem } from "../features/chatbot/types";
+import type {
+ Chat,
+ ChatMessage,
+ ChatQueueItem,
+ Citation,
+ SourceSystem,
+} from "../features/chatbot/types";
import { insertQuoteIntoDraft } from "../features/chatbot/utils/quoteFormat";
type MessagesByChat = Record;
diff --git a/src/features/chatbot/components/ChatComposer.tsx b/src/features/chatbot/components/ChatComposer.tsx
index a21da8e2a..7c4b3b18d 100644
--- a/src/features/chatbot/components/ChatComposer.tsx
+++ b/src/features/chatbot/components/ChatComposer.tsx
@@ -1,10 +1,11 @@
-import { Calendar, Check, Filter, RotateCcw, Send, Square, X } from "lucide-react";
+import { Calendar, Check, Filter, ListPlus, RotateCcw, Send, Square, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import type { FormEvent, RefObject } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { SOURCE_META } from "../../data-ingestion/data";
-import type { SourceSystem } from "../types";
+import type { ChatQueueItem, SourceSystem } from "../types";
import { centralSpringToken } from "../../../styles/tokens";
+import { QueuedMessages } from "./QueuedMessages";
function formatDateFilterLabel(from: string, to: string): string {
if (from && to) return `${from} → ${to}`;
@@ -213,6 +214,13 @@ export function ChatComposer({
return (
+
{/* Active filter chips strip — visible whenever filters are active */}
{activeFilterCount > 0 && (
diff --git a/src/features/chatbot/utils/quoteFormat.ts b/src/features/chatbot/utils/quoteFormat.ts
index ddcba040b..9b2663514 100644
--- a/src/features/chatbot/utils/quoteFormat.ts
+++ b/src/features/chatbot/utils/quoteFormat.ts
@@ -13,9 +13,7 @@ export function formatMarkdownQuote(text: string): string {
.filter((line) => line.length > 0);
if (paragraphs.length === 0) return "";
- return paragraphs
- .map((line) => (line.startsWith(">") ? line : `> ${line}`))
- .join("\n>\n");
+ return paragraphs.map((line) => (line.startsWith(">") ? line : `> ${line}`)).join("\n>\n");
}
/**
diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx
index 28c53d4b5..a84875959 100644
--- a/src/pages/ChatPage.tsx
+++ b/src/pages/ChatPage.tsx
@@ -161,41 +161,6 @@ export function ChatPage() {
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
diff --git a/tests/unit/features/buddy/BuddyDock.test.tsx b/tests/unit/features/buddy/BuddyDock.test.tsx
index 0ff8c9930..78a957a4e 100644
--- a/tests/unit/features/buddy/BuddyDock.test.tsx
+++ b/tests/unit/features/buddy/BuddyDock.test.tsx
@@ -106,9 +106,7 @@ describe("a reply the mentor already offered to keep", () => {
]);
expect(screen.getByRole("button", { name: /keep this as a checklist/i })).toBeInTheDocument();
- expect(
- screen.queryByRole("button", { name: /keep as checklist/i }),
- ).not.toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: /keep as checklist/i })).not.toBeInTheDocument();
});
/** Declining the mentor's list is not declining every way to keep one. */
diff --git a/tests/unit/features/chatbot/components/ChatComposer.filters.test.tsx b/tests/unit/features/chatbot/components/ChatComposer.filters.test.tsx
index ded6d6c29..9b6359043 100644
--- a/tests/unit/features/chatbot/components/ChatComposer.filters.test.tsx
+++ b/tests/unit/features/chatbot/components/ChatComposer.filters.test.tsx
@@ -14,6 +14,11 @@ describe("ChatComposer filter interactions", () => {
}),
onStop: vi.fn(),
isBusy: false,
+ queuedMessages: [],
+ queuePaused: false,
+ onRemoveQueued: vi.fn(),
+ onEditQueued: vi.fn(),
+ onSendQueued: vi.fn(),
hasProject: true,
promptHistory: [],
availableSources: ["GITHUB", "JIRA", "UPLOAD"] as SourceSystem[],
From fd632647bb64f32ad1da5a64f36d8c79eb1ce3f5 Mon Sep 17 00:00:00 2001
From: Daniil Perkin
Date: Fri, 18 Sep 2026 19:41:36 +0200
Subject: [PATCH 26/51] feat(chat): add reply option to quote AI chat response
(#227)
---
src/context/ChatContext.ts | 12 +++
src/context/ChatProvider.tsx | 20 +++++
.../board/selection/SelectionActions.tsx | 48 ++++++++----
.../board/selection/selectionCapture.ts | 14 ++++
.../chatbot/components/MessageRow.tsx | 1 +
src/features/chatbot/hooks/useChat.ts | 15 ++++
src/features/chatbot/utils/quoteFormat.ts | 30 ++++++++
.../industry/ProjectIndustryPanel.tsx | 5 +-
.../hooks/useStarterWorkReview.ts | 3 +-
src/services/projectService.ts | 6 +-
.../features/board/SelectionActions.test.tsx | 76 ++++++++++++++++++-
.../features/board/selectionCapture.test.ts | 17 +++++
.../unit/features/chatbot/quoteFormat.test.ts | 55 ++++++++++++++
.../industry/ProjectIndustryWidget.test.tsx | 4 +-
tests/unit/pages/ChatPage.test.tsx | 4 +-
.../unit/services/starterWorkService.test.ts | 7 +-
16 files changed, 289 insertions(+), 28 deletions(-)
create mode 100644 src/features/chatbot/utils/quoteFormat.ts
create mode 100644 tests/unit/features/chatbot/quoteFormat.test.ts
diff --git a/src/context/ChatContext.ts b/src/context/ChatContext.ts
index 5fc222f35..f03e47823 100644
--- a/src/context/ChatContext.ts
+++ b/src/context/ChatContext.ts
@@ -60,6 +60,18 @@ export type ChatContextValue = {
newRequest: string;
setNewRequest: (value: string) => void;
+ /**
+ * Registers a callback to focus the composer textarea and place the caret at the end.
+ * Called by useChat when mounted, cleaned up on unmount.
+ */
+ registerFocusComposer: (fn: () => void) => () => void;
+
+ /**
+ * Quotes the selected AI message text into the chat composer as a Markdown blockquote,
+ * focuses the textarea, and places the caret at the end.
+ */
+ quoteSelection: (text: string) => void;
+
showFilters: boolean;
setShowFilters: (value: boolean | ((prev: boolean) => boolean)) => void;
diff --git a/src/context/ChatProvider.tsx b/src/context/ChatProvider.tsx
index c5d1d6ffa..8190701af 100644
--- a/src/context/ChatProvider.tsx
+++ b/src/context/ChatProvider.tsx
@@ -19,6 +19,7 @@ import type {
Citation,
SourceSystem,
} from "../features/chatbot/types";
+import { insertQuoteIntoDraft } from "../features/chatbot/utils/quoteFormat";
type MessagesByChat = Record;
@@ -131,6 +132,23 @@ export function ChatProvider({ children }: { children: ReactNode }) {
const [selectedCitation, setSelectedCitation] = useState(null);
const [newRequest, setNewRequest] = useState("");
+ const focusComposerFnRef = useRef<(() => void) | null>(null);
+
+ const registerFocusComposer = useCallback((fn: () => void) => {
+ focusComposerFnRef.current = fn;
+ return () => {
+ if (focusComposerFnRef.current === fn) {
+ focusComposerFnRef.current = null;
+ }
+ };
+ }, []);
+
+ const quoteSelection = useCallback((text: string) => {
+ setNewRequest((prev) => insertQuoteIntoDraft(prev, text));
+ requestAnimationFrame(() => {
+ focusComposerFnRef.current?.();
+ });
+ }, []);
const [showFilters, setShowFilters] = useState(false);
@@ -977,6 +995,8 @@ export function ChatProvider({ children }: { children: ReactNode }) {
setSelectedCitation,
newRequest,
setNewRequest,
+ registerFocusComposer,
+ quoteSelection,
showFilters,
setShowFilters,
from,
diff --git a/src/features/board/selection/SelectionActions.tsx b/src/features/board/selection/SelectionActions.tsx
index 58028264b..a6a3be8f0 100644
--- a/src/features/board/selection/SelectionActions.tsx
+++ b/src/features/board/selection/SelectionActions.tsx
@@ -1,9 +1,10 @@
-import { useCallback, useState } from "react";
+import { useCallback, useContext, useState } from "react";
import { useNavigate } from "react-router-dom";
-import { BookmarkPlus, Eraser, Highlighter } from "lucide-react";
+import { BookmarkPlus, Eraser, Highlighter, Reply } from "lucide-react";
import { Button } from "../../../components/ui/Button";
import { useToast } from "../../../context/useToast";
import { useProjectContext } from "../../projects/useProjectContext";
+import { ChatContext } from "../../../context/ChatContext";
import { boardService } from "../../../services/boardService";
import { rememberOrigin } from "../layout/cardOrigins";
import { useCardMarks } from "../marks/useCardMarks";
@@ -32,6 +33,8 @@ export function SelectionActions() {
const { selection, clear } = useTextSelection();
const { selectedProjectId } = useProjectContext();
const { canMark, colorAt, enclosingColorAt, mark, unmark } = useCardMarks();
+ const chatContext = useContext(ChatContext);
+ const quoteSelection = chatContext?.quoteSelection;
const [saving, setSaving] = useState(false);
const toast = useToast();
const navigate = useNavigate();
@@ -66,9 +69,10 @@ export function SelectionActions() {
}
}, [selection, selectedProjectId, toast, navigate, clear]);
- // Nothing to keep, or nowhere to keep it. A hire on no project has no board, and an offer that
- // can only fail is worse than no offer.
- if (!selection || !selectedProjectId) return null;
+ if (!selection) return null;
+
+ // Nothing to offer: text on no card with no project to keep it on, and not an AI message to reply to.
+ if (!selection.cardId && !selectedProjectId && !selection.isAiMessage) return null;
/**
* Whether this selection is text on a card the board is already holding.
@@ -191,15 +195,31 @@ export function SelectionActions() {
) : (
- void add()}
- loading={saving}
- icon={ }
- >
- Add to board
-
+ {selection.isAiMessage && quoteSelection && (
+ {
+ quoteSelection(selection.text);
+ clear();
+ }}
+ icon={ }
+ >
+ Reply
+
+ )}
+
+ {selectedProjectId && (
+ void add()}
+ loading={saving}
+ icon={ }
+ >
+ Add to board
+
+ )}
)}
diff --git a/src/features/board/selection/selectionCapture.ts b/src/features/board/selection/selectionCapture.ts
index f37b0042c..ca456fc96 100644
--- a/src/features/board/selection/selectionCapture.ts
+++ b/src/features/board/selection/selectionCapture.ts
@@ -46,6 +46,8 @@ export type CapturedSelection = {
inLink: boolean;
/** Where the toolbar should sit, in viewport coordinates. */
rect: DOMRect;
+ /** Whether the selection was made inside an AI assistant chat message. */
+ isAiMessage: boolean;
};
/** Anything shorter is a stray click or a double-click that caught a space, not a selection. */
@@ -76,6 +78,7 @@ export function captureSelection(selection: Selection | null): CapturedSelection
cardId: elementOf(anchor)?.closest("[data-card-id]")?.getAttribute("data-card-id") ?? null,
inLink: Boolean(elementOf(anchor)?.closest("a")),
rect: range.getBoundingClientRect(),
+ isAiMessage: isInsideAiMessage(anchor, range),
};
}
@@ -191,3 +194,14 @@ function isInsideEditable(node: Node): boolean {
function elementOf(node: Node): Element | null {
return node.nodeType === Node.ELEMENT_NODE ? (node as Element) : node.parentElement;
}
+
+/**
+ * Whether the selection is located within an AI assistant message bubble.
+ */
+function isInsideAiMessage(anchor: Node, range: Range): boolean {
+ const ancestorEl = elementOf(range.commonAncestorContainer);
+ if (ancestorEl?.closest("[data-chat-message-role='ASSISTANT']")) return true;
+
+ const anchorEl = elementOf(anchor);
+ return Boolean(anchorEl?.closest("[data-chat-message-role='ASSISTANT']"));
+}
diff --git a/src/features/chatbot/components/MessageRow.tsx b/src/features/chatbot/components/MessageRow.tsx
index 23bd8ab5c..50e3cf37a 100644
--- a/src/features/chatbot/components/MessageRow.tsx
+++ b/src/features/chatbot/components/MessageRow.tsx
@@ -147,6 +147,7 @@ function MessageRowImpl({
// E5: mark the actively-streaming message as busy so
// screen readers don't announce partial content mid-stream.
aria-busy={showStreamingCaret || undefined}
+ data-chat-message-role={message.role}
className={`chat-md rounded-2xl px-4 py-2.5 text-sm leading-relaxed shadow-sm ${
isRequest
? "chat-md-user rounded-tr-sm bg-app-brand text-white"
diff --git a/src/features/chatbot/hooks/useChat.ts b/src/features/chatbot/hooks/useChat.ts
index 130df9d7d..ade20b756 100644
--- a/src/features/chatbot/hooks/useChat.ts
+++ b/src/features/chatbot/hooks/useChat.ts
@@ -129,6 +129,8 @@ export function useChat() {
setSelectedCitation,
newRequest,
setNewRequest,
+ registerFocusComposer,
+ quoteSelection,
showFilters,
setShowFilters,
from,
@@ -149,6 +151,17 @@ export function useChat() {
deleteChat: ctxDeleteChat,
} = ctx;
+ useEffect(() => {
+ return registerFocusComposer(() => {
+ const el = textareaRef.current;
+ if (!el) return;
+ el.focus();
+ el.setSelectionRange(el.value.length, el.value.length);
+ el.style.height = "auto";
+ el.style.height = `${el.scrollHeight}px`;
+ });
+ }, [registerFocusComposer]);
+
/**
* When the user navigates to `/chat` (no chatId) from the main sidebar
* and they already have conversations, redirect to the most recent one
@@ -486,6 +499,8 @@ export function useChat() {
bottomRef,
scrollContainerRef,
+ quoteSelection,
+
isAtBottom,
scrollToBottom,
diff --git a/src/features/chatbot/utils/quoteFormat.ts b/src/features/chatbot/utils/quoteFormat.ts
new file mode 100644
index 000000000..52df037bc
--- /dev/null
+++ b/src/features/chatbot/utils/quoteFormat.ts
@@ -0,0 +1,30 @@
+/**
+ * Formats a text snippet as a Markdown blockquote by prefixing each line with `> `.
+ */
+export function formatMarkdownQuote(text: string): string {
+ const trimmed = text.trim();
+ if (!trimmed) return "";
+
+ return trimmed
+ .split("\n")
+ .map((line) => (line.startsWith(">") ? line : `> ${line}`))
+ .join("\n");
+}
+
+/**
+ * Inserts a quoted AI response at the top of the current draft.
+ *
+ * Markdown blockquotes require a blank line separation before normal prose,
+ * otherwise subsequent text is treated as a lazy continuation of the quote block.
+ */
+export function insertQuoteIntoDraft(currentDraft: string, quoteText: string): string {
+ const formattedQuote = formatMarkdownQuote(quoteText);
+ if (!formattedQuote) return currentDraft;
+
+ const trimmedDraft = currentDraft.trim();
+ if (!trimmedDraft) {
+ return `${formattedQuote}\n\n`;
+ }
+
+ return `${formattedQuote}\n\n${trimmedDraft}`;
+}
diff --git a/src/features/projects/industry/ProjectIndustryPanel.tsx b/src/features/projects/industry/ProjectIndustryPanel.tsx
index dc7f3d615..90e6b8888 100644
--- a/src/features/projects/industry/ProjectIndustryPanel.tsx
+++ b/src/features/projects/industry/ProjectIndustryPanel.tsx
@@ -170,7 +170,10 @@ export function ProjectIndustryPanel({
{industry || "Not determined yet"}
{industry && (
-
+
)}
>
)}
diff --git a/src/features/starter-work/hooks/useStarterWorkReview.ts b/src/features/starter-work/hooks/useStarterWorkReview.ts
index bce2eed68..b4e394f5c 100644
--- a/src/features/starter-work/hooks/useStarterWorkReview.ts
+++ b/src/features/starter-work/hooks/useStarterWorkReview.ts
@@ -16,8 +16,7 @@ function toMessage(error: unknown, fallback: string): string {
export type TaskOrigin = "authored" | "picked";
type ReviewAction =
- | { kind: "generate"; projectId: string }
- | { kind: "create"; input: CreateStarterWorkTaskInput };
+ { kind: "generate"; projectId: string } | { kind: "create"; input: CreateStarterWorkTaskInput };
const NO_TASKS: StarterWorkTask[] = [];
diff --git a/src/services/projectService.ts b/src/services/projectService.ts
index 8489862c7..ac06e95de 100644
--- a/src/services/projectService.ts
+++ b/src/services/projectService.ts
@@ -538,7 +538,11 @@ export const projectService = {
async setProjectIndustry(
projectId: string,
industry: string,
- ): Promise<{ industry: string; industryConfidence: IndustryConfidence | null; industryCustom: boolean }> {
+ ): Promise<{
+ industry: string;
+ industryConfidence: IndustryConfidence | null;
+ industryCustom: boolean;
+ }> {
const result = await apiClient.fetch<{
industry: string | null;
industryConfidence: string | null;
diff --git a/tests/unit/features/board/SelectionActions.test.tsx b/tests/unit/features/board/SelectionActions.test.tsx
index 9912c92b5..77282f440 100644
--- a/tests/unit/features/board/SelectionActions.test.tsx
+++ b/tests/unit/features/board/SelectionActions.test.tsx
@@ -4,6 +4,7 @@ import { MemoryRouter } from "react-router-dom";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { SelectionActions } from "../../../../src/features/board/selection/SelectionActions";
import { boardService } from "../../../../src/services/boardService";
+import { ChatContext, type ChatContextValue } from "../../../../src/context/ChatContext";
const navigate = vi.fn();
vi.mock("react-router-dom", async () => {
@@ -19,6 +20,11 @@ vi.mock("../../../../src/features/projects/useProjectContext", () => ({
const toast = { success: vi.fn(), error: vi.fn() };
vi.mock("../../../../src/context/useToast", () => ({ useToast: () => toast }));
+const mockQuoteSelection = vi.fn();
+const mockChatContext = {
+ quoteSelection: mockQuoteSelection,
+} as unknown as ChatContextValue;
+
/**
* The toolbar, at the level a hire meets it: highlight something, press the button, and find out
* whether it landed. The decisions about *what* the card becomes are covered on their own in
@@ -51,7 +57,9 @@ describe("SelectionActions", () => {
function renderToolbar() {
return render(
-
+
+
+
,
);
}
@@ -109,11 +117,75 @@ describe("SelectionActions", () => {
});
/** A hire on no project has no board, and an offer that can only fail is worse than none. */
- it("offers nothing when no project is selected", () => {
+ it("offers nothing when no project is selected and not an AI message", () => {
selectedProjectId = "";
renderToolbar();
highlight("Run the migration first.");
expect(screen.queryByRole("toolbar")).not.toBeInTheDocument();
});
+
+ it("offers Reply button when selecting text in an AI assistant message", async () => {
+ renderToolbar();
+ const container = document.createElement("div");
+ container.setAttribute("data-chat-message-role", "ASSISTANT");
+ const p = document.createElement("p");
+ p.textContent = "AI generated response.";
+ container.appendChild(p);
+ document.body.appendChild(container);
+
+ const range = document.createRange();
+ range.selectNodeContents(p.firstChild!);
+ const selection = window.getSelection()!;
+ selection.removeAllRanges();
+ selection.addRange(range);
+ document.dispatchEvent(new Event("selectionchange"));
+
+ expect(await screen.findByRole("button", { name: /reply/i })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /add to board/i })).toBeInTheDocument();
+ });
+
+ it("calls quoteSelection and clears selection when Reply is clicked", async () => {
+ renderToolbar();
+ const container = document.createElement("div");
+ container.setAttribute("data-chat-message-role", "ASSISTANT");
+ const p = document.createElement("p");
+ p.textContent = "AI generated response.";
+ container.appendChild(p);
+ document.body.appendChild(container);
+
+ const range = document.createRange();
+ range.selectNodeContents(p.firstChild!);
+ const selection = window.getSelection()!;
+ selection.removeAllRanges();
+ selection.addRange(range);
+ document.dispatchEvent(new Event("selectionchange"));
+
+ const replyButton = await screen.findByRole("button", { name: /reply/i });
+ await userEvent.click(replyButton);
+
+ expect(mockQuoteSelection).toHaveBeenCalledWith("AI generated response.");
+ expect(window.getSelection()?.rangeCount).toBe(0);
+ });
+
+ it("offers Reply button when no project is selected if text is in an AI message", async () => {
+ selectedProjectId = "";
+ renderToolbar();
+ const container = document.createElement("div");
+ container.setAttribute("data-chat-message-role", "ASSISTANT");
+ const p = document.createElement("p");
+ p.textContent = "AI generated response.";
+ container.appendChild(p);
+ document.body.appendChild(container);
+
+ const range = document.createRange();
+ range.selectNodeContents(p.firstChild!);
+ const selection = window.getSelection()!;
+ selection.removeAllRanges();
+ selection.addRange(range);
+ document.dispatchEvent(new Event("selectionchange"));
+
+ expect(await screen.findByRole("button", { name: /reply/i })).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: /add to board/i })).not.toBeInTheDocument();
+ });
});
diff --git a/tests/unit/features/board/selectionCapture.test.ts b/tests/unit/features/board/selectionCapture.test.ts
index d32c6c5f4..8376be882 100644
--- a/tests/unit/features/board/selectionCapture.test.ts
+++ b/tests/unit/features/board/selectionCapture.test.ts
@@ -51,6 +51,23 @@ describe("selectionCapture", () => {
document.body.innerHTML = "
one\n two
";
expect(capture("#t").text).toBe("one two");
});
+
+ it("identifies when selection is inside an AI assistant message", () => {
+ document.body.innerHTML =
+ "
";
+ expect(capture("#t").isAiMessage).toBe(true);
+ });
+
+ it("identifies when selection is inside a user message", () => {
+ document.body.innerHTML =
+ "
";
+ expect(capture("#t").isAiMessage).toBe(false);
+ });
+
+ it("marks isAiMessage false for generic text outside chat", () => {
+ document.body.innerHTML = "
";
+ expect(capture("#t").isAiMessage).toBe(false);
+ });
});
describe("links", () => {
diff --git a/tests/unit/features/chatbot/quoteFormat.test.ts b/tests/unit/features/chatbot/quoteFormat.test.ts
new file mode 100644
index 000000000..b47f082b4
--- /dev/null
+++ b/tests/unit/features/chatbot/quoteFormat.test.ts
@@ -0,0 +1,55 @@
+import { describe, it, expect } from "vitest";
+import {
+ formatMarkdownQuote,
+ insertQuoteIntoDraft,
+} from "../../../../src/features/chatbot/utils/quoteFormat";
+
+describe("quoteFormat", () => {
+ describe("formatMarkdownQuote", () => {
+ it("returns an empty string when given empty or whitespace text", () => {
+ expect(formatMarkdownQuote("")).toBe("");
+ expect(formatMarkdownQuote(" \n ")).toBe("");
+ });
+
+ it("prefixes a single line with `> `", () => {
+ expect(formatMarkdownQuote("Hello world")).toBe("> Hello world");
+ });
+
+ it("prefixes every line in multi-line text with `> `", () => {
+ const input = "First line\nSecond line\nThird line";
+ const expected = "> First line\n> Second line\n> Third line";
+ expect(formatMarkdownQuote(input)).toBe(expected);
+ });
+
+ it("preserves lines that already have blockquote syntax", () => {
+ const input = "> Already quoted\nNew line";
+ const expected = "> Already quoted\n> New line";
+ expect(formatMarkdownQuote(input)).toBe(expected);
+ });
+ });
+
+ describe("insertQuoteIntoDraft", () => {
+ it("returns formatted quote followed by double newlines when draft is empty", () => {
+ const result = insertQuoteIntoDraft("", "Quoted message");
+ expect(result).toBe("> Quoted message\n\n");
+ });
+
+ it("prepends the quote at the top when draft already contains text", () => {
+ const currentDraft = "What does this mean?";
+ const result = insertQuoteIntoDraft(currentDraft, "AI explanation here");
+ expect(result).toBe("> AI explanation here\n\nWhat does this mean?");
+ });
+
+ it("handles multi-line quotes prepended to existing draft", () => {
+ const currentDraft = "My question.";
+ const quote = "Line 1\nLine 2";
+ const result = insertQuoteIntoDraft(currentDraft, quote);
+ expect(result).toBe("> Line 1\n> Line 2\n\nMy question.");
+ });
+
+ it("returns current draft untouched if quote text is empty", () => {
+ expect(insertQuoteIntoDraft("Draft text", "")).toBe("Draft text");
+ expect(insertQuoteIntoDraft("Draft text", " ")).toBe("Draft text");
+ });
+ });
+});
diff --git a/tests/unit/features/projects/industry/ProjectIndustryWidget.test.tsx b/tests/unit/features/projects/industry/ProjectIndustryWidget.test.tsx
index b0e5ef740..70fe71c84 100644
--- a/tests/unit/features/projects/industry/ProjectIndustryWidget.test.tsx
+++ b/tests/unit/features/projects/industry/ProjectIndustryWidget.test.tsx
@@ -375,9 +375,7 @@ describe("ProjectIndustryWidget", () => {
await user.click(screen.getByTestId("save-industry-button"));
await waitFor(() =>
- expect(
- within(toastStack()).getByText("You may not edit this project."),
- ).toBeInTheDocument(),
+ expect(within(toastStack()).getByText("You may not edit this project.")).toBeInTheDocument(),
);
expect(screen.getByTestId("industry-edit-input")).toHaveValue("Healthcare");
});
diff --git a/tests/unit/pages/ChatPage.test.tsx b/tests/unit/pages/ChatPage.test.tsx
index ee6e69df3..331963ae3 100644
--- a/tests/unit/pages/ChatPage.test.tsx
+++ b/tests/unit/pages/ChatPage.test.tsx
@@ -95,13 +95,15 @@ describe("ChatPage", () => {
});
it("renders the message list with user and assistant messages", () => {
- render(
+ const { container } = render(
,
);
expect(screen.getByText("Hello bot")).toBeInTheDocument();
expect(screen.getByText("Hi there")).toBeInTheDocument();
+ expect(container.querySelector('[data-chat-message-role="ASSISTANT"]')).toBeInTheDocument();
+ expect(container.querySelector('[data-chat-message-role="USER"]')).toBeInTheDocument();
});
/*
diff --git a/tests/unit/services/starterWorkService.test.ts b/tests/unit/services/starterWorkService.test.ts
index d60bd2799..2d60380b0 100644
--- a/tests/unit/services/starterWorkService.test.ts
+++ b/tests/unit/services/starterWorkService.test.ts
@@ -14,10 +14,9 @@ describe("starterWorkService", () => {
await starterWorkService.generate("p1");
- expect(fetchSpy).toHaveBeenCalledWith(
- "/api/v1/onboarding/starter-work/generate?projectId=p1",
- { method: "POST" },
- );
+ expect(fetchSpy).toHaveBeenCalledWith("/api/v1/onboarding/starter-work/generate?projectId=p1", {
+ method: "POST",
+ });
});
it("reads candidate issues scoped to the project", async () => {
From c52e81021024ec66bb91e6bf0696a02650c02553 Mon Sep 17 00:00:00 2001
From: Daniil Perkin
Date: Fri, 18 Sep 2026 19:52:57 +0200
Subject: [PATCH 27/51] fix(chat): append quote at bottom under existing draft
text
---
src/features/chatbot/utils/quoteFormat.ts | 10 +++++-----
tests/unit/features/chatbot/quoteFormat.test.ts | 10 +++++-----
2 files changed, 10 insertions(+), 10 deletions(-)
diff --git a/src/features/chatbot/utils/quoteFormat.ts b/src/features/chatbot/utils/quoteFormat.ts
index 52df037bc..2c7f276ec 100644
--- a/src/features/chatbot/utils/quoteFormat.ts
+++ b/src/features/chatbot/utils/quoteFormat.ts
@@ -1,4 +1,4 @@
-/**
+/**
* Formats a text snippet as a Markdown blockquote by prefixing each line with `> `.
*/
export function formatMarkdownQuote(text: string): string {
@@ -12,19 +12,19 @@ export function formatMarkdownQuote(text: string): string {
}
/**
- * Inserts a quoted AI response at the top of the current draft.
+ * Inserts a quoted AI response at the bottom of the current draft.
*
- * Markdown blockquotes require a blank line separation before normal prose,
+ * Markdown blockquotes require a blank line separation before and after normal prose,
* otherwise subsequent text is treated as a lazy continuation of the quote block.
*/
export function insertQuoteIntoDraft(currentDraft: string, quoteText: string): string {
const formattedQuote = formatMarkdownQuote(quoteText);
if (!formattedQuote) return currentDraft;
- const trimmedDraft = currentDraft.trim();
+ const trimmedDraft = currentDraft.trimEnd();
if (!trimmedDraft) {
return `${formattedQuote}\n\n`;
}
- return `${formattedQuote}\n\n${trimmedDraft}`;
+ return `${trimmedDraft}\n\n${formattedQuote}\n\n`;
}
diff --git a/tests/unit/features/chatbot/quoteFormat.test.ts b/tests/unit/features/chatbot/quoteFormat.test.ts
index b47f082b4..bea3282c8 100644
--- a/tests/unit/features/chatbot/quoteFormat.test.ts
+++ b/tests/unit/features/chatbot/quoteFormat.test.ts
@@ -1,4 +1,4 @@
-import { describe, it, expect } from "vitest";
+import { describe, it, expect } from "vitest";
import {
formatMarkdownQuote,
insertQuoteIntoDraft,
@@ -34,17 +34,17 @@ describe("quoteFormat", () => {
expect(result).toBe("> Quoted message\n\n");
});
- it("prepends the quote at the top when draft already contains text", () => {
+ it("appends the quote at the bottom when draft already contains text", () => {
const currentDraft = "What does this mean?";
const result = insertQuoteIntoDraft(currentDraft, "AI explanation here");
- expect(result).toBe("> AI explanation here\n\nWhat does this mean?");
+ expect(result).toBe("What does this mean?\n\n> AI explanation here\n\n");
});
- it("handles multi-line quotes prepended to existing draft", () => {
+ it("handles multi-line quotes appended to existing draft", () => {
const currentDraft = "My question.";
const quote = "Line 1\nLine 2";
const result = insertQuoteIntoDraft(currentDraft, quote);
- expect(result).toBe("> Line 1\n> Line 2\n\nMy question.");
+ expect(result).toBe("My question.\n\n> Line 1\n> Line 2\n\n");
});
it("returns current draft untouched if quote text is empty", () => {
From 5733c16c9f2672ba6457d879d51a61c33574495c Mon Sep 17 00:00:00 2001
From: Daniil Perkin
Date: Sat, 19 Sep 2026 16:52:23 +0200
Subject: [PATCH 28/51] fix(chat): keep an answer's paragraphs in the Reply
quote
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Review on #229 flagged that formatMarkdownQuote's per-line splitting was
dead code on the real path: the only input it ever receives is the
selection text from captureSelection, and that runs through normalise(),
which collapses every whitespace run — newlines included — before it gets
there. A two-paragraph answer arrived as one line and was quoted as one
line; only the synthetic multi-line strings in the unit tests ever
exercised the splitting.
The quote now keeps the answer's line structure:
- captureSelection captures the raw selection text a second time, with
line breaks kept, into a new quoteText field. `text` stays collapsed —
cards, highlights and text fragments all want one line of words, and
the mark matching reads exactly the text that was stored.
- The Reply offer sends quoteSelection the quoteText, not the text.
- formatMarkdownQuote treats each line as a paragraph and separates the
quoted lines with an empty quoted line. Two consecutive `>` lines
without one render as a single paragraph with a soft line break, not
as the paragraphs the answer had, so the separator is what makes the
structure survive the composer.
isInsideAiMessage, also flagged in the review: it answered
direction-dependently. It checked the range's common ancestor first and
fell back to the drag's anchor, so a selection starting inside an answer
and ending in the next message counted as "reply to the AI" — while the
same visual selection dragged the other way did not. It now requires
both range ends to resolve to the same assistant bubble: independent of
direction, and a quote that would carry the hire's own words is not
offered either way.
Tests: an end-to-end capture test feeds captureSelection a selection
whose toString carries the paragraph breaks a real browser inserts
between blocks (jsdom's toString inserts none, so it is stated) and
follows the quote all the way into the draft; cross-bubble selections
are covered in both drag directions plus the two-answers case; the
quoteFormat contract tests now describe the paragraph-per-line shape.
The review's third note (the loosened early-return in SelectionActions)
traced clean: the only way the toolbar shows without a selected project
is an AI-message selection, the Reply button is gated on quoteSelection,
which ChatProvider always provides (it wraps all of AppContent), and
add() guards the project id itself.
---
.../board/selection/SelectionActions.tsx | 4 +-
.../board/selection/selectionCapture.ts | 49 ++++++--
src/features/chatbot/utils/quoteFormat.ts | 19 ++-
.../features/board/selectionCapture.test.ts | 112 ++++++++++++++++++
.../unit/features/chatbot/quoteFormat.test.ts | 16 ++-
5 files changed, 179 insertions(+), 21 deletions(-)
diff --git a/src/features/board/selection/SelectionActions.tsx b/src/features/board/selection/SelectionActions.tsx
index a6a3be8f0..b9a3edddb 100644
--- a/src/features/board/selection/SelectionActions.tsx
+++ b/src/features/board/selection/SelectionActions.tsx
@@ -200,7 +200,9 @@ export function SelectionActions() {
size="sm"
variant="ghost"
onClick={() => {
- quoteSelection(selection.text);
+ // quoteText rather than text: it keeps the answer's paragraph breaks, which
+ // `text` has collapsed away for the card paths.
+ quoteSelection(selection.quoteText);
clear();
}}
icon={ }
diff --git a/src/features/board/selection/selectionCapture.ts b/src/features/board/selection/selectionCapture.ts
index ca456fc96..1d510f691 100644
--- a/src/features/board/selection/selectionCapture.ts
+++ b/src/features/board/selection/selectionCapture.ts
@@ -14,6 +14,15 @@ import { composeNote, normalise } from "../generation/noteComposition";
export type CapturedSelection = {
/** The selected text, trimmed and with collapsed whitespace runs. */
text: string;
+ /**
+ * The selected text with its line structure kept, for the reply-to-AI quote.
+ *
+ * [text] collapses every whitespace run because cards and highlights want one line of words; a
+ * blockquote of a two-paragraph answer, though, is worth two paragraphs. The Reply offer sends
+ * this instead, and the chatbot's `formatMarkdownQuote` turns each line into its own quoted
+ * paragraph.
+ */
+ quoteText: string;
/** The link the selection sits in or is, when it is one. */
url: string | null;
/** Where in the app it came from, in words. Null when nothing better than the app name exists. */
@@ -63,7 +72,8 @@ const MIN_SELECTION_LENGTH = 2;
export function captureSelection(selection: Selection | null): CapturedSelection | null {
if (!selection || selection.isCollapsed || selection.rangeCount === 0) return null;
- const text = normalise(selection.toString());
+ const rawText = selection.toString();
+ const text = normalise(rawText);
if (text.length < MIN_SELECTION_LENGTH) return null;
const anchor = selection.anchorNode;
@@ -72,16 +82,34 @@ export function captureSelection(selection: Selection | null): CapturedSelection
const range = selection.getRangeAt(0);
return {
text,
+ quoteText: selectionQuoteText(rawText),
url: linkFor(anchor, text),
source: sourceFor(anchor),
origin: originUrl(window.location, text),
cardId: elementOf(anchor)?.closest("[data-card-id]")?.getAttribute("data-card-id") ?? null,
inLink: Boolean(elementOf(anchor)?.closest("a")),
rect: range.getBoundingClientRect(),
- isAiMessage: isInsideAiMessage(anchor, range),
+ isAiMessage: isInsideAiMessage(range),
};
}
+/**
+ * The quote-ready form of a selection's raw text: line structure kept, everything else tidied.
+ *
+ * A browser's selection `toString` puts a newline at block boundaries — paragraphs, list items —
+ * and hands back the stray spacing a drag across inline markup picks up. The quote keeps the
+ * paragraph breaks, because that is the whole point of replying with a quote, while dropping
+ * empty lines and collapsing horizontal whitespace — so the chatbot's `formatMarkdownQuote`
+ * receives one line per paragraph and nothing else.
+ */
+export function selectionQuoteText(raw: string): string {
+ return raw
+ .split("\n")
+ .map((line) => line.replace(/[^\S\n]+/g, " ").trim())
+ .filter((line) => line.length > 0)
+ .join("\n");
+}
+
/**
* The card a capture should become.
*
@@ -196,12 +224,15 @@ function elementOf(node: Node): Element | null {
}
/**
- * Whether the selection is located within an AI assistant message bubble.
+ * Whether the selection sits entirely inside one AI assistant message bubble.
+ *
+ * Both ends have to resolve to the same bubble, which keeps the answer independent of the drag
+ * direction: a selection that runs from an answer into the next message is not a reply to that
+ * answer — its text would carry the hire's own words — whichever end the drag started at.
*/
-function isInsideAiMessage(anchor: Node, range: Range): boolean {
- const ancestorEl = elementOf(range.commonAncestorContainer);
- if (ancestorEl?.closest("[data-chat-message-role='ASSISTANT']")) return true;
-
- const anchorEl = elementOf(anchor);
- return Boolean(anchorEl?.closest("[data-chat-message-role='ASSISTANT']"));
+function isInsideAiMessage(range: Range): boolean {
+ const bubbleOf = (node: Node): Element | null =>
+ elementOf(node)?.closest("[data-chat-message-role='ASSISTANT']") ?? null;
+ const bubble = bubbleOf(range.startContainer);
+ return bubble !== null && bubble === bubbleOf(range.endContainer);
}
diff --git a/src/features/chatbot/utils/quoteFormat.ts b/src/features/chatbot/utils/quoteFormat.ts
index 2c7f276ec..ddcba040b 100644
--- a/src/features/chatbot/utils/quoteFormat.ts
+++ b/src/features/chatbot/utils/quoteFormat.ts
@@ -1,14 +1,21 @@
/**
- * Formats a text snippet as a Markdown blockquote by prefixing each line with `> `.
+ * Formats quote text as a Markdown blockquote, one quoted paragraph per line.
+ *
+ * The input is one logical line per paragraph — what a selection capture hands over once it has
+ * dropped the empty lines (see `selectionQuoteText`). Each line becomes `> line`, and the lines
+ * are separated by an empty quoted line, because consecutive `>` lines without one render as a
+ * single paragraph with a line break, not as the paragraphs the answer had.
*/
export function formatMarkdownQuote(text: string): string {
- const trimmed = text.trim();
- if (!trimmed) return "";
-
- return trimmed
+ const paragraphs = text
.split("\n")
+ .map((line) => line.trim())
+ .filter((line) => line.length > 0);
+ if (paragraphs.length === 0) return "";
+
+ return paragraphs
.map((line) => (line.startsWith(">") ? line : `> ${line}`))
- .join("\n");
+ .join("\n>\n");
}
/**
diff --git a/tests/unit/features/board/selectionCapture.test.ts b/tests/unit/features/board/selectionCapture.test.ts
index 8376be882..f36de8a52 100644
--- a/tests/unit/features/board/selectionCapture.test.ts
+++ b/tests/unit/features/board/selectionCapture.test.ts
@@ -2,8 +2,10 @@ import { describe, it, expect, beforeEach } from "vitest";
import {
captureSelection,
cardFor,
+ selectionQuoteText,
type CapturedSelection,
} from "../../../../src/features/board/selection/selectionCapture";
+import { insertQuoteIntoDraft } from "../../../../src/features/chatbot/utils/quoteFormat";
/**
* The decisions behind "add this to my board", tested on strings and a real DOM rather than
@@ -68,6 +70,116 @@ describe("selectionCapture", () => {
document.body.innerHTML = "";
expect(capture("#t").isAiMessage).toBe(false);
});
+
+ /**
+ * A drag across two bubbles, reported the way a browser hands a selection over — the range
+ * as dragged, the anchor on whichever end the drag began. jsdom has no layout, so the
+ * toString a real browser would carry has to be stated rather than derived.
+ */
+ function dragAcross(
+ startSelector: string,
+ endSelector: string,
+ endOffset: number,
+ anchorAtStart: boolean,
+ ): Selection {
+ const startNode = document.querySelector(startSelector)!.firstChild!;
+ const endNode = document.querySelector(endSelector)!.firstChild!;
+ const range = document.createRange();
+ range.setStart(startNode, 0);
+ range.setEnd(endNode, endOffset);
+ return {
+ isCollapsed: false,
+ rangeCount: 1,
+ anchorNode: anchorAtStart ? range.startContainer : range.endContainer,
+ getRangeAt: () => range,
+ toString: () => "The answer. A follow-up question?",
+ } as unknown as Selection;
+ }
+
+ const twoBubbles =
+ "" +
+ "" +
+ "";
+
+ it("says no when the drag runs from an answer into the next message", () => {
+ document.body.innerHTML = twoBubbles;
+
+ expect(captureSelection(dragAcross("#a1", "#u1", 6, true))!.isAiMessage).toBe(false);
+ });
+
+ /**
+ * The same visual selection, dragged the other way. The old check read the drag's anchor as
+ * a fallback and answered differently for it; both ends have to sit in one bubble, which
+ * does not care about direction.
+ */
+ it("says the same for the same selection dragged the other way", () => {
+ document.body.innerHTML = twoBubbles;
+
+ expect(captureSelection(dragAcross("#a1", "#u1", 6, false))!.isAiMessage).toBe(false);
+ });
+
+ it("says no when the selection spans two answers", () => {
+ document.body.innerHTML = twoBubbles;
+
+ expect(captureSelection(dragAcross("#a1", "#a2", 4, true))!.isAiMessage).toBe(false);
+ });
+
+ it("still says yes when the selection spans paragraphs inside one answer", () => {
+ document.body.innerHTML =
+ "";
+
+ expect(captureSelection(dragAcross("#a1", "#a2", 6, true))!.isAiMessage).toBe(true);
+ });
+ });
+
+ describe("the quote the Reply offer sends", () => {
+ /**
+ * A selection the way a browser reports it, with the toString a real drag carries. jsdom's
+ * own toString never inserts the newlines a browser puts between block elements, so stubbing
+ * it in is the one honest way to exercise a multi-paragraph capture here.
+ */
+ function selectionReporting(selector: string, rawText: string): Selection {
+ const range = selectTextIn(selector).getRangeAt(0);
+ return {
+ isCollapsed: false,
+ rangeCount: 1,
+ anchorNode: range.startContainer,
+ getRangeAt: () => range,
+ toString: () => rawText,
+ } as unknown as Selection;
+ }
+
+ it("keeps the paragraph breaks that `text` collapses away", () => {
+ document.body.innerHTML = "First paragraph.
Second paragraph.
";
+ const captured = captureSelection(
+ selectionReporting("#t", "First paragraph.\n\nSecond paragraph."),
+ )!;
+
+ expect(captured.text).toBe("First paragraph. Second paragraph.");
+ expect(captured.quoteText).toBe("First paragraph.\nSecond paragraph.");
+ });
+
+ /**
+ * The path the review flagged, end to end: a real multi-paragraph answer, captured the way
+ * the toolbar captures it, through to what lands in the composer. The paragraph breaks have
+ * to survive as separate quoted paragraphs, not one collapsed line.
+ */
+ it("lands a two-paragraph answer as two quoted paragraphs in the draft", () => {
+ document.body.innerHTML = "First paragraph.
Second paragraph.
";
+ const captured = captureSelection(
+ selectionReporting("#t", "First paragraph.\n\nSecond paragraph."),
+ )!;
+
+ expect(insertQuoteIntoDraft("", captured.quoteText)).toBe(
+ "> First paragraph.\n>\n> Second paragraph.\n\n",
+ );
+ });
+
+ it("drops empty lines and stray spacing from the raw selection text", () => {
+ expect(selectionQuoteText(" First word \n\n\nsecond\n\t\n last ")).toBe(
+ "First word\nsecond\nlast",
+ );
+ });
});
describe("links", () => {
diff --git a/tests/unit/features/chatbot/quoteFormat.test.ts b/tests/unit/features/chatbot/quoteFormat.test.ts
index bea3282c8..caa4442b7 100644
--- a/tests/unit/features/chatbot/quoteFormat.test.ts
+++ b/tests/unit/features/chatbot/quoteFormat.test.ts
@@ -15,15 +15,21 @@ describe("quoteFormat", () => {
expect(formatMarkdownQuote("Hello world")).toBe("> Hello world");
});
- it("prefixes every line in multi-line text with `> `", () => {
+ it("quotes each paragraph, separated by an empty quoted line", () => {
const input = "First line\nSecond line\nThird line";
- const expected = "> First line\n> Second line\n> Third line";
+ const expected = "> First line\n>\n> Second line\n>\n> Third line";
+ expect(formatMarkdownQuote(input)).toBe(expected);
+ });
+
+ it("drops blank lines between paragraphs", () => {
+ const input = "First\n\n\nSecond\n \nThird";
+ const expected = "> First\n>\n> Second\n>\n> Third";
expect(formatMarkdownQuote(input)).toBe(expected);
});
it("preserves lines that already have blockquote syntax", () => {
const input = "> Already quoted\nNew line";
- const expected = "> Already quoted\n> New line";
+ const expected = "> Already quoted\n>\n> New line";
expect(formatMarkdownQuote(input)).toBe(expected);
});
});
@@ -40,11 +46,11 @@ describe("quoteFormat", () => {
expect(result).toBe("What does this mean?\n\n> AI explanation here\n\n");
});
- it("handles multi-line quotes appended to existing draft", () => {
+ it("handles multi-paragraph quotes appended to existing draft", () => {
const currentDraft = "My question.";
const quote = "Line 1\nLine 2";
const result = insertQuoteIntoDraft(currentDraft, quote);
- expect(result).toBe("My question.\n\n> Line 1\n> Line 2\n\n");
+ expect(result).toBe("My question.\n\n> Line 1\n>\n> Line 2\n\n");
});
it("returns current draft untouched if quote text is empty", () => {
From 1ec1e5f447299d167fa7a33d68c7155dfba75e6e Mon Sep 17 00:00:00 2001
From: Daniil Perkin
Date: Thu, 17 Sep 2026 17:12:08 +0200
Subject: [PATCH 29/51] feat(chatbot): redesign source and date filter with
floating glass popover
- Replace static inline filter card in ChatComposer with a floating glassmorphic popover
- Add interactive source pill chips with provider icons and soft brand active states
- Add quick date presets (All time, Past 7 days, Past 30 days) and styled custom date range inputs
- Introduce persistent active filter chips strip above composer with 1-click removal and Clear all
- Fix activeFilterCount in ChatProvider so date range is counted as 1 filter instead of 2
- Add comprehensive unit tests for ChatComposer filter interactions and ChatProvider filter count
---
src/context/ChatProvider.tsx | 2 +-
.../chatbot/components/ChatComposer.tsx | 523 +++++++++++++-----
.../features/chatbot/ChatProvider.test.tsx | 54 +-
.../components/ChatComposer.filters.test.tsx | 196 +++++++
4 files changed, 625 insertions(+), 150 deletions(-)
create mode 100644 tests/unit/features/chatbot/components/ChatComposer.filters.test.tsx
diff --git a/src/context/ChatProvider.tsx b/src/context/ChatProvider.tsx
index 8190701af..8ce46810e 100644
--- a/src/context/ChatProvider.tsx
+++ b/src/context/ChatProvider.tsx
@@ -279,7 +279,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
}, [flushDraft]);
const activeFilterCount = useMemo(() => {
- return sourceSystems.length + (from ? 1 : 0) + (to ? 1 : 0);
+ return sourceSystems.length + (from || to ? 1 : 0);
}, [sourceSystems, from, to]);
const clearFilters = useCallback(() => {
diff --git a/src/features/chatbot/components/ChatComposer.tsx b/src/features/chatbot/components/ChatComposer.tsx
index e691eb97f..c32f10cce 100644
--- a/src/features/chatbot/components/ChatComposer.tsx
+++ b/src/features/chatbot/components/ChatComposer.tsx
@@ -1,9 +1,18 @@
-import { Check, Filter, ListPlus, Send, Square, X } from "lucide-react";
-import { useState } from "react";
+import { Calendar, Check, Filter, ListPlus, RotateCcw, Send, Square, X } from "lucide-react";
+import { useEffect, useRef, useState } from "react";
import type { FormEvent, RefObject } from "react";
+import { AnimatePresence, motion } from "framer-motion";
import { SOURCE_META } from "../../data-ingestion/data";
import type { ChatQueueItem, SourceSystem } from "../types";
import { QueuedMessages } from "./QueuedMessages";
+import { centralSpringToken } from "../../../styles/tokens";
+
+function formatDateFilterLabel(from: string, to: string): string {
+ if (from && to) return `${from} → ${to}`;
+ if (from) return `From ${from}`;
+ if (to) return `Until ${to}`;
+ return "";
+}
type ChatComposerProps = {
/** Current draft text. */
@@ -132,6 +141,61 @@ export function ChatComposer({
};
const blocked = rangeInvalid || !hasProject;
+ const filterButtonRef = useRef(null);
+ const popoverRef = useRef(null);
+
+ // Close floating popover on Escape or click outside
+ useEffect(() => {
+ if (!showFilters) return;
+
+ const handlePointerDown = (event: PointerEvent) => {
+ const target = event.target as Node;
+ if (
+ popoverRef.current &&
+ !popoverRef.current.contains(target) &&
+ filterButtonRef.current &&
+ !filterButtonRef.current.contains(target)
+ ) {
+ onToggleFilters();
+ }
+ };
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === "Escape") {
+ event.preventDefault();
+ onToggleFilters();
+ filterButtonRef.current?.focus();
+ }
+ };
+
+ document.addEventListener("pointerdown", handlePointerDown);
+ document.addEventListener("keydown", handleKeyDown);
+
+ return () => {
+ document.removeEventListener("pointerdown", handlePointerDown);
+ document.removeEventListener("keydown", handleKeyDown);
+ };
+ }, [showFilters, onToggleFilters]);
+
+ const setPastDays = (days: number) => {
+ const now = new Date();
+ const past = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
+ setFrom(past.toISOString().split("T")[0]);
+ setTo(now.toISOString().split("T")[0]);
+ };
+
+ const diffDays =
+ from && to
+ ? Math.round(
+ (new Date(`${to}T00:00:00`).getTime() - new Date(`${from}T00:00:00`).getTime()) /
+ (24 * 60 * 60 * 1000),
+ )
+ : null;
+
+ const isAllTime = !from && !to;
+ const isPast7Days = diffDays === 7;
+ const isPast30Days = diffDays === 30;
+
return (
- {showFilters && (
-
-
-
-
- Narrow the sources
-
+ {/* Active filter chips strip — visible whenever filters are active */}
+
+ {activeFilterCount > 0 && (
+
+
+
+ Filtering:
+
- {activeFilterCount > 0 && (
+ {sourceSystems.map((source) => {
+ const meta = SOURCE_META[source] ?? {
+ type: source,
+ description: source,
+ icon: Filter,
+ };
+ const Icon = meta.icon;
+ return (
+ toggleSourceSystem(source)}
+ title={`Remove ${meta.type} filter`}
+ className="group inline-flex items-center gap-1.5 rounded-full border border-app-brand-border bg-app-brand/10 px-2.5 py-0.5 text-xs font-medium text-app-brand-text transition-colors hover:border-app-danger-border hover:bg-app-danger-bg hover:text-app-danger-text"
+ >
+
+ {meta.type}
+
+
+ );
+ })}
+
+ {(from || to) && (
{
+ setFrom("");
+ setTo("");
+ }}
+ title="Clear date filter"
+ className="group inline-flex items-center gap-1.5 rounded-full border border-app-brand-border bg-app-brand/10 px-2.5 py-0.5 text-xs font-medium text-app-brand-text transition-colors hover:border-app-danger-border hover:bg-app-danger-bg hover:text-app-danger-text"
>
-
- Reset
+
+ {formatDateFilterLabel(from, to)}
+
)}
-
-
-
-
-
- Sources
-
-
- {availableSources.length === 0 ? (
-
- {sourcesLoading
- ? "Loading connected sources…"
- : "No sources are connected yet, so there is nothing to narrow down."}
-
- ) : (
-
- {availableSources.map((source) => {
- const selected = sourceSystems.includes(source);
- const meta = SOURCE_META[source];
- const Icon = meta.icon;
-
- return (
- toggleSourceSystem(source)}
- className={`group flex items-center gap-2 rounded-xl border px-3 py-2 text-sm font-medium transition-all ${
- selected
- ? "border-app-brand-border bg-app-brand/10 text-app-brand-text"
- : "border-app-border bg-app-bg text-app-text-muted hover:border-app-border-strong hover:text-app-text"
- }`}
- >
-
- {selected && }
-
-
- {meta.type}
-
- );
- })}
-
- )}
-
-
- {sourceSystems.length === 0
- ? "Nothing selected — every connected source is searched."
- : "Only the selected sources are searched."}
-
-
-
-
-
-
-
- Indexed between
-
-
-
- setFrom(e.target.value)}
- className={`h-10 rounded-xl border bg-app-bg px-3 text-sm text-app-text transition-colors outline-none focus-visible:ring-2 focus-visible:ring-app-focus/50 ${
- rangeInvalid ? "border-app-danger-border" : "border-app-border"
- }`}
- />
-
- to
-
- setTo(e.target.value)}
- className={`h-10 rounded-xl border bg-app-bg px-3 text-sm text-app-text transition-colors outline-none focus-visible:ring-2 focus-visible:ring-app-focus/50 ${
- rangeInvalid ? "border-app-danger-border" : "border-app-border"
- }`}
- />
-
- {(from || to) && (
- {
- setFrom("");
- setTo("");
- }}
- className="rounded-lg px-2 py-1 text-xs font-medium text-app-text-muted transition-colors hover:bg-app-surface-hover hover:text-app-text"
- >
- Clear dates
-
- )}
-
-
- {rangeInvalid ? (
-
- The first date is after the second one, so nothing would match.
-
- ) : (
-
- Leave empty to search regardless of when a document was indexed.
-
- )}
-
-
-
- )}
+
+
+
+ Clear all
+
+
+ )}
+
-
-
- {activeFilterCount > 0 && (
-
- {activeFilterCount}
-
- )}
-
+
+
0
+ ? "border-app-brand-border-strong bg-app-brand/10 text-app-brand-text shadow-xs"
+ : "border-app-border-muted bg-app-surface text-app-text-muted hover:bg-app-surface-hover hover:text-app-text"
+ }`}
+ >
+
+ {activeFilterCount > 0 && (
+
+ {activeFilterCount}
+
+ )}
+
+
+ {/* Floating Glass Popover */}
+
+ {showFilters && (
+ {
+ // Prevent Enter key in filter popover from submitting the chat message form
+ if (e.key === "Enter") e.stopPropagation();
+ }}
+ className="absolute bottom-full left-0 z-30 mb-3 w-80 max-w-[calc(100vw-2rem)] rounded-2xl border border-app-border/80 bg-app-surface/95 p-4 shadow-[0_20px_50px_-15px_rgba(0,0,0,0.35)] backdrop-blur-xl sm:w-[380px]"
+ >
+ {/* Popover Header */}
+
+
+
+
+
+
+ Filter Knowledge Sources
+
+
+
+
+ {activeFilterCount > 0 && (
+
+
+ Reset
+
+ )}
+
+
+
+
+
+
+ {/* Sources Selection */}
+
+
+
+ Sources
+
+
+ {sourceSystems.length === 0
+ ? "Searching all sources"
+ : `${sourceSystems.length} selected`}
+
+
+
+ {availableSources.length === 0 ? (
+
+ {sourcesLoading
+ ? "Loading connected sources…"
+ : "No sources are connected yet, so there is nothing to narrow down."}
+
+ ) : (
+
+ {availableSources.map((source) => {
+ const selected = sourceSystems.includes(source);
+ const meta = SOURCE_META[source] ?? {
+ type: source,
+ description: source,
+ icon: Filter,
+ };
+ const Icon = meta.icon;
+
+ return (
+
toggleSourceSystem(source)}
+ className={`group flex items-center justify-between rounded-xl border px-2.5 py-2 text-xs font-medium transition-all ${
+ selected
+ ? "border-app-brand-border-strong bg-app-brand/10 text-app-brand-text shadow-xs"
+ : "border-app-border bg-app-surface text-app-text-muted hover:border-app-border-strong hover:bg-app-surface-hover hover:text-app-text"
+ }`}
+ >
+
+
+ {meta.type}
+
+
+ {selected && }
+
+
+ );
+ })}
+
+ )}
+
+
+ {/* Timeframe Selection */}
+
+
+
+
+
+ Indexed Date
+
+ {(from || to) && (
+ {
+ setFrom("");
+ setTo("");
+ }}
+ className="text-[11px] font-medium text-app-brand-text hover:underline"
+ >
+ Clear dates
+
+ )}
+
+
+ {/* Preset Buttons */}
+
+ {
+ setFrom("");
+ setTo("");
+ }}
+ className={`rounded-lg px-2.5 py-1 text-xs font-medium transition-colors ${
+ isAllTime
+ ? "bg-app-brand font-semibold text-white shadow-xs"
+ : "border border-app-border bg-app-surface text-app-text-muted hover:bg-app-surface-hover hover:text-app-text"
+ }`}
+ >
+ All time
+
+ setPastDays(7)}
+ className={`rounded-lg px-2.5 py-1 text-xs font-medium transition-colors ${
+ isPast7Days
+ ? "bg-app-brand font-semibold text-white shadow-xs"
+ : "border border-app-border bg-app-surface text-app-text-muted hover:bg-app-surface-hover hover:text-app-text"
+ }`}
+ >
+ Past 7 days
+
+ setPastDays(30)}
+ className={`rounded-lg px-2.5 py-1 text-xs font-medium transition-colors ${
+ isPast30Days
+ ? "bg-app-brand font-semibold text-white shadow-xs"
+ : "border border-app-border bg-app-surface text-app-text-muted hover:bg-app-surface-hover hover:text-app-text"
+ }`}
+ >
+ Past 30 days
+
+
+
+ {/* Custom Inputs */}
+
+
+
+ From
+
+ setFrom(e.target.value)}
+ className="w-full min-w-0 bg-transparent text-xs text-app-text outline-none"
+ />
+
+
+
→
+
+
+
+ To
+
+ setTo(e.target.value)}
+ className="w-full min-w-0 bg-transparent text-xs text-app-text outline-none"
+ />
+
+
+
+ {rangeInvalid ? (
+
+ Start date cannot be after end date.
+
+ ) : (
+
+ Filter documents indexed within this date range.
+
+ )}
+
+
+ )}
+
+
{
expect(vi.mocked(getMyChats)).not.toHaveBeenCalled();
});
});
+
+function FilterProbe() {
+ const context = useContext(ChatContext);
+ if (!context) return null;
+
+ return (
+
+ {context.activeFilterCount}
+ context.toggleSourceSystem("GITHUB")}>Add GitHub
+ {
+ context.setFrom("2026-09-01");
+ context.setTo("2026-09-10");
+ }}
+ >
+ Set Range
+
+ Clear
+
+ );
+}
+
+describe("ChatProvider activeFilterCount", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ project.selected = true;
+ vi.mocked(getMyChats).mockResolvedValue({ chats: [] });
+ });
+
+ it("counts a date range (both from and to set) as 1 filter", async () => {
+ render(
+
+
+ ,
+ );
+
+ await waitFor(() => expect(vi.mocked(getMyChats)).toHaveBeenCalledWith("p1"));
+ expect(screen.getByTestId("filter-count")).toHaveTextContent("0");
+
+ // Setting a date range increments count by 1 (not 2)
+ fireEvent.click(screen.getByText("Set Range"));
+ expect(screen.getByTestId("filter-count")).toHaveTextContent("1");
+
+ // Adding a source brings count to 2
+ fireEvent.click(screen.getByText("Add GitHub"));
+ expect(screen.getByTestId("filter-count")).toHaveTextContent("2");
+
+ // Clearing resets count to 0
+ fireEvent.click(screen.getByText("Clear"));
+ expect(screen.getByTestId("filter-count")).toHaveTextContent("0");
+ });
+});
diff --git a/tests/unit/features/chatbot/components/ChatComposer.filters.test.tsx b/tests/unit/features/chatbot/components/ChatComposer.filters.test.tsx
new file mode 100644
index 000000000..249aa572e
--- /dev/null
+++ b/tests/unit/features/chatbot/components/ChatComposer.filters.test.tsx
@@ -0,0 +1,196 @@
+import { createRef } from "react";
+import { render, screen, fireEvent } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, it, expect, vi } from "vitest";
+import { ChatComposer } from "../../../../../src/features/chatbot/components/ChatComposer";
+import type { SourceSystem } from "../../../../../src/features/chatbot/types";
+
+describe("ChatComposer filter interactions", () => {
+ const defaultProps = {
+ value: "",
+ onChange: vi.fn(),
+ onSubmit: vi.fn((e: React.FormEvent) => {
+ e.preventDefault();
+ }),
+ onStop: vi.fn(),
+ isBusy: false,
+ hasProject: true,
+ promptHistory: [],
+ availableSources: ["GITHUB", "JIRA", "UPLOAD"] as SourceSystem[],
+ sourcesLoading: false,
+ textareaRef: createRef(),
+ showFilters: false,
+ onToggleFilters: vi.fn(),
+ from: "",
+ setFrom: vi.fn(),
+ to: "",
+ setTo: vi.fn(),
+ sourceSystems: [] as SourceSystem[],
+ toggleSourceSystem: vi.fn(),
+ activeFilterCount: 0,
+ clearFilters: vi.fn(),
+ };
+
+ it("renders the filter toggle button with badge when filters are active", () => {
+ const { rerender } = render( );
+
+ const toggle = screen.getByTestId("chat-filters-toggle");
+ expect(toggle).toBeInTheDocument();
+ expect(toggle).toHaveAttribute("aria-expanded", "false");
+ expect(screen.queryByText("2")).not.toBeInTheDocument();
+
+ rerender( );
+ expect(screen.getByText("2")).toBeInTheDocument();
+ });
+
+ it("calls onToggleFilters when clicking the filter toggle button", async () => {
+ const onToggleFilters = vi.fn();
+ const user = userEvent.setup();
+
+ render( );
+
+ await user.click(screen.getByTestId("chat-filters-toggle"));
+ expect(onToggleFilters).toHaveBeenCalledTimes(1);
+ });
+
+ it("renders the active filter chips strip when activeFilterCount > 0", async () => {
+ const toggleSourceSystem = vi.fn();
+ const setFrom = vi.fn();
+ const setTo = vi.fn();
+ const clearFilters = vi.fn();
+ const user = userEvent.setup();
+
+ render(
+ ,
+ );
+
+ // Active strip indicator
+ expect(screen.getByText("Filtering:")).toBeInTheDocument();
+
+ // Source chip
+ expect(screen.getByText("GitHub")).toBeInTheDocument();
+ const removeGithub = screen.getByTitle("Remove GitHub filter");
+ await user.click(removeGithub);
+ expect(toggleSourceSystem).toHaveBeenCalledWith("GITHUB");
+
+ // Date chip
+ expect(screen.getByText("2026-09-01 → 2026-09-10")).toBeInTheDocument();
+ const removeDate = screen.getByTitle("Clear date filter");
+ await user.click(removeDate);
+ expect(setFrom).toHaveBeenCalledWith("");
+ expect(setTo).toHaveBeenCalledWith("");
+
+ // Clear all button
+ const clearAll = screen.getByText("Clear all");
+ await user.click(clearAll);
+ expect(clearFilters).toHaveBeenCalledTimes(1);
+ });
+
+ it("renders floating popover when showFilters is true", async () => {
+ const toggleSourceSystem = vi.fn();
+ const setFrom = vi.fn();
+ const setTo = vi.fn();
+ const user = userEvent.setup();
+
+ render(
+ ,
+ );
+
+ expect(screen.getByText("Filter Knowledge Sources")).toBeInTheDocument();
+ expect(screen.getByText("1 selected")).toBeInTheDocument();
+
+ // Available sources rendered
+ const githubBtn = screen.getByRole("button", { name: /github/i });
+ expect(githubBtn).toBeInTheDocument();
+ expect(githubBtn).toHaveAttribute("aria-pressed", "true");
+
+ const jiraBtn = screen.getByRole("button", { name: /jira/i });
+ expect(jiraBtn).toBeInTheDocument();
+ expect(jiraBtn).toHaveAttribute("aria-pressed", "false");
+
+ await user.click(jiraBtn);
+ expect(toggleSourceSystem).toHaveBeenCalledWith("JIRA");
+
+ // Date presets
+ const past7Btn = screen.getByRole("button", { name: "Past 7 days" });
+ await user.click(past7Btn);
+ expect(setFrom).toHaveBeenCalled();
+ expect(setTo).toHaveBeenCalled();
+
+ const allTimeBtn = screen.getByRole("button", { name: "All time" });
+ await user.click(allTimeBtn);
+ expect(setFrom).toHaveBeenCalledWith("");
+ expect(setTo).toHaveBeenCalledWith("");
+ });
+
+ it("updates custom date range inputs in popover", () => {
+ const setFrom = vi.fn();
+ const setTo = vi.fn();
+
+ render(
+ ,
+ );
+
+ const fromInput = screen.getByLabelText("Earliest date");
+ const toInput = screen.getByLabelText("Latest date");
+
+ fireEvent.change(fromInput, { target: { value: "2026-09-05" } });
+ expect(setFrom).toHaveBeenCalledWith("2026-09-05");
+
+ fireEvent.change(toInput, { target: { value: "2026-09-15" } });
+ expect(setTo).toHaveBeenCalledWith("2026-09-15");
+ });
+
+ it("shows error alert when date range is inverted", () => {
+ render( );
+
+ expect(screen.getByRole("alert")).toHaveTextContent("Start date cannot be after end date.");
+ });
+
+ it("closes popover when pressing Escape", () => {
+ const onToggleFilters = vi.fn();
+
+ render( );
+
+ fireEvent.keyDown(document, { key: "Escape" });
+ expect(onToggleFilters).toHaveBeenCalledTimes(1);
+ });
+
+ it("closes popover when clicking outside", () => {
+ const onToggleFilters = vi.fn();
+
+ render(
+
+ Outside
+
+
,
+ );
+
+ fireEvent.pointerDown(screen.getByTestId("outside-element"));
+ expect(onToggleFilters).toHaveBeenCalledTimes(1);
+ });
+});
From 97d9074edfdc7e4b2ba9e64c28df1731af0a4502 Mon Sep 17 00:00:00 2001
From: Daniil Perkin
Date: Thu, 17 Sep 2026 17:17:39 +0200
Subject: [PATCH 30/51] fix(test): remove unused act import in ChatProvider
test
---
tests/unit/features/chatbot/ChatProvider.test.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/tests/unit/features/chatbot/ChatProvider.test.tsx b/tests/unit/features/chatbot/ChatProvider.test.tsx
index 17a8a3699..42c1e8b09 100644
--- a/tests/unit/features/chatbot/ChatProvider.test.tsx
+++ b/tests/unit/features/chatbot/ChatProvider.test.tsx
@@ -1,4 +1,4 @@
-import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { useContext } from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { ChatProvider } from "../../../../src/context/ChatProvider";
From 9f5aa575d9042af813d32e139228277c0c6e307b Mon Sep 17 00:00:00 2001
From: Daniil Perkin
Date: Fri, 18 Sep 2026 18:45:17 +0200
Subject: [PATCH 31/51] fix(chatbot): address review feedback on filter popover
and presets
- Prevent implicit form submission when pressing Enter in date inputs
- Use local date formatting for quick presets to avoid UTC day offset
- Guard preset highlights against false-positive historical date spans
- Add accessible aria-label to filter chip remove buttons and aria-pressed to presets
- Restore red border highlight on date inputs when date range is inverted
- Wrap onToggleFilters in useCallback to prevent document listener churn
- Extract presetClass helper and document SOURCE_META runtime defense
- Update and expand unit tests in ChatComposer.filters.test.tsx
---
.../chatbot/components/ChatComposer.tsx | 87 ++++++++++++++-----
src/pages/ChatPage.tsx | 6 +-
.../components/ChatComposer.filters.test.tsx | 69 ++++++++++++++-
3 files changed, 133 insertions(+), 29 deletions(-)
diff --git a/src/features/chatbot/components/ChatComposer.tsx b/src/features/chatbot/components/ChatComposer.tsx
index c32f10cce..da6d42598 100644
--- a/src/features/chatbot/components/ChatComposer.tsx
+++ b/src/features/chatbot/components/ChatComposer.tsx
@@ -14,6 +14,13 @@ function formatDateFilterLabel(from: string, to: string): string {
return "";
}
+function formatLocalDate(date: Date): string {
+ const year = date.getFullYear();
+ const month = String(date.getMonth() + 1).padStart(2, "0");
+ const day = String(date.getDate()).padStart(2, "0");
+ return `${year}-${month}-${day}`;
+}
+
type ChatComposerProps = {
/** Current draft text. */
value: string;
@@ -179,11 +186,13 @@ export function ChatComposer({
const setPastDays = (days: number) => {
const now = new Date();
- const past = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
- setFrom(past.toISOString().split("T")[0]);
- setTo(now.toISOString().split("T")[0]);
+ const past = new Date(now.getFullYear(), now.getMonth(), now.getDate() - days);
+ setFrom(formatLocalDate(past));
+ setTo(formatLocalDate(now));
};
+ const today = formatLocalDate(new Date());
+
const diffDays =
from && to
? Math.round(
@@ -193,8 +202,15 @@ export function ChatComposer({
: null;
const isAllTime = !from && !to;
- const isPast7Days = diffDays === 7;
- const isPast30Days = diffDays === 30;
+ const isPast7Days = to === today && diffDays === 7;
+ const isPast30Days = to === today && diffDays === 30;
+
+ const presetClass = (active: boolean) =>
+ `rounded-lg px-2.5 py-1 text-xs font-medium transition-colors ${
+ active
+ ? "bg-app-brand font-semibold text-white shadow-xs"
+ : "border border-app-border bg-app-surface text-app-text-muted hover:bg-app-surface-hover hover:text-app-text"
+ }`;
return (
@@ -860,7 +864,7 @@ export function DinoGame({ onExit, replyReady = false }: DinoGameProps) {
onClick={onExit}
className="rounded-lg bg-app-brand px-3.5 py-1.5 text-xs font-semibold text-white transition-colors hover:bg-app-brand-hover"
>
- View Reply (Space)
+ {completionLabel ? `${completionLabel} (Space)` : "View Reply (Space)"}
) : (
- {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/onboarding/components/journey/GenerationScreen.tsx b/src/features/onboarding/components/journey/GenerationScreen.tsx
index 6ff9a245c..a8902c211 100644
--- a/src/features/onboarding/components/journey/GenerationScreen.tsx
+++ b/src/features/onboarding/components/journey/GenerationScreen.tsx
@@ -2,6 +2,7 @@ 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";
function elapsed(startedAt: number, now: number): string {
@@ -20,53 +21,34 @@ function elapsed(startedAt: number, now: number): string {
export function GenerationScreen({
phases,
startedAt,
+ isCompleted = false,
+ onGameActiveChange,
}: {
phases: GenerationPhaseProgress[];
startedAt: number;
+ isCompleted?: boolean;
+ 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);
- }, []);
+ const isGenerating =
+ !isCompleted &&
+ (phases.length === 0 ||
+ phases.some((phase) => phase.state === "waiting" || phase.state === "working"));
+
+ 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]);
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]);
+ const timer = window.setInterval(() => setNow(Date.now()), 1000);
+ return () => window.clearInterval(timer);
+ }, []);
const done = phases.filter((phase) => phase.state === "done" || phase.state === "failed").length;
const total = phases.length;
@@ -162,9 +144,23 @@ export function GenerationScreen({
) : null}
+ {dinoUnlocked && !gameActive && isGenerating && (
+
+ Press{" "}
+
+ Space
+ {" "}
+ to pass the time 🦖
+
+ )}
+
{gameActive ? (
- setGameActive(false)} />
+
) : null}
diff --git a/src/features/settings/components/DinoUnlockPopover.tsx b/src/features/settings/components/DinoUnlockPopover.tsx
index 77e194640..6aa38e832 100644
--- a/src/features/settings/components/DinoUnlockPopover.tsx
+++ b/src/features/settings/components/DinoUnlockPopover.tsx
@@ -55,7 +55,7 @@ export function DinoUnlockPopover({ toast, className = "" }: DinoUnlockPopoverPr
Space
- while AI is thinking
+ {"whenever you're waiting"}
) : isLock ? (
diff --git a/src/features/settings/hooks/useDinoEasterEgg.ts b/src/features/settings/hooks/useDinoEasterEgg.ts
index 96f04e49d..a343088bd 100644
--- a/src/features/settings/hooks/useDinoEasterEgg.ts
+++ b/src/features/settings/hooks/useDinoEasterEgg.ts
@@ -67,7 +67,7 @@ export function useDinoEasterEgg(): DinoEasterEgg {
queueMicrotask(() => window.dispatchEvent(new Event("dinoUnlockChanged")));
showToast(
- currentlyUnlocked ? "you saw nothing... 🫣" : "shh... 🤫 press Space while AI is thinking",
+ currentlyUnlocked ? "you saw nothing... 🫣" : "shh... 🤫 press Space whenever you're waiting",
);
}, [showToast]);
diff --git a/src/pages/OnBoardingPage.tsx b/src/pages/OnBoardingPage.tsx
index b32db6742..1308986fe 100644
--- a/src/pages/OnBoardingPage.tsx
+++ b/src/pages/OnBoardingPage.tsx
@@ -43,6 +43,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";
@@ -183,6 +184,23 @@ export function OnBoardingPage() {
const [expandedItemId, setExpandedItemId] = useState(focusItemId ?? null);
const [graphItemId, setGraphItemId] = useState(null);
const [confirmRegenerate, setConfirmRegenerate] = useState(false);
+ const [isDinoActiveInGeneration, setIsDinoActiveInGeneration] = useState(false);
+ const [lastGeneration, setLastGeneration] = useState<{
+ phases: GenerationPhaseProgress[];
+ startedAt: number;
+ }>({ phases: [], startedAt: 0 });
+
+ useEffect(() => {
+ if (generation.status === "running") {
+ const { phases: currentPhases, startedAt } = generation;
+ queueMicrotask(() => {
+ setLastGeneration({
+ phases: currentPhases,
+ startedAt,
+ });
+ });
+ }
+ }, [generation]);
// Set when the page itself moves the member on, so the item they land on is scrolled to.
const scrollToItemRef = useRef(focusItemId ?? null);
@@ -559,8 +577,19 @@ export function OnBoardingPage() {
// ── Render: generating ──────────────────────────────────────
- if (generation.status === "running") {
- return ;
+ if (generation.status === "running" || isDinoActiveInGeneration) {
+ 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/tests/unit/features/data-ingestion/components/SourceDetailsPanel.test.tsx b/tests/unit/features/data-ingestion/components/SourceDetailsPanel.test.tsx
index 3c1e59914..a2389c1ff 100644
--- a/tests/unit/features/data-ingestion/components/SourceDetailsPanel.test.tsx
+++ b/tests/unit/features/data-ingestion/components/SourceDetailsPanel.test.tsx
@@ -1,4 +1,4 @@
-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";
@@ -89,6 +89,7 @@ const confluenceSource: DataSource = {
describe("SourceDetailsPanel", () => {
beforeEach(() => {
+ window.localStorage.clear();
vi.clearAllMocks();
});
@@ -501,4 +502,69 @@ 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.getByText(/sync complete/i)).toBeInTheDocument();
+ });
});
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..0750ce6ce
--- /dev/null
+++ b/tests/unit/features/onboarding/components/journey/GenerationScreen.test.tsx
@@ -0,0 +1,88 @@
+import { 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.getByText(/path ready/i)).toBeInTheDocument();
+ });
+});
diff --git a/tests/unit/features/settings/components/DinoUnlockPopover.test.tsx b/tests/unit/features/settings/components/DinoUnlockPopover.test.tsx
index 40da322b3..53521a1e6 100644
--- a/tests/unit/features/settings/components/DinoUnlockPopover.test.tsx
+++ b/tests/unit/features/settings/components/DinoUnlockPopover.test.tsx
@@ -9,13 +9,13 @@ describe("DinoUnlockPopover", () => {
});
it("renders playful whisper with kbd badge when unlocking", () => {
- render( );
+ render( );
expect(screen.getByRole("status")).toBeInTheDocument();
expect(screen.getByText(/shh\.\.\. press/i)).toBeInTheDocument();
expect(screen.getByText("Space")).toBeInTheDocument();
expect(screen.getByText("Space").tagName.toLowerCase()).toBe("kbd");
- expect(screen.getByText(/while AI is thinking/i)).toBeInTheDocument();
+ expect(screen.getByText(/whenever you're waiting/i)).toBeInTheDocument();
});
it("renders you saw nothing whisper when locking", () => {
From 1aa8868dffaba453bdba37ff91dd97dce9f77f6d Mon Sep 17 00:00:00 2001
From: Daniil Perkin
Date: Mon, 21 Sep 2026 15:24:41 +0200
Subject: [PATCH 40/51] tests(chatbot): wrap queuedMessages length assertion in
waitFor
---
tests/unit/features/chatbot/chatQueue.test.tsx | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
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 () => {
From 3f701f5ea55a13c50d262d7cc915ebea91cd8832 Mon Sep 17 00:00:00 2001
From: Daniil Perkin
Date: Mon, 21 Sep 2026 16:52:11 +0200
Subject: [PATCH 41/51] Fix the bugs the review of the unpushed commits found
Reviewed the 16 owner-authored commits since the last push (buddy team
mode, the GitHub repository facet, the dino waiting game evolution and
the closed-task card) with three parallel read-only reviews, verified
every finding against the sources, and fixed what was real. BabuPlk's
three commits (arriving via the #248 re-merge) were excluded as other
people's work.
Buddy team mode (major + minors):
- useBuddyConversation: a refused hire action's "Try again" button was
dead. The card renders it only for a resolved refusal
(!action.ok && "action" in action), but confirmAction's guard let
only idle/error through - every click hit the guard and returned.
The component-level test stubbed onConfirm, so it passed while the
hook refused the retry. Now a resolved refusal that still carries
its action passes the guard; a permanent refusal (no action payload)
and everything confirming/confirmed/declined stays spent. The
per-card flight lock already cleared in finally, so a retry runs.
- useBuddyConversation: teamTargetRef survived a signed-in-user
change. The userId effect set team mode directly, bypassing
setTeamMode - the only place that cleared the target - so the next
user's adopt effect compared their selection against the previous
user's binding and overwrote the restored preference with a bogus
exit. The ref now lives above the effect and is cleared on every
subject change.
- useBuddyConversation: the dock's "Clear the earlier conversation"
control is handed to BuddyThread unconditionally, and
startFreshVisit's guard checked greeting/decisions but not a live
reply - clicking it mid-stream silently dropped the tokens,
citations and proposed actions still arriving. The guard now also
refuses while isThinking/isStreaming (one-line fix at the hook; the
durable stream-abort redesign stays a separate piece of work).
- BuddyWidget: stale comment claimed the mode switcher still carries
the restore audit; it moved into the session - the comment now
points at the session.
Dino waiting game (major + minors):
- DinoGame: the window keydown/keyup handlers had no typing-target
guard, and the game now outlives its turn - exactly when the
composer regains focus. Space/w/s/arrows were prevented and eaten
mid-sentence, and a stray Escape closed the game while typing. Both
handlers now bail when the event lands in a text field, reusing the
same isTypingTarget guard the trigger applies (now exported,
widened to EventTarget for event.target).
- The completion badges claimed success from "not busy anymore":
- ChatPage promised "Reply ready" while tokens were still streaming
(isThinking flips false at the first token) and on a failed or
stopped turn; now it waits for the stream to actually end. A
provider-level clean-end flag would fix the error/stop labels
too - left as a follow-up, failures surface as toasts so there is
no state to read from the page today.
- OnBoardingPage promised "Path ready" for a failed generation
(status error also leaves running); now only status done claims
it.
- SourceDetailsPanel promised "Sync complete" when a sync failed
into attention or was disabled; now only state connected claims
it.
- ThinkingIndicator: the suppression comment claimed the
ReasoningPanel displays the thinking state - it does not render
thinkingState, so a post-reasoning tool label has no surface. The
comment now documents the accepted gap instead of a fiction.
Knowledge base (minors):
- githubMetadata: getArtifactRepository re-parsed and re-warned the
raw metadata JSON on every card render, drawer render and facet
memo evaluation - the WeakMap cache only covered
matchesRepository. The parse now runs once per artifact object for
every reader (compute core + cache wrapper), which also caches the
org-login lookup.
- orgMetadata: parseOrgMetadata accepted a blank-string login
(unlike the repo parser's blank rejection) and returned it
untrimmed, silently missing the case-folded owner-half match
against a trimmed repositoryFullName. It now rejects blank logins
and trims like the repo parser does.
- useKnowledgeBase: the facet count allocated a Set inside the
per-candidate filter - O(repos x candidates) throwaway allocations
per evaluation; now one per offered repository.
- githubMetadata.test: the matchesRepository tests leaked real
console.warns for the malformed-metadata case, masking unexpected
warnings; silenced like the parse tests, asserting the warn fired.
Verified: npm run build, npm run lint, npm run format:check green;
unit suite 2911/2911 across 353 files. Not pushed, per instruction.
---
src/features/buddy/components/BuddyWidget.tsx | 3 +-
.../buddy/hooks/useBuddyConversation.ts | 31 ++++++++++----
src/features/chatbot/components/DinoGame.tsx | 8 ++++
.../chatbot/components/ThinkingIndicator.tsx | 9 ++--
.../components/SourceDetailsPanel.tsx | 5 ++-
.../easter-eggs/hooks/useDinoWaitingGame.ts | 3 +-
src/features/knowledge-base/githubMetadata.ts | 41 ++++++++++++-------
.../knowledge-base/hooks/useKnowledgeBase.ts | 19 +++++----
src/features/knowledge-base/orgMetadata.ts | 8 +++-
src/pages/ChatPage.tsx | 2 +-
src/pages/OnBoardingPage.tsx | 6 ++-
.../knowledge-base/githubMetadata.test.ts | 4 ++
12 files changed, 100 insertions(+), 39 deletions(-)
diff --git a/src/features/buddy/components/BuddyWidget.tsx b/src/features/buddy/components/BuddyWidget.tsx
index 8ee71ff53..9e78ab6a2 100644
--- a/src/features/buddy/components/BuddyWidget.tsx
+++ b/src/features/buddy/components/BuddyWidget.tsx
@@ -320,7 +320,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);
+ // 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.
@@ -148,7 +155,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);
@@ -437,8 +443,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([]);
@@ -454,7 +469,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.
@@ -662,9 +677,11 @@ export function useBuddyConversation(
const confirmAction = useCallback(
(messageId: string, action: ProposedAction) => {
- // Retryable after a transport error; anything already on its way, answered or declined is
- // not confirmable again.
- if (action.status !== "idle" && action.status !== "error") return;
+ // Retryable after a transport error. A hire offer that came back refused is offered again
+ // under its reason ("Try again: …") when the refusal is not permanent, so it must pass the
+ // guard too; anything else already on its way, answered or declined is spent.
+ const retryableRefusal = action.status === "resolved" && "action" in action && !action.ok;
+ if (action.status !== "idle" && action.status !== "error" && !retryableRefusal) return;
// One lock per proposal card, shared by both decisions: confirm and dismiss are the two
// halves of one question, and letting both run would let the slower response overwrite
diff --git a/src/features/chatbot/components/DinoGame.tsx b/src/features/chatbot/components/DinoGame.tsx
index 6fe410c2d..a979decf2 100644
--- a/src/features/chatbot/components/DinoGame.tsx
+++ b/src/features/chatbot/components/DinoGame.tsx
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
+import { isTypingTarget } from "../../easter-eggs/hooks/useDinoWaitingGame";
type DinoGameProps = {
/**
@@ -269,6 +270,12 @@ export function DinoGame({ onExit, replyReady = false, completionLabel }: DinoGa
// Keyboard + pointer controls.
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
+ // 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.key === "Escape") {
e.preventDefault();
onExit();
@@ -288,6 +295,7 @@ export function DinoGame({ onExit, replyReady = false, completionLabel }: DinoGa
};
const onKeyUp = (e: KeyboardEvent) => {
+ if (isTypingTarget(e.target)) return;
if (e.code === "Space" || e.key === "ArrowUp" || e.key === "w") {
releaseJump();
return;
diff --git a/src/features/chatbot/components/ThinkingIndicator.tsx b/src/features/chatbot/components/ThinkingIndicator.tsx
index c1c375547..f20acd57f 100644
--- a/src/features/chatbot/components/ThinkingIndicator.tsx
+++ b/src/features/chatbot/components/ThinkingIndicator.tsx
@@ -34,9 +34,12 @@ export function ThinkingIndicator({
// If neither thinking nor game active, nothing to render.
if (!isThinking && !gameActive) return null;
- // When thoughts/reasoning are streaming and the user has not opened the dino game,
- // the ReasoningPanel already visibly displays the thinking state, so suppress the
- // redundant bouncing dots indicator.
+ // 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.
diff --git a/src/features/data-ingestion/components/SourceDetailsPanel.tsx b/src/features/data-ingestion/components/SourceDetailsPanel.tsx
index e265edb60..ff8ba22d9 100644
--- a/src/features/data-ingestion/components/SourceDetailsPanel.tsx
+++ b/src/features/data-ingestion/components/SourceDetailsPanel.tsx
@@ -423,7 +423,10 @@ export function SourceDetailsPanel({
diff --git a/src/features/easter-eggs/hooks/useDinoWaitingGame.ts b/src/features/easter-eggs/hooks/useDinoWaitingGame.ts
index 1efc37cce..93f8d9b21 100644
--- a/src/features/easter-eggs/hooks/useDinoWaitingGame.ts
+++ b/src/features/easter-eggs/hooks/useDinoWaitingGame.ts
@@ -24,7 +24,8 @@ export function useDinoUnlocked(): boolean {
return isUnlocked;
}
-const isTypingTarget = (el: Element | null) =>
+/** Shared guard: true while the event landed in a text field that owns the keys. */
+export const isTypingTarget = (el: EventTarget | null) =>
el instanceof HTMLElement &&
(el.tagName === "TEXTAREA" || el.tagName === "INPUT" || el.isContentEditable);
diff --git a/src/features/knowledge-base/githubMetadata.ts b/src/features/knowledge-base/githubMetadata.ts
index 0f361e4b9..22077af76 100644
--- a/src/features/knowledge-base/githubMetadata.ts
+++ b/src/features/knowledge-base/githubMetadata.ts
@@ -88,14 +88,16 @@ export function parseGithubMetadata(
*
* @param artifact The artifact whose metadata should be interpreted.
* @returns The `owner/repository` string, or `null`.
+ *
+ * Cached per artifact object through `repositoryInfoOf`: the card list, the
+ * drawer and the facet loop all call this on every render pass, and without
+ * the cache each call re-ran `JSON.parse` (and re-warned on a malformed
+ * payload) for the same metadata.
*/
export function getArtifactRepository(
artifact: Pick,
): string | null {
- if (artifact.sourceSystem !== "GITHUB" || artifact.artifactType === "ORG_METADATA") {
- return null;
- }
- return parseGithubMetadata(artifact.metadata)?.repositoryFullName ?? null;
+ return repositoryInfoOf(artifact).repository;
}
/**
@@ -111,6 +113,25 @@ interface RepositoryInfo {
orgLogin: string | null;
}
+/** Uncached core both public readers share; see the WeakMap note below. */
+function computeRepositoryInfo(
+ artifact: Pick,
+): RepositoryInfo {
+ const repository =
+ artifact.sourceSystem === "GITHUB" && artifact.artifactType !== "ORG_METADATA"
+ ? (parseGithubMetadata(artifact.metadata)?.repositoryFullName ?? null)
+ : null;
+ if (repository !== null) return { repository, orgLogin: null };
+
+ return {
+ repository: null,
+ orgLogin:
+ artifact.artifactType === "ORG_METADATA"
+ ? (parseOrgMetadata(artifact.metadata)?.login ?? null)
+ : null,
+ };
+}
+
const repositoryInfoCache = new WeakMap();
function repositoryInfoOf(
@@ -119,17 +140,7 @@ function repositoryInfoOf(
const cached = repositoryInfoCache.get(artifact);
if (cached !== undefined) return cached;
- const repository = getArtifactRepository(artifact);
- const info: RepositoryInfo =
- repository !== null
- ? { repository, orgLogin: null }
- : {
- repository: null,
- orgLogin:
- artifact.artifactType === "ORG_METADATA"
- ? (parseOrgMetadata(artifact.metadata)?.login ?? null)
- : null,
- };
+ const info = computeRepositoryInfo(artifact);
repositoryInfoCache.set(artifact, info);
return info;
}
diff --git a/src/features/knowledge-base/hooks/useKnowledgeBase.ts b/src/features/knowledge-base/hooks/useKnowledgeBase.ts
index 3dfcf061a..9ed698631 100644
--- a/src/features/knowledge-base/hooks/useKnowledgeBase.ts
+++ b/src/features/knowledge-base/hooks/useKnowledgeBase.ts
@@ -264,13 +264,18 @@ export function useKnowledgeBase(projectId: string | null) {
const offered = new Set([...repos, ...selectedRepositories]);
return [...offered]
.sort((a, b) => a.localeCompare(b))
- .map((repository) => ({
- value: repository,
- label: repository,
- count: githubCandidates.filter((artifact) =>
- matchesRepository(artifact, new Set([repository])),
- ).length,
- }));
+ .map((repository) => {
+ // One set per offered repository (not per candidate): the count filter
+ // runs per artifact, so allocating inside the filter was O(repos ×
+ // candidates) throwaway Set allocations per facet evaluation.
+ const selection = new Set([repository]);
+ return {
+ value: repository,
+ label: repository,
+ count: githubCandidates.filter((artifact) => matchesRepository(artifact, selection))
+ .length,
+ };
+ });
}, [
artifacts,
deferredSearchQuery,
diff --git a/src/features/knowledge-base/orgMetadata.ts b/src/features/knowledge-base/orgMetadata.ts
index 1c4bbcab2..9d553db35 100644
--- a/src/features/knowledge-base/orgMetadata.ts
+++ b/src/features/knowledge-base/orgMetadata.ts
@@ -80,7 +80,8 @@ export function parseOrgMetadata(
}
const p = parsed as Record;
- if (typeof p["login"] !== "string" || !Array.isArray(p["members"])) {
+ const login = p["login"];
+ if (typeof login !== "string" || login.trim() === "" || !Array.isArray(p["members"])) {
return null;
}
@@ -102,5 +103,8 @@ export function parseOrgMetadata(
if (!everyTeamUsable) return null;
}
- return parsed as OrgMetadataArtifactMetadata;
+ // Same normalization as the repo parser applies to repositoryFullName: a
+ // padded login must not silently miss the owner-half match, which folds
+ // case but compares against a trimmed `owner/repo` string.
+ return { ...(parsed as OrgMetadataArtifactMetadata), login: login.trim() };
}
diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx
index e242fe301..f9366fe5f 100644
--- a/src/pages/ChatPage.tsx
+++ b/src/pages/ChatPage.tsx
@@ -466,7 +466,7 @@ export function ChatPage() {
gameActive={gameActive}
thinkingState={thinkingState}
hasReasoning={hasReasoning}
- replyReady={!isThinking && gameActive}
+ replyReady={gameActive && !isThinking && !isStreaming}
onGameExit={closeGame}
/>
diff --git a/src/pages/OnBoardingPage.tsx b/src/pages/OnBoardingPage.tsx
index 1308986fe..1b9b6a0e0 100644
--- a/src/pages/OnBoardingPage.tsx
+++ b/src/pages/OnBoardingPage.tsx
@@ -586,7 +586,11 @@ export function OnBoardingPage() {
);
diff --git a/tests/unit/features/knowledge-base/githubMetadata.test.ts b/tests/unit/features/knowledge-base/githubMetadata.test.ts
index fc873c6bd..2769435f9 100644
--- a/tests/unit/features/knowledge-base/githubMetadata.test.ts
+++ b/tests/unit/features/knowledge-base/githubMetadata.test.ts
@@ -178,12 +178,16 @@ describe("matchesRepository", () => {
),
).toBe(false);
// Repo-scoped artifacts with unusable metadata have no repository to match.
+ // The malformed blob makes the parser warn — silence it here like the parse
+ // tests do, so the real warnings stay visible in the output.
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
expect(
matchesRepository(
{ sourceSystem: "GITHUB", artifactType: "FILE", metadata: "{not json" },
new Set(["owner/repo"]),
),
).toBe(false);
+ expect(warn).toHaveBeenCalled();
});
it("matches owner halves case-insensitively", () => {
From de0557940d6836fd3b609c159df490934027bf8e Mon Sep 17 00:00:00 2001
From: Daniil Perkin
Date: Tue, 22 Sep 2026 12:10:37 +0200
Subject: [PATCH 42/51] Upgrade the egg surfaces: honest waiting-game badges,
replayable barrel roll, dialog focus contract, and a truthful confetti
palette
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Fact-checked pass over the six strict-review findings, keeping the ones
that survived verification and refuting the rest:
- ConfettiBurst: the fifth palette candidate read a variable that does not
exist (--color-app-yellow-text); the real token is
--color-app-highlight-yellow, so the burst now paints all five app hues
instead of silently running on four. The claim that the whole reader was
dead is refuted: the --color-app-* tokens are defined via @theme inline
in styles/index.css, so getComputedStyle resolves them. The inert
animate-fade-in / shadow-card classes (defined nowhere) are dropped.
- Buddy waiting game: the one surface where the game died at the first
token. The hook now arms useSpaceOpensDino with keepActiveUntilExit like
the chat and the drawer; the thread keeps the typing bubble mounted while
the game is open even after thinking ends; isStreaming flows from both
callers (dock and full page) into the thread, and replyReady is only
true once thinking AND streaming are done — so the dots row stops lying
once the reply has arrived and the game's completion badge takes over.
- EggEffectsLayer: re-triggering the barrel roll mid-run removed and
re-added the class inside one commit, which the engine answers with a
shrug — the animation kept its already-finished state. A forced style
recalc between cleanup and re-add makes the second roll actually roll
from the top.
- EggModalShell: focus contract without fighting the games — focus lands
on the dialog on open (tabIndex={-1}, so it joins no Tab order) and
returns to the opener on close; Tab wraps inside the dialog only. Tab
is deliberately the only trapped key: the canvas games own
arrows/w/s/space, and keydowns inside the 2048 iframe never reach this
window, so neither surface can collide with the trap.
- OnBoardingPage: the queueMicrotask around the last-generation snapshot
stays — fact-checking showed it looks redundant but exists to satisfy
react-hooks/set-state-in-effect, which errors on the direct setState the
reviewer suggested. A comment now records that so the next reader does
not "simplify" it away.
Refuted by verification, left unchanged: the useRepeatClicks window
finding — the inter-click-gap semantics are the documented design ("the
guard only has to rule out accumulation across a session"), and the
proposed total-window reading would tighten a deliberate gesture.
Verified: npm run build, npm run lint, npm run format:check green; unit
suite 3017/3017 across 360 files.
---
.../buddy/components/BuddyConversation.tsx | 1 +
src/features/buddy/components/BuddyDock.tsx | 1 +
.../buddy/components/BuddyMessage.tsx | 33 ++++++---
src/features/buddy/components/BuddyThread.tsx | 10 ++-
.../buddy/hooks/useBuddyConversation.ts | 7 +-
.../easter-eggs/components/ConfettiBurst.tsx | 4 +-
.../components/EggEffectsLayer.tsx | 7 +-
.../easter-eggs/components/EggModalShell.tsx | 71 ++++++++++++++++++-
src/pages/OnBoardingPage.tsx | 8 +--
9 files changed, 118 insertions(+), 24 deletions(-)
diff --git a/src/features/buddy/components/BuddyConversation.tsx b/src/features/buddy/components/BuddyConversation.tsx
index 83dfce7c9..7145c5c89 100644
--- a/src/features/buddy/components/BuddyConversation.tsx
+++ b/src/features/buddy/components/BuddyConversation.tsx
@@ -166,6 +166,7 @@ export function BuddyConversation({
)}
messages={messages}
isThinking={isThinking}
+ isStreaming={isStreaming}
activeTool={activeTool}
confirmAction={confirmAction}
dismissAction={dismissAction}
diff --git a/src/features/buddy/components/BuddyDock.tsx b/src/features/buddy/components/BuddyDock.tsx
index e708a6460..5efde6e6c 100644
--- a/src/features/buddy/components/BuddyDock.tsx
+++ b/src/features/buddy/components/BuddyDock.tsx
@@ -334,6 +334,7 @@ export function BuddyDock({
compact
messages={messages}
isThinking={isThinking}
+ isStreaming={isStreaming}
activeTool={activeTool}
lastMessageFooter={lastMessageFooter}
confirmAction={confirmAction}
diff --git a/src/features/buddy/components/BuddyMessage.tsx b/src/features/buddy/components/BuddyMessage.tsx
index dedfc26a9..d3ff2c720 100644
--- a/src/features/buddy/components/BuddyMessage.tsx
+++ b/src/features/buddy/components/BuddyMessage.tsx
@@ -221,12 +221,19 @@ export function BuddyTypingMessage({
label,
showName = false,
gameActive = false,
+ replyReady = false,
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;
/** Called when the player leaves the dino game (Escape / exit button). */
onGameExit?: () => void;
}) {
@@ -234,8 +241,10 @@ export function BuddyTypingMessage({
// 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, exactly like ThinkingIndicator does, and
- // the label keeps explaining what the buddy is doing behind the game.
+ // 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 (
-
+
-
-
-
-
-
-
- {label && {label} }
-
+ {!replyReady && (
+
+
+
+
+
+
+ {label && {label} }
+
+ )}
);
diff --git a/src/features/buddy/components/BuddyThread.tsx b/src/features/buddy/components/BuddyThread.tsx
index b8b821301..644df38d5 100644
--- a/src/features/buddy/components/BuddyThread.tsx
+++ b/src/features/buddy/components/BuddyThread.tsx
@@ -11,6 +11,12 @@ import { BuddyMessage, BuddyTypingMessage } from "./BuddyMessage";
type BuddyThreadProps = {
messages: BuddyMessageView[];
isThinking: boolean;
+ /**
+ * True while the reply is still receiving tokens. Together with `isThinking`
+ * this is what keeps the waiting game's "reply ready" badge honest: the game
+ * only claims the reply is there once the turn has actually finished.
+ */
+ isStreaming?: boolean;
/** The tool the buddy is running right now, if any — becomes "Checking your progress…". */
activeTool: string | null;
/** Confirms a buddy-proposed action (the only path that mutates). */
@@ -98,6 +104,7 @@ type BuddyThreadProps = {
export function BuddyThread({
messages,
isThinking,
+ isStreaming = false,
activeTool,
confirmAction,
dismissAction,
@@ -227,11 +234,12 @@ export function BuddyThread({
);
})}
- {isThinking && (
+ {(isThinking || dinoGameActive) && (
)}
diff --git a/src/features/buddy/hooks/useBuddyConversation.ts b/src/features/buddy/hooks/useBuddyConversation.ts
index 3b429d03c..ac523be19 100644
--- a/src/features/buddy/hooks/useBuddyConversation.ts
+++ b/src/features/buddy/hooks/useBuddyConversation.ts
@@ -96,7 +96,12 @@ export function useBuddyConversation(
// offers. Closing is handled inside the hook: on exit, when the turn ends,
// or when the cogwheel unlock flag flips off.
const dinoUnlocked = useDinoUnlocked();
- const [dinoGameActive, closeDinoGame] = useSpaceOpensDino(isThinking, dinoUnlocked);
+ const [dinoGameActive, closeDinoGame] = useSpaceOpensDino(isThinking, 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 one suggested next step the opening greeting invites, until the hire acts or asks.
const [openerAction, setOpenerAction] = useState(null);
diff --git a/src/features/easter-eggs/components/ConfettiBurst.tsx b/src/features/easter-eggs/components/ConfettiBurst.tsx
index 03db06392..e84a311b2 100644
--- a/src/features/easter-eggs/components/ConfettiBurst.tsx
+++ b/src/features/easter-eggs/components/ConfettiBurst.tsx
@@ -248,7 +248,7 @@ export function ConfettiBurst() {
{prefersReducedMotion ? (
🎉 Party!
@@ -280,7 +280,7 @@ function readPalette(): string[] {
"--color-app-purple-text",
"--color-app-pink-text",
"--color-app-orange-text",
- "--color-app-yellow-text",
+ "--color-app-highlight-yellow",
];
const resolved = candidates.map((name) => styles.getPropertyValue(name).trim()).filter(Boolean);
return resolved.length >= 3 ? resolved : FALLBACK_PALETTE;
diff --git a/src/features/easter-eggs/components/EggEffectsLayer.tsx b/src/features/easter-eggs/components/EggEffectsLayer.tsx
index 62d31e4ab..7f26a5b48 100644
--- a/src/features/easter-eggs/components/EggEffectsLayer.tsx
+++ b/src/features/easter-eggs/components/EggEffectsLayer.tsx
@@ -37,11 +37,14 @@ export function EggEffectsLayer() {
clearEggEffect();
return;
}
- document.body.classList.add("barrel-roll-active");
// `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 puts it straight back.
+ // 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");
diff --git a/src/features/easter-eggs/components/EggModalShell.tsx b/src/features/easter-eggs/components/EggModalShell.tsx
index 7c62be919..be7e734d7 100644
--- a/src/features/easter-eggs/components/EggModalShell.tsx
+++ b/src/features/easter-eggs/components/EggModalShell.tsx
@@ -36,9 +36,15 @@ type EggModalShellProps = {
* 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 one thing every overlay needs
- * regardless — the page behind it holding still — comes from
- * `useScrollLock`.
+ * 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; Tab wraps inside the
+ * dialog — the canvas games never use it, and keydowns inside the 2048
+ * iframe don't reach this window, so the trap cannot fight either surface.
+ * The one thing every overlay needs regardless — the page behind it
+ * holding still — comes from `useScrollLock`.
*/
export function EggModalShell({ eggId, open, onClose }: EggModalShellProps) {
const egg = EGG_REGISTRY[eggId];
@@ -47,6 +53,63 @@ export function EggModalShell({ eggId, open, onClose }: EggModalShellProps) {
const prefersReducedMotion = useReducedMotion();
const dialogVariants = getModalDialogVariants(Boolean(prefersReducedMotion));
+ // Focus in on open, back to the opener on close. The dialog carries
+ // `tabIndex={-1}` so it is programmatically focusable without joining the
+ // page's Tab order. Restoring happens in the effect cleanup, i.e. the
+ // moment `open` flips false — before the exit animation finishes, but the
+ // return is what matters: focus never ends up on a removed node (the
+ // trigger button would be unreachable to keyboard users otherwise).
+ const dialogRef = useRef(null);
+ const restoreFocusRef = useRef(null);
+ useEffect(() => {
+ if (!open) return;
+ restoreFocusRef.current =
+ document.activeElement instanceof HTMLElement ? document.activeElement : null;
+ dialogRef.current?.focus();
+ return () => {
+ restoreFocusRef.current?.focus();
+ restoreFocusRef.current = null;
+ };
+ }, [open]);
+
+ // Tab wraps inside the dialog. Deliberately Tab-only: canvas games bind
+ // arrows/w/s/space on the window, and a Tab press inside the 2048 iframe
+ // is confined to the frame's document, so neither surface ever fights
+ // this handler. The focusable list includes the iframe itself, so one Tab
+ // from the header close button walks into the frame and the next wrap
+ // brings focus back to the header.
+ 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 = [
+ ...dialog.querySelectorAll(
+ 'a[href], button:not([disabled]), iframe, [tabindex]:not([tabindex="-1"])',
+ ),
+ ];
+ 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
@@ -92,6 +155,8 @@ export function EggModalShell({ eggId, open, onClose }: EggModalShellProps) {
aria-label={egg.label}
>
{
if (generation.status === "running") {
const { phases: currentPhases, startedAt } = generation;
+ // Deferred out of the effect body on purpose: `react-hooks/set-state-in-effect`
+ // flags a synchronous setState here, and semantically this only needs to land
+ // before the next paint, not before the next effect runs.
queueMicrotask(() => {
- setLastGeneration({
- phases: currentPhases,
- startedAt,
- });
+ setLastGeneration({ phases: currentPhases, startedAt });
});
}
}, [generation]);
From ba9f7d0d723696bb3e992c929e6ba9e152941425 Mon Sep 17 00:00:00 2001
From: Daniil Perkin
Date: Thu, 24 Sep 2026 13:59:14 +0200
Subject: [PATCH 43/51] upgrade(easter-eggs): hand the knowledge-base and
tooling tweaks to #264
This branch carried seven files that have nothing to do with easter
eggs: hardening in the knowledge-base metadata readers, a Set hoist in
useKnowledgeBase, and .worktrees excludes for vite, eslint and
prettier. They were review fixes to code already on dev, parked here
while this branch was the only open one. Now #264 is open and rewrites
the same KB code: merge-tree showed useKnowledgeBase.ts (about 240
lines) and vite.config.ts conflicting whichever PR landed second.
All seven now match origin/dev again:
- githubMetadata/orgMetadata hardening + its test: moved to #264
(9cd5081e) with a new trim test the original lacked.
- useKnowledgeBase Set hoist: dropped. #264 moved facet counting
server-side, so the loop it optimised no longer exists.
- vite/eslint .worktrees excludes: #264 carries its own versions. This
also drops the duplicate `vitest/config` import the vite change left.
- .prettierignore: moved to #264 with the KB commit.
No worktree nests inside this branch's own checkout, so its gates are
unaffected.
---
.prettierignore | 5 ---
eslint.config.js | 4 --
src/features/knowledge-base/githubMetadata.ts | 41 +++++++------------
.../knowledge-base/hooks/useKnowledgeBase.ts | 19 ++++-----
src/features/knowledge-base/orgMetadata.ts | 8 +---
.../knowledge-base/githubMetadata.test.ts | 4 --
vite.config.ts | 5 ---
7 files changed, 24 insertions(+), 62 deletions(-)
diff --git a/.prettierignore b/.prettierignore
index f803711cd..189edadc0 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -4,11 +4,6 @@ coverage
node_modules
package-lock.json
-# Local per-task worktrees (git worktree add .worktrees/) carry full
-# copies of the tree, each formatted against its own branch state; checking
-# them here makes format:check walk and flag four extra repos.
-.worktrees
-
# Vendored Keycloak theme. `keycloakify sync-extensions` runs on every
# `npm install` and rewrites everything except the handful of "owned" files
# listed in src/keycloak-theme/.gitignore — formatting the rest is churn that
diff --git a/eslint.config.js b/eslint.config.js
index 1c0709167..5ca7c1ef1 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -13,10 +13,6 @@ export default defineConfig([
"dist_keycloak",
"node_modules",
"coverage",
- // Local per-task worktrees (git worktree add .worktrees/) carry full
- // copies of src/ and tests/; without this the type-checked lint run walks
- // every copy and takes minutes instead of seconds.
- ".worktrees/**",
"src/keycloak-theme/**",
"public/keycloak-theme/**",
"public/keycloakify-dev-resources/**",
diff --git a/src/features/knowledge-base/githubMetadata.ts b/src/features/knowledge-base/githubMetadata.ts
index 22077af76..0f361e4b9 100644
--- a/src/features/knowledge-base/githubMetadata.ts
+++ b/src/features/knowledge-base/githubMetadata.ts
@@ -88,16 +88,14 @@ export function parseGithubMetadata(
*
* @param artifact The artifact whose metadata should be interpreted.
* @returns The `owner/repository` string, or `null`.
- *
- * Cached per artifact object through `repositoryInfoOf`: the card list, the
- * drawer and the facet loop all call this on every render pass, and without
- * the cache each call re-ran `JSON.parse` (and re-warned on a malformed
- * payload) for the same metadata.
*/
export function getArtifactRepository(
artifact: Pick,
): string | null {
- return repositoryInfoOf(artifact).repository;
+ if (artifact.sourceSystem !== "GITHUB" || artifact.artifactType === "ORG_METADATA") {
+ return null;
+ }
+ return parseGithubMetadata(artifact.metadata)?.repositoryFullName ?? null;
}
/**
@@ -113,25 +111,6 @@ interface RepositoryInfo {
orgLogin: string | null;
}
-/** Uncached core both public readers share; see the WeakMap note below. */
-function computeRepositoryInfo(
- artifact: Pick,
-): RepositoryInfo {
- const repository =
- artifact.sourceSystem === "GITHUB" && artifact.artifactType !== "ORG_METADATA"
- ? (parseGithubMetadata(artifact.metadata)?.repositoryFullName ?? null)
- : null;
- if (repository !== null) return { repository, orgLogin: null };
-
- return {
- repository: null,
- orgLogin:
- artifact.artifactType === "ORG_METADATA"
- ? (parseOrgMetadata(artifact.metadata)?.login ?? null)
- : null,
- };
-}
-
const repositoryInfoCache = new WeakMap();
function repositoryInfoOf(
@@ -140,7 +119,17 @@ function repositoryInfoOf(
const cached = repositoryInfoCache.get(artifact);
if (cached !== undefined) return cached;
- const info = computeRepositoryInfo(artifact);
+ const repository = getArtifactRepository(artifact);
+ const info: RepositoryInfo =
+ repository !== null
+ ? { repository, orgLogin: null }
+ : {
+ repository: null,
+ orgLogin:
+ artifact.artifactType === "ORG_METADATA"
+ ? (parseOrgMetadata(artifact.metadata)?.login ?? null)
+ : null,
+ };
repositoryInfoCache.set(artifact, info);
return info;
}
diff --git a/src/features/knowledge-base/hooks/useKnowledgeBase.ts b/src/features/knowledge-base/hooks/useKnowledgeBase.ts
index 9ed698631..3dfcf061a 100644
--- a/src/features/knowledge-base/hooks/useKnowledgeBase.ts
+++ b/src/features/knowledge-base/hooks/useKnowledgeBase.ts
@@ -264,18 +264,13 @@ export function useKnowledgeBase(projectId: string | null) {
const offered = new Set([...repos, ...selectedRepositories]);
return [...offered]
.sort((a, b) => a.localeCompare(b))
- .map((repository) => {
- // One set per offered repository (not per candidate): the count filter
- // runs per artifact, so allocating inside the filter was O(repos ×
- // candidates) throwaway Set allocations per facet evaluation.
- const selection = new Set([repository]);
- return {
- value: repository,
- label: repository,
- count: githubCandidates.filter((artifact) => matchesRepository(artifact, selection))
- .length,
- };
- });
+ .map((repository) => ({
+ value: repository,
+ label: repository,
+ count: githubCandidates.filter((artifact) =>
+ matchesRepository(artifact, new Set([repository])),
+ ).length,
+ }));
}, [
artifacts,
deferredSearchQuery,
diff --git a/src/features/knowledge-base/orgMetadata.ts b/src/features/knowledge-base/orgMetadata.ts
index 9d553db35..1c4bbcab2 100644
--- a/src/features/knowledge-base/orgMetadata.ts
+++ b/src/features/knowledge-base/orgMetadata.ts
@@ -80,8 +80,7 @@ export function parseOrgMetadata(
}
const p = parsed as Record;
- const login = p["login"];
- if (typeof login !== "string" || login.trim() === "" || !Array.isArray(p["members"])) {
+ if (typeof p["login"] !== "string" || !Array.isArray(p["members"])) {
return null;
}
@@ -103,8 +102,5 @@ export function parseOrgMetadata(
if (!everyTeamUsable) return null;
}
- // Same normalization as the repo parser applies to repositoryFullName: a
- // padded login must not silently miss the owner-half match, which folds
- // case but compares against a trimmed `owner/repo` string.
- return { ...(parsed as OrgMetadataArtifactMetadata), login: login.trim() };
+ return parsed as OrgMetadataArtifactMetadata;
}
diff --git a/tests/unit/features/knowledge-base/githubMetadata.test.ts b/tests/unit/features/knowledge-base/githubMetadata.test.ts
index 2769435f9..fc873c6bd 100644
--- a/tests/unit/features/knowledge-base/githubMetadata.test.ts
+++ b/tests/unit/features/knowledge-base/githubMetadata.test.ts
@@ -178,16 +178,12 @@ describe("matchesRepository", () => {
),
).toBe(false);
// Repo-scoped artifacts with unusable metadata have no repository to match.
- // The malformed blob makes the parser warn — silence it here like the parse
- // tests do, so the real warnings stay visible in the output.
- const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
expect(
matchesRepository(
{ sourceSystem: "GITHUB", artifactType: "FILE", metadata: "{not json" },
new Set(["owner/repo"]),
),
).toBe(false);
- expect(warn).toHaveBeenCalled();
});
it("matches owner halves case-insensitively", () => {
diff --git a/vite.config.ts b/vite.config.ts
index 42a2b2500..33c8d8790 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -3,7 +3,6 @@ import { fileURLToPath } from "node:url";
import { keycloakify } from "keycloakify/vite-plugin";
///
import { defineConfig } from "vitest/config";
-import { defaultExclude } from "vitest/config";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
@@ -44,10 +43,6 @@ export default defineConfig({
// parallel. The default 5s bounds machine load rather than the code under test, so a
// busy runner fails whichever file happens to be scheduled last.
testTimeout: 30000,
- // Per-task worktrees under .worktrees/ carry full tests/ copies of their own
- // branches; the default include would run all of them against this tree's
- // modules and fail thousands of tests that are not this branch's concern.
- exclude: [...defaultExclude, ".worktrees/**"],
alias: [
// Every test's `render`/`renderHook` gets a QueryClientProvider for free, without
// touching the ~230 files that import them directly — see tests/unit/setup/rtl.tsx.
From 983097886d1daad17e9238219a7fdb583a74b0b6 Mon Sep 17 00:00:00 2001
From: Daniil Perkin
Date: Thu, 24 Sep 2026 13:59:15 +0200
Subject: [PATCH 44/51] upgrade(easter-eggs): keep focus inside the game dialog
and survive failed chunks
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The modal shell promised a focus contract it could not keep, and the
lazy game chunks could take the whole app down.
- Focus trap: keydowns inside the 2048 iframe never reach the parent,
so Tab from its last link walked out of the aria-modal dialog onto
the page behind it (WCAG 2.4.3). A document focusin guard now pulls
focus back to the dialog's first or last control, depending on the
direction it left.
- Focus restore: on a cached reopen the iframe focused itself (a fresh
about:blank already reads readyState "complete") before the shell's
effect ran, so the shell recorded the iframe as the opener and
restored focus to a removed node. The shell now captures the opener
in useLayoutEffect, drops the guard before restoring, and restores
only an opener that is still connected. Game2048Frame focuses on the
iframe load event only.
- EggErrorBoundary: a game chunk that fails (stale tab after a deploy,
offline) used to unmount the root, since the app has no boundary.
The shell now shows an EmptyState "Couldn't load " with a
working close. The 2048 header renders outside Suspense so it can
always be closed.
- Reduced motion: MatrixRain ignored the preference the effects layer
documents. It now shows a static chip like confetti, via the new
shared ReducedMotionEffectChip.
- Space trigger: isInteractiveTarget exempts buttons, selects, links
and ARIA widgets, so Space presses the focused control instead of
opening the dino. Modifier keys, auto-repeat and already-handled
presses are also ignored. isTypingTarget keeps its text-field-only
meaning for the game's own key filter. Unlock reads are guarded
against a throwing localStorage.
- useRepeatClicks: the window now spans the whole gesture, not each
gap, which is what the docs said (3 clicks in 3 s, not 3 s per gap).
- Confetti: its status now mounts empty and fills a tick later so
screen readers announce it. The pale highlight yellow is dropped;
the hex fallback is kept and documented as jsdom-only.
- Hardening: ui/Button close control. 2048 messages must come from
our iframe; the page posts to location.origin, not "*". Phrases
accept curly apostrophes (iOS "let’s party").
Tests: focus contract (open, restore, removed opener, Tab wrap, focus
pulled back), failing chunks, two armed hosts on one Space, focused
controls keep Space, load-only frame focus, foreign messages, matrix
reduced motion, curly apostrophes, gesture window.
---
public/easter-eggs/2048.html | 2 +-
.../easter-eggs/components/ConfettiBurst.tsx | 61 +++----
.../components/EggEffectsLayer.tsx | 20 ++-
.../components/EggErrorBoundary.tsx | 111 +++++++++++++
.../easter-eggs/components/EggModalShell.tsx | 154 ++++++++++++------
.../easter-eggs/components/Game2048Frame.tsx | 17 +-
.../components/ReducedMotionEffectChip.tsx | 55 +++++++
.../easter-eggs/hooks/useDinoWaitingGame.ts | 66 +++++++-
.../easter-eggs/hooks/useRepeatClicks.ts | 21 ++-
src/features/easter-eggs/lib/eggPhrases.ts | 11 +-
.../easter-eggs/ConfettiBurst.test.tsx | 8 +-
.../easter-eggs/EggEffectsLayer.test.tsx | 60 ++++++-
.../easter-eggs/EggModalShell.test.tsx | 120 +++++++++++++-
.../easter-eggs/EggModalShellError.test.tsx | 72 ++++++++
.../easter-eggs/Game2048Frame.test.tsx | 56 +++++--
.../features/easter-eggs/eggPhrases.test.ts | 17 +-
.../easter-eggs/useDinoWaitingGame.test.tsx | 123 +++++++++++++-
.../easter-eggs/useRepeatClicks.test.tsx | 33 +++-
18 files changed, 847 insertions(+), 160 deletions(-)
create mode 100644 src/features/easter-eggs/components/EggErrorBoundary.tsx
create mode 100644 src/features/easter-eggs/components/ReducedMotionEffectChip.tsx
create mode 100644 tests/unit/features/easter-eggs/EggModalShellError.test.tsx
diff --git a/public/easter-eggs/2048.html b/public/easter-eggs/2048.html
index 26b8f72b8..e97ba57aa 100644
--- a/public/easter-eggs/2048.html
+++ b/public/easter-eggs/2048.html
@@ -1002,7 +1002,7 @@
if (!modifiers) {
if (event.key === "Escape") {
try {
- window.parent.postMessage({ type: "EGG_EXIT" }, "*");
+ window.parent.postMessage({ type: "EGG_EXIT" }, window.location.origin);
} catch (_) {}
return;
}
diff --git a/src/features/easter-eggs/components/ConfettiBurst.tsx b/src/features/easter-eggs/components/ConfettiBurst.tsx
index e84a311b2..7152739b3 100644
--- a/src/features/easter-eggs/components/ConfettiBurst.tsx
+++ b/src/features/easter-eggs/components/ConfettiBurst.tsx
@@ -1,6 +1,7 @@
import { useEffect, useRef } from "react";
import { useReducedMotion } from "framer-motion";
-import { clearEggEffect } from "../eggEffectBus";
+import { clearEggEffect } from "../eggEffectBus.ts";
+import { ReducedMotionEffectChip } from "./ReducedMotionEffectChip.tsx";
/**
* Confetti particle: position, velocity, spin and look. Plain mutable data —
@@ -29,8 +30,6 @@ type Particle = {
/** How long a particle lives at most, in seconds. */
const MAX_AGE_S = 3.5;
-/** How long the reduced-motion chip stays before the effect clears (ms). */
-const REDUCED_FADE_MS = 1500;
/** 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). */
@@ -64,7 +63,7 @@ const CANNON_ANGLES = {
* 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 ReducedPartyChip.
+ * - 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
@@ -82,18 +81,9 @@ export function ConfettiBurst() {
// instance's timer clear the *new* effect a few hundred ms in.
const fadeTimeoutRef = useRef | null>(null);
- // Reduced motion: particle animation IS the effect, so there is no
- // honest way to keep it — show a static celebratory chip instead
- // (opacity-only fade), so typing "party" never disappears into a void.
- useEffect(() => {
- if (!prefersReducedMotion) return;
- const timeout = setTimeout(() => clearEggEffect(), REDUCED_FADE_MS);
- return () => clearTimeout(timeout);
- }, [prefersReducedMotion]);
-
useEffect(() => {
if (prefersReducedMotion) return;
- // The reduced-motion branch above owns the chip's timer; this effect
+ // 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
@@ -243,32 +233,27 @@ export function ConfettiBurst() {
};
}, [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 (
- <>
- {prefersReducedMotion ? (
-
- 🎉 Party!
-
- ) : (
-
- )}
- >
+
);
}
/**
* Reads the confetti colors from the app's CSS custom properties.
*
- * Uses the extended badge hues (brand, purple, pink, orange, amber/yellow)
- * — festive but still the app's own palette. Falls back to sensible colors
- * when getComputedStyle cannot resolve a variable (e.g. jsdom).
+ * 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") {
@@ -280,10 +265,18 @@ function readPalette(): string[] {
"--color-app-purple-text",
"--color-app-pink-text",
"--color-app-orange-text",
- "--color-app-highlight-yellow",
+ "--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
index 7f26a5b48..c3792c5ad 100644
--- a/src/features/easter-eggs/components/EggEffectsLayer.tsx
+++ b/src/features/easter-eggs/components/EggEffectsLayer.tsx
@@ -1,8 +1,9 @@
import { useEffect } from "react";
import { useReducedMotion } from "framer-motion";
-import { clearEggEffect, useActiveEggEffect } from "../eggEffectBus";
-import { MatrixRain } from "./MatrixRain";
-import { ConfettiBurst } from "./ConfettiBurst";
+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;
@@ -18,9 +19,10 @@ const BARREL_ROLL_MS = 2000;
* 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 are skipped for users who asked for less of
- * it: see the barrel-roll branch below, and the reduced-motion chip the
- * confetti falls back to.
+ * 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();
@@ -63,6 +65,12 @@ export function EggEffectsLayer() {
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 ;
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
index be7e734d7..921bfc863 100644
--- a/src/features/easter-eggs/components/EggModalShell.tsx
+++ b/src/features/easter-eggs/components/EggModalShell.tsx
@@ -1,9 +1,20 @@
-import { Suspense, useEffect, useRef, useState } from "react";
+import { Suspense, useEffect, useLayoutEffect, useRef, useState } from "react";
import { X } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
-import { useScrollLock } from "../../../components/ui/useScrollLock";
-import { getModalDialogVariants, modalBackdropVariants } from "../../../styles/tokens";
-import { EGG_REGISTRY, type EggId } from "../registry";
+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. */
@@ -24,7 +35,7 @@ type EggModalShellProps = {
*
* Two shapes fall out of the registry:
*
- * - Canvas games (dino, invaders) own their keyboard and already draw
+ * - 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).
@@ -40,11 +51,18 @@ type EggModalShellProps = {
* 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; Tab wraps inside the
- * dialog — the canvas games never use it, and keydowns inside the 2048
- * iframe don't reach this window, so the trap cannot fight either surface.
+ * 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];
@@ -53,31 +71,60 @@ export function EggModalShell({ eggId, open, onClose }: EggModalShellProps) {
const prefersReducedMotion = useReducedMotion();
const dialogVariants = getModalDialogVariants(Boolean(prefersReducedMotion));
- // Focus in on open, back to the opener on close. The dialog carries
- // `tabIndex={-1}` so it is programmatically focusable without joining the
- // page's Tab order. Restoring happens in the effect cleanup, i.e. the
- // moment `open` flips false — before the exit animation finishes, but the
- // return is what matters: focus never ends up on a removed node (the
- // trigger button would be unreachable to keyboard users otherwise).
+ // 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);
- const restoreFocusRef = useRef(null);
- useEffect(() => {
+ useLayoutEffect(() => {
if (!open) return;
- restoreFocusRef.current =
- document.activeElement instanceof HTMLElement ? document.activeElement : null;
+ 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 () => {
- restoreFocusRef.current?.focus();
- restoreFocusRef.current = null;
+ document.removeEventListener("focusin", onFocusIn);
+ if (opener?.isConnected) opener.focus();
};
}, [open]);
- // Tab wraps inside the dialog. Deliberately Tab-only: canvas games bind
- // arrows/w/s/space on the window, and a Tab press inside the 2048 iframe
- // is confined to the frame's document, so neither surface ever fights
- // this handler. The focusable list includes the iframe itself, so one Tab
- // from the header close button walks into the frame and the next wrap
- // brings focus back to the header.
+ // 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) => {
@@ -85,11 +132,7 @@ export function EggModalShell({ eggId, open, onClose }: EggModalShellProps) {
const dialog = dialogRef.current;
if (!dialog || !(e.target instanceof Node) || !dialog.contains(e.target)) return;
- const focusables = [
- ...dialog.querySelectorAll(
- 'a[href], button:not([disabled]), iframe, [tabindex]:not([tabindex="-1"])',
- ),
- ];
+ const focusables = getFocusables(dialog);
if (focusables.length === 0) {
e.preventDefault();
dialog.focus();
@@ -166,30 +209,33 @@ export function EggModalShell({ eggId, open, onClose }: EggModalShellProps) {
exit="exit"
onClick={(e) => e.stopPropagation()}
>
- {/* One Suspense around every branch: all registry components are
- lazy, and an unsuspended lazy child would tear down the tree. */}
- }>
- {egg.kind === "iframe" ? (
- <>
- {/* Header bar for the iframe game (canvas games draw their own chrome). */}
-
-
{egg.label}
-
-
-
-
-
- >
- ) : (
+ {/* 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. */}
+
+ }>
- )}
-
+
+
)}
diff --git a/src/features/easter-eggs/components/Game2048Frame.tsx b/src/features/easter-eggs/components/Game2048Frame.tsx
index 51e65c8f9..7a039ad3d 100644
--- a/src/features/easter-eggs/components/Game2048Frame.tsx
+++ b/src/features/easter-eggs/components/Game2048Frame.tsx
@@ -39,10 +39,12 @@ export function Game2048Frame({ onExit }: Game2048FrameProps) {
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
- // Same origin only: the game page is served by this app, so a message
- // from anywhere else can only be somebody else's frame closing a modal
- // that is not its business.
+ // 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();
}
@@ -69,9 +71,12 @@ export function Game2048Frame({ onExit }: Game2048FrameProps) {
}
};
- if (iframe.contentDocument?.readyState === "complete") {
- focusFrame();
- }
+ // 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);
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/hooks/useDinoWaitingGame.ts b/src/features/easter-eggs/hooks/useDinoWaitingGame.ts
index 93f8d9b21..74149e8bc 100644
--- a/src/features/easter-eggs/hooks/useDinoWaitingGame.ts
+++ b/src/features/easter-eggs/hooks/useDinoWaitingGame.ts
@@ -1,5 +1,18 @@
import { useCallback, useEffect, useState } from "react";
+/**
+ * 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
@@ -7,12 +20,10 @@ import { useCallback, useEffect, useState } from "react";
* browser's cross-tab `storage` event.
*/
export function useDinoUnlocked(): boolean {
- const [isUnlocked, setIsUnlocked] = useState(
- () => localStorage.getItem("dinoUnlocked") === "true",
- );
+ const [isUnlocked, setIsUnlocked] = useState(readDinoUnlocked);
useEffect(() => {
- const sync = () => setIsUnlocked(localStorage.getItem("dinoUnlocked") === "true");
+ const sync = () => setIsUnlocked(readDinoUnlocked());
window.addEventListener("dinoUnlockChanged", sync);
window.addEventListener("storage", sync);
return () => {
@@ -25,9 +36,44 @@ export function useDinoUnlocked(): boolean {
}
/** Shared guard: true while the event landed in a text field that owns the keys. */
-export const isTypingTarget = (el: EventTarget | null) =>
+export const isTypingTarget = (el: EventTarget | null): boolean =>
el instanceof HTMLElement &&
- (el.tagName === "TEXTAREA" || el.tagName === "INPUT" || el.isContentEditable);
+ (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.
+ */
+export const isInteractiveTarget = (el: EventTarget | null): boolean =>
+ isTypingTarget(el) || (el instanceof Element && el.matches(SPACE_ACTIVATED_SELECTOR));
/**
* Which host currently has the one waiting-game open, if any.
@@ -60,7 +106,9 @@ export type SpaceOpensDinoOptions = {
* 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, in which case Space stays a space.
+ * 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
@@ -86,7 +134,9 @@ export function useSpaceOpensDino(
const onKeyDown = (e: KeyboardEvent) => {
if (e.code !== "Space") return;
- if (isTypingTarget(document.activeElement)) 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;
diff --git a/src/features/easter-eggs/hooks/useRepeatClicks.ts b/src/features/easter-eggs/hooks/useRepeatClicks.ts
index 0176521cd..569022c10 100644
--- a/src/features/easter-eggs/hooks/useRepeatClicks.ts
+++ b/src/features/easter-eggs/hooks/useRepeatClicks.ts
@@ -1,14 +1,17 @@
import { useCallback, useEffect, useRef } from "react";
-/** Longest gap between two clicks that still counts as the same gesture (ms). */
-const CLICK_GAP_MS = 1000;
+/** 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.
*
- * Clicks only count as consecutive while each follows the previous within
- * `windowMs`; a longer pause starts the count over. Without that, the
+ * 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.
@@ -31,10 +34,10 @@ const CLICK_GAP_MS = 1000;
export function useRepeatClicks(
requiredClicks: number,
onReached: () => void,
- windowMs: number = requiredClicks * CLICK_GAP_MS,
+ windowMs: number = requiredClicks * MS_PER_CLICK,
): () => void {
const countRef = useRef(0);
- const lastClickRef = useRef(0);
+ const firstClickRef = useRef(0);
const onReachedRef = useRef(onReached);
useEffect(() => {
@@ -43,8 +46,10 @@ export function useRepeatClicks(
return useCallback(() => {
const now = Date.now();
- if (now - lastClickRef.current > windowMs) countRef.current = 0;
- lastClickRef.current = now;
+ if (countRef.current === 0 || now - firstClickRef.current > windowMs) {
+ countRef.current = 0;
+ firstClickRef.current = now;
+ }
countRef.current += 1;
if (countRef.current < requiredClicks) return;
diff --git a/src/features/easter-eggs/lib/eggPhrases.ts b/src/features/easter-eggs/lib/eggPhrases.ts
index c7b718f8e..ec1d60df9 100644
--- a/src/features/easter-eggs/lib/eggPhrases.ts
+++ b/src/features/easter-eggs/lib/eggPhrases.ts
@@ -1,12 +1,14 @@
/**
* 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.
+ * {@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";
+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"],
@@ -21,7 +23,10 @@ export type { EggEffectId } from "../eggEffectBus";
* or null when the text is a normal message.
*/
export function matchEggPhrase(text: string): EggEffectId | null {
- const normalized = text.trim().toLowerCase();
+ 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;
diff --git a/tests/unit/features/easter-eggs/ConfettiBurst.test.tsx b/tests/unit/features/easter-eggs/ConfettiBurst.test.tsx
index 731c19929..129c9a7c3 100644
--- a/tests/unit/features/easter-eggs/ConfettiBurst.test.tsx
+++ b/tests/unit/features/easter-eggs/ConfettiBurst.test.tsx
@@ -89,13 +89,19 @@ describe("ConfettiBurst (via EggEffectsLayer)", () => {
unmount();
});
- it("shows a static status chip instead of particles under reduced motion", () => {
+ 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);
});
diff --git a/tests/unit/features/easter-eggs/EggEffectsLayer.test.tsx b/tests/unit/features/easter-eggs/EggEffectsLayer.test.tsx
index 0e08f43ea..5c76fa701 100644
--- a/tests/unit/features/easter-eggs/EggEffectsLayer.test.tsx
+++ b/tests/unit/features/easter-eggs/EggEffectsLayer.test.tsx
@@ -1,7 +1,11 @@
-import { act, render } from "@testing-library/react";
-import { 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";
+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
@@ -15,6 +19,9 @@ vi.mock("framer-motion", async () => {
};
});
+const { useReducedMotion } = await import("framer-motion");
+const mockReducedMotion = vi.mocked(useReducedMotion);
+
const ctxStub = {
save: () => {},
restore: () => {},
@@ -83,3 +90,48 @@ describe("EggEffectsLayer re-fires", () => {
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
index ab28b8f48..d3f7947bf 100644
--- a/tests/unit/features/easter-eggs/EggModalShell.test.tsx
+++ b/tests/unit/features/easter-eggs/EggModalShell.test.tsx
@@ -1,6 +1,6 @@
-import { describe, it, expect, vi } from "vitest";
+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";
+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.
@@ -10,11 +10,23 @@ vi.mock("../../../../src/features/easter-eggs/components/SpaceInvaders", () => (
),
}));
-vi.mock("../../../../src/features/easter-eggs/components/Game2048Frame", () => ({
- Game2048Frame: ({ onExit }: { onExit: () => void }) => (
- frame-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", () => {
@@ -50,4 +62,98 @@ describe("EggModalShell", () => {
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/Game2048Frame.test.tsx b/tests/unit/features/easter-eggs/Game2048Frame.test.tsx
index 27ef47457..a9d51e812 100644
--- a/tests/unit/features/easter-eggs/Game2048Frame.test.tsx
+++ b/tests/unit/features/easter-eggs/Game2048Frame.test.tsx
@@ -1,26 +1,28 @@
-import { describe, it, expect, vi } from "vitest";
+import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
-import { Game2048Frame } from "../../../../src/features/easter-eggs/components/Game2048Frame";
+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 = screen.getByTitle("2048 game");
+ const iframe = getFrame();
expect(iframe).toBeInTheDocument();
expect(iframe).toHaveAttribute("src", "/easter-eggs/2048.html");
});
- it("calls onExit when the game page reports EGG_EXIT", () => {
+ it("calls onExit when its own game page reports EGG_EXIT", () => {
const onExit = vi.fn();
render( );
- fireEvent(
- window,
- new MessageEvent("message", {
- data: { type: "EGG_EXIT" },
- origin: window.location.origin,
- }),
- );
+ postFromSource(getFrame().contentWindow);
expect(onExit).toHaveBeenCalledTimes(1);
});
@@ -29,14 +31,32 @@ describe("Game2048Frame", () => {
const onExit = vi.fn();
render( );
- fireEvent(
- window,
- new MessageEvent("message", {
- data: { type: "EGG_EXIT" },
- origin: "https://somewhere-else.example",
- }),
- );
+ 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/eggPhrases.test.ts b/tests/unit/features/easter-eggs/eggPhrases.test.ts
index 2eef935a2..4b82fa9f0 100644
--- a/tests/unit/features/easter-eggs/eggPhrases.test.ts
+++ b/tests/unit/features/easter-eggs/eggPhrases.test.ts
@@ -13,12 +13,17 @@ describe("matchEggPhrase", () => {
expect(matchEggPhrase(text)).toBe("matrix");
});
- it.each(["party", "party time", "let's party", "🎉", " PARTY "])(
- "recognizes %j as party",
- (text) => {
- expect(matchEggPhrase(text)).toBe("party");
- },
- );
+ 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?",
diff --git a/tests/unit/features/easter-eggs/useDinoWaitingGame.test.tsx b/tests/unit/features/easter-eggs/useDinoWaitingGame.test.tsx
index ff21d4933..73b49679e 100644
--- a/tests/unit/features/easter-eggs/useDinoWaitingGame.test.tsx
+++ b/tests/unit/features/easter-eggs/useDinoWaitingGame.test.tsx
@@ -1,10 +1,12 @@
-import { afterEach, describe, it, expect, beforeEach } from "vitest";
+import { afterEach, describe, it, expect, beforeEach, vi } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { fireEvent } from "@testing-library/react";
import {
+ isInteractiveTarget,
+ isTypingTarget,
useDinoUnlocked,
useSpaceOpensDino,
-} from "../../../../src/features/easter-eggs/hooks/useDinoWaitingGame";
+} from "../../../../src/features/easter-eggs/hooks/useDinoWaitingGame.ts";
describe("useDinoWaitingGame hooks", () => {
beforeEach(() => {
@@ -13,6 +15,7 @@ describe("useDinoWaitingGame hooks", () => {
afterEach(() => {
window.localStorage.clear();
+ document.body.innerHTML = "";
});
describe("useDinoUnlocked", () => {
@@ -152,5 +155,121 @@ describe("useDinoWaitingGame hooks", () => {
});
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);
+ });
+ });
+
+ 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
index c03b5654b..03ba06233 100644
--- a/tests/unit/features/easter-eggs/useRepeatClicks.test.tsx
+++ b/tests/unit/features/easter-eggs/useRepeatClicks.test.tsx
@@ -74,8 +74,9 @@ describe("useRepeatClicks", () => {
}
expect(onReached).toHaveBeenCalledTimes(1);
- // And the 3s budget still rules out accumulation: a click four seconds
- // after the last one starts a fresh count.
+ // 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;
@@ -87,4 +88,32 @@ describe("useRepeatClicks", () => {
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();
+ }
+ });
});
From 2908104e83ca04b47376caebd23e52932f0bf9c0 Mon Sep 17 00:00:00 2001
From: Daniil Perkin
Date: Thu, 24 Sep 2026 13:59:15 +0200
Subject: [PATCH 45/51] upgrade(settings): announce the dino unlock reliably
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The unlock popover's live region was mounted together with its text
inside AnimatePresence, so screen readers often skipped the "unlocked"
announcement entirely. The emoji's aria-label also repeated the
"shh…" copy, so it was read twice.
- The status region is now always mounted; only the bubble animates.
The emoji is aria-hidden.
- useDinoEasterEgg returns an explicit kind ("unlocked" | "locked").
The popover used to pick lock vs unlock by matching substrings of
the toast text, so any wording change would flip it silently.
- The popover uses the floating shadow-lg and app tokens instead of
shadow-black/10 with dark: overrides.
---
.../settings/components/DinoUnlockPopover.tsx | 145 ++++++++++--------
.../settings/hooks/useDinoEasterEgg.ts | 26 ++--
src/pages/SettingsPage.tsx | 2 +-
.../components/DinoUnlockPopover.test.tsx | 60 +++++---
.../settings/useDinoEasterEgg.test.ts | 39 ++++-
5 files changed, 175 insertions(+), 97 deletions(-)
diff --git a/src/features/settings/components/DinoUnlockPopover.tsx b/src/features/settings/components/DinoUnlockPopover.tsx
index 6aa38e832..d82e71716 100644
--- a/src/features/settings/components/DinoUnlockPopover.tsx
+++ b/src/features/settings/components/DinoUnlockPopover.tsx
@@ -1,78 +1,99 @@
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
-import { centralSpringToken } from "../../../styles/tokens";
+import { getModalDialogVariants } from "../../../styles/tokens.ts";
+import type { DinoToggleKind } from "../hooks/useDinoEasterEgg.ts";
type DinoUnlockPopoverProps = {
- /** The current toast message from useDinoEasterEgg, or null when hidden. */
- toast: string | null;
- /** Optional custom CSS classes for the container. */
+ /** 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).
*
- * Replaces the disconnected bottom-screen solid blue toast with an organic,
- * theme-adaptive frosted bubble featuring a styled keyboard badge and spring
- * enter/exit transitions.
+ * 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({ toast, className = "" }: DinoUnlockPopoverProps) {
- const prefersReducedMotion = useReducedMotion();
-
- const isUnlock = Boolean(toast && (toast.includes("press Space") || toast.includes("shh")));
- const isLock = Boolean(toast && toast.includes("saw nothing"));
+export function DinoUnlockPopover({ kind, className = "" }: DinoUnlockPopoverProps) {
+ const prefersReducedMotion = useReducedMotion() ?? false;
+ const variants = getModalDialogVariants(prefersReducedMotion);
return (
-
- {toast && (
-
-
- {/* Upward caret arrow pointing to the cogwheel icon above */}
-
+ <>
+
+ {kind ? ANNOUNCEMENT[kind] : ""}
+
+
+
+ {kind && (
+
+
+ {/* Upward caret arrow pointing to the cogwheel icon above */}
+
- {isUnlock ? (
-
-
- 🤫
-
-
- shh... press
-
- Space
-
- {"whenever you're waiting"}
-
-
- ) : isLock ? (
-
-
- 🫣
-
-
- you saw nothing...
-
-
- ) : (
-
{toast}
- )}
-
-
- )}
-
+ {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 a343088bd..8ffabd614 100644
--- a/src/features/settings/hooks/useDinoEasterEgg.ts
+++ b/src/features/settings/hooks/useDinoEasterEgg.ts
@@ -5,9 +5,17 @@ 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 = {
- /** Current unlock toast text, or null when none is showing. */
- 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;
};
@@ -28,7 +36,7 @@ type DinoEasterEgg = {
* because private-browsing modes can reject localStorage writes.
*/
export function useDinoEasterEgg(): DinoEasterEgg {
- const [toast, setToast] = useState(null);
+ const [kind, setKind] = useState(null);
const toastTimerRef = useRef | null>(null);
const lastToggleRef = useRef(0);
@@ -39,11 +47,11 @@ export function useDinoEasterEgg(): DinoEasterEgg {
};
}, []);
- const showToast = useCallback((message: string) => {
- setToast(message);
+ const showToast = useCallback((nextKind: DinoToggleKind) => {
+ setKind(nextKind);
if (toastTimerRef.current) clearTimeout(toastTimerRef.current);
toastTimerRef.current = setTimeout(() => {
- setToast(null);
+ setKind(null);
}, TOAST_DURATION_MS);
}, []);
@@ -66,15 +74,13 @@ export function useDinoEasterEgg(): DinoEasterEgg {
// a different component").
queueMicrotask(() => window.dispatchEvent(new Event("dinoUnlockChanged")));
- showToast(
- currentlyUnlocked ? "you saw nothing... 🫣" : "shh... 🤫 press Space whenever you're waiting",
- );
+ showToast(currentlyUnlocked ? "locked" : "unlocked");
}, [showToast]);
const handleIconClick = useRepeatClicks(UNLOCK_THRESHOLD, toggleUnlock);
return {
- toast,
+ kind,
handleIconClick,
};
}
diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx
index 12f8207a4..31feb56ce 100644
--- a/src/pages/SettingsPage.tsx
+++ b/src/pages/SettingsPage.tsx
@@ -123,7 +123,7 @@ export function SettingsPage() {
subtitle="Manage your profile, appearance and access tokens in one place."
onIconClick={dino.handleIconClick}
eggHint
- iconPopover={ }
+ iconPopover={ }
/>
diff --git a/tests/unit/features/settings/components/DinoUnlockPopover.test.tsx b/tests/unit/features/settings/components/DinoUnlockPopover.test.tsx
index 53521a1e6..40cf47e5e 100644
--- a/tests/unit/features/settings/components/DinoUnlockPopover.test.tsx
+++ b/tests/unit/features/settings/components/DinoUnlockPopover.test.tsx
@@ -1,34 +1,58 @@
import { render, screen } from "@testing-library/react";
import { describe, it, expect } from "vitest";
-import { DinoUnlockPopover } from "../../../../../src/features/settings/components/DinoUnlockPopover";
+import { DinoUnlockPopover } from "../../../../../src/features/settings/components/DinoUnlockPopover.tsx";
describe("DinoUnlockPopover", () => {
- it("renders nothing when toast is null", () => {
- const { container } = render( );
- expect(container.firstChild).toBeNull();
+ 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 playful whisper with kbd badge when unlocking", () => {
- render( );
+ it("renders the unlock whisper with a kbd badge when kind is unlocked", () => {
+ render( );
- expect(screen.getByRole("status")).toBeInTheDocument();
- expect(screen.getByText(/shh\.\.\. press/i)).toBeInTheDocument();
- expect(screen.getByText("Space")).toBeInTheDocument();
+ 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)).toBeInTheDocument();
+ expect(
+ screen.getByText(/whenever you're waiting/i, { selector: "span.text-app-text-muted" }),
+ ).toBeInTheDocument();
});
- it("renders you saw nothing whisper when locking", () => {
- render( );
+ it("renders the lock whisper when kind is locked", () => {
+ render( );
- expect(screen.getByRole("status")).toBeInTheDocument();
- expect(screen.getByText(/you saw nothing\.\.\./i)).toBeInTheDocument();
- expect(screen.getByLabelText("peeking")).toBeInTheDocument();
+ 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("renders raw string for any unmapped toast message", () => {
- render( );
+ 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();
- expect(screen.getByText("Custom message")).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 ed62332c4..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,10 +11,10 @@ describe("useDinoEasterEgg", () => {
vi.restoreAllMocks();
});
- it("starts with no toast", () => {
+ it("starts with no notice", () => {
const { result } = renderHook(() => useDinoEasterEgg());
- expect(result.current.toast).toBeNull();
+ expect(result.current.kind).toBeNull();
});
it("unlocks after three cogwheel clicks and shows a toast", () => {
@@ -22,11 +22,11 @@ describe("useDinoEasterEgg", () => {
act(() => result.current.handleIconClick());
act(() => result.current.handleIconClick());
- expect(result.current.toast).toBeNull();
+ expect(result.current.kind).toBeNull();
expect(window.localStorage.getItem("dinoUnlocked")).toBeNull();
act(() => result.current.handleIconClick());
- expect(result.current.toast).toContain("press Space");
+ expect(result.current.kind).toBe("unlocked");
expect(window.localStorage.getItem("dinoUnlocked")).toBe("true");
});
@@ -55,7 +55,34 @@ describe("useDinoEasterEgg", () => {
act(() => result.current.handleIconClick());
expect(window.localStorage.getItem("dinoUnlocked")).toBe("false");
- expect(result.current.toast).toContain("you saw nothing");
+ 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 (second triple-click within 2s is ignored)", () => {
From 772a651d6b6a5d968d3cbc48b0bcf0588b9caeed Mon Sep 17 00:00:00 2001
From: Daniil Perkin
Date: Thu, 24 Sep 2026 13:59:15 +0200
Subject: [PATCH 46/51] upgrade(layout): token the logo springs, fix the 404
teaser, drop a revived icon
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- SidebarNavIcons: StarterWorkIcon came back in merge fix-up 44f3cc5c
after dev deleted it (d922f8e7, Starter Work became Hire Setup).
Nothing imports it. Removing it and its Target import leaves the file
byte-identical to dev, so this PR no longer touches it.
- SidebarLogo springs: the hover springs use hoverSpringToken instead
of inline 400/16 and 420/14. The five-click drop really needs a
bouncier landing, so it gets a named logoHopSpringToken in tokens.ts
instead of magic numbers. The comment claiming the egg lives on the
login page too now says two in-app surfaces; the login copy
deliberately has none.
- SidebarLogo tests: the old motion mock turned every tag into a div
and dropped `animate`, so "idle below five clicks" could never fail.
The logo root now exposes data-drop-phase. The tests check idle
after four clicks, falling after five, each stage in turn, no restart
mid-drop, and staying idle under reduced motion (a stubbed
useReducedMotion, since framer caches matchMedia).
- NotFoundPage: the Space Invaders teaser said "While you wait for your
manager's approval…", which makes no sense on a 404. It now reads
"While you're lost in space…" with a screen-reader-only "play Space
Invaders" suffix, so the accessible name still starts with the
visible text (WCAG 2.5.3). It is a ui/Button ghost instead of a
hand-styled button.
---
src/components/layout/SidebarLogo.tsx | 18 ++-
src/components/layout/SidebarNavIcons.tsx | 132 +++++----------
src/pages/NotFoundPage.tsx | 21 +--
src/styles/tokens.ts | 13 ++
tests/unit/a11y/NotFoundPage.a11y.test.tsx | 16 +-
.../components/layout/SidebarLogo.test.tsx | 152 ++++++++++++------
tests/unit/pages/NotFoundPage.test.tsx | 10 +-
7 files changed, 201 insertions(+), 161 deletions(-)
diff --git a/src/components/layout/SidebarLogo.tsx b/src/components/layout/SidebarLogo.tsx
index 821ea8b1a..5497b1975 100644
--- a/src/components/layout/SidebarLogo.tsx
+++ b/src/components/layout/SidebarLogo.tsx
@@ -1,6 +1,7 @@
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;
@@ -46,7 +47,7 @@ const DROP_PHASES: Record, TargetAndTransition> = {
scaleX: 1,
scaleY: 1,
rotate: 0,
- transition: { type: "spring", stiffness: 260, damping: 11 },
+ transition: logoHopSpringToken,
},
};
@@ -103,7 +104,7 @@ export function SidebarLogo({ className = "" }: { className?: string }) {
rest: { scale: 1 },
lift: {
scale: 1.08,
- transition: { type: "spring", stiffness: 400, damping: 16 },
+ transition: hoverSpringToken,
},
};
@@ -119,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,
},
};
@@ -169,10 +170,15 @@ export function SidebarLogo({ className = "" }: { className?: string }) {
variants={badgeVariants}
onAnimationComplete={advanceDrop}
onClick={handleLogoClick}
+ data-drop-phase={dropPhase}
// The logo is deliberately decorative markup — the egg behind it is
- // pure whimsy with no function, and making it tab-focusable on all
- // three surfaces (including the login page's focus order) would
- // worsen keyboard navigation for everyone to serve a secret.
+ // pure whimsy with no function, and making it tab-focusable on both
+ // in-app surfaces (the sidebar header and the mobile header) would
+ // worsen keyboard navigation for everyone to serve a secret. The
+ // login page's copy of the mark carries no egg at all.
+ //
+ // `data-drop-phase` exposes the choreography's state machine so tests
+ // (and devtools) can observe the egg without a running animation loop.
//
// The pointer cursor stays: unlike a plain decorative wrapper this one
// does answer clicks (that is the whole egg), and it is the only hint
diff --git a/src/components/layout/SidebarNavIcons.tsx b/src/components/layout/SidebarNavIcons.tsx
index a2d7b8efb..72fcecd9d 100644
--- a/src/components/layout/SidebarNavIcons.tsx
+++ b/src/components/layout/SidebarNavIcons.tsx
@@ -12,7 +12,6 @@ import {
MessageSquare,
PlaneLanding,
Rocket,
- Target,
Terminal,
} from "lucide-react";
@@ -347,48 +346,57 @@ export function DataIngestionIcon({ isActive }: SidebarIconProps) {
);
}
-/** Starter Work: the sight locks on — rings close in, then the bullseye lands. */
-export function StarterWorkIcon({ isActive }: SidebarIconProps) {
+/**
+ * Hire Setup: the door swings open, then the threshold settles in at its foot.
+ *
+ * Same silhouette as the page's own header icon, which is what makes the sidebar entry and the
+ * page recognisably the same thing.
+ */
+export function HireSetupIcon({ isActive }: SidebarIconProps) {
const playKey = usePlayOnActivate(isActive);
const hasPlayed = playKey > 0;
const prefersReducedMotion = useReducedMotion();
- if (prefersReducedMotion) return ;
+ if (prefersReducedMotion) return ;
return (
- {/* The two outer rings close inward with a short stagger, so the
- target reads as a sight settling rather than three circles
- fading in together. Scaled about the centre they share. */}
- {[
- { r: 10, delay: 0 },
- { r: 6, delay: 0.07 },
- ].map((ring) => (
-
- ))}
+ {/* The frame is fixed, so the door has something to swing out of. */}
+
- {/* The bullseye lands last and overshoots, so the lock-on has a
- beat of impact instead of merely appearing. Filled rather
- than stroked: a 2px ring at r=2 is nearly a dot anyway. */}
-
+
+ {/* The handle pops in once the door has settled open. */}
+
+
+ {/* The threshold draws outward from the doorway last, the floor arriving under it. */}
+
+
);
@@ -563,59 +571,3 @@ export function ArrivalStepsIcon({ isActive }: SidebarIconProps) {
);
}
-
-/**
- * Hire Setup: the door swings open, then the threshold settles in at its foot.
- *
- * Same silhouette as the page's own header icon, which is what makes the sidebar entry and the
- * page recognisably the same thing.
- */
-export function HireSetupIcon({ isActive }: SidebarIconProps) {
- const playKey = usePlayOnActivate(isActive);
- const hasPlayed = playKey > 0;
- const prefersReducedMotion = useReducedMotion();
-
- if (prefersReducedMotion) return ;
-
- return (
-
- {/* The frame is fixed, so the door has something to swing out of. */}
-
-
- {/* The door itself swings open from flush against the frame, pivoting on its hinge at
- the left edge, and overshoots slightly before settling. */}
-
-
- {/* The handle pops in once the door has settled open. */}
-
-
- {/* The threshold draws outward from the doorway last, the floor arriving under it. */}
-
-
-
- );
-}
diff --git a/src/pages/NotFoundPage.tsx b/src/pages/NotFoundPage.tsx
index d4000a820..1851a5064 100644
--- a/src/pages/NotFoundPage.tsx
+++ b/src/pages/NotFoundPage.tsx
@@ -8,7 +8,7 @@ import { Rocket } from "lucide-react";
/**
* 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 waiting.
+ * game for whoever notices it while they are stranded here.
*/
export function NotFoundPage() {
const navigate = useNavigate();
@@ -34,20 +34,23 @@ 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.
+ 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)}
- className="mt-4 flex cursor-pointer items-center gap-2 rounded-lg px-3 py-1.5 text-sm text-app-text-subtle transition-colors hover:bg-app-surface-hover hover:text-app-text focus-visible:ring-2 focus-visible:ring-app-focus focus-visible:outline-none"
+ trailingIcon={🚀 }
+ className="mt-4"
>
- While you wait for your manager's approval…
- — open Space Invaders
- 🚀
-
+ While you're lost in space…
+ play Space Invaders
+
{
);
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/SidebarLogo.test.tsx b/tests/unit/components/layout/SidebarLogo.test.tsx
index e016f25c9..dc6882953 100644
--- a/tests/unit/components/layout/SidebarLogo.test.tsx
+++ b/tests/unit/components/layout/SidebarLogo.test.tsx
@@ -1,77 +1,129 @@
-import { fireEvent, render, screen } from "@testing-library/react";
+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";
-// jsdom has no rAF-driven framer-motion animation loop; these tests assert
-// the trigger contract (click counting, phase gating, reduced-motion skip),
-// not pixel choreography — same boundary as the game tests.
-vi.mock("framer-motion", async () => {
- const actual = await vi.importActual("framer-motion");
- return {
- ...actual,
- motion: new Proxy(actual.motion, {
- get(_target, prop) {
- // Strip animation props the DOM would reject; keep everything else.
- const Component = (props: Record) => {
- const {
- initial: _i,
- animate: _a,
- exit: _e,
- variants: _v,
- transition: _t,
- whileHover: _w,
- onAnimationComplete: _o,
- ...rest
- } = props;
- void _i;
- void _a;
- void _e;
- void _v;
- void _t;
- void _w;
- void _o;
- const { [prop as string]: Tag = "div" } = {};
- return ;
+/**
+ * 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(() => {
- localStorage.clear();
+ motionControl.reducedMotion = false;
+ motionControl.completeRootAnimation = null;
});
- const clickTimes = (times: number) => {
- const logo = document.querySelector(".cursor-pointer");
+ const getLogo = () => {
+ const logo = document.querySelector("[data-drop-phase]");
expect(logo).not.toBeNull();
- for (let i = 0; i < times; i++) fireEvent.click(logo!);
+ 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, cursor-pointer badge", () => {
+ it("renders a decorative, non-focusable badge", () => {
render( );
- const logo = document.querySelector(".cursor-pointer");
- expect(logo).not.toBeNull();
- expect(logo!.tagName).toBe("DIV");
+ 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);
- // No way to observe phases directly through the mocked motion — assert
- // via the class contract instead: no transform-related inline style.
- const logo = document.querySelector(".cursor-pointer") as HTMLElement;
- expect(logo.style.transform).toBe("");
+ 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("does not crash on five rapid clicks under reduced motion", () => {
- window.matchMedia = vi
- .fn()
- .mockReturnValue({ matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn() });
+ it("never starts the drop under reduced motion", () => {
+ motionControl.reducedMotion = true;
render( );
- expect(() => clickTimes(5)).not.toThrow();
+ clickTimes(5);
+ expect(getLogo()).toHaveAttribute("data-drop-phase", "idle");
});
});
diff --git a/tests/unit/pages/NotFoundPage.test.tsx b/tests/unit/pages/NotFoundPage.test.tsx
index fb7bdc421..61235ae20 100644
--- a/tests/unit/pages/NotFoundPage.test.tsx
+++ b/tests/unit/pages/NotFoundPage.test.tsx
@@ -39,12 +39,12 @@ describe("NotFoundPage", () => {
expect(screen.queryByTestId("egg-modal")).not.toBeInTheDocument();
// The teaser is a small muted row, not an obvious CTA. Its accessible
- // name has to carry the visible words (WCAG 2.5.3), with the "open
- // Space Invaders" hint appended rather than replacing them.
- const teaser = screen.getByRole("button", { name: /while you wait for your manager/i });
- expect(teaser).toHaveTextContent("While you wait for your manager");
+ // 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(/open space invaders/i);
+ expect(teaser).toHaveAccessibleName(/^While you're lost in space….*play Space Invaders$/);
fireEvent.click(teaser);
expect(screen.getByTestId("egg-modal")).toBeInTheDocument();
From 29ea4bc548f4104361e4d78c1b368a1cea631311 Mon Sep 17 00:00:00 2001
From: Daniil Perkin
Date: Thu, 24 Sep 2026 13:59:15 +0200
Subject: [PATCH 47/51] upgrade(chatbot): make the dino waiting game honest on
every surface
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The game now stays open past the end of the wait, which exposed bugs in
every surface that hosts it. Each one is fixed and pinned by a test.
- Chat and buddy refocus: when the reply landed, the composer took
focus back mid-run (during play focus sits on body). DinoGame then
ignored every key aimed at the text field: the dino died, Esc was
dead, and Space typed into the box. The refocus now waits until the
game closes, then returns focus if nothing else holds it.
- Buddy visibility: Space was armed on isThinking alone, but the
session lives app-wide and the dock mounts only while open. So a
minimised dock plus Space opened an invisible game that held the
one-game slot for good. Surfaces now register themselves (the open
dock, BuddyPage); Space arms only while one is visible, and the game
closes when none is. The hire/team conversation switch also closes it.
- Outcome labels: "Reply ready" used to show after Stop and after
failed streams too. The chat records each turn's outcome
(finished / stopped / failed, exposed per chat by useChat.turnOutcome);
buddy derives it from the last message's error. The badge now reads
"Stopped" or "Reply failed", in words not just colour. The shared
label lives in dinoOutcome.ts.
- Onboarding: a failed generation fell back to the last snapshot,
whose working phases kept isGenerating true. Phases froze, the timer
kept running, and retry never appeared. Failure now wins and closes
the game; on success the snapshot shows every phase completed.
- Data sources: "Sync complete" ignored an update still in flight;
replyReady now also needs !isSyncing.
- Drawer Escape: SidePanel's document listener closed the drawer
before DinoGame's window listener saw the key. DinoGame now takes
Escape in the capture phase, and SidePanel skips a key already
handled, so the first Esc closes the game and the second the drawer.
- Screen readers: the whole game sat in role=status, so the score was
announced about 12 times a second. The game is out of the live
region now; one status line gives the result and final score, the
running score is aria-hidden, and the typing bubbles keep a short
"Thinking…".
- Consistency: the game's raw emerald/white colours are now app
tokens, its buttons ui/Button, the buddy bubble uses
centralSpringToken, and dino-game* test ids are added. BuddyDock's
prop docs are back on their own props. ChatPage closes the game on a
chat switch from an effect instead of during render.
---
src/components/ui/SidePanel.tsx | 8 +
src/context/ChatContext.ts | 17 ++
src/context/ChatProvider.tsx | 17 +-
.../buddy/components/BuddyComposer.tsx | 11 +-
.../buddy/components/BuddyConversation.tsx | 1 +
src/features/buddy/components/BuddyDock.tsx | 18 +-
.../buddy/components/BuddyMessage.tsx | 23 ++-
src/features/buddy/components/BuddyThread.tsx | 3 +
src/features/buddy/components/BuddyWidget.tsx | 9 +
.../buddy/hooks/useBuddyConversation.ts | 52 +++++-
src/features/chatbot/components/DinoGame.tsx | 136 ++++++++++----
.../chatbot/components/ThinkingIndicator.tsx | 46 +++--
src/features/chatbot/dinoOutcome.ts | 47 +++++
src/features/chatbot/hooks/useChat.ts | 4 +
.../components/SourceDetailsPanel.tsx | 16 +-
.../components/journey/GenerationScreen.tsx | 35 +++-
src/pages/BuddyPage.tsx | 5 +
src/pages/ChatPage.tsx | 28 ++-
src/pages/OnBoardingPage.tsx | 56 ++++--
tests/unit/components/ui/SidePanel.test.tsx | 23 ++-
.../buddy/BuddyComposerCaret.test.tsx | 39 ++++
.../buddy/BuddyTypingMessage.test.tsx | 24 +++
.../features/buddy/buddyDinoSurface.test.tsx | 164 +++++++++++++++++
.../chatbot/components/DinoGame.test.tsx | 60 ++++++
.../components/ThinkingIndicator.test.tsx | 17 ++
tests/unit/features/chatbot/useChat.test.tsx | 4 +
.../components/SourceDetailsPanel.test.tsx | 99 +++++++++-
.../journey/GenerationScreen.test.tsx | 46 ++++-
tests/unit/pages/ChatPage.dino.test.tsx | 173 ++++++++++++++++++
.../OnBoardingPage.generationGame.test.tsx | 153 ++++++++++++++++
30 files changed, 1214 insertions(+), 120 deletions(-)
create mode 100644 src/features/chatbot/dinoOutcome.ts
create mode 100644 tests/unit/features/buddy/BuddyTypingMessage.test.tsx
create mode 100644 tests/unit/features/buddy/buddyDinoSurface.test.tsx
create mode 100644 tests/unit/features/chatbot/components/DinoGame.test.tsx
create mode 100644 tests/unit/pages/ChatPage.dino.test.tsx
create mode 100644 tests/unit/pages/OnBoardingPage.generationGame.test.tsx
diff --git a/src/components/ui/SidePanel.tsx b/src/components/ui/SidePanel.tsx
index d9d3b19b4..c2fc34fe1 100644
--- a/src/components/ui/SidePanel.tsx
+++ b/src/components/ui/SidePanel.tsx
@@ -111,7 +111,15 @@ export function SidePanel({
useEffect(() => {
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 9ee2bcc9a..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);
@@ -529,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.
@@ -576,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();
@@ -753,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.
@@ -770,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.
@@ -791,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);
}
},
@@ -824,6 +837,7 @@ 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;
@@ -1028,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 ab6a94a76..8568d68d8 100644
--- a/src/features/buddy/components/BuddyComposer.tsx
+++ b/src/features/buddy/components/BuddyComposer.tsx
@@ -36,6 +36,12 @@ type BuddyComposerProps = {
* 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;
};
/**
@@ -59,6 +65,7 @@ export function BuddyComposer({
compact = false,
focusOnMount = false,
busy = false,
+ gameActive = false,
}: BuddyComposerProps) {
const fieldRef = useRef(null);
@@ -85,14 +92,14 @@ export function BuddyComposer({
// 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 || !handedOffCaretRef.current) return;
+ 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]);
+ }, [busy, gameActive]);
/**
* Sends, then hands the caret back to the page.
diff --git a/src/features/buddy/components/BuddyConversation.tsx b/src/features/buddy/components/BuddyConversation.tsx
index 7145c5c89..5c7f1a1c9 100644
--- a/src/features/buddy/components/BuddyConversation.tsx
+++ b/src/features/buddy/components/BuddyConversation.tsx
@@ -197,6 +197,7 @@ export function BuddyConversation({
placeholder={placeholder}
focusOnMount={focusComposerOnMount}
busy={isThinking || isStreaming}
+ gameActive={dinoGameActive}
/>
diff --git a/src/features/buddy/components/BuddyDock.tsx b/src/features/buddy/components/BuddyDock.tsx
index 5efde6e6c..e7d1dca7d 100644
--- a/src/features/buddy/components/BuddyDock.tsx
+++ b/src/features/buddy/components/BuddyDock.tsx
@@ -73,18 +73,17 @@ type BuddyDockProps = Pick<
* rather than picked off the session, like every other callback here: the dock stays a
* presentational component the widget drives, which is what keeps it testable without one.
*/
- /**
- * Whether the dino waiting-game is open while the buddy thinks (see `BuddyThread`).
- * The prop is named `onDinoGameExit` to match BuddyThread, which is what the
- * dock forwards it to — keeping one name across the dock → thread boundary
- * so callers pass it once and forget.
- */
- dinoGameActive?: boolean;
- /** Called when the player leaves the dino waiting-game. */
- onDinoGameExit?: () => void;
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. */
@@ -397,6 +396,7 @@ export function BuddyDock({
compact
focusOnMount
busy={isBusy}
+ gameActive={dinoGameActive}
/>
diff --git a/src/features/buddy/components/BuddyMessage.tsx b/src/features/buddy/components/BuddyMessage.tsx
index d3ff2c720..2739e9366 100644
--- a/src/features/buddy/components/BuddyMessage.tsx
+++ b/src/features/buddy/components/BuddyMessage.tsx
@@ -3,7 +3,10 @@ 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";
+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";
@@ -222,6 +225,7 @@ export function BuddyTypingMessage({
showName = false,
gameActive = false,
replyReady = false,
+ turnOutcome = null,
onGameExit,
}: {
label?: string;
@@ -234,6 +238,11 @@ export function BuddyTypingMessage({
* 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;
}) {
@@ -253,17 +262,23 @@ export function BuddyTypingMessage({
: {
initial: { opacity: 0, y: 8 },
animate: { opacity: 1, y: 0 },
- transition: { duration: 0.26, ease: [0.16, 1, 0.3, 1] as const },
+ transition: centralSpringToken,
})}
- role="status"
className="flex w-full min-w-0 gap-2.5"
>
+ {/* 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 && (
diff --git a/src/features/buddy/components/BuddyThread.tsx b/src/features/buddy/components/BuddyThread.tsx
index 644df38d5..580281dec 100644
--- a/src/features/buddy/components/BuddyThread.tsx
+++ b/src/features/buddy/components/BuddyThread.tsx
@@ -240,6 +240,9 @@ export function BuddyThread({
showName={showNames}
gameActive={dinoGameActive}
replyReady={dinoGameActive && !isThinking && !isStreaming}
+ // A failed reply carries its error on the last turn; announcing it as
+ // "Reply ready" would be a lie. The buddy has no Stop, so only two outcomes.
+ turnOutcome={messages[messages.length - 1]?.error ? "failed" : "done"}
onGameExit={onDinoGameExit}
/>
)}
diff --git a/src/features/buddy/components/BuddyWidget.tsx b/src/features/buddy/components/BuddyWidget.tsx
index 9e78ab6a2..bd794db83 100644
--- a/src/features/buddy/components/BuddyWidget.tsx
+++ b/src/features/buddy/components/BuddyWidget.tsx
@@ -74,6 +74,7 @@ export function BuddyWidget() {
suggestions,
dinoGameActive,
closeDinoGame,
+ registerDinoSurface,
openError,
retryOpen,
closeDock,
@@ -263,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
diff --git a/src/features/buddy/hooks/useBuddyConversation.ts b/src/features/buddy/hooks/useBuddyConversation.ts
index ac523be19..8be719446 100644
--- a/src/features/buddy/hooks/useBuddyConversation.ts
+++ b/src/features/buddy/hooks/useBuddyConversation.ts
@@ -96,12 +96,37 @@ export function useBuddyConversation(
// offers. Closing is handled inside the hook: on exit, when the turn ends,
// or when the cogwheel unlock flag flips off.
const dinoUnlocked = useDinoUnlocked();
- const [dinoGameActive, closeDinoGame] = useSpaceOpensDino(isThinking, 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,
- });
+ // 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);
@@ -856,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
@@ -956,6 +993,7 @@ export function useBuddyConversation(
dinoGameActive,
closeDinoGame,
dinoUnlocked,
+ registerDinoSurface,
ensureOpened,
retryOpen,
diff --git a/src/features/chatbot/components/DinoGame.tsx b/src/features/chatbot/components/DinoGame.tsx
index a979decf2..fb3153ed4 100644
--- a/src/features/chatbot/components/DinoGame.tsx
+++ b/src/features/chatbot/components/DinoGame.tsx
@@ -1,5 +1,20 @@
import { useCallback, useEffect, useRef, useState } from "react";
-import { isTypingTarget } from "../../easter-eggs/hooks/useDinoWaitingGame";
+import { Button } from "../../../components/ui/Button.tsx";
+import { isTypingTarget } from "../../easter-eggs/hooks/useDinoWaitingGame.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 = {
/**
@@ -14,6 +29,16 @@ type DinoGameProps = {
* 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";
@@ -169,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, replyReady = false, completionLabel }: DinoGameProps) {
+export function DinoGame({
+ onExit,
+ replyReady = false,
+ completionLabel,
+ completionTone = "success",
+ continueLabel,
+}: DinoGameProps) {
const containerRef = useRef(null);
const canvasRef = useRef(null);
@@ -276,12 +307,6 @@ export function DinoGame({ onExit, replyReady = false, completionLabel }: DinoGa
// 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.key === "Escape") {
- e.preventDefault();
- onExit();
- return;
- }
-
if (e.code === "Space" || e.key === "ArrowUp" || e.key === "w") {
e.preventDefault();
if (!e.repeat) pressJump();
@@ -306,9 +331,26 @@ export function DinoGame({ onExit, replyReady = false, completionLabel }: DinoGa
}
};
+ // 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);
};
@@ -806,7 +848,21 @@ export function DinoGame({ onExit, replyReady = false, completionLabel }: DinoGa
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")}
@@ -826,19 +882,30 @@ export function DinoGame({ onExit, replyReady = false, completionLabel }: DinoGa
-
+ ) : undefined
+ }
>
- {replyReady && (
-
+ {replyReady ? (
+
+ {`${completionLabel ?? "Reply ready"} · Esc ✕`}
+
+ ) : (
+ "Esc ✕"
)}
-
{replyReady ? `${completionLabel ?? "Reply ready"} · Esc ✕` : "Esc ✕"}
-
+
{/* Controls hint */}
@@ -856,8 +923,8 @@ export function DinoGame({ onExit, replyReady = false, completionLabel }: DinoGa
GAME OVER
{newHighScore ? (
-
-
🏆
+
+ 🏆
New High Score! {score}
) : (
@@ -867,29 +934,22 @@ export function DinoGame({ onExit, replyReady = false, completionLabel }: DinoGa
)}
{replyReady ? (
-
- {completionLabel ? `${completionLabel} (Space)` : "View Reply (Space)"}
-
+
+ {`${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 f20acd57f..b075dd1b3 100644
--- a/src/features/chatbot/components/ThinkingIndicator.tsx
+++ b/src/features/chatbot/components/ThinkingIndicator.tsx
@@ -1,6 +1,7 @@
-import { BotGlyph } from "./BotGlyph";
-import { DinoGame } from "./DinoGame";
-import { THINKING_LABELS, type ThinkingState } from "../constants";
+import { BotGlyph } from "./BotGlyph.tsx";
+import { DinoGame } from "./DinoGame.tsx";
+import { THINKING_LABELS, type ThinkingState } from "../constants.ts";
+import { dinoCompletionProps, type DinoTurnOutcome } from "../dinoOutcome.ts";
type ThinkingIndicatorProps = {
/** True while the assistant is working (before the first reply token arrives). */
@@ -11,8 +12,13 @@ type ThinkingIndicatorProps = {
thinkingState: string | null;
/** Whether thoughts/reasoning are actively present (suppresses duplicate dots). */
hasReasoning?: boolean;
- /** True if the reply is ready while the game is still active. */
+ /** 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;
};
@@ -29,6 +35,7 @@ export function ThinkingIndicator({
thinkingState,
hasReasoning = false,
replyReady = false,
+ turnOutcome = null,
onGameExit,
}: ThinkingIndicatorProps) {
// If neither thinking nor game active, nothing to render.
@@ -62,29 +69,30 @@ 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} }
)}
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 ff8ba22d9..8d050c3b3 100644
--- a/src/features/data-ingestion/components/SourceDetailsPanel.tsx
+++ b/src/features/data-ingestion/components/SourceDetailsPanel.tsx
@@ -120,6 +120,7 @@ export function SourceDetailsPanel({
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,
@@ -423,11 +424,16 @@ export function SourceDetailsPanel({
)}
diff --git a/src/features/onboarding/components/journey/GenerationScreen.tsx b/src/features/onboarding/components/journey/GenerationScreen.tsx
index a8902c211..569aaf7ec 100644
--- a/src/features/onboarding/components/journey/GenerationScreen.tsx
+++ b/src/features/onboarding/components/journey/GenerationScreen.tsx
@@ -5,6 +5,19 @@ 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);
@@ -26,7 +39,9 @@ export function GenerationScreen({
}: {
phases: GenerationPhaseProgress[];
startedAt: number;
+ /** 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());
@@ -36,6 +51,7 @@ export function GenerationScreen({
!isCompleted &&
(phases.length === 0 ||
phases.some((phase) => phase.state === "waiting" || phase.state === "working"));
+ const shownPhases = isCompleted ? settle(phases) : phases;
const [gameActive, closeGame] = useSpaceOpensDino(isGenerating, dinoUnlocked, {
keepActiveUntilExit: true,
@@ -45,13 +61,18 @@ export function GenerationScreen({
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 (!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 (
@@ -89,7 +110,9 @@ export function GenerationScreen({
{total > 0 ? `${done} of ${total} phases assembled` : "Starting up…"}
-
{elapsed(startedAt, now)}
+
+ {elapsed(startedAt, now)}
+
0 ? (
- {phases.map((phase) => (
+ {shownPhases.map((phase) => (
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({
diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx
index f9366fe5f..955b9b9cb 100644
--- a/src/pages/ChatPage.tsx
+++ b/src/pages/ChatPage.tsx
@@ -1,6 +1,6 @@
import { BookmarkPlus, MessageSquareText, X } from "lucide-react";
import { ArrowDown } from "lucide-react";
-import { useCallback, useEffect, useMemo, useState } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { AnimatePresence, motion } from "framer-motion";
import { centralSpringToken } from "../styles/tokens";
@@ -132,6 +132,7 @@ export function ChatPage() {
removeQueuedMessage,
editQueuedMessage,
sendQueuedNow,
+ turnOutcome,
} = useChat();
const { sources: availableSources, loading: sourcesLoading } = useAvailableSources();
@@ -156,13 +157,15 @@ export function ChatPage() {
});
// When switching between chats while a waiting game is open, exit cleanly.
- const [prevChatId, setPrevChatId] = useState(chatId);
- if (prevChatId !== chatId) {
- setPrevChatId(chatId);
- if (gameActive) {
- closeGame();
- }
- }
+ // 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);
@@ -201,13 +204,17 @@ export function ChatPage() {
// 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]);
// Custom submit handler to intercept easter eggs: a phrase match plays
// the app-wide effect (via the bus) and swallows the message — it never
@@ -467,6 +474,7 @@ export function ChatPage() {
thinkingState={thinkingState}
hasReasoning={hasReasoning}
replyReady={gameActive && !isThinking && !isStreaming}
+ turnOutcome={turnOutcome}
onGameExit={closeGame}
/>
diff --git a/src/pages/OnBoardingPage.tsx b/src/pages/OnBoardingPage.tsx
index a8e7f13c2..a1e488c01 100644
--- a/src/pages/OnBoardingPage.tsx
+++ b/src/pages/OnBoardingPage.tsx
@@ -223,22 +223,45 @@ 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;
- }>({ phases: [], startedAt: 0 });
+ completed: boolean;
+ }>(() =>
+ generation.status === "running"
+ ? { phases: generation.phases, startedAt: generation.startedAt, completed: false }
+ : { phases: [], startedAt: 0, completed: false },
+ );
- useEffect(() => {
+ // 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") {
- const { phases: currentPhases, startedAt } = generation;
- // Deferred out of the effect body on purpose: `react-hooks/set-state-in-effect`
- // flags a synchronous setState here, and semantically this only needs to land
- // before the next paint, not before the next effect runs.
- queueMicrotask(() => {
- setLastGeneration({ phases: currentPhases, startedAt });
+ 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);
}
- }, [generation]);
+ }
// Set when the page itself moves the member on, so the item they land on is scrolled to.
const scrollToItemRef = useRef(focusItemId ?? null);
@@ -687,7 +710,12 @@ export function OnBoardingPage() {
// ── Render: generating ──────────────────────────────────────
- if (generation.status === "running" || isDinoActiveInGeneration) {
+ // 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;
@@ -696,11 +724,9 @@ export function OnBoardingPage() {
);
diff --git a/tests/unit/components/ui/SidePanel.test.tsx b/tests/unit/components/ui/SidePanel.test.tsx
index 2a26b7fd2..8f94cb00b 100644
--- a/tests/unit/components/ui/SidePanel.test.tsx
+++ b/tests/unit/components/ui/SidePanel.test.tsx
@@ -1,7 +1,7 @@
import { useState } from "react";
-import { render, screen, within, waitFor } from "@testing-library/react";
+import { fireEvent, render, screen, within, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
-import { describe, it, expect } from "vitest";
+import { describe, it, expect, vi } from "vitest";
import { SidePanel } from "../../../../src/components/ui/SidePanel";
function SidePanelHarness({
@@ -178,4 +178,23 @@ describe("SidePanel", () => {
await waitFor(() => expect(dialog).toHaveAttribute("aria-hidden", "true"));
});
+ 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
index a2ad0db1c..d475846e0 100644
--- a/tests/unit/features/buddy/BuddyComposerCaret.test.tsx
+++ b/tests/unit/features/buddy/BuddyComposerCaret.test.tsx
@@ -23,4 +23,43 @@ describe("BuddyComposer caret handoff", () => {
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/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
index 459748ced..b22d9b911 100644
--- a/tests/unit/features/chatbot/components/ThinkingIndicator.test.tsx
+++ b/tests/unit/features/chatbot/components/ThinkingIndicator.test.tsx
@@ -88,4 +88,21 @@ describe("ThinkingIndicator", () => {
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 146e1f4fb..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 () => {
@@ -561,6 +563,8 @@ describe("useChat", () => {
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 () => {
diff --git a/tests/unit/features/data-ingestion/components/SourceDetailsPanel.test.tsx b/tests/unit/features/data-ingestion/components/SourceDetailsPanel.test.tsx
index a2389c1ff..2532d50da 100644
--- a/tests/unit/features/data-ingestion/components/SourceDetailsPanel.test.tsx
+++ b/tests/unit/features/data-ingestion/components/SourceDetailsPanel.test.tsx
@@ -2,6 +2,7 @@ import { fireEvent, render as rtlRender, screen, waitFor, within } from "@testin
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 {
@@ -565,6 +566,102 @@ describe("SourceDetailsPanel", () => {
// Source finishes syncing
rerender( );
- expect(screen.getByText(/sync complete/i)).toBeInTheDocument();
+ 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(
+ ,
+ );
+
+ await user.click(screen.getByRole("button", { name: /Update repo/ }));
+ // Space on a focused button is that button's own activation, so the game
+ // only opens once focus has left it.
+ (document.activeElement as HTMLElement | null)?.blur();
+ // The trigger arms in an effect once the update is in flight; under a loaded
+ // runner that can land a tick after the click resolves, so retry the press.
+ await waitFor(() => {
+ 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/onboarding/components/journey/GenerationScreen.test.tsx b/tests/unit/features/onboarding/components/journey/GenerationScreen.test.tsx
index 0750ce6ce..97de847d3 100644
--- a/tests/unit/features/onboarding/components/journey/GenerationScreen.test.tsx
+++ b/tests/unit/features/onboarding/components/journey/GenerationScreen.test.tsx
@@ -1,4 +1,4 @@
-import { render, screen, fireEvent } from "@testing-library/react";
+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";
@@ -83,6 +83,48 @@ describe("GenerationScreen", () => {
,
);
- expect(screen.getByText(/path ready/i)).toBeInTheDocument();
+ expect(screen.getByTestId("dino-game-reply-ready")).toHaveTextContent(/path ready/i);
+ });
+
+ 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/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/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");
+ }
+ });
+});
From 321fc30a653afde0cb6e28789a1a6d95fba7a0b5 Mon Sep 17 00:00:00 2001
From: Daniil Perkin
Date: Thu, 24 Sep 2026 14:00:39 +0200
Subject: [PATCH 48/51] upgrade(ui): keep the SidePanel Escape test in its own
file
The drawer test for "an Escape already handled inside is ignored" was
appended to SidePanel.test.tsx. #264 also adds imports and tests at the
end of that file, so whichever PR merged second hit a conflict on its
import lines and on its final block.
The test moves unchanged into SidePanel.escape.test.tsx, and
SidePanel.test.tsx matches dev again. merge-tree against
feature/KB-updates-final is now conflict-free.
---
.../components/ui/SidePanel.escape.test.tsx | 27 +++++++++++++++++++
tests/unit/components/ui/SidePanel.test.tsx | 23 ++--------------
2 files changed, 29 insertions(+), 21 deletions(-)
create mode 100644 tests/unit/components/ui/SidePanel.escape.test.tsx
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/components/ui/SidePanel.test.tsx b/tests/unit/components/ui/SidePanel.test.tsx
index 8f94cb00b..2a26b7fd2 100644
--- a/tests/unit/components/ui/SidePanel.test.tsx
+++ b/tests/unit/components/ui/SidePanel.test.tsx
@@ -1,7 +1,7 @@
import { useState } from "react";
-import { fireEvent, render, screen, within, waitFor } from "@testing-library/react";
+import { render, screen, within, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
-import { describe, it, expect, vi } from "vitest";
+import { describe, it, expect } from "vitest";
import { SidePanel } from "../../../../src/components/ui/SidePanel";
function SidePanelHarness({
@@ -178,23 +178,4 @@ describe("SidePanel", () => {
await waitFor(() => expect(dialog).toHaveAttribute("aria-hidden", "true"));
});
- 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);
- });
});
From 17cdf9b6cbd2b32dbe98f90b7cec5e3836c38191 Mon Sep 17 00:00:00 2001
From: Daniil Perkin
Date: Thu, 24 Sep 2026 14:17:33 +0200
Subject: [PATCH 49/51] upgrade(easter-eggs): let Space through on a disabled
control
The trigger's "this control owns Space" guard also covered disabled
controls. Space on a disabled button does nothing, and a busy ui/Button
(`loading`) is disabled while it can still hold focus. So clicking
"Update repo" in the source drawer and then pressing Space never opened
the game, during the one wait that is exactly what the game is for.
isInteractiveTarget now skips `:disabled` and `[aria-disabled="true"]`
controls. Text fields stay protected even when disabled: the typing
guard is unchanged.
This was also why the SourceDetailsPanel "update in flight" test was
flaky. In jsdom, blur() does nothing on a disabled element, so focus
stayed on the busy button. The test passed only when the drawer's
initial-focus frame happened to land after the click. The test now waits
for that frame, clicks, asserts focus is still on the busy button, and
presses Space once, without the retry loop. Passed 8/8 runs alone and
3/3 with its folder.
---
.../easter-eggs/hooks/useDinoWaitingGame.ts | 10 +++++++++-
.../components/SourceDetailsPanel.test.tsx | 20 +++++++++----------
.../easter-eggs/useDinoWaitingGame.test.tsx | 17 ++++++++++++++++
3 files changed, 36 insertions(+), 11 deletions(-)
diff --git a/src/features/easter-eggs/hooks/useDinoWaitingGame.ts b/src/features/easter-eggs/hooks/useDinoWaitingGame.ts
index 74149e8bc..85bc55d00 100644
--- a/src/features/easter-eggs/hooks/useDinoWaitingGame.ts
+++ b/src/features/easter-eggs/hooks/useDinoWaitingGame.ts
@@ -71,9 +71,17 @@ const SPACE_ACTIVATED_SELECTOR = [
* 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));
+ isTypingTarget(el) ||
+ (el instanceof Element &&
+ el.matches(SPACE_ACTIVATED_SELECTOR) &&
+ !el.matches(':disabled, [aria-disabled="true"]'));
/**
* Which host currently has the one waiting-game open, if any.
diff --git a/tests/unit/features/data-ingestion/components/SourceDetailsPanel.test.tsx b/tests/unit/features/data-ingestion/components/SourceDetailsPanel.test.tsx
index 2532d50da..e3037e3ec 100644
--- a/tests/unit/features/data-ingestion/components/SourceDetailsPanel.test.tsx
+++ b/tests/unit/features/data-ingestion/components/SourceDetailsPanel.test.tsx
@@ -583,17 +583,17 @@ describe("SourceDetailsPanel", () => {
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/ }));
- // Space on a focused button is that button's own activation, so the game
- // only opens once focus has left it.
- (document.activeElement as HTMLElement | null)?.blur();
- // The trigger arms in an effect once the update is in flight; under a loaded
- // runner that can land a tick after the click resolves, so retry the press.
- await waitFor(() => {
- fireEvent.keyDown(window, { code: "Space" });
- expect(screen.getByTestId("dino-game")).toBeInTheDocument();
- });
+ // 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();
diff --git a/tests/unit/features/easter-eggs/useDinoWaitingGame.test.tsx b/tests/unit/features/easter-eggs/useDinoWaitingGame.test.tsx
index 73b49679e..64dac8256 100644
--- a/tests/unit/features/easter-eggs/useDinoWaitingGame.test.tsx
+++ b/tests/unit/features/easter-eggs/useDinoWaitingGame.test.tsx
@@ -253,6 +253,23 @@ describe("useDinoWaitingGame hooks", () => {
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", () => {
From 061276edb07a9fdedc3e44ba5bd490553428deba Mon Sep 17 00:00:00 2001
From: Daniil Perkin
Date: Fri, 25 Sep 2026 12:32:56 +0200
Subject: [PATCH 50/51] upgrade(easter-eggs): free the game slot after commit,
keep shortcuts out of Space Invaders
Addresses the verified findings from the PR #184 review.
1. useSpaceOpensDino released the shared game slot during render.
`releaseGameSlot(host)` mutated the module-level `gameHost` inside the
render-phase "adjust state when a prop changes" blocks (unlock flipped
off, wait ended). Render must stay pure: a render React throws away
(concurrent retry, StrictMode double render) would free a slot the
committed game still holds, and a second armed surface could then claim
it and open a second game - exactly what the slot exists to prevent.
The render-phase blocks now only call `setGameActive(false)`; a new
effect releases the slot whenever `gameActive` is false after commit.
`close()` still releases eagerly because it runs in an event handler,
where mutating module state is fine. The suggested alternative (move
both the release and `setGameActive(false)` into one effect) was not
used: a setState inside an effect trips `react-hooks/set-state-in-effect`
and costs an extra render with the stale game still on screen.
2. Space Invaders handled keys with no modifier or typing guard.
Ctrl/Cmd+A (select all) and Alt+D matched `KeyA`/`KeyD`, got
`preventDefault` and moved the ship. The keydown handler now ignores
Ctrl/Meta/Alt presses and keys typed into a text field, the same guards
DinoGame already has. keyup stays unguarded on purpose so a held key is
always released. Escape moved to a capture-phase window listener with
`preventDefault` + `stopPropagation`, again mirroring DinoGame, so one
press closes only the game and never a surrounding surface too.
New tests/unit/features/easter-eggs/SpaceInvaders.test.tsx covers movement
keys taken, modified presses and text-field typing left alone, and Escape
claimed before document listeners.
Findings checked and deliberately not changed:
- Raw s in SpaceInvaders: game surfaces are a listed exception in
FRONTEND_CODING_STANDARDS.md section 4.
- `text-white` on `bg-app-brand`: the same pairing `ui/Button` primary uses;
`text-app-text-inverse` would turn dark in dark mode.
- EggLoadingFallback `w-[680px]`: the dialog is `max-w-full overflow-hidden`,
so it clips instead of widening the viewport; `w-full` inside that
shrink-to-fit dialog would collapse the reserved box to zero width.
Gates: prettier format:check clean, npm run build OK, npm run lint 0
problems, npm run test 368 files / 3082 tests passed.
---
.../easter-eggs/components/SpaceInvaders.tsx | 22 +++++++--
.../easter-eggs/hooks/useDinoWaitingGame.ts | 15 ++++--
.../easter-eggs/SpaceInvaders.test.tsx | 48 +++++++++++++++++++
3 files changed, 77 insertions(+), 8 deletions(-)
create mode 100644 tests/unit/features/easter-eggs/SpaceInvaders.test.tsx
diff --git a/src/features/easter-eggs/components/SpaceInvaders.tsx b/src/features/easter-eggs/components/SpaceInvaders.tsx
index 5ae810315..badd79436 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 "../hooks/useDinoWaitingGame.ts";
type SpaceInvadersProps = {
/**
@@ -329,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();
@@ -367,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/hooks/useDinoWaitingGame.ts b/src/features/easter-eggs/hooks/useDinoWaitingGame.ts
index 85bc55d00..562f9906f 100644
--- a/src/features/easter-eggs/hooks/useDinoWaitingGame.ts
+++ b/src/features/easter-eggs/hooks/useDinoWaitingGame.ts
@@ -159,6 +159,16 @@ export function useSpaceOpensDino(
// 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
@@ -167,7 +177,6 @@ export function useSpaceOpensDino(
if (prevUnlocked !== isUnlocked) {
setPrevUnlocked(isUnlocked);
if (!isUnlocked) {
- releaseGameSlot(host);
setGameActive(false);
}
}
@@ -178,14 +187,14 @@ export function useSpaceOpensDino(
// 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.
+ // 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)) {
- releaseGameSlot(host);
setGameActive(false);
}
}
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);
+ });
+});
From b3dd474eacea8780f16b0f583573deae58be1902 Mon Sep 17 00:00:00 2001
From: Daniil Perkin
Date: Fri, 25 Sep 2026 16:40:36 +0200
Subject: [PATCH 51/51] upgrade(easter-eggs): address David's review -
generation status drives the game, key guards to lib
Responds to review 5317855360 on PR #184. Every point was checked against
the code.
1. GenerationScreen derived "still generating" from the phases (real).
`isGenerating` was `!isCompleted && (no phases || some phase
waiting/working)`. OnboardingJourneyProvider builds `phases`
incrementally from `onStage` events (unknown names are pushed) and a
phase's state comes from its detail line, so every phase can read done
while `generation.status` is still "running": after the last phase
reports "Completed..." but before `onDone` (path still persisting), and
in any gap before the next phase's first stage event. In that window an
open game showed "Path ready", the trigger disarmed, the Space hint
vanished and the clock stopped - although the run could still fail.
GenerationScreen now takes a required `isRunning` prop, OnBoardingPage
passes `generation.status === "running"` (which it already computed),
and `isGenerating = isRunning && !isCompleted`. The phases are purely
presentational. Made the prop required rather than optional so no
caller can silently fall back to the old phase-based guess. New test:
all phases done but still running -> hint shown, game opens, no
"Path ready"; existing tests now pass `isRunning` explicitly.
2. isTypingTarget / isInteractiveTarget moved to easter-eggs/lib/keyTargets.ts.
They are generic key guards used by DinoGame, SpaceInvaders and the
Space-to-play hook, not dino-hook internals. No re-export from the old
module: all three importers (plus the hook test) now import from lib.
3. Import-specifier churn reverted. ThinkingIndicator's untouched imports
are back to dev's extensionless form and NotFoundPage's are back to
dev's `.tsx` form; the one import each file genuinely adds follows that
file's style. BuddyMessage was not churn - its extension-bearing lines
are all new imports - so it is left alone.
4. registry.ts: Space Invaders' lazy() is inline like 2048's instead of a
hoisted const.
Not changed here: the PR description's Verification head SHA is updated
on the PR itself after this push.
Gates: format:check clean, npm run build OK, npm run lint 0 problems,
npm run test 368 files / 3083 tests passed.
---
src/features/chatbot/components/DinoGame.tsx | 2 +-
.../chatbot/components/ThinkingIndicator.tsx | 8 +--
.../easter-eggs/components/SpaceInvaders.tsx | 2 +-
.../easter-eggs/hooks/useDinoWaitingGame.ts | 49 +----------------
src/features/easter-eggs/lib/keyTargets.ts | 53 +++++++++++++++++++
src/features/easter-eggs/registry.ts | 9 ++--
.../components/journey/GenerationScreen.tsx | 14 +++--
src/pages/NotFoundPage.tsx | 6 +--
src/pages/OnBoardingPage.tsx | 1 +
.../easter-eggs/useDinoWaitingGame.test.tsx | 6 ++-
.../journey/GenerationScreen.test.tsx | 41 ++++++++++++--
11 files changed, 117 insertions(+), 74 deletions(-)
create mode 100644 src/features/easter-eggs/lib/keyTargets.ts
diff --git a/src/features/chatbot/components/DinoGame.tsx b/src/features/chatbot/components/DinoGame.tsx
index fb3153ed4..06f693d4d 100644
--- a/src/features/chatbot/components/DinoGame.tsx
+++ b/src/features/chatbot/components/DinoGame.tsx
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Button } from "../../../components/ui/Button.tsx";
-import { isTypingTarget } from "../../easter-eggs/hooks/useDinoWaitingGame.ts";
+import { isTypingTarget } from "../../easter-eggs/lib/keyTargets.ts";
/**
* How the work the player is waiting on ended. Drives the badge colour *and*
diff --git a/src/features/chatbot/components/ThinkingIndicator.tsx b/src/features/chatbot/components/ThinkingIndicator.tsx
index b075dd1b3..34d487df1 100644
--- a/src/features/chatbot/components/ThinkingIndicator.tsx
+++ b/src/features/chatbot/components/ThinkingIndicator.tsx
@@ -1,7 +1,7 @@
-import { BotGlyph } from "./BotGlyph.tsx";
-import { DinoGame } from "./DinoGame.tsx";
-import { THINKING_LABELS, type ThinkingState } from "../constants.ts";
-import { dinoCompletionProps, type DinoTurnOutcome } from "../dinoOutcome.ts";
+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 reply token arrives). */
diff --git a/src/features/easter-eggs/components/SpaceInvaders.tsx b/src/features/easter-eggs/components/SpaceInvaders.tsx
index badd79436..1a2b52166 100644
--- a/src/features/easter-eggs/components/SpaceInvaders.tsx
+++ b/src/features/easter-eggs/components/SpaceInvaders.tsx
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { centralSpringToken } from "../../../styles/tokens";
-import { isTypingTarget } from "../hooks/useDinoWaitingGame.ts";
+import { isTypingTarget } from "../lib/keyTargets.ts";
type SpaceInvadersProps = {
/**
diff --git a/src/features/easter-eggs/hooks/useDinoWaitingGame.ts b/src/features/easter-eggs/hooks/useDinoWaitingGame.ts
index 562f9906f..bcd05ee05 100644
--- a/src/features/easter-eggs/hooks/useDinoWaitingGame.ts
+++ b/src/features/easter-eggs/hooks/useDinoWaitingGame.ts
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from "react";
+import { isInteractiveTarget } from "../lib/keyTargets.ts";
/**
* Reads the unlock flag, treating unavailable storage as "locked". Storage
@@ -35,54 +36,6 @@ export function useDinoUnlocked(): boolean {
return isUnlocked;
}
-/** 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"]'));
-
/**
* Which host currently has the one waiting-game open, if any.
*
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
index 06a7b4dd1..c6fee39cd 100644
--- a/src/features/easter-eggs/registry.ts
+++ b/src/features/easter-eggs/registry.ts
@@ -11,11 +11,6 @@ import { lazy } from "react";
* file; every trigger, page and test keeps addressing eggs by id.
*/
-/** Lazy-loaded: Space Invaders (chunk fetched on first open). */
-const SpaceInvaders = lazy(() =>
- import("./components/SpaceInvaders").then((m) => ({ default: m.SpaceInvaders })),
-);
-
export type EggId = "game-2048" | "space-invaders";
type EggDefinition = {
@@ -43,6 +38,8 @@ export const EGG_REGISTRY: Record = {
"space-invaders": {
label: "Space Invaders game",
kind: "canvas",
- component: SpaceInvaders,
+ component: lazy(() =>
+ import("./components/SpaceInvaders").then((m) => ({ default: m.SpaceInvaders })),
+ ),
},
};
diff --git a/src/features/onboarding/components/journey/GenerationScreen.tsx b/src/features/onboarding/components/journey/GenerationScreen.tsx
index 569aaf7ec..12998a66d 100644
--- a/src/features/onboarding/components/journey/GenerationScreen.tsx
+++ b/src/features/onboarding/components/journey/GenerationScreen.tsx
@@ -34,11 +34,18 @@ 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. */
@@ -47,10 +54,9 @@ export function GenerationScreen({
const [now, setNow] = useState(() => Date.now());
const dinoUnlocked = useDinoUnlocked();
- const isGenerating =
- !isCompleted &&
- (phases.length === 0 ||
- phases.some((phase) => phase.state === "waiting" || phase.state === "working"));
+ // 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, {
diff --git a/src/pages/NotFoundPage.tsx b/src/pages/NotFoundPage.tsx
index 1851a5064..4a94c412f 100644
--- a/src/pages/NotFoundPage.tsx
+++ b/src/pages/NotFoundPage.tsx
@@ -1,8 +1,8 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
-import { EggModalShell } from "../features/easter-eggs/components/EggModalShell";
-import { PageHeader } from "../components/layout/PageHeader";
-import { Button } from "../components/ui/Button";
+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";
/**
diff --git a/src/pages/OnBoardingPage.tsx b/src/pages/OnBoardingPage.tsx
index a1e488c01..cc6434a71 100644
--- a/src/pages/OnBoardingPage.tsx
+++ b/src/pages/OnBoardingPage.tsx
@@ -724,6 +724,7 @@ export function OnBoardingPage() {
{
beforeEach(() => {
diff --git a/tests/unit/features/onboarding/components/journey/GenerationScreen.test.tsx b/tests/unit/features/onboarding/components/journey/GenerationScreen.test.tsx
index 97de847d3..fbfbee0b1 100644
--- a/tests/unit/features/onboarding/components/journey/GenerationScreen.test.tsx
+++ b/tests/unit/features/onboarding/components/journey/GenerationScreen.test.tsx
@@ -19,7 +19,7 @@ describe("GenerationScreen", () => {
it("renders phases and assembling status", () => {
render(
-
+
,
);
@@ -32,7 +32,7 @@ describe("GenerationScreen", () => {
it("does not show space hint or start game when dino is locked", () => {
render(
-
+
,
);
@@ -51,6 +51,7 @@ describe("GenerationScreen", () => {
,
@@ -69,7 +70,7 @@ describe("GenerationScreen", () => {
const { rerender } = render(
-
+
,
);
@@ -79,20 +80,49 @@ describe("GenerationScreen", () => {
// 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(
-
+
,
);
@@ -117,6 +147,7 @@ describe("GenerationScreen", () => {
,