Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
7e64bc2
[tb/testing-font-color-conversion] Add regression stories
Myranae Mar 31, 2026
3e8cac7
[tb/testing-font-color-conversion] Fix test error
Myranae Mar 31, 2026
2dcfc57
[tb/testing-font-color-conversion] Color conversions
Myranae Mar 31, 2026
122de80
[tb/testing-font-color-conversion] Update regression stories
Myranae Mar 31, 2026
9aeb1ef
Revert "[tb/testing-font-color-conversion] Color conversions"
Myranae Mar 31, 2026
39416bc
[tb/testing-font-color-conversion] Linting fixes
Myranae Mar 31, 2026
a6d1e15
Reapply "[tb/testing-font-color-conversion] Color conversions"
Myranae Mar 31, 2026
47b8a24
[tb/testing-font-color-conversion] Darken the separator dot for visib…
Myranae Mar 31, 2026
f2b5e81
[tb/testing-font-color-conversion] New story covering graded colors
Myranae Mar 31, 2026
449198a
[tb/testing-font-color-conversion] Add story for incorrect state colors
Myranae Mar 31, 2026
b740a05
[tb/testing-font-color-conversion] docs(changeset): Convert font and …
Myranae Mar 31, 2026
275fefb
[tb/testing-font-color-conversion] Revert to straight token conversio…
Myranae Apr 1, 2026
4bc562d
[tb/testing-font-color-conversion] Change background to foreground color
Myranae Apr 1, 2026
9095183
[tb/testing-font-color-conversion] Change background to foreground co…
Myranae Apr 1, 2026
cd3ebbd
[tb/testing-font-color-conversion] Remove planning docs
Myranae Apr 1, 2026
6c3cdd8
[tb/testing-font-color-conversion] Revert back to background
Myranae Apr 1, 2026
9097781
[tb/testing-font-color-conversion] Merge branch 'main' into tb/testin…
Myranae Apr 1, 2026
2e5523a
[tb/testing-font-color-conversion] Update stories based on improved r…
Myranae Apr 10, 2026
4b17115
[tb/testing-font-color-conversion] Update changeset to be more accurate
Myranae Apr 10, 2026
abc7462
[tb/testing-font-color-conversion] Merge branch 'main' into tb/testin…
Myranae Apr 10, 2026
1f47c22
[tb/testing-font-color-conversion] Remove RTL stories as they aren't …
Myranae Apr 10, 2026
840cd30
[tb/testing-font-color-conversion] Use label image decorator and prop…
Myranae Apr 10, 2026
2452094
[tb/testing-font-color-conversion] Merge branch 'main' into tb/testin…
Myranae Apr 28, 2026
bebe7d0
[tb/testing-font-color-conversion] Update story tag to !autodocs
Myranae Apr 28, 2026
92c05a3
[tb/testing-font-color-conversion] Merge branch 'main' into tb/testin…
Myranae Apr 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/nervous-moons-roll.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@khanacademy/perseus": patch
---

Convert hardcoded color values to semantic tokens for label image
4 changes: 4 additions & 0 deletions packages/perseus-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<ServerItemRendererWithDebugUI
item={generateTestPerseusItem({
question: generateTestPerseusRenderer({
content: parameters?.content ?? "[[☃ label-image 1]]",
widgets: {
"label-image 1": generateLabelImageWidget({
options: generateLabelImageOptions({
...args,
}),
}),
},
}),
})}
/>
);
};
Original file line number Diff line number Diff line change
@@ -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<typeof LabelImageWidget> = {
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<typeof LabelImageWidget>;

// 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<PerseusLabelImageWidgetOptions>,
};

// 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<PerseusLabelImageWidgetOptions>,
};

// 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<PerseusLabelImageWidgetOptions>,
};
Original file line number Diff line number Diff line change
@@ -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<typeof LabelImageWidget> = {
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<PerseusLabelImageWidgetOptions>;

// 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<PerseusLabelImageWidgetOptions>,
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<PerseusLabelImageWidgetOptions>,
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<PerseusLabelImageWidgetOptions>,
play: async ({canvasElement, userEvent}) => {
const canvas = within(canvasElement);
const marker = canvas.getByLabelText("The fourth unlabeled bar line.");
await userEvent.click(marker);
},
};
Loading
Loading