From 5c184eef2abe2088e907d672f1e60351974f7e6c Mon Sep 17 00:00:00 2001 From: anbu1504 Date: Thu, 16 Jul 2026 09:07:12 -0400 Subject: [PATCH] Question Icons For Explaining Features The purpose of this PR is to implement the functionality of having question mark icons to explain different key features of the memory model editor application. Users can click said question mark icons to which then they would be able to see some explanatory text that gives them a rundown of the feature in concern. --- .../canvas/components/CallStack.test.tsx | 59 +++++++++ .../features/canvas/components/CallStack.tsx | 14 ++ .../canvasControls/CanvasControls.module.css | 27 ++++ .../canvasControls/CanvasControls.test.tsx | 93 +++++++++++++ .../canvasControls/CanvasControls.tsx | 81 ++++++++---- .../questionTab/QuestionTab.tsx | 4 + .../components/QuestionSelector.module.css | 7 + .../components/QuestionSelector.test.tsx | 84 ++++++++++++ .../components/QuestionSelector.tsx | 26 +++- .../src/features/palette/Palette.module.css | 16 +++ .../src/features/palette/Palette.test.tsx | 31 ++++- frontend/src/features/palette/Palette.tsx | 41 +++++- .../shared/components/HelpIcon.module.css | 123 ++++++++++++++++++ .../shared/components/HelpIcon.test.tsx | 106 +++++++++++++++ .../features/shared/components/HelpIcon.tsx | 81 ++++++++++++ 15 files changed, 755 insertions(+), 38 deletions(-) create mode 100644 frontend/src/features/canvas/components/CallStack.test.tsx create mode 100644 frontend/src/features/informationTabs/questionTab/components/QuestionSelector.test.tsx create mode 100644 frontend/src/features/shared/components/HelpIcon.module.css create mode 100644 frontend/src/features/shared/components/HelpIcon.test.tsx create mode 100644 frontend/src/features/shared/components/HelpIcon.tsx 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 + )} + + ); +}