diff --git a/README.md b/README.md index 335d47b7a..dd2fc8ab3 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ self-contained under `src/features/` and surfaced through dedicated routes: - **Insights — Knowledge Gaps** — AI-detected missing documentation per component, with severity (high / medium / low), component owners, and refresh tracking. - **Insights — FAQ** — AI-generated clusters of frequently asked questions and the documents that answer them. - **Settings & Profile** — Central configuration hub with tabs for user profile management (avatar, display name, password update), appearance (light/dark/system theme), chat preferences, moments toggles, and access tokens (GitHub PAT / Jira credential management for authorized roles). -- **Moments & Easter Eggs** — Gamified celebrations (confetti, achievement moments, sound effects) and interactive easter eggs (`dino`, `game2048`, `space-invaders`). +- **Moments & Easter Eggs** — Gamified celebrations (confetti, achievement moments, sound effects) and interactive easter eggs (the dino waiting-game, `2048` behind the dashboard header icon, Space Invaders behind the 404 page's rocket). --- @@ -70,10 +70,9 @@ src/ │ ├── connectors/ # Connector + source allow/deny management │ ├── dashboard/ # Dashboard hero & NextStepWidget │ ├── data-ingestion/ # Sources, ingestion runs, artifact tables -│ ├── dino/ # Dino easter-egg mini game -│ ├── easter-eggs/ # Easter-egg trigger hooks & modals +│ ├── easter-eggs/ # Easter eggs: egg shell + game registry, the +│ │ # effect bus, phrases and the dino waiting-game │ ├── faq/ # AI FAQ clusters (insights) -│ ├── game2048/ # 2048 easter-egg mini game │ ├── knowledge-base/ # Artifact browsing + streamed summaries + file upload │ ├── knowledge-gaps/ # AI-detected documentation gaps (insights) │ ├── moments/ # Celebrations, confetti, rocket animations @@ -81,7 +80,6 @@ src/ │ ├── profile/ # Profile form components │ ├── projects/ # Multi-project switching & global ProjectContext │ ├── settings/ # User settings tabs, themes & credentials -│ ├── space-invaders/ # Space Invaders easter-egg mini game │ └── team-management/ # Team overview, member detail, Skill Wizard ├── pages/ # Route-level views (one per user-facing flow) ├── router/ # React Router v7 config + AuthGuard diff --git a/public/easter-eggs/2048.html b/public/easter-eggs/2048.html index 1fe538f85..e97ba57aa 100644 --- a/public/easter-eggs/2048.html +++ b/public/easter-eggs/2048.html @@ -1000,6 +1000,13 @@ const mapped = keyMap[event.key]; if (!modifiers) { + if (event.key === "Escape") { + try { + window.parent.postMessage({ type: "EGG_EXIT" }, window.location.origin); + } catch (_) {} + return; + } + if (mapped !== undefined) { event.preventDefault(); // Ignore auto-repeat key events so holding an arrow doesn't diff --git a/src/App.tsx b/src/App.tsx index 68f5cf5c0..1b8587466 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,6 +18,7 @@ import { SelectionActions } from "./features/board/selection/SelectionActions"; import { CardMarksProvider } from "./features/board/marks/CardMarksProvider"; import { useAuth } from "./context/useAuth"; import { AuroraBackground } from "./components/layout/AuroraBackground"; +import { EggEffectsLayer } from "./features/easter-eggs/components/EggEffectsLayer"; import { MyKnowledgeGapsProvider } from "./features/knowledge-gaps/MyKnowledgeGapsProvider"; import { KnowledgeGapOwnerAnnouncement } from "./features/knowledge-gaps/components/KnowledgeGapOwnerAnnouncement"; import { useScrollRestoration } from "./hooks/useScrollRestoration"; @@ -139,6 +140,12 @@ function AppContent() { sits on top of the login screen, and off unless turned on in Settings (see AppearanceSection). */} {signedIn && showRocketPet && } + + {/* Whole-window egg effects (barrel roll, matrix rain), rendered + once for the whole app. Any chat surface fires them through the + bus (playEggEffect); this is where they actually draw. Not gated + on signedIn: a fired effect must always have its renderer. */} + ); diff --git a/src/components/layout/PageHeader.tsx b/src/components/layout/PageHeader.tsx index 8335d8d02..647ae5c80 100644 --- a/src/components/layout/PageHeader.tsx +++ b/src/components/layout/PageHeader.tsx @@ -4,7 +4,7 @@ import type { LucideIcon } from "lucide-react"; type PageHeaderProps = { icon: LucideIcon; title: string; - subtitle: string; + subtitle?: string; actions?: ReactNode; className?: string; /** @@ -13,20 +13,32 @@ type PageHeaderProps = { * where the subtitle is secondary. `undefined` keeps the subtitle always visible. */ hideSubtitleBelow?: "sm" | "md" | "lg"; + /** Optional click handler for the header icon (e.g., for easter eggs). */ + onIconClick?: () => void; /** - * Optional click handler for the header icon (e.g., for easter eggs). + * Marks the icon as hiding an easter egg: it grows slightly on hover and + * plays a one-shot glow pulse on mount. Purely decorative, so it is not + * reflected in the accessible name; users who prefer reduced motion get + * neither effect (see the page-header-icon rules in styles/index.css). */ - onIconClick?: () => void; + eggHint?: boolean; + /** + * Optional popover or speech bubble anchored directly to the header icon + * (e.g. for easter-egg hints or status badges). + */ + iconPopover?: ReactNode; }; export function PageHeader({ icon: Icon, title, - subtitle, + subtitle = "", actions, className = "", hideSubtitleBelow, onIconClick, + eggHint = false, + iconPopover, }: PageHeaderProps) { return (
@@ -34,15 +46,23 @@ export function PageHeader({
{onIconClick ? ( - +
+ + {iconPopover} +
) : ( - +
+ + {iconPopover} +
)}

@@ -50,13 +70,15 @@ export function PageHeader({

-

- {subtitle} -

+ {subtitle ? ( +

+ {subtitle} +

+ ) : null}
{actions ? ( diff --git a/src/components/layout/SidebarLogo.tsx b/src/components/layout/SidebarLogo.tsx index 811b17a3a..5497b1975 100644 --- a/src/components/layout/SidebarLogo.tsx +++ b/src/components/layout/SidebarLogo.tsx @@ -1,8 +1,59 @@ -import { motion, useReducedMotion, type Variants } from "framer-motion"; +import { useState } from "react"; +import { motion, useReducedMotion, type TargetAndTransition, type Variants } from "framer-motion"; +import { useRepeatClicks } from "../../features/easter-eggs/hooks/useRepeatClicks"; +import { hoverSpringToken, logoHopSpringToken } from "../../styles/tokens"; const BADGE_SIZE = 44; const MARK_SIZE = 28; +/** + * How far the badge falls before it hits the floor. Chosen to clear the + * header row visually (parents are overflow-visible) so the drop reads as + * "fell past its shelf, bounced, hopped back" without portals or measured + * rects — a fixed translateY keeps the effect layout-independent. + */ +const DROP_DISTANCE = 140; + +/** + * The gravity choreography, one entry per phase. Each phase animates the + * whole badge; `onAnimationComplete` walks idle → falling → impact → + * bouncing → hopping → idle, so every beat lands exactly when the previous + * one finishes instead of relying on hand-tuned delays. + */ +const DROP_PHASES: Record, TargetAndTransition> = { + // Gravity accelerates: easeIn, with a slight forward tumble. + falling: { y: DROP_DISTANCE, rotate: 6, transition: { duration: 0.45, ease: "easeIn" } }, + // Sideways squash on impact — the classic cartoon beat. + impact: { + scaleX: 1.14, + scaleY: 0.8, + rotate: 0, + transition: { duration: 0.09, ease: "easeOut" }, + }, + // Two diminishing bounces; each landing re-squashes a little less. + bouncing: { + y: [DROP_DISTANCE, 94, DROP_DISTANCE, 119, DROP_DISTANCE], + scaleX: [1.14, 1.02, 1.1, 1.03, 1.1], + scaleY: [0.8, 1, 0.87, 1, 0.93], + transition: { + duration: 0.62, + times: [0, 0.26, 0.5, 0.74, 1], + ease: ["easeOut", "easeIn", "easeOut", "easeIn"], + }, + }, + // Spring back to the shelf with a small overshoot, rotation settling. + hopping: { + y: 0, + scaleX: 1, + scaleY: 1, + rotate: 0, + transition: logoHopSpringToken, + }, +}; + +/** The gravity sequence phases: idle at rest, then falling → impact → bouncing → hopping. */ +type DropPhase = "idle" | "falling" | "impact" | "bouncing" | "hopping"; + /** * Where the exhaust meets the body, in viewBox units. The flame scales from * here so the plume grows away from the engine instead of stretching from its @@ -29,13 +80,31 @@ const NOZZLE_ORIGIN = { transformOrigin: "7px 17px", transformBox: "view-box" } export function SidebarLogo({ className = "" }: { className?: string }) { const prefersReducedMotion = useReducedMotion(); + // The gravity easter egg: five clicks drop the badge off its shelf. + // Reduced motion skips the animation entirely (pure motion has no honest + // static equivalent) — the counter still consumes, nothing plays. + const [dropPhase, setDropPhase] = useState("idle"); + const handleLogoClick = useRepeatClicks(5, () => { + if (!prefersReducedMotion && dropPhase === "idle") setDropPhase("falling"); + }); + + /** Walks the choreography forward as each beat's animation completes. */ + const advanceDrop = () => { + setDropPhase((current) => { + if (current === "falling") return "impact"; + if (current === "impact") return "bouncing"; + if (current === "bouncing") return "hopping"; + return "idle"; + }); + }; + const badgeVariants: Variants = prefersReducedMotion ? { rest: {}, lift: {} } : { rest: { scale: 1 }, lift: { scale: 1.08, - transition: { type: "spring", stiffness: 400, damping: 16 }, + transition: hoverSpringToken, }, }; @@ -51,7 +120,7 @@ export function SidebarLogo({ className = "" }: { className?: string }) { lift: { x: 1.5, y: -1.5, - transition: { type: "spring", stiffness: 420, damping: 14 }, + transition: hoverSpringToken, }, }; @@ -94,11 +163,29 @@ export function SidebarLogo({ className = "" }: { className?: string }) { return ( { if (!isOpen) return; + /** + * Escape closes the drawer only when nothing inside it has already claimed + * the key. Inner consumers (e.g. the dino mini-game, which exits on Escape) + * call `preventDefault()`; without this check one keypress would close both + * the inner widget and the whole drawer. + */ function handleKeyDown(event: KeyboardEvent) { + if (event.key === "Escape" && event.defaultPrevented) return; + if (event.key === "Escape" && closeOnEscape) { onClose(); return; diff --git a/src/context/ChatContext.ts b/src/context/ChatContext.ts index f03e47823..d1c34dba0 100644 --- a/src/context/ChatContext.ts +++ b/src/context/ChatContext.ts @@ -54,6 +54,14 @@ export type ChatContextValue = { */ streamingChatId: string | null; + /** + * How the most recent turn ended, per chat. `null` while a turn is running + * or before the first one. Exists so waiting UI (the dino easter egg) can + * say "Stopped" / "Reply failed" instead of claiming a reply is ready when + * the user pressed Stop or the stream errored. + */ + lastTurnOutcome: ChatTurnOutcome | null; + selectedCitation: SelectedCitation | null; setSelectedCitation: (value: SelectedCitation | null) => void; @@ -170,4 +178,13 @@ export type ChatContextValue = { deleteChat: (chatId: string) => Promise; }; +/** + * Outcome of a finished chat turn, keyed by the chat it belongs to so a + * background chat's result never leaks into the one on screen. + */ +export type ChatTurnOutcome = { + chatId: string; + kind: "done" | "stopped" | "failed"; +}; + export const ChatContext = createContext(undefined); diff --git a/src/context/ChatProvider.tsx b/src/context/ChatProvider.tsx index df63f2a2f..8cdf647ef 100644 --- a/src/context/ChatProvider.tsx +++ b/src/context/ChatProvider.tsx @@ -11,7 +11,7 @@ import { useAuth } from "./useAuth"; import { useToastApi } from "./useToast"; import { useProjectContext } from "../features/projects/useProjectContext"; import { ChatContext } from "./ChatContext"; -import type { ChatContextValue, SelectedCitation } from "./ChatContext"; +import type { ChatContextValue, ChatTurnOutcome, SelectedCitation } from "./ChatContext"; import type { Chat, ChatMessage, @@ -85,6 +85,7 @@ export function ChatProvider({ children }: { children: ReactNode }) { const [messagesByChat, setMessagesByChat] = useState({}); const [isThinking, setIsThinking] = useState(false); + const [lastTurnOutcome, setLastTurnOutcome] = useState(null); const [isStreaming, setIsStreaming] = useState(false); const [streamingMessageId, setStreamingMessageId] = useState(null); @@ -173,6 +174,9 @@ export function ChatProvider({ children }: { children: ReactNode }) { // first token of the post-tool answer glues onto pre-tool preamble text // ("Let me search…Searching knowledge base…") inside the same bubble. const sawToolUseRef = useRef(false); + // Set to true on the first content token from onToken. Keeps isThinking true + // during any reasoning/thoughts streaming phase so that the waiting game stays active. + const replyStartedRef = useRef(false); // Monotonic id per `sendMessage` call. Each handler captures the streamId // it was created for and no-ops if it doesn't match the current value — @@ -317,6 +321,7 @@ export function ChatProvider({ children }: { children: ReactNode }) { clearStreamTimeout(); streamingStartedRef.current = false; sawToolUseRef.current = false; + replyStartedRef.current = false; streamIdRef.current += 1; latestLoadRef.current = null; if (draftRef.current && draftRef.current.rafId !== null) { @@ -525,6 +530,7 @@ export function ChatProvider({ children }: { children: ReactNode }) { })); setIsThinking(true); + setLastTurnOutcome(null); // A follow-up sent mid-stream would otherwise inherit the previous // stream's flags — a caret on a message that no longer receives tokens // and the previous turn's tool label under the dots. @@ -534,6 +540,7 @@ export function ChatProvider({ children }: { children: ReactNode }) { commitStreamingChat(currentChatId); streamingStartedRef.current = false; sawToolUseRef.current = false; + replyStartedRef.current = false; // Initialize the rAF-batched draft so token/reasoning/citation events // append to a mutable buffer instead of triggering a state update each. @@ -571,6 +578,7 @@ export function ChatProvider({ children }: { children: ReactNode }) { "The answer timed out", "The assistant stopped responding for five minutes.", ); + setLastTurnOutcome({ chatId: currentChatId, kind: "failed" }); }, STREAM_TIMEOUT_MS); }; armStreamTimeout(); @@ -592,6 +600,7 @@ export function ChatProvider({ children }: { children: ReactNode }) { if (!isCurrentStream()) return; streamingStartedRef.current = false; sawToolUseRef.current = false; + replyStartedRef.current = false; abortControllerRef.current = null; setIsStreaming(false); setIsThinking(false); @@ -648,7 +657,8 @@ export function ChatProvider({ children }: { children: ReactNode }) { if (!streamingStartedRef.current) { streamingStartedRef.current = true; setIsStreaming(true); - setIsThinking(false); + // Keep isThinking true while thoughts/reasoning stream, + // so the Dino waiting game remains armed until the actual reply arrives. setStreamingMessageId(assistantId); } @@ -666,9 +676,12 @@ export function ChatProvider({ children }: { children: ReactNode }) { if (!streamingStartedRef.current) { streamingStartedRef.current = true; setIsStreaming(true); - setIsThinking(false); setStreamingMessageId(assistantId); } + if (!replyStartedRef.current) { + replyStartedRef.current = true; + setIsThinking(false); + } const draft = draftRef.current; if (draft) { @@ -743,6 +756,14 @@ export function ChatProvider({ children }: { children: ReactNode }) { ), })); + // The timeout path aborts and then lands here — keep its + // "failed" verdict instead of overwriting it with "done". + setLastTurnOutcome((prev) => + prev?.chatId === currentChatId && prev.kind === "failed" + ? prev + : { chatId: currentChatId, kind: "done" }, + ); + void refreshChats(); // The answer is complete, so the next queued message may go. @@ -760,6 +781,7 @@ export function ChatProvider({ children }: { children: ReactNode }) { if (!isCurrentStream()) return; reportFailure("The answer failed", err); + setLastTurnOutcome({ chatId: currentChatId, kind: "failed" }); // A failed turn still frees the queue: the next message may well // be the retry, and holding it hostage to a failure the user has // already been told about helps nobody. @@ -781,6 +803,7 @@ export function ChatProvider({ children }: { children: ReactNode }) { if (!isCurrentStream()) return; reportFailure("The answer failed", "Unexpected error during streaming."); + setLastTurnOutcome({ chatId: currentChatId, kind: "failed" }); drainRef.current(currentChatId); } }, @@ -814,10 +837,12 @@ export function ChatProvider({ children }: { children: ReactNode }) { flushDraft(); const stopped = draftRef.current; draftRef.current = null; + if (stopped) setLastTurnOutcome({ chatId: stopped.chatId, kind: "stopped" }); abortControllerRef.current?.abort(); abortControllerRef.current = null; streamingStartedRef.current = false; sawToolUseRef.current = false; + replyStartedRef.current = false; setIsStreaming(false); setIsThinking(false); setStreamingMessageId(null); @@ -1017,6 +1042,7 @@ export function ChatProvider({ children }: { children: ReactNode }) { streamingMessageId, thinkingState, streamingChatId, + lastTurnOutcome, selectedCitation, setSelectedCitation, newRequest, diff --git a/src/features/buddy/components/BuddyComposer.tsx b/src/features/buddy/components/BuddyComposer.tsx index 6875a604c..8568d68d8 100644 --- a/src/features/buddy/components/BuddyComposer.tsx +++ b/src/features/buddy/components/BuddyComposer.tsx @@ -7,7 +7,12 @@ import { useAutoResize } from "../../../components/ui/useAutoResize"; type BuddyComposerProps = { draft: string; setDraft: (value: string) => void; - handleSubmit: (event: React.FormEvent) => void; + /** + * Submits the box. Returns whether a turn was actually started: `false` when the caller + * swallowed the submission (an easter-egg phrase, an empty draft), which is what keeps the + * caret here instead of handing it off on a send that never happened — see `submit`. + */ + handleSubmit: (event: React.FormEvent) => boolean; /** Composer placeholder — "Type your answer…" while the buddy is intaking. */ placeholder?: string; /** Drops the keyboard hint under the box, for the dock where the room is better spent. */ @@ -20,6 +25,23 @@ type BuddyComposerProps = { * the dock would otherwise be typed in front of. */ focusOnMount?: boolean; + /** + * Whether the buddy is still writing (thinking dots or streaming text). + * + * Used for one thing: the caret comes back to this box when the answer + * finishes, undoing the deliberate blur a send performs (see `submit`). + * The chat page runs the same two-way dance — see `ChatPage`'s focus + * effect — and for the same reason: Space starts the dino waiting-game + * only while nothing is focused, so a box that keeps the caret after a + * send would quietly make that egg unreachable on this surface. + */ + busy?: boolean; + /** + * Whether the dino waiting-game is open. The caret is held back while it is: the game + * ignores keys aimed at text fields, so a refocus mid-run (the reply arriving) would kill + * the dino, deaden Escape and type Spaces in here. Closing the game hands the caret back. + */ + gameActive?: boolean; }; /** @@ -42,9 +64,17 @@ export function BuddyComposer({ placeholder = "Ask your buddy anything...", compact = false, focusOnMount = false, + busy = false, + gameActive = false, }: BuddyComposerProps) { const fieldRef = useRef(null); + // Set when *this* composer gave up the caret on a send, so the refocus + // below never reaches for focus it never had: the dock mounts on every + // page, and a composer that grabbed the caret on its own would steal it + // from whatever the hire was actually typing in. + const handedOffCaretRef = useRef(false); + // One line at rest, six at most — past that it scrolls rather than eating the thread. useAutoResize({ ref: fieldRef, value: draft, minRows: 1, maxRows: 6 }); @@ -58,6 +88,42 @@ export function BuddyComposer({ field.setSelectionRange(field.value.length, field.value.length); }, [focusOnMount]); + // The mirror of the blur in `submit`: once the buddy has stopped writing, the caret comes + // back so a follow-up question does not need a click first. Skipped when somebody else holds + // focus, so this never pulls the caret out of a field the hire moved to in the meantime. + useEffect(() => { + if (busy || gameActive || !handedOffCaretRef.current) return; + handedOffCaretRef.current = false; + const field = fieldRef.current; + if (!field) return; + const active = document.activeElement; + if (active && active !== document.body) return; + field.focus(); + }, [busy, gameActive]); + + /** + * Sends, then hands the caret back to the page. + * + * Space opens the dino waiting-game while the buddy works, and the trigger refuses to fire + * while a text field holds focus — otherwise it would eat the space bar. The chat page solves + * this by blurring its composer on submit; this box said nothing about focus, so the game was + * only reachable after clicking away from it. Pressing Escape or clicking the thread still + * works too: this only makes the documented gesture (just press Space) true here. + * + * Only a submission that started a turn counts. An egg phrase is swallowed by the caller — + * there is no turn to play a game under, and the caret belongs in the box the hire is still + * typing in, not handed away and left to come back on its own (which it never would: the + * refocus below hangs off `busy` flipping, and nothing ever became busy). + */ + const submit = (event: React.FormEvent) => { + if (!handleSubmit(event)) return; + const field = fieldRef.current; + if (field && document.activeElement === field) { + handedOffCaretRef.current = true; + field.blur(); + } + }; + const handleKeyDown = (event: KeyboardEvent) => { if (event.key !== "Enter" || event.shiftKey) return; // Enter also *commits* an IME candidate — a compose-key 'ü', or any CJK input. Sending @@ -76,7 +142,7 @@ export function BuddyComposer({ return (