diff --git a/frontend/src/features/canvas/components/CallStack.test.tsx b/frontend/src/features/canvas/components/CallStack.test.tsx new file mode 100644 index 0000000..bc21c03 --- /dev/null +++ b/frontend/src/features/canvas/components/CallStack.test.tsx @@ -0,0 +1,59 @@ +import React from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; +import CallStack from "./CallStack"; + +function renderCallStack( + overrides: Partial> = {} +) { + return render( + + + + ); +} + +describe("CallStack help icon", () => { + it("shows a help icon labelled for the MemoryViz 'Call Stack' title", () => { + renderCallStack({ visualStyle: "memoryviz" }); + + expect( + screen.getByRole("button", { name: "Help: Call Stack" }) + ).toBeInTheDocument(); + }); + + it("shows a help icon labelled for the Python Tutor 'Frames' title", () => { + renderCallStack({ visualStyle: "pythonTutor" }); + + expect( + screen.getByRole("button", { name: "Help: Frames" }) + ).toBeInTheDocument(); + }); + + it("opens a dialog explaining the call stack on click", () => { + renderCallStack({ visualStyle: "memoryviz" }); + + fireEvent.click(screen.getByRole("button", { name: "Help: Call Stack" })); + + expect(screen.getByRole("dialog")).toBeInTheDocument(); + expect(screen.getByText("Call Stack", { selector: "h4" })).toBeInTheDocument(); + expect( + screen.getByText(/currently active function calls/i) + ).toBeInTheDocument(); + }); + + it("closes the dialog when the close button is clicked", () => { + renderCallStack({ visualStyle: "memoryviz" }); + + fireEvent.click(screen.getByRole("button", { name: "Help: Call Stack" })); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Close" })); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/features/canvas/components/CallStack.tsx b/frontend/src/features/canvas/components/CallStack.tsx index 1603bc1..ae07e44 100644 --- a/frontend/src/features/canvas/components/CallStack.tsx +++ b/frontend/src/features/canvas/components/CallStack.tsx @@ -12,6 +12,7 @@ import { } from "../../shared/types"; import { BoxDimensions } from "../utils/box.types"; import CanvasBox from "./CanvasBox"; +import HelpIcon from "../../shared/components/HelpIcon"; import styles from "./CallStack.module.css"; import { DEFAULT_BOX_WIDTH, @@ -389,6 +390,19 @@ const CallStack: React.FC = ({ {visualStyle === "pythonTutor" ? "Frames" : "Call Stack"} + + + + *:first-child { + flex: 1; + min-width: 0; +} + .feedbackWrapper { margin-top: 16px; } diff --git a/frontend/src/features/canvasControls/CanvasControls.test.tsx b/frontend/src/features/canvasControls/CanvasControls.test.tsx index ab1e212..1e9f984 100644 --- a/frontend/src/features/canvasControls/CanvasControls.test.tsx +++ b/frontend/src/features/canvasControls/CanvasControls.test.tsx @@ -25,6 +25,10 @@ function openSettingsTab() { fireEvent.click(screen.getByRole("button", { name: /settings/i })); } +function openViewTab() { + fireEvent.click(screen.getByRole("button", { name: /^view$/i })); +} + describe("CanvasControls", () => { beforeEach(() => { localStorage.clear(); @@ -93,3 +97,92 @@ describe("CanvasControls", () => { expect(handleReferenceArrowChange).toHaveBeenCalledWith(true); }); }); + +describe("CanvasControls help icons", () => { + beforeEach(() => { + localStorage.clear(); + }); + + it("shows a help icon for the panel title explaining the tabs", () => { + renderControls(); + + fireEvent.click( + screen.getByRole("button", { name: "Help: Canvas Controls" }) + ); + + expect(screen.getByRole("dialog")).toBeInTheDocument(); + expect(screen.getByText(/Actions to undo/i)).toBeInTheDocument(); + }); + + it("shows help icons for Clear and Download without triggering their actions", () => { + const handleClear = jest.fn(); + renderControls({ onClear: handleClear, elements: [] }); + + fireEvent.click(screen.getByRole("button", { name: "Help: Clear" })); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + expect(handleClear).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole("button", { name: "Close" })); + + fireEvent.click(screen.getByRole("button", { name: "Help: Download" })); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + }); + + it("shows help icons for all three zoom controls in the View tab", () => { + renderControls({ + onScaleChange: jest.fn(), + onEditorScaleChange: jest.fn(), + onFontScaleChange: jest.fn(), + }); + openViewTab(); + + expect( + screen.getByRole("button", { name: "Help: Canvas Zoom" }) + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Help: Editor Zoom" }) + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Help: Question Zoom" }) + ).toBeInTheDocument(); + }); + + it("shows a help icon for the practice/test mode toggle without flipping it", () => { + const handleModeToggle = jest.fn(); + renderControls({ onModeToggle: handleModeToggle, isSandboxMode: true }); + openSettingsTab(); + + fireEvent.click( + screen.getByRole("button", { name: "Help: Practice / Test Mode" }) + ); + + expect(screen.getByRole("dialog")).toBeInTheDocument(); + expect(handleModeToggle).not.toHaveBeenCalled(); + }); + + it("shows a help icon for the Python Tutor Style toggle", () => { + renderControls(); + openSettingsTab(); + + fireEvent.click( + screen.getByRole("button", { name: "Help: Python Tutor Style" }) + ); + + expect(screen.getByRole("dialog")).toBeInTheDocument(); + expect( + screen.getByText(/look like PythonTutor's visualizer/i) + ).toBeInTheDocument(); + }); + + it("shows help icons for Standalone Primitives and Reference Arrows in Python Tutor mode", () => { + renderControls({ visualStyle: "pythonTutor" }); + openSettingsTab(); + + expect( + screen.getByRole("button", { name: "Help: Standalone Primitives" }) + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Help: Reference Arrows" }) + ).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/features/canvasControls/CanvasControls.tsx b/frontend/src/features/canvasControls/CanvasControls.tsx index 00b3e5d..59e6763 100644 --- a/frontend/src/features/canvasControls/CanvasControls.tsx +++ b/frontend/src/features/canvasControls/CanvasControls.tsx @@ -7,6 +7,7 @@ import React, { useState } from "react"; import { CanvasElement, VisualStyle } from "../shared/types"; import { ClearCanvasButton, DownloadButton, ZoomControls, UndoButton, RedoButton, FeedbackButton } from "../canvas/components/CanvasButtons"; import { useTheme } from "../../contexts/ThemeContext"; +import HelpIcon from "../shared/components/HelpIcon"; import styles from "./CanvasControls.module.css"; interface CanvasControlsProps { @@ -97,7 +98,10 @@ export default function CanvasControls({
-

Canvas Controls

+

+ Canvas Controls + +

{/* Actions Tab */} {activeTab === "actions" && ( @@ -110,13 +114,15 @@ export default function CanvasControls({ )} {onClear && ( -
+
+
)} -
+
+
)} @@ -127,7 +133,10 @@ export default function CanvasControls({ {onScaleChange && ( <>
- + + + + {Math.round(scale * 100)}%
@@ -139,7 +148,10 @@ export default function CanvasControls({ {onEditorScaleChange && ( <>
- + + + + {Math.round(editorScale * 100)}%
@@ -151,7 +163,10 @@ export default function CanvasControls({ {onFontScaleChange && ( <>
- + + + + {Math.round(fontScale * 100)}%
@@ -189,9 +204,12 @@ export default function CanvasControls({ {/* Mode Toggle */} {onModeToggle && (
- + + + +
)} diff --git a/frontend/src/features/informationTabs/questionTab/components/QuestionSelector.module.css b/frontend/src/features/informationTabs/questionTab/components/QuestionSelector.module.css index 8b4c641..f411e9c 100644 --- a/frontend/src/features/informationTabs/questionTab/components/QuestionSelector.module.css +++ b/frontend/src/features/informationTabs/questionTab/components/QuestionSelector.module.css @@ -104,6 +104,13 @@ display: flex; flex-direction: column; gap: 0.125rem; + min-width: 0; +} + +.categoryTitleRow { + display: flex; + align-items: center; + gap: 0.4rem; } .categoryTitle { diff --git a/frontend/src/features/informationTabs/questionTab/components/QuestionSelector.test.tsx b/frontend/src/features/informationTabs/questionTab/components/QuestionSelector.test.tsx new file mode 100644 index 0000000..2995f54 --- /dev/null +++ b/frontend/src/features/informationTabs/questionTab/components/QuestionSelector.test.tsx @@ -0,0 +1,84 @@ +import React from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; +import QuestionSelector from "./QuestionSelector"; + +describe("QuestionSelector help icon", () => { + it("renders a help icon on a category card when helpText is provided", () => { + render( + + ); + + expect( + screen.getByRole("button", { name: "Help: Practice Questions" }) + ).toBeInTheDocument(); + }); + + it("does not render a help icon when helpText is omitted", () => { + render( + + ); + + expect( + screen.queryByRole("button", { name: /^Help/ }) + ).not.toBeInTheDocument(); + }); + + it("does not trigger the card's onClick when the help icon is clicked", () => { + const handleClick = jest.fn(); + render( + + ); + + fireEvent.click( + screen.getByRole("button", { name: "Help: Practice Questions" }) + ); + + expect(handleClick).not.toHaveBeenCalled(); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + expect( + screen.getByText("Build the memory model yourself.") + ).toBeInTheDocument(); + }); + + it("still triggers onClick when the rest of the card is clicked", () => { + const handleClick = jest.fn(); + render( + + ); + + fireEvent.click(screen.getByText("Practice Questions")); + + expect(handleClick).toHaveBeenCalledTimes(1); + }); + + it("does not render a help icon for the pill variant", () => { + render(); + + expect( + screen.queryByRole("button", { name: /^Help/ }) + ).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/features/informationTabs/questionTab/components/QuestionSelector.tsx b/frontend/src/features/informationTabs/questionTab/components/QuestionSelector.tsx index 5dfc2c9..ea60a28 100644 --- a/frontend/src/features/informationTabs/questionTab/components/QuestionSelector.tsx +++ b/frontend/src/features/informationTabs/questionTab/components/QuestionSelector.tsx @@ -1,3 +1,5 @@ +import React from "react"; +import HelpIcon from "../../../shared/components/HelpIcon"; import styles from "./QuestionSelector.module.css"; type QuestionStatus = "unattempted" | "attempted" | "completed"; @@ -10,6 +12,7 @@ interface QuestionSelectorProps { icon?: string; subtitle?: string; categoryType?: "practice" | "test" | "prep" | "experiment"; + helpText?: string; } export default function QuestionSelector({ @@ -20,6 +23,7 @@ export default function QuestionSelector({ icon, subtitle, categoryType, + helpText, }: QuestionSelectorProps) { const getStatusClass = () => { switch (status) { @@ -33,11 +37,22 @@ export default function QuestionSelector({ }; if (variant === "category") { + // Rendered as a div (not a button) because it contains its own nested + // HelpIcon button — a +
); } diff --git a/frontend/src/features/palette/Palette.module.css b/frontend/src/features/palette/Palette.module.css index 9f12b34..dca7591 100644 --- a/frontend/src/features/palette/Palette.module.css +++ b/frontend/src/features/palette/Palette.module.css @@ -194,6 +194,10 @@ ======================================= */ .paletteTitle { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; text-align: center; font-size: 0.85rem; font-weight: 700; @@ -215,6 +219,18 @@ gap: 16px; } +.paletteBoxItem { + position: relative; + display: inline-block; +} + +.paletteBoxHelpSlot { + position: absolute; + top: -6px; + right: -6px; + z-index: 1; +} + .emptyState { margin: 0; color: var(--text-tertiary); diff --git a/frontend/src/features/palette/Palette.test.tsx b/frontend/src/features/palette/Palette.test.tsx index b8aae92..84d327d 100644 --- a/frontend/src/features/palette/Palette.test.tsx +++ b/frontend/src/features/palette/Palette.test.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import Palette from "./Palette"; jest.mock("./components/PaletteBox", () => ({ @@ -54,3 +54,32 @@ describe("Palette Python Tutor primitive mode", () => { expect(screen.getAllByTestId("palette-box")).toHaveLength(5); }); }); + +describe("Palette help icons", () => { + beforeAll(() => { + (global as typeof globalThis).ResizeObserver = + ResizeObserverMock as unknown as typeof ResizeObserver; + }); + + it("shows a help icon for the palette title", () => { + render( + + ); + + fireEvent.click(screen.getByRole("button", { name: "Help: Palette" })); + + expect(screen.getByRole("dialog")).toBeInTheDocument(); + expect( + screen.getByText(/Drag a box onto the canvas/i) + ).toBeInTheDocument(); + }); + + it("shows a help icon for each box with its own explanation", () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: "Help: int" })); + + expect(screen.getByRole("dialog")).toBeInTheDocument(); + expect(screen.getByText("An integer value.")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/features/palette/Palette.tsx b/frontend/src/features/palette/Palette.tsx index 16056e1..3f8cb8c 100644 --- a/frontend/src/features/palette/Palette.tsx +++ b/frontend/src/features/palette/Palette.tsx @@ -1,6 +1,7 @@ import React, { useState, useEffect, useRef } from "react"; import PaletteBox from "./components/PaletteBox"; import CanvasControls from "../canvasControls/CanvasControls"; +import HelpIcon from "../shared/components/HelpIcon"; import { useResizable } from "./hooks/useResizable"; import styles from "./Palette.module.css"; import { @@ -10,6 +11,21 @@ import { VisualStyle, } from "../shared/types"; +const BOX_DESCRIPTIONS: Record = { + function: "A stack frame for a function call — shows its name and local variables.", + class: "An instance of a class — shows its id and its attributes.", + none: "Python's None value.", + int: "An integer value.", + float: "A floating-point (decimal) value.", + str: "A string value.", + bool: "A boolean value — True or False.", + list: "An ordered, mutable collection of values.", + tuple: "An ordered, immutable collection of values.", + set: "An unordered collection of unique values.", + dict: "A collection of key-value pairs.", + primitive: "A basic value type, such as an int, float, str, or bool.", +}; + // Move constants here for better organization const ALL_TYPES: readonly BoxTypeName[] = [ "function", @@ -213,7 +229,13 @@ export default function Palette({
-

Palette

+

+ Palette + +

{visibleBoxes.length > 0 ? ( visibleBoxes.map((boxType) => ( - +
+ + + + +
)) ) : (

diff --git a/frontend/src/features/shared/components/HelpIcon.module.css b/frontend/src/features/shared/components/HelpIcon.module.css new file mode 100644 index 0000000..65ed9fe --- /dev/null +++ b/frontend/src/features/shared/components/HelpIcon.module.css @@ -0,0 +1,123 @@ +.icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 15px; + height: 15px; + flex-shrink: 0; + padding: 0; + margin: 0; + font-family: inherit; + border-radius: 50%; + background: var(--bg-tertiary, #f1f5f9); + color: var(--text-tertiary, #64748b); + border: 1px solid var(--border-secondary, #cbd5e1); + font-size: 10px; + font-weight: 700; + line-height: 1; + cursor: pointer; + user-select: none; + transition: background 120ms ease, color 120ms ease, border-color 120ms ease; +} + +.icon:hover, +.icon:focus-visible { + background: var(--accent-blue, #3b82f6); + border-color: var(--accent-blue, #3b82f6); + color: #ffffff; + outline: none; +} + +/* Overlay + dialog, portaled to document.body so they always center on + the viewport regardless of where the icon lives in the layout. */ +.overlay { + position: fixed; + inset: 0; + z-index: 99999; + display: flex; + align-items: center; + justify-content: center; + background: rgba(15, 23, 42, 0.25); + backdrop-filter: blur(3px); + -webkit-backdrop-filter: blur(3px); + animation: fadeIn 120ms ease-out; +} + +:root[data-theme="dark"] .overlay { + background: rgba(0, 0, 0, 0.5); +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.dialog { + position: relative; + width: min(360px, calc(100vw - 48px)); + max-height: calc(100vh - 48px); + overflow-y: auto; + background: var(--bg-primary, #ffffff); + border: 1px solid var(--border-primary, #e2e8f0); + border-radius: 12px; + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2); + padding: 20px 24px; + animation: popIn 140ms ease-out; +} + +@keyframes popIn { + from { + opacity: 0; + transform: scale(0.96); + } + to { + opacity: 1; + transform: scale(1); + } +} + +.dialogTitle { + margin: 0 0 8px 0; + padding-right: 20px; + font-size: 0.95rem; + font-weight: 700; + color: var(--text-primary, #0f172a); +} + +.dialogText { + margin: 0; + padding-right: 20px; + font-size: 0.875rem; + line-height: 1.55; + color: var(--text-secondary, #334155); +} + +.closeButton { + position: absolute; + top: 10px; + right: 10px; + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border: none; + border-radius: 6px; + background: transparent; + color: var(--text-tertiary, #64748b); + font-size: 1.1rem; + line-height: 1; + cursor: pointer; + transition: background 120ms ease, color 120ms ease; +} + +.closeButton:hover, +.closeButton:focus-visible { + background: var(--bg-hover, #e2e8f0); + color: var(--text-primary, #0f172a); + outline: none; +} diff --git a/frontend/src/features/shared/components/HelpIcon.test.tsx b/frontend/src/features/shared/components/HelpIcon.test.tsx new file mode 100644 index 0000000..f23b2b6 --- /dev/null +++ b/frontend/src/features/shared/components/HelpIcon.test.tsx @@ -0,0 +1,106 @@ +import React from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; +import HelpIcon from "./HelpIcon"; + +describe("HelpIcon", () => { + it("does not show the explanation until the icon is clicked", () => { + render(); + + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect(screen.queryByText("Explains the thing.")).not.toBeInTheDocument(); + }); + + it("labels the icon 'Help' when no title is given", () => { + render(); + + expect(screen.getByRole("button", { name: "Help" })).toBeInTheDocument(); + }); + + it("opens a dialog with the title and explanation on click", () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: "Help: My Feature" })); + + expect(screen.getByRole("dialog")).toBeInTheDocument(); + expect(screen.getByText("My Feature")).toBeInTheDocument(); + expect(screen.getByText("Explains the thing.")).toBeInTheDocument(); + }); + + it("closes when the close button is clicked", () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: "Help" })); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Close" })); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + it("closes when clicking the backdrop outside the dialog", () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: "Help" })); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("presentation")); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + it("does not close when clicking inside the dialog content", () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: "Help" })); + fireEvent.click(screen.getByText("Explains the thing.")); + + expect(screen.getByRole("dialog")).toBeInTheDocument(); + }); + + it("closes when the Escape key is pressed", () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: "Help" })); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + + fireEvent.keyDown(document, { key: "Escape" }); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + it("does not react to Escape while closed", () => { + render(); + + expect(() => fireEvent.keyDown(document, { key: "Escape" })).not.toThrow(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + // The ancestor uses role="button" on a

rather than a real + + {isOpen && + createPortal( +
setIsOpen(false)} + role="presentation" + > +
+ + {title &&

{title}

} +

{text}

+
+
, + document.body + )} + + ); +}