diff --git a/.changeset/nervous-moons-roll.md b/.changeset/nervous-moons-roll.md
new file mode 100644
index 00000000000..4f902012237
--- /dev/null
+++ b/.changeset/nervous-moons-roll.md
@@ -0,0 +1,5 @@
+---
+"@khanacademy/perseus": patch
+---
+
+Convert hardcoded color values to semantic tokens for label image
diff --git a/packages/perseus-core/src/index.ts b/packages/perseus-core/src/index.ts
index b2e9650887b..1805203d71d 100644
--- a/packages/perseus-core/src/index.ts
+++ b/packages/perseus-core/src/index.ts
@@ -368,6 +368,10 @@ export {
generateImageWidget,
} from "./utils/generators/image-widget-generator";
/** @hidden */
+export {
+ generateLabelImageOptions,
+ generateLabelImageWidget,
+} from "./utils/generators/label-image-widget-generator";
export {
generateInteractiveGraphOptions,
generateIGAngleGraph,
diff --git a/packages/perseus/src/widgets/__testutils__/label-image-renderer-decorator.tsx b/packages/perseus/src/widgets/__testutils__/label-image-renderer-decorator.tsx
new file mode 100644
index 00000000000..43f5ab812b3
--- /dev/null
+++ b/packages/perseus/src/widgets/__testutils__/label-image-renderer-decorator.tsx
@@ -0,0 +1,28 @@
+import {
+ generateLabelImageOptions,
+ generateLabelImageWidget,
+ generateTestPerseusItem,
+ generateTestPerseusRenderer,
+} from "@khanacademy/perseus-core";
+import * as React from "react";
+
+import {ServerItemRendererWithDebugUI} from "../../testing/server-item-renderer-with-debug-ui";
+
+export const labelImageRendererDecorator = (_, {args, parameters}) => {
+ return (
+
+ );
+};
diff --git a/packages/perseus/src/widgets/label-image/__docs__/label-image-initial-state-regression.stories.tsx b/packages/perseus/src/widgets/label-image/__docs__/label-image-initial-state-regression.stories.tsx
new file mode 100644
index 00000000000..ca5da9dfda4
--- /dev/null
+++ b/packages/perseus/src/widgets/label-image/__docs__/label-image-initial-state-regression.stories.tsx
@@ -0,0 +1,122 @@
+import {themeModes} from "../../../../../../.storybook/modes";
+import {getWidget} from "../../../widgets";
+import {labelImageRendererDecorator} from "../../__testutils__/label-image-renderer-decorator";
+
+import type {PerseusLabelImageWidgetOptions} from "@khanacademy/perseus-core";
+import type {Meta, StoryObj} from "@storybook/react-vite";
+
+const LabelImageWidget = getWidget("label-image")!;
+
+const meta: Meta = {
+ title: "Widgets/Label Image/Visual Regression Tests/Initial State",
+ component: LabelImageWidget,
+ tags: ["!autodocs"],
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "Regression tests for the Label Image widget that do NOT " +
+ "need any interactions to test.",
+ },
+ },
+ chromatic: {disableSnapshot: false, modes: themeModes},
+ },
+};
+export default meta;
+
+type Story = StoryObj;
+
+// Verifies the default unanswered state: all markers visible and pulsating,
+// no answers selected, text choices hidden from instructions.
+export const DefaultUnanswered: Story = {
+ decorators: [labelImageRendererDecorator],
+ args: {
+ imageUrl:
+ "web+graphie://ka-perseus-graphie.s3.amazonaws.com/56c60c72e96cd353e4a8b5434506cd3a21e717af",
+ imageWidth: 415,
+ imageHeight: 314,
+ imageAlt: "A bar graph with four unlabeled bar lines.",
+ choices: ["Trucks", "Vans", "Cars", "SUVs"],
+ markers: [
+ {
+ answers: ["SUVs"],
+ label: "The fourth unlabeled bar line.",
+ x: 25,
+ y: 17.7,
+ },
+ {
+ answers: ["Trucks"],
+ label: "The third unlabeled bar line.",
+ x: 25,
+ y: 35.3,
+ },
+ {
+ answers: ["Cars"],
+ label: "The second unlabeled bar line.",
+ x: 25,
+ y: 53,
+ },
+ {
+ answers: ["Vans"],
+ label: "The first unlabeled bar line.",
+ x: 25,
+ y: 70.3,
+ },
+ ],
+ multipleAnswers: false,
+ hideChoicesFromInstructions: true,
+ } satisfies Partial,
+};
+
+// Verifies choices shown in the instructions section (hideChoicesFromInstructions: false),
+// including TeX fraction choices and the semanticColor.core.border.neutral.default separator dots
+// that appear between each choice.
+export const WithChoicesInInstructions: Story = {
+ decorators: [labelImageRendererDecorator],
+ args: {
+ imageUrl:
+ "web+graphie://ka-perseus-graphie.s3.amazonaws.com/05faa925d02e5effd3069bf24da4777e3ae1a28b",
+ imageWidth: 360,
+ imageHeight: 160,
+ imageAlt:
+ "A number line from negative 6 halves to negative 3 halves with three labeled points.",
+ choices: ["$-\\dfrac{7}{3}$", "$-2\\dfrac{5}{8}$", "$-2.9$"],
+ markers: [
+ {answers: ["$-2.9$"], label: "Point a", x: 14.25, y: 50},
+ {answers: ["$-2\\dfrac{5}{8}$"], label: "Point b", x: 29.5, y: 50},
+ {answers: ["$-\\dfrac{7}{3}$"], label: "Point c", x: 45.5, y: 50},
+ ],
+ multipleAnswers: false,
+ hideChoicesFromInstructions: false,
+ } satisfies Partial,
+};
+
+// Verifies the incorrect marker state: marker dot renders with neutral
+// background (semanticColor.core.border.neutral.default) when showCorrectness
+// is "incorrect". No answer pill is shown because no answer is selected in
+// this static state.
+// Note: showCorrectness is runtime UI state injected by the renderer, not part
+// of PerseusLabelImageMarker schema, so the marker requires a type cast.
+export const IncorrectMarker: Story = {
+ decorators: [labelImageRendererDecorator],
+ args: {
+ imageUrl:
+ "web+graphie://ka-perseus-graphie.s3.amazonaws.com/56c60c72e96cd353e4a8b5434506cd3a21e717af",
+ imageWidth: 415,
+ imageHeight: 314,
+ imageAlt: "A bar graph with four unlabeled bar lines.",
+ choices: ["Trucks", "Vans", "Cars", "SUVs"],
+ markers: [
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ {
+ answers: ["SUVs"],
+ label: "The fourth unlabeled bar line.",
+ x: 25,
+ y: 17.7,
+ showCorrectness: "incorrect",
+ } as any,
+ ],
+ multipleAnswers: false,
+ hideChoicesFromInstructions: true,
+ } satisfies Partial,
+};
diff --git a/packages/perseus/src/widgets/label-image/__docs__/label-image-interactions-regression.stories.tsx b/packages/perseus/src/widgets/label-image/__docs__/label-image-interactions-regression.stories.tsx
new file mode 100644
index 00000000000..463f445096e
--- /dev/null
+++ b/packages/perseus/src/widgets/label-image/__docs__/label-image-interactions-regression.stories.tsx
@@ -0,0 +1,229 @@
+import {within} from "storybook/test";
+
+import {themeModes} from "../../../../../../.storybook/modes";
+import {getWidget} from "../../../widgets";
+import {labelImageRendererDecorator} from "../../__testutils__/label-image-renderer-decorator";
+
+import type {PerseusLabelImageWidgetOptions} from "@khanacademy/perseus-core";
+import type {Meta} from "@storybook/react-vite";
+
+const LabelImageWidget = getWidget("label-image")!;
+
+const meta: Meta = {
+ title: "Widgets/Label Image/Visual Regression Tests/Interactions",
+ component: LabelImageWidget,
+ tags: ["!autodocs"],
+ parameters: {
+ chromatic: {disableSnapshot: false, modes: themeModes},
+ },
+};
+
+export default meta;
+
+const barGraphArgs = {
+ imageUrl:
+ "web+graphie://ka-perseus-graphie.s3.amazonaws.com/56c60c72e96cd353e4a8b5434506cd3a21e717af",
+ imageWidth: 415,
+ imageHeight: 314,
+ imageAlt: "A bar graph with four unlabeled bar lines.",
+ choices: ["Trucks", "Vans", "Cars", "SUVs"],
+ markers: [
+ {
+ answers: ["SUVs"],
+ label: "The fourth unlabeled bar line.",
+ x: 25,
+ y: 17.7,
+ },
+ {
+ answers: ["Trucks"],
+ label: "The third unlabeled bar line.",
+ x: 25,
+ y: 35.3,
+ },
+ {
+ answers: ["Cars"],
+ label: "The second unlabeled bar line.",
+ x: 25,
+ y: 53,
+ },
+ {
+ answers: ["Vans"],
+ label: "The first unlabeled bar line.",
+ x: 25,
+ y: 70.3,
+ },
+ ],
+ multipleAnswers: false,
+ hideChoicesFromInstructions: true,
+} satisfies Partial;
+
+// Verifies the marker open/selected state: clicking a marker button opens the
+// answer choices dropdown and shows the active marker styling.
+export const MarkerOpened = {
+ decorators: [labelImageRendererDecorator],
+ args: barGraphArgs,
+ play: async ({canvasElement, userEvent}) => {
+ const canvas = within(canvasElement);
+ const marker = canvas.getByLabelText("The fourth unlabeled bar line.");
+ await userEvent.click(marker);
+ },
+};
+
+// Verifies the post-interaction marker state: after selecting an answer and
+// closing the dropdown, all markers render as white circles (not the default
+// pulsing blue).
+export const AnswerSelected = {
+ decorators: [labelImageRendererDecorator],
+ args: barGraphArgs,
+ play: async ({canvasElement, userEvent}) => {
+ const canvas = within(canvasElement);
+ const marker = canvas.getByLabelText("The fourth unlabeled bar line.");
+ await userEvent.click(marker);
+
+ // WonderBlocks SingleSelect renders options into a React portal outside
+ // the canvas, so we scope to document.body.
+ const suvsChoice = within(document.body).getByRole("option", {
+ name: "SUVs",
+ });
+ await userEvent.click(suvsChoice);
+ },
+};
+
+// Verifies the correct answer state: after selecting the right answer and
+// clicking Check, the marker and answer pill render in green (success.strong).
+// Uses a single marker to avoid needing to fill all markers before checking.
+// Check is clicked twice due to a server-side scoring quirk in Storybook.
+export const CorrectAnswerGraded = {
+ decorators: [labelImageRendererDecorator],
+ args: {
+ imageUrl:
+ "web+graphie://ka-perseus-graphie.s3.amazonaws.com/56c60c72e96cd353e4a8b5434506cd3a21e717af",
+ imageWidth: 415,
+ imageHeight: 314,
+ imageAlt: "A bar graph with four unlabeled bar lines.",
+ choices: ["Trucks", "Vans", "Cars", "SUVs"],
+ markers: [
+ {
+ answers: ["SUVs"],
+ label: "The fourth unlabeled bar line.",
+ x: 25,
+ y: 17.7,
+ },
+ ],
+ multipleAnswers: false,
+ hideChoicesFromInstructions: true,
+ } satisfies Partial,
+ play: async ({canvasElement, userEvent}) => {
+ const canvas = within(canvasElement);
+
+ const marker = canvas.getByLabelText("The fourth unlabeled bar line.");
+ await userEvent.click(marker);
+
+ // WonderBlocks SingleSelect renders options into a React portal outside
+ // the canvas, so we scope to document.body.
+ const suvsChoice = within(document.body).getByRole("option", {
+ name: "SUVs",
+ });
+ await userEvent.click(suvsChoice);
+
+ const checkButton = canvas.getByRole("button", {name: "Check answer"});
+ await userEvent.click(checkButton);
+ await userEvent.click(checkButton);
+ },
+};
+
+// Verifies the incorrect answer state: marker dot renders with neutral
+// background (semanticColor.core.border.neutral.default) and the answer pill
+// shows the wrong selection with the same neutral styling.
+// showCorrectness is runtime UI state injected by the renderer, not part of
+// PerseusLabelImageMarker schema, so the marker requires a type cast.
+// The play function selects an answer so the pill becomes visible.
+export const IncorrectAnswerWithPill = {
+ decorators: [labelImageRendererDecorator],
+ args: {
+ imageUrl:
+ "web+graphie://ka-perseus-graphie.s3.amazonaws.com/56c60c72e96cd353e4a8b5434506cd3a21e717af",
+ imageWidth: 415,
+ imageHeight: 314,
+ imageAlt: "A bar graph with four unlabeled bar lines.",
+ choices: ["Trucks", "Vans", "Cars", "SUVs"],
+ markers: [
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ {
+ answers: ["SUVs"],
+ label: "The fourth unlabeled bar line.",
+ x: 25,
+ y: 17.7,
+ showCorrectness: "incorrect",
+ } as any,
+ ],
+ multipleAnswers: false,
+ hideChoicesFromInstructions: true,
+ } satisfies Partial,
+ play: async ({canvasElement, userEvent}) => {
+ const canvas = within(canvasElement);
+
+ const marker = canvas.getByLabelText("The fourth unlabeled bar line.");
+ await userEvent.click(marker);
+
+ // WonderBlocks SingleSelect renders options into a React portal outside
+ // the canvas, so we scope to document.body.
+ const trucksChoice = within(document.body).getByRole("option", {
+ name: "Trucks",
+ });
+ await userEvent.click(trucksChoice);
+ },
+};
+
+// Verifies that math choices render correctly inside an open marker dropdown.
+// The math choices are only visible after opening a marker, so we capture
+// the open state here.
+export const MathChoicesVisible = {
+ decorators: [labelImageRendererDecorator],
+ args: {
+ imageUrl:
+ "web+graphie://ka-perseus-graphie.s3.amazonaws.com/56c60c72e96cd353e4a8b5434506cd3a21e717af",
+ imageWidth: 415,
+ imageHeight: 314,
+ imageAlt: "A bar graph with four unlabeled bar lines.",
+ choices: [
+ "$\\dfrac{1}{2}$",
+ "$\\dfrac{3}{4}$",
+ "$\\dfrac{5}{6}$",
+ "$\\dfrac{7}{8}$",
+ ],
+ markers: [
+ {
+ answers: ["$\\dfrac{1}{2}$"],
+ label: "The fourth unlabeled bar line.",
+ x: 25,
+ y: 17.7,
+ },
+ {
+ answers: ["$\\dfrac{3}{4}$"],
+ label: "The third unlabeled bar line.",
+ x: 25,
+ y: 35.3,
+ },
+ {
+ answers: ["$\\dfrac{5}{6}$"],
+ label: "The second unlabeled bar line.",
+ x: 25,
+ y: 53,
+ },
+ {
+ answers: ["$\\dfrac{7}{8}$"],
+ label: "The first unlabeled bar line.",
+ x: 25,
+ y: 70.3,
+ },
+ ],
+ multipleAnswers: false,
+ hideChoicesFromInstructions: true,
+ } satisfies Partial,
+ play: async ({canvasElement, userEvent}) => {
+ const canvas = within(canvasElement);
+ const marker = canvas.getByLabelText("The fourth unlabeled bar line.");
+ await userEvent.click(marker);
+ },
+};
diff --git a/packages/perseus/src/widgets/label-image/__tests__/label-image.testdata.ts b/packages/perseus/src/widgets/label-image/__tests__/label-image.testdata.ts
index 524e1c8691c..613736e2d1b 100644
--- a/packages/perseus/src/widgets/label-image/__tests__/label-image.testdata.ts
+++ b/packages/perseus/src/widgets/label-image/__tests__/label-image.testdata.ts
@@ -223,6 +223,54 @@ export const numberline: PerseusRenderer = {
},
};
+// Not typed as PerseusRenderer because showCorrectness is part of
+// InteractiveMarkerType (runtime UI state) but not PerseusLabelImageMarker
+// (the schema type). The field is supported by the widget component at runtime.
+export const incorrectAnswerQuestion = {
+ content:
+ "Carol created a chart and a bar graph to show how many of each type of vehicle were in her supermarket parking lot.\n\nVehicle Type | Number in the parking lot\n:- | :-: \nTrucks| $25$ \nVans | $5$ \nCars| $40$ \nSUVs | $10$ \n\n**Label each bar on the bar graph.**\n\n[[☃ label-image 1]]\n\n",
+ images: {
+ "web+graphie://ka-perseus-graphie.s3.amazonaws.com/1e28332fd2321975639ab50c9ce442e568c18421":
+ {
+ width: 341,
+ height: 310,
+ },
+ },
+ widgets: {
+ "label-image 1": {
+ type: "label-image" as const,
+ alignment: "default",
+ static: false,
+ graded: true,
+ options: {
+ static: false,
+ choices: ["Trucks", "Vans", "Cars", "SUVs"],
+ imageAlt:
+ "A bar graph with four bar lines that shows the horizontal axis labeled Number in the parking lot and the vertical axis labeled Vehicle Type. The horizontal axis is labeled, from left to right: 0, 10, 20, 30, 40, and 50. The vertical axis has, from the bottom to the top, four unlabeled bar lines as follows: the first unlabeled bar line extends to the middle of 0 and 10, the second unlabeled bar line extends to 40, the third unlabeled bar line extends to the middle of 20 and 30, and fourth unlabeled bar line extends to 10.",
+ imageUrl:
+ "web+graphie://ka-perseus-graphie.s3.amazonaws.com/56c60c72e96cd353e4a8b5434506cd3a21e717af",
+ imageWidth: 415,
+ imageHeight: 314,
+ markers: [
+ {
+ answers: ["SUVs"],
+ label: "The fourth unlabeled bar line.",
+ x: 25,
+ y: 17.7,
+ showCorrectness: "incorrect",
+ },
+ ],
+ multipleAnswers: false,
+ hideChoicesFromInstructions: true,
+ },
+ version: {
+ major: 0,
+ minor: 0,
+ },
+ },
+ },
+};
+
export const longTextFromArticle: PerseusRenderer = {
content: "[[☃ label-image 1]]",
images: {},
diff --git a/packages/perseus/src/widgets/label-image/answer-pill.tsx b/packages/perseus/src/widgets/label-image/answer-pill.tsx
index 348d0736b79..6c561a039e1 100644
--- a/packages/perseus/src/widgets/label-image/answer-pill.tsx
+++ b/packages/perseus/src/widgets/label-image/answer-pill.tsx
@@ -85,8 +85,7 @@ export const AnswerPill = (props: {
const styles = StyleSheet.create({
correct: {
- // WB green darkened by 18%
- backgroundColor: "#00880b",
+ backgroundColor: semanticColor.core.background.success.strong,
},
incorrect: {
backgroundColor: semanticColor.core.background.neutral.default,
diff --git a/packages/perseus/src/widgets/label-image/label-image.tsx b/packages/perseus/src/widgets/label-image/label-image.tsx
index f4559f23881..232477074eb 100644
--- a/packages/perseus/src/widgets/label-image/label-image.tsx
+++ b/packages/perseus/src/widgets/label-image/label-image.tsx
@@ -9,6 +9,7 @@
import {scoreLabelImageMarker} from "@khanacademy/perseus-score";
import Clickable from "@khanacademy/wonder-blocks-clickable";
import {View} from "@khanacademy/wonder-blocks-core";
+import {semanticColor} from "@khanacademy/wonder-blocks-tokens";
import {StyleSheet, css} from "aphrodite";
import classNames from "classnames";
import * as React from "react";
@@ -862,7 +863,7 @@ const styles = StyleSheet.create({
marginLeft: 5,
marginRight: 5,
- background: "rgba(33, 36, 44, 0.32)",
+ background: semanticColor.core.border.neutral.default,
borderRadius: 2,
},
diff --git a/packages/perseus/src/widgets/label-image/marker.tsx b/packages/perseus/src/widgets/label-image/marker.tsx
index c151c39b3c0..bf83a4747ce 100644
--- a/packages/perseus/src/widgets/label-image/marker.tsx
+++ b/packages/perseus/src/widgets/label-image/marker.tsx
@@ -265,7 +265,7 @@ const styles = StyleSheet.create({
// The learner has made a selection
markerFilled: {
- backgroundColor: "#ECF3FE",
+ backgroundColor: semanticColor.core.background.instructive.subtle,
border: `4px solid ${semanticColor.core.border.instructive.default}`,
},
@@ -279,7 +279,7 @@ const styles = StyleSheet.create({
},
markerCorrect: {
- background: "#00880b", // WB green darkened by 18%
+ background: semanticColor.core.background.success.strong,
},
markerIncorrect: {